Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Loopky Privacy Policy

**Last updated:** 23 August 2026
**Last updated:** 30 August 2026

Loopky is a flashcards app built on [Pubky](https://pubky.org). This policy describes what happens
to your information when you use it.
Expand Down Expand Up @@ -107,7 +107,7 @@ has its own operator and its own privacy practices.
| **Pubky Nexus indexer** (`nexus.pubky.app`) | Search, discovery, trending topics, profile pictures | Your search terms, the public keys and decks you look up, your IP address |
| **Homegate** (`homegate.pubky.app`) | Signing up for a new homeserver account only | Your phone number if you verify by SMS, or Lightning payment details if you pay. Your phone number is handled by Homegate and is never stored by Loopky |
| **Unsplash** (`api.unsplash.com`) | Only when you search for a picture from the web | Your search terms and your IP address, under [Unsplash's privacy policy](https://unsplash.com/privacy) |
| **Google Play** | Only if you tap to install Pubky Ring from the backup screen | Standard Play Store request |
| **Google Play** or the **App Store** | Only if you tap to install Pubky Ring from the backup screen | A standard store request, to whichever store your device uses |

Loopky does not contact any of these to report on you. It contacts them to do the thing you asked
for.
Expand All @@ -125,16 +125,24 @@ The **Speak** practice mode records audio so you can practise pronunciation.
- The permission is requested the first time you use Speak, and Speak is optional. Declining it
leaves the rest of the app working normally.

Depending on your device settings, your device's speech recognition may itself run in the cloud
rather than on-device. That processing is governed by your device manufacturer's or Google's
privacy policy, not this one.
Depending on your device and its settings, your device's speech recognition may itself run in the
cloud rather than on-device — on iOS through Apple's speech recognition, on Android through
Google's. Loopky does not force either mode, so which one you get is your platform's decision. That
processing is governed by Apple's or Google's privacy policy, not this one.

---

## 7. Photos and images
## 7. Camera, photos and images

Choosing a picture for a card uses the Android system photo picker, which hands Loopky only the one
image you select. Loopky requests no storage or gallery permission.
**The camera is used for one thing: reading a QR code that holds someone's pubky**, so you can add
them without typing a long key by hand. Loopky reads only the text decoded from the code. No photo
or video is captured, saved or uploaded, and the camera runs only while that scanner is open. The
permission is requested the first time you open it, and scanning is optional — you can always type
or paste a pubky instead.

Choosing a picture for a card uses the system photo picker — `PhotosPicker` on iOS, the Android
photo picker on Android. Both run outside Loopky and hand it only the one image you select, so
Loopky requests no gallery or storage permission on either platform.

Images you attach to a published deck are uploaded to your homeserver and are public along with the
deck.
Expand Down
26 changes: 24 additions & 2 deletions iosApp/Configuration/Config.xcconfig
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
// Your Apple Developer Team ID, from developer.apple.com → Membership.
// Ten characters, e.g. A1B2C3D4E5. Needed to sign a device build or archive;
// a simulator build works without it.
TEAM_ID=

// The app's permanent identity on the App Store.
//
// This is deliberately NOT derived from TEAM_ID. The Kotlin Multiplatform template
// ships it as `...Loopky$(TEAM_ID)`, which keeps two developers' dev builds from
// colliding but makes the bundle identifier change the moment TEAM_ID is filled in —
// silently breaking the match with the App Store Connect record and every provisioning
// profile. It is fixed here instead.
//
// Change it only BEFORE the app record exists in App Store Connect. After that it is
// permanent: a new identifier is a new app, with no path to move testers or reviews.
//
// Matches Android's applicationId exactly. It also makes the identifiers already in
// Info.plist real children of it rather than merely looking like them: the two
// BGTaskScheduler ids, the `...loopky.login` URL name, and the `.apkg` / `.pkarr` UTIs
// were all written as `com.github.jvsena42.loopky.*` while the bundle was
// `...loopky.Loopky`, so none of them actually nested under it.
PRODUCT_BUNDLE_IDENTIFIER=com.github.jvsena42.loopky

PRODUCT_NAME=Loopky
PRODUCT_BUNDLE_IDENTIFIER=com.github.jvsena42.loopky.Loopky$(TEAM_ID)

// MARKETING_VERSION is what testers see. CURRENT_PROJECT_VERSION must be unique for
// every upload to App Store Connect — bump it per upload, not per release.
CURRENT_PROJECT_VERSION=13
MARKETING_VERSION=0.6.1
MARKETING_VERSION=0.6.1
104 changes: 41 additions & 63 deletions iosApp/iosApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,28 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.github.jvsena42.loopky.media-rehost</string>
<string>com.github.jvsena42.loopky.deck-compaction</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Anki deck</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.github.jvsena42.loopky.apkg</string>
<string>public.plain-text</string>
<string>public.comma-separated-values-text</string>
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
Expand All @@ -15,60 +35,38 @@
</array>
</dict>
</array>
<!--
Makes Loopky's Documents folder visible in Files and over Finder, so an .apkg can be dropped
in and imported without going through a share sheet — the same way Anki's own iOS app works.
`OpeningDocumentsInPlace` means the picker hands back the original file rather than a copy,
which is why the reader takes a security-scoped URL.
-->
<!--
Lets Loopky appear in "Open with" for a deck file, which is how an Anki export usually
arrives — a chat attachment or a Mail download, not something already sitting in Files.
Android has the same entry point via ACTION_VIEW / ACTION_SEND (`ImportFileIntent`).
`.apkg` has no registered UTType, so it is declared here as a zip-conforming type.
-->
<!--
Speak (pronunciation practice) needs both, and iOS terminates the app on the first request if
either is missing. Two separate prompts: recognition happens on Apple's terms, and the
microphone is what feeds it.
-->
<key>ITSAppUsesNonExemptEncryption</key>
<true/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>pubkyring</string>
<string>pubkyauth</string>
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Loopky uses the camera to read a QR code holding someone's pubky, so you can find them without typing it.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Loopky listens while you practise saying a card out loud, and stops as soon as you have answered.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Loopky turns what you say into text to check your pronunciation against the card. Nothing is stored.</string>
<key>CFBundleDocumentTypes</key>
<key>UIBackgroundModes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Anki deck</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.github.jvsena42.loopky.apkg</string>
<string>public.plain-text</string>
<string>public.comma-separated-values-text</string>
</array>
</dict>
<string>processing</string>
</array>
<!--
A recovery file has no registered UTType either, and the export picker needs one: given only
`public.data` it strips the extension off the suggested name, so "recovery.pkarr" is offered
as "recovery" and saved without the extension the user needs to recognise it later.
-->
<key>UIFileSharingEnabled</key>
<true/>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.github.jvsena42.loopky.pkarr</string>
<key>UTTypeDescription</key>
<string>Pubky recovery file</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>Pubky recovery file</string>
<key>UTTypeIdentifier</key>
<string>com.github.jvsena42.loopky.pkarr</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
Expand All @@ -81,14 +79,14 @@
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.github.jvsena42.loopky.apkg</string>
<key>UTTypeDescription</key>
<string>Anki deck</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.zip-archive</string>
</array>
<key>UTTypeDescription</key>
<string>Anki deck</string>
<key>UTTypeIdentifier</key>
<string>com.github.jvsena42.loopky.apkg</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
Expand All @@ -98,25 +96,5 @@
</dict>
</dict>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.github.jvsena42.loopky.media-rehost</string>
<string>com.github.jvsena42.loopky.deck-compaction</string>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>pubkyring</string>
<!-- The scheme the auth deeplinks actually use, and the one IosPubkyRingPresence
probes. Without it canOpenURL answers false on every device. -->
<string>pubkyauth</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>processing</string>
</array>
</dict>
</plist>
57 changes: 57 additions & 0 deletions iosApp/iosApp/PrivacyInfo.xcprivacy
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Apple's privacy manifest. Required since spring 2024; a build without one draws
ITMS-91053/91061 on upload.

The answers below follow PRIVACY.md, which is the source of truth for them. Keep the
two in step: the Loopky project runs no servers, ships no analytics, crash reporting
or advertising SDKs, and holds no account.

Note the scope of "collect". Your decks and review state DO leave the device — they
are written to your Pubky homeserver — but they go to the homeserver operator under
your own key, not to the developer, and Loopky retains nothing. That is a
third-party service you direct the app to write to, which is a different question
from the App Store Connect "App Privacy" nutrition label. Answer that questionnaire
separately, from sections 4 and 5 of PRIVACY.md.
-->

<!-- No tracking as Apple defines it: no linking to third-party data for ads or
measurement, and no data brokers. PRIVACY.md §2. -->
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>

<!-- Nothing is collected by the developer: there is no Loopky backend to collect it
into. PRIVACY.md §2 and §12. -->
<key>NSPrivacyCollectedDataTypes</key>
<array/>

<!--
Required-reason APIs. One category applies, verified by grepping iosMain and iosApp
for every listed category: only NSUserDefaults is reached.

IosAppPreferences, IosPendingReviewStore and IosStudyProgressStore all use
NSUserDefaults, each namespaced by PREFERENCES_NAME and read only by this app —
which is exactly reason CA92.1.

No file-timestamp, disk-space, system-boot-time or active-keyboard API is used. If
that changes, this array has to change with it; nothing in the build reports a
missing entry, and it surfaces as an upload-time rejection instead.
-->
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
Loading