chore(ios): prepare the first TestFlight upload - #185
Open
jvsena42 wants to merge 4 commits into
Open
Conversation
Three things stood between the project and an App Store Connect build. None of them fails a build, which is why a simulator-only history never surfaced them. **The bundle identifier was derived from TEAM_ID.** The KMP template ships it as `...Loopky$(TEAM_ID)` so two developers' dev builds cannot collide. TEAM_ID is empty today, so it resolves to `com.github.jvsena42.loopky.Loopky` — but filling TEAM_ID in to sign a device build would silently move the identifier and break the match with the App Store Connect record and every provisioning profile. It is now fixed independently of the team, verified by resolving build settings with a dummy TEAM_ID: DEVELOPMENT_TEAM picks it up, the identifier does not move. **No privacy manifest existed.** Apple has required one since spring 2024, and its absence draws ITMS-91053/91061 at upload. The answers follow PRIVACY.md: no tracking, no tracking domains, and nothing collected by the developer, because the project runs no servers. One required-reason API applies — NSUserDefaults, reason CA92.1, used by IosAppPreferences, IosPendingReviewStore and IosStudyProgressStore. Every other listed category was grepped for across iosMain and iosApp and none is reached. **Export compliance was undeclared**, so every upload would stall behind the manual questionnaire. ITSAppUsesNonExemptEncryption is now declared. See the PR for why this one is a legal declaration to confirm rather than a settled fact. Verified by building Release for `generic/platform=iOS` with signing off — the first device build this project has ever had. It succeeds on arm64, so the KMP iosArm64 slice and PubkyCore's ios-arm64 slice link fine, and the shipped bundle carries PrivacyInfo.xcprivacy and the encryption key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQ2Gt7ifAHdEb6bnq4eJYe
`com.github.jvsena42.loopky.Loopky` becomes `com.github.jvsena42.loopky`, which matches Android's applicationId exactly. It also makes the identifiers already in Info.plist real children of the bundle id 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. Free to make now and not later: the identifier is permanent once an App Store Connect record exists, and there is none yet — enrollment is still in progress. Verified with a clean Release build for `generic/platform=iOS`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQ2Gt7ifAHdEb6bnq4eJYe
…nly platform App Store Connect takes the GitHub URL for this file as the app's privacy policy, which makes four passages a problem the moment a reviewer reads it beside the app's permission prompts. **The camera was not mentioned at all.** iOS ships NSCameraUsageDescription and QrScannerSheet runs an AVCaptureSession in this process, so the user is asked for the camera and the policy said nothing about it. Section 7 now covers it, and can be specific because the scanner uses AVCaptureMetadataOutput and reads only `stringValue`: no photo or video is captured, saved or uploaded. The other three described Android as if it were the only platform. The photo picker is `PhotosPicker` on iOS, not "the Android system photo picker" — both run out of process and need no permission, so the privacy outcome was right and the mechanism was wrong. The Pubky Ring install link goes to whichever store the device uses. And speech recognition may go to the cloud through **Apple** on iOS, not only Google — SFSpeechRecognizer is created without requiresOnDeviceRecognition, so that is a real possibility rather than a hypothetical one. Section 3 already named both Keystore and Keychain and is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQ2Gt7ifAHdEb6bnq4eJYe
ITSAppUsesNonExemptEncryption goes from false to true, which is the accurate answer rather than the convenient one. Apple's questionnaire offers three exemption routes and Loopky fails the first two. The crypto is not "only encryption within Apple's operating system" — it comes from the Rust pubky-core FFI. And it is not "limited to authentication": Ed25519 signing would qualify, but createRecoveryFile encrypts the user's private key at rest with Argon2id, which is data encryption. Only the standard published algorithms route is left, and that is mass-market classification, not exemption. The cost of true is a one-time export compliance questionnaire in App Store Connect instead of nothing. The cost of a wrong false is a false declaration on every build, so this is the cheaper mistake to avoid. Once Apple issues a compliance code it can be pinned here as ITSEncryptionExportComplianceCode so the questionnaire stops being asked per version. Verified in a Release build for `generic/platform=iOS`: the shipped Info.plist carries the key as true. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQ2Gt7ifAHdEb6bnq4eJYe
jvsena42
force-pushed
the
chore/testflight-prep
branch
from
August 30, 2026 20:53
7a2fb0f to
cc63d44
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Everything standing between this project and an App Store Connect build. None of it fails a build,
which is exactly why a simulator-only history never surfaced any of it.
Four commits, each independently reviewable.
1. The bundle identifier was derived from
TEAM_IDThe KMP template ships it this way so two developers' dev builds cannot collide.
TEAM_IDis emptytoday, so it resolved to
...loopky.Loopky— but fillingTEAM_IDin to sign a device buildwould silently move the identifier, breaking the match with the App Store Connect record and
every provisioning profile, at the worst possible moment. It is now fixed independently of the
team, proven by resolving build settings with a dummy team:
2. The identifier itself:
com.github.jvsena42.loopkyThe duplicated
.Loopkyis gone, so it now matches Android'sapplicationIdexactly. This alsomakes the identifiers already in
Info.plistreal children of it rather than merely looking likethem — the two
BGTaskSchedulerids, the...loopky.loginURL name and the.apkg/.pkarrUTIs were all written as
com.github.jvsena42.loopky.*while the bundle was...loopky.Loopky.Free to change now and never again: the identifier is permanent once an App Store Connect record
exists, and there is none yet.
3. No privacy manifest existed
Required since spring 2024; absence draws ITMS-91053/91061 at upload. Answers follow
PRIVACY.md:no tracking, no tracking domains, nothing collected by the developer — the project runs no servers.
One required-reason API applies:
NSUserDefaults, reasonCA92.1, used byIosAppPreferences,IosPendingReviewStoreandIosStudyProgressStore. Every other listedcategory — file timestamps, disk space, boot time, active keyboard — was grepped for across
iosMainandiosAppand none is reached.4. Export compliance: declared
true, notfalseApple's questionnaire offers three exemption routes and Loopky fails the first two. The crypto is
not "only encryption within Apple's operating system" — it comes from the Rust
pubky-coreFFI.And it is not "limited to authentication": Ed25519 signing would qualify, but
createRecoveryFileencrypts the user's private key at rest with Argon2id, which is data encryption. Only the standard
published algorithms route is left, and that is mass-market classification rather than exemption.
The cost of
trueis a one-time export compliance questionnaire in App Store Connect. The cost ofa wrong
falseis a false declaration on every build. Once Apple issues a compliance code it can bepinned as
ITSEncryptionExportComplianceCodeso the questionnaire stops being asked per version.5.
PRIVACY.mddescribed Android as the only platformApp Store Connect will take this file's GitHub URL as the app's privacy policy, which puts it in
front of a reviewer beside the app's own permission prompts.
The camera was not mentioned at all — iOS ships
NSCameraUsageDescriptionandQrScannerSheetruns an
AVCaptureSessionin this process, so the user is asked for the camera and the policy saidnothing. Section 7 now covers it, and is specific because the scanner uses
AVCaptureMetadataOutputand reads onlystringValue: no photo or video is captured, saved oruploaded.
Three others: the photo picker is
PhotosPickeron iOS, not "the Android system photo picker"(both run out of process and need no permission, so the outcome was right and the mechanism wrong);
the Ring install link goes to whichever store the device uses; and cloud speech recognition goes
through Apple on iOS, not only Google —
SFSpeechRecognizeris created withoutrequiresOnDeviceRecognition, so that is real rather than hypothetical. Section 3 already namedboth Keystore and Keychain and is unchanged.
Verification
Built Release for
generic/platform=iOSwith signing off — the first device build this projecthas ever had. It succeeds on arm64, so the KMP
iosArm64slice and PubkyCore'sios-arm64slicelink correctly, and only signing now stands between this and an archive. Clean-rebuild check of the
shipped bundle:
Checked and found fine
UIFileSharingEnabledis on, which exposes the app's Documents folder in Files — but recovery filesgo through
fileExporterto a security-scoped URL the user picks, and nothing in Swift or Kotlinwrites to Documents. The folder is empty, and the file is Argon2id-encrypted regardless.
Still open, outside this PR
TEAM_IDstays empty until enrollment completes; it is the only thing between here and an archive.UIBackgroundModes: processingships twoBGTaskScheduleridentifiers that CLAUDE.md records aswritten but unverified — they will fail silently in testers' hands if they are wrong.
🤖 Generated with Claude Code
https://claude.ai/code/session_01CQ2Gt7ifAHdEb6bnq4eJYe