Skip to content

chore(ios): prepare the first TestFlight upload - #185

Open
jvsena42 wants to merge 4 commits into
mainfrom
chore/testflight-prep
Open

chore(ios): prepare the first TestFlight upload#185
jvsena42 wants to merge 4 commits into
mainfrom
chore/testflight-prep

Conversation

@jvsena42

@jvsena42 jvsena42 commented Aug 30, 2026

Copy link
Copy Markdown
Owner

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_ID

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

The KMP template ships it this way so two developers' dev builds cannot collide. TEAM_ID is empty
today, so it resolved to ...loopky.Loopky — but filling TEAM_ID in to sign a device build
would 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:

$ xcodebuild -showBuildSettings TEAM_ID=A1B2C3D4E5
DEVELOPMENT_TEAM = A1B2C3D4E5
PRODUCT_BUNDLE_IDENTIFIER = com.github.jvsena42.loopky   # unmoved

2. The identifier itself: com.github.jvsena42.loopky

The duplicated .Loopky is gone, so it now matches Android's applicationId exactly. This 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.

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, reason CA92.1, used by
IosAppPreferences, IosPendingReviewStore and IosStudyProgressStore. Every other listed
category — file timestamps, disk space, boot time, active keyboard — was grepped for across
iosMain and iosApp and none is reached.

4. Export compliance: declared true, not false

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 rather than exemption.

The cost of true is a one-time export compliance questionnaire in App Store Connect. The cost of
a wrong false is a false declaration on every build. Once Apple issues a compliance code it can be
pinned as ITSEncryptionExportComplianceCode so the questionnaire stops being asked per version.

5. PRIVACY.md described Android as the only platform

App 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 NSCameraUsageDescription and QrScannerSheet
runs an AVCaptureSession in this process, so the user is asked for the camera and the policy said
nothing. Section 7 now covers it, and is specific because the scanner uses
AVCaptureMetadataOutput and reads only stringValue: no photo or video is captured, saved or
uploaded.

Three others: the photo picker is PhotosPicker on 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 — SFSpeechRecognizer is created without
requiresOnDeviceRecognition, so that is real rather than hypothetical. Section 3 already named
both Keystore and Keychain and is unchanged.

Verification

Built 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 correctly, and only signing now stands between this and an archive. Clean-rebuild check of the
shipped bundle:

CFBundleIdentifier                com.github.jvsena42.loopky
CFBundleShortVersionString (Ver)  0.6.0 (12)
ITSAppUsesNonExemptEncryption     true
BGTaskSchedulerPermittedIdentifiers  ...loopky.media-rehost, ...loopky.deck-compaction
PrivacyInfo.xcprivacy             bundled

Checked and found fine

UIFileSharingEnabled is on, which exposes the app's Documents folder in Files — but recovery files
go through fileExporter to a security-scoped URL the user picks, and nothing in Swift or Kotlin
writes to Documents. The folder is empty, and the file is Argon2id-encrypted regardless.

Still open, outside this PR

  • TEAM_ID stays empty until enrollment completes; it is the only thing between here and an archive.
  • UIBackgroundModes: processing ships two BGTaskScheduler identifiers that CLAUDE.md records as
    written but unverified — they will fail silently in testers' hands if they are wrong.
  • Deployment target is iOS 18.2, which is high and narrows the tester pool.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CQ2Gt7ifAHdEb6bnq4eJYe

@jvsena42 jvsena42 changed the title chore(ios): unblock the first TestFlight upload chore(ios): prepare the first TestFlight upload Aug 30, 2026
jvsena42 and others added 4 commits August 30, 2026 17:52
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
jvsena42 force-pushed the chore/testflight-prep branch from 7a2fb0f to cc63d44 Compare August 30, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant