iOS build: Ktor networking migration + iOS target#11
Open
nisfeb wants to merge 55 commits into
Open
Conversation
Replaces the JVM-only OkHttp client threaded through commonMain with a multiplatform Ktor client so the shared HTTP surface can compile on a future Kotlin/Native (iOS) target. - UrbitChannel/UrbitSession, TlonChatRepo, S3Uploader, AiClient, AgentClient, McpClient, BraveSearchClient, UrlFetcher, RelayClient, HttpUpdateChecker, LinkPreviewCache all now speak Ktor. - S3 SigV4 signing moves to okio (SHA-256/HMAC) + kotlinx-datetime (UTC ISO-basic timestamps) — no JVM crypto, signs identically on all targets. Verified bit-for-bit by S3UploaderTest. - SSRF post-DNS IP guard stays JVM-only behind createUrlFetcherClient expect/actual; the literal-hostname guard stays in common. - Roots keep an OkHttpClient for the JVM-only leaf consumers (image downloaders, weather/digest) and add a shared Ktor client for the session/repo/UI path. - Drops okhttp/okhttp-sse from commonMain deps; switches commonMain @volatile to kotlin.concurrent.Volatile. - Tests moved to Ktor MockEngine (S3, MCP, slash); isInternalAddress coverage relocated to desktopTest. Desktop + Android compile; full desktopTest suite green.
Declares iosX64/iosArm64/iosSimulatorArm64 targets producing the
static ComposeApp framework, and implements the full iosMain leaf so
the shared commonMain compiles on Kotlin/Native.
Cannot be built on this Linux dev box — Apple targets compile only on
macOS; the first real iOS compile happens on nomac's backend. Android
and desktop stay green (verified: compileKotlinDesktop,
compileDebugKotlinAndroid, desktopTest all pass).
iosMain contents:
- expect/actual: Platform (Dispatchers.Default, arc4random_buf,
NSTimeZone), HttpClientFactory (Darwin), Log, AppContext, UrlFetcher
(Darwin, hostname guard only), Capabilities (on-device AI/digest/
loops off, assistant on), EmojiFont, PointerInputs,
PlatformBackHandler, FileDropTarget, TalonLogo (bundle icon via
skia), QrCodeGenerator (CoreImage), ImagePicker
(UIImagePickerController + UIDocumentPickerViewController),
AppDatabase (Room native + BundledSQLiteDriver under Documents).
- platform services: persistent SessionStore, AiSettings,
ThemePreference (okio JSON under Documents); UiSettings uses the
in-memory default for now (DB rail-visibility projection pending).
- MainViewController: ComposeUIViewController { App(...) } wiring the
six required deps.
Gradle: coil-network-okhttp moved to the JVM leaves; iosMain gets
ktor-client-darwin + coil-network-ktor3; Room KSP runs per iOS target.
Highest-risk files for the first nomac build (heavy blind interop):
QrCodeGenerator.ios.kt (CoreGraphics pixel readback) and
ImagePicker.ios.kt (UIKit delegate bridging).
Remaining for M2: the iosApp Xcode project (deferred to M4 — its
build phases couple to nomac's environment).
The Xcode host nomac builds: an iOS app target that embeds the ComposeApp framework via the canonical CMP run-script phase (./gradlew :composeApp:embedAndSignAppleFrameworkForXcode) and mounts MainViewControllerKt.MainViewController() in a SwiftUI UIViewControllerRepresentable. - Bundle id io.nisfeb.talon, display name Talon, min iOS 15.0, via Configuration/Config.xcconfig. - Shared iosApp scheme so xcodebuild -scheme iosApp resolves. - Info.plist grants photo-library access (image picker) and an ATS arbitrary-loads exception (user ships may be plain http). - Assets: empty AppIcon / AccentColor placeholders. Unbuildable on Linux — validated only that the Gradle framework tasks it calls (embedAndSignAppleFrameworkForXcode, linkReleaseFrameworkIos*) exist. First real build is M4 on nomac; expect pbxproj / signing iteration there.
Replaces the hand-authored project.pbxproj with canonical output from the Ruby xcodeproj gem (real UUIDs, well-formed) and removes the SwiftUI "Preview Content" group (a space-in-path dev-only artifact not needed for headless builds). Context: probing nomac's snapshot endpoint showed its Xcode-project deep-scan returns HTTP 500 (unhandled "internal") above ~200 KB compressed once an .xcodeproj is detected. A bare project pushes fine; the full 3.2 MB Talon repo does not. This commit keeps the project correct for any macOS CI path; the nomac size ceiling is tracked separately.
Add a macOS job to the test workflow that links the iosArm64 device framework, forcing KSP and all of commonMain + iosMain to compile. iOS can't build on the Linux/JVM path, so this is the only automated iOS signal. Lives in test.yml, not release.yml, since the compile emits no shippable artifact to package at tag time.
KSP1 only passes a processor's direct klib deps to the Kotlin/Native compiler, so Room's iosArm64 codegen failed with "KLIB resolver: Could not find room-common ... klib". Enable KSP2, which resolves the full transitive klib closure. KSP2 runs codegen in a standalone KspAATask, so also wire generateTalonBuild as a dependency of the ksp tasks to satisfy Gradle's input validation. JVM + Android KSP, compile, and desktopTest verified locally.
KSP2 2.0.20-1.0.25 crashes validating Room's annotation default values on native (NPE in getDefaultValue, JavaClassImpl cast). KSP 2.0.21-1.0.28 fixes it. The Kotlin patch bump stays within Compose Multiplatform 1.7.3's supported range. JVM + Android KSP, compile, and desktopTest verified locally.
Room 2.7.2's iOS klibs are built with Kotlin 2.1.10, so a 2.0.21 Kotlin/Native compiler rejects them (incompatible klib ABI 1.201.0). Bump Kotlin to 2.1.20 (reads them, forward-compatible), which pulls Compose Multiplatform to 1.8.0 and KSP to 2.1.20-1.0.32 to stay aligned. JVM + Android compile and desktopTest verified locally.
The Kotlin/Native compile surfaced ~90 JVM-only API uses in shared code that only ever compiled on the JVM. Replaced with multiplatform equivalents: - String.format -> Double.formatDecimals helper - ConcurrentHashMap -> ConcurrentMap/ConcurrentSet (atomicfu-locked) - java.util.Calendar / java.time.LocalDate -> kotlinx-datetime - java.io.File -> okio-backed deleteFile/readFileBytes/createTempFileUri - Thread daemon -> GlobalScope coroutine (same fire-and-forget lifetime) - System.getProperty(os.name) -> isMacOsHost expect/actual - System.identityHashCode -> Any.hashCode (identity on JVM + native) - java net exceptions -> isTransientNetworkError helper - StoryCache LinkedHashMap-LRU + synchronized -> manual LRU + atomicfu - surrogate-aware codePointAtCommon (no java.lang.String.codePointAt) Also drops the invalid CoreImage outputImage import and fixes the iOS KVC setValue import. New util helpers (Formatting, Concurrency, Files, NetErrors) plus isMacOsHost / tempDirPath expect-actuals. JVM + Android compile and desktopTest (incl. new FormattingTest) pass locally; iOS compile verified on CI.
Clear the nomac review-lint blockers for a first TestFlight build: add a 1024 AppIcon (alpha stripped), a PrivacyInfo.xcprivacy declaring the required-reason APIs (UserDefaults, file timestamp, disk space, system boot time), and ITSAppUsesNonExemptEncryption=NO. Bump the iOS MARKETING_VERSION to 0.15.0 and hardcode the io.nisfeb.talon bundle id so nomac's snapshot detector resolves it. pbxproj regenerated to bundle the privacy manifest as a resource.
The pbxproj regen dropped project.xcworkspace/contents.xcworkspacedata; xcodebuild expects the embedded workspace stub. Restore it.
The Kotlin/Native release link (whole-program optimization over all of commonMain + iosMain) OOMs at -Xmx4g during the TestFlight archive; the Debug link and JVM/Android builds fit under it. Bump to 6g. Also set the framework bundleId explicitly to silence the K/N inference warning, and switch the CI ios-compile job to the release link so it exercises the shipping config (where the OOM lived) instead of Debug.
The TestFlight archive OOMed at -Xmx6g even though a plain `gradlew linkReleaseFrameworkIosArm64` fits in 6g: via xcodebuild's run-script phase the Kotlin/Native compile runs in the Kotlin daemon, whose heap is kotlin.daemon.jvmargs, not org.gradle.jvmargs. Set it to 6g. Switch the CI ios job to `xcodebuild archive` (unsigned) so it runs the same path as the TestFlight build and catches this for free.
nomac's builder sets a small org.gradle.jvmargs in its GRADLE_USER_HOME, which outranks our project gradle.properties, so the Kotlin/Native release link OOMs there even though a plain gradlew (and GitHub CI) fit in 6g. The framework run-script now points GRADLE_USER_HOME at a project-local gradle.properties with -Xmx6g, which wins regardless of the builder default. CI simulates a 2g builder default before the xcodebuild archive to prove the override holds.
The shared Ktor client set only connectTimeoutMillis, so the engine default read timeout (OkHttp = 10s) leaked in and undercut every per-call requestTimeoutMillis. A poke to a slow ship that stayed silent past 10s died with "socket timeout has expired" well before its own 30s budget. Set socketTimeoutMillis past the longest per-call budget so the per-call timeout is the real authority; streaming (SSE, AI) is unaffected since bytes keep arriving. Also make composer error text copy to clipboard on tap.
Fixes slow-ship poke failures ("socket timeout has expired") and adds
tap-to-copy on composer error text.
AndroidDraftStore updated SharedPreferences on save/clear but only refreshed its backing StateFlow via the async change listener, which can miss callbacks. load() reads prefs directly, so after a send the composer field went empty while the list kept advertising "Draft:". Refresh backing synchronously from the mutation instead.
The Ktor migration replaced master's hand-tuned OkHttpClient (readTimeout=0, 15s connect/write, one shared connection pool) with the bare Ktor OkHttp engine, which inherits OkHttp's default 10s read timeout and manages its own client. That undercut the per-call poke timeouts and diverged from the shared-connection transport, which is why group posts felt slower than before the iOS changes. Hand Ktor that same client via preconfigured so desktop and android network exactly as they did pre-migration. This also supersedes the socketTimeoutMillis workaround: readTimeout=0 is how master kept the SSE open forever while per-call requestTimeoutMillis bounds pokes.
Pins desktop/android to the pre-Ktor OkHttp transport (faster group posts, no slow-ship poke timeouts) and clears the stale "Draft:" badge left in the conversation list after a send.
Two things made a just-sent message sit greyed-out long after it actually posted, worst on busy group channels: - The SSE collect loop awaited a network ack after every event, so ingestion ran at one round-trip per event. A burst of events (and the poke-echo that reaps the pending row) drained only as fast as we could ack. Acks now ride their own ordered queue drained off the ingestion path, so applyEvent — which does the reap — runs at DB speed. - The chat-list rebuild keyed every message's day with a toLocalDateTime (allocation + DST-table lookup) across the full history on every insert. A windowed DayKeyer keys by an epoch-ms range check, paying a conversion only at a day boundary — back to O(distinct days) like the pre-Ktor Calendar path.
Decouples SSE acks from event ingestion and restores the O(distinct days) chat-list rebuild, so a just-sent message stops showing greyed long after it posted (worst on busy group channels).
A long prompt grew the field past the bottom of the screen and pushed the schedule chips, the authorize switch and the Save button out of reach, with no way to scroll to them. Give the form weight below the title and wrap it in a verticalScroll, and add imePadding so Save clears the keyboard while editing.
A channel post's grey optimistic twin is reaped when the host echoes the post back, matched on exact (whom, author, sentMs). If the host doesn't round-trip essay.sent byte-for-byte the match misses, so the grey local_ row lingers next to the white server row — the duplicate. Make reapLocalTwin report how many rows it removed; on an exact miss for a fresh echo, fall back to reaping the oldest still-pending twin (echoes arrive in send order). A recency guard keeps a re-delivered old history item from clobbering a different in-flight post, and the miss is logged so a sent-skew failure is distinguishable from a slow one. Covered by MessageReapDaoTest.
Clears the grey+white duplicate when the host skews essay.sent (with a diagnostic log for sent-skew vs slow reap), and makes the loop editor scroll so Save stays reachable with a long prompt. Rides on rc4's SSE ack-decoupling throughput work.
Loops can now run at a set local time on chosen weekdays (e.g. 6am Sunday), not only at intervals. Adds scheduleKind/atMinuteOfDay/ daysMask to LoopEntity (db 38->39, Android migration + destructive fallback on desktop), weekly next-fire math in LoopSchedule, and an editor toggle with a time input and day chips. Empty day set fires every day; device-local zone throughout. Every headless run now prefixes the prompt with the current local date, weekday, and time so the agent can reason about "now". Also pad the loop editor with the system-bar inset so a long prompt no longer overruns the top menu bar.
%groups sends the leaving member no delete fact (only a host deleting the group emits one), so a left group lingered in the home list until the next full /v2/groups reconcile — i.e. an app restart. Register the group-leave poke id and, on the ship's ack, remove the group and its channels locally; on a nack keep it and log, since the leave didn't take. Mirrors tlon-apps' optimistic leave, adapted to the async ack model. A silent nack and a silent success were previously indistinguishable.
Brings the whole toolchain to latest stable: Compose Multiplatform 1.8.0 -> 1.11.1 (skiko 0.9.4 -> 0.144.6), Kotlin 2.1.20 -> 2.2.20, KSP -> 2.2.20-2.0.4, Room 2.7.2 -> 2.8.4, androidx.sqlite -> 2.6.2. The skiko jump is the point: a native font-stream crash (SkTypeface_fontconfig::onOpenStream, plus heap-corruption SIGABRTs) had been killing the desktop AppImage roughly weekly. That class of bug is fixed ~140 skiko milestones on from 0.9.4. Fallout handled: - kotlinx-datetime 0.6.1 -> 0.7.1 (forced by the new stack). 0.7 moved Instant/Clock to kotlin.time; migrated every call site and opted into kotlin.time.ExperimentalTime once for all targets. - Dropped the iosX64 target: Compose 1.11 no longer publishes Intel iOS-simulator artifacts. Device + Apple-silicon simulator remain. Verified on desktop + Android compile and the full desktop test suite. iOS builds on macOS only (nomac/CI).
Two things surfaced investigating slow sends / lingering grey twins: - applyChannelDelta's PostsBatch branch never reaped our own grey optimistic twins — only the singular PostSet/Reply paths did. An echo that arrives batched (SSE reconnect, paginated catch-up) left the grey duplicate on screen until the next full bootstrap. Reap own posts in the batch too; the exact-sentMs match no-ops on re-delivered history so it's safe. - The desktop Log wrote only to stderr, which a GUI-launched AppImage sends to /dev/null — so there were no logs to diagnose from. Add a wall-clock-stamped file sink at <userData>/log/talon.log (rotated at 2 MB). reapOwnEchoTwin now logs the send->echo round-trip latency, the single number that says whether slow sending is the network round-trip or our own ingest.
Investigation of "slow message sending" (with the new file log): the entire ~1.5-2s send delay is the channel poke's HTTP PUT, which eyre holds until %channels finishes processing the post. Everything downstream — ship echo, our SSE ingest, the grey→white reap — totals under 15ms. Measured out the alternatives: - raw HTTPS RTT to the ship: ~150ms - small authenticated scry: ~150ms - a channel post-add poke: ~1500ms (even to a locally-hosted channel, so it isn't Ames forwarding) - forcing HTTP/1.1 (vs shared-HTTP/2): no change, so it isn't the SSE head-of-line-blocking pokes on a shared socket either So it's the ship's gall compute on a heavy ship (2MB groups state, 138 channels), not our transport and not the Ktor migration — the grey/pending machinery is byte-identical to master and predates this branch. Keep the two timing probes but only warn past 1s, so a laggy ship self-reports without spamming a normal send.
coroutines 1.8.1->1.11.0, serialization 1.7.2->1.11.0, atomicfu 0.25.0->0.33.0 (align with Kotlin 2.2.20); security-crypto alpha06 ->1.1.0 stable, okio 3.9.1->3.17.0, sqlite-jdbc ->3.53.2.0, work-runtime ->2.11.2, slf4j ->2.0.18. Desktop+Android compile and full desktop tests green.
AGP 8.7.3->8.13.2, Gradle 8.10.2->8.14.5, compileSdk/targetSdk 35->36; ktor 3.0.3->3.5.1, coil 3.0.4->3.4.0, composeBom ->2026.06.01, reorderable 2.5.0->3.1.0. Coil held at 3.4.0 (not 3.5.0): 3.5.0 requires kotlin-stdlib 2.4.0, unreadable by the Kotlin 2.2.20 compiler our CMP 1.11.1 / Room / iOS foundation is pinned to. 3.4.0 (stdlib 2.3.10) is the newest the 2.2.20 compiler can read. Green on desktop+android+tests.
core-ktx 1.13.1->1.19.0, activity-compose 1.9.2->1.13.0, lifecycle
2.8.6->2.11.0, media3 1.4.1->1.10.1 — all require AGP 9.1+, so:
AGP 8.13.2->9.3.0, Gradle 8.14.5->9.6.1.
AGP 9 dropped com.android.application x KMP-plugin compat. Bridged
with the official android.builtInKotlin=false / android.newDsl=false
flags rather than the full com.android.kotlin.multiplatform.library
migration (tracked as follow-up; flags go away in AGP 10). Fixed one
Gradle-9 break: providers.provider now rejects a nullable value type
(Provider<File?> -> Provider<File>). Deprecation warnings remain for
the android{} DSL and source-set getting-delegates (removed in
Gradle/AGP 10). Green on desktop+android+tests.
okhttp 4.12.0->5.4.0 (major, source-compatible for our Builder usage), djl 0.34.0->0.36.0, mediapipe-tasks-text 0.10.21->0.10.35, unifiedpush 3.0.5->3.3.3, zxing-core 3.5.3->3.5.4. Green on desktop+android+tests.
Latest stable; stdlib 2.1.20, compatible with the 2.2.20 compiler. Green on desktop+android+tests.
Compose 1.11 dropped the iosX64 target, so the ComposeApp framework is arm64-only. The simulator build still requested x86_64, failing syncComposeResourcesForIos with 'Unknown iOS simulator arch: x86_64'. Exclude x86_64 for the simulator SDK in Config.xcconfig.
nomac provisions JAVA_TOOL_OPTIONS=-Xmx6g on every build, so the run-script no longer needs its own GRADLE_USER_HOME. The separate .gradle-ios-heap started empty each build and re-downloaded every klib; under gradle 9 one components-resources artifact transform failed to materialize, breaking iosSimulatorArm64 klib resolution. Reverting to the default cache lets nomac's warmed ~/.gradle serve the klibs.
compose multiplatform 1.11.1 ships ios klibs built by the 2.3.20 compiler (abi 2.3.0). kotlin 2.2.20 caps at abi 2.2.0, so the native target could not resolve components-resources on ios. jvm and android tolerated the skew; kotlin/native klib abi does not. kotlin 2.3.20 matches both cmp's compiler and the stdlib that ksp 2.3.10 embeds. kotlin 2.4.x stays out of reach until ksp ships a 2.4 line (room codegen needs ksp). desktop + android compile clean and the full desktop test suite passes.
ktor's sse plugin left eyre holding the channel GET open but never surfaced the facts. the field symptom: the 90s watchdog reconnected forever with zero events, so every optimistic grey post was stranded (its echo never arrived to reap it) and each reconnect's re-scry re-added the post as a second row — the duplicate-message bug. the transport and cookies were fine (proven by test); the plugin's request/response handling was the regression from the okhttp EventSource the pre-ktor build used. read the event-stream directly: a plain streaming GET with accept-encoding identity so eyre emits each frame uncompressed the instant it is ready, parsed frame by frame in commonMain so every platform shares it. adds UrbitChannelSseTest driving the real events() path against a held-open local server.
the live SSE delta path reaps our optimistic local_ post twin when the server echoes it back, but the init-posts re-scry did not. so a post whose SSE echo was missed (stream dropped between poke and echo) kept its grey twin while the next reconnect re-scry re-added the same post under its real id — two rows, the duplicate-message bug. reap the exact (whom, author, sentMs) twin for every own top-level real post the snapshot ingests. exact-match only, so a mid-flight snapshot can't clobber a different still-pending post's twin via the FIFO fallback. clears stranded twins on the next reconnect no matter why the echo was missed.
refreshConversation re-scries a single whom and re-adds our own posts under their real id, the same duplicate path the init-posts bootstrap had. apply the identical exact-match twin reap so opening a conversation can't strand a grey+white pair either.
checkReleaseAarMetadata failed the rc8 android build: core-ktx 1.19.0, core 1.19.0 and lifecycle-runtime-compose 2.11.0 all require compiling against api 37. that check only runs in the assemble path, so the package-upgrade loop (which built compileDebugKotlin, not assembleRelease) never caught it. bump compileSdk to 37; targetSdk stays 36 so we don't take android 17 runtime behavior in the same step. release.yml installs the platform since the runner image may not preinstall it. verified with a local assembleRelease.
opening a thread on a phone (or any compact window) swaps DmChatScreen out for a full-screen ThreadScreen — they are mutually exclusive when-branches. the chat's LazyListState and first-anchor flag were owned by DmChatScreen, so the unmount dropped them; on return the anchor effect re-snapped to the newest message. with 50 unreads that dumped the user at the bottom, forcing a long scroll back up. hoist the scroll state and anchor flag into the callers (App.kt and android TalonApp.kt), keyed on the open chat: they survive the thread round-trip and reset only on a real chat switch, so a freshly opened conversation still snaps to newest. wide windows never unmounted the chat (thread is a right pane) and are unaffected.
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.
Adds iOS as a third target to Talon (previously Android + JVM desktop only) and takes it all the way to a signed TestFlight build. Landed as staged, individually-green commits.
Result
ios-compile, release config)desktopTestsuite pass at every commitasc_build_id f41d54e5…, v0.15.0)M1 — networking to Ktor
Replaced the JVM-only OkHttp client threaded through
commonMainwith a multiplatform Ktor client so the shared HTTP surface compiles on Kotlin/Native: UrbitChannel/Session, TlonChatRepo, S3Uploader (SigV4 now via okio + kotlinx-datetime, no JVM crypto), AiClient/AgentClient/McpClient/BraveSearch, UrlFetcher, RelayClient, HttpUpdateChecker, LinkPreviewCache. Tests moved to Ktor MockEngine.M2 — iOS target + leaf + Xcode project
iosX64/iosArm64/iosSimulatorArm64targets producing the staticComposeAppframework.iosMainleaf: everyexpectactual (Darwin engine,arc4random_buf,NSTimeZone, CoreImage QR, UIKit image picker, Room native under Documents, capability flags), plus persistent SessionStore / AiSettings / ThemePreference andMainViewController.iosAppXcode project wiringComposeAppthrough the standard Gradle framework-embed run-script phase.Toolchain — Kotlin 2.1.20 / Compose 1.8.0 / KSP2
Room 2.7.2's iOS klibs are built with Kotlin 2.1.10, so the whole toolchain had to move to read them on native:
ksp.useKSP2=true, KSP 2.1.20-1.0.32) — KSP1 drops transitive klibs and crashes validating Room's annotations on native.commonMain — off JVM-only APIs
The Kotlin/Native compile surfaced ~90 JVM-only API uses in shared code that only ever compiled on the JVM. Ported to multiplatform equivalents:
String.format→Double.formatDecimals(+FormattingTest)ConcurrentHashMap→ConcurrentMap/ConcurrentSet(atomicfu-locked)java.util.Calendar/java.time.LocalDate→ kotlinx-datetimejava.io.File→ okio-backeddeleteFile/readFileBytes/createTempFileUriThread→GlobalScopecoroutine;System.getProperty(os.name)→isMacOsHostexpect/actual;System.identityHashCode→Any.hashCodeisTransientNetworkError; StoryCache LRU +synchronized→ manual LRU + atomicfu; surrogate-awarecodePointAtRelease readiness (App Store lint)
1024
AppIcon(alpha stripped),PrivacyInfo.xcprivacy(required-reason APIs),ITSAppUsesNonExemptEncryption=NO, iOSMARKETING_VERSION0.15.0, explicitio.nisfeb.talonbundle id. Review lint 🟢 green.Build infra
ios-compileCI job (macOS runner) archives viaxcodebuild— the exact TestFlight path — so release-only failures surface for free before spending a nomac build.GRADLE_USER_HOME(-Xmx6g), because the nomac/Codemagic builder pins a smallerorg.gradle.jvmargsin its ownGRADLE_USER_HOMEthat outranks the project file. Reported upstream (nomactkt_xqOTCEKSzcIecLiRQ2zyx); the run-script hack can drop once they raise the builder default.Follow-ups
QrCodeGenerator.ios.kt(CoreImage) compiles but was written without a device — verify the finder-pattern orientation by scanning a generated login QR (NOTE(port/ios)).🤖 Generated with Claude Code