---
title: "iOS app: the shared UI on iPhone and iPad, at feature parity with Android"
type: feature
status: proposed
date: 2026-10-09
owner: commons
consumers: commons, commonsUI, quartz, quic, nestsClient, new iosApp
supersedes: amethyst/plans/2026-05-24-ios-support.md
---

# iOS app at feature parity with Android

## Goal

Ship Amethyst on iPhone and iPad with **every feature Android has**. Each feature either works
the same way or gets the closest design iOS allows (see "What iOS does not allow").

The iOS app is a **third shim** over the one shared UI, built the same way as the new desktop app
(`desktopApp/…/desktop/app/`, see
[2026-09-27-one-ui-android-desktop.md](2026-09-27-one-ui-android-desktop.md) step 8). It renders
`AmethystApp` from `commonsUI` and fills the same ports. It has **no screens of its own**.

This plan replaces `amethyst/plans/2026-05-24-ios-support.md`. That plan predates One-UI: it
proposed a native UIKit tab bar around shared content, and it stopped at "read/write minus audio
rooms".

Part A builds groundwork that is not specific to iOS. The web plan
([2026-10-09-web-app.md](2026-10-09-web-app.md)) reuses it unchanged, so Part A is written for
both targets.

## Where we start (measured 2026-10-09 on `main` @ `08885ee8`)

**Done:**
- `quartz`, `commons` and `commonsUI` declare `iosArm64` + `iosSimulatorArm64`.
- CI runs `:quartz` tests on the iOS simulator (`test-quartz-ios`).
- The lint job runs `verifyKmpPurity` and the `commonMain` metadata compile for all three modules.
- `commonMain` is free of JVM-only APIs. That covers about 820k lines: `quartz` 286k, `commons`
  195k, `commonsUI` 339k.
- The app root is shared:
  - `LocalCache` and `Account` are in `commons/commonMain`.
  - `AccountViewModel`, `AmethystApp`, the navigation shell (`AppShellLayout`) and 238 of the
    261 destinations are in `commonsUI/commonMain`.
  - Navigation 3 publishes iOS variants.

**Compile-only, so nothing runs yet:**
- **No app target.** No Xcode project and no framework binary.
- **No networking.** There is no relay socket on iOS: `WebsocketBuilder` has only OkHttp
  implementations, in `jvmAndroid`.
- **No HTTP client.** `IRoleBasedHttpClientBuilder` is an empty `actual interface` on iOS.
- **No key storage.** `SecureKeyStorage.ios.kt` throws "Keychain Services binding pending" on
  every call.
- **Stubbed platform pieces.** `encodeQrMatrix` returns `null`, `canCaptureFromCamera = false`,
  and several pickers are stubs.

**What the desktop shim needs that is still JVM-only** (the graph a shim builds). These are
`commons/jvmAndroid` classes that `desktopApp/…/desktop/app/` imports:
- `AccountCacheState`, `StoreAccountSessionStore`, `SecureKeyStorageVault`
- `RoleBasedHttpClientBuilder`, `DualHttpClientManager(ForRelays)`, `OkHttpWebSocket`
- `CacheClientConnector`, `RelayProxyClientConnector`, `NotifyCoordinator`
- `Nip11CachedRetriever`, `Nip86Retriever`, `NamecoinServices`
- `PoWJobStore` / `PowJobRestorer`, `BlossomClient`, `ImageDownloader`, `ServerInfoRetriever`
- the crash-report classes
- the Marmot encrypted stores
- `AccountsTorStateConnector`, `TorRelayState`

Why they are JVM-only, counting files across the 127 in `commons/jvmAndroid`:

| Reason | Files |
|---|---|
| OkHttp | 45 |
| `java.util.concurrent` | 18 |
| `java.net` | 16 |
| `java.io.File` / `java.nio` | 15 |
| `java.text` / `java.time` / `Locale` | 13 |

**Screens that only exist on JVM/Android today:**
- Registered by the Android app, `amethyst/…/ui/navigation/AppNavigation.kt`: `MyFitness`,
  `WebApp`, `NostrApp`, `ConnectedAppDetail`, `NewHlsVideo`, `ScanQrImage`,
  `NowPlayingSettings`, `ResourceUsage`, `UpdateReactionType`, `ShareNoteAsImage(File)`.
- In `commonsUI/jvmAndroid` (`JvmDestinations.kt`): the Git repository screens and
  `CordnGroupChat`. Both depend on the richtext markdown fork.
- Also in `commonsUI/jvmAndroid`: the audio-room (nests) screens, the PDF viewer and the
  fitness dashboard.

## Reuse rules for this plan

1. **No iOS screens.** A missing screen moves to `commonsUI` (One-UI step 6). It does not get
   rewritten for iOS.
2. **Shared before Apple-specific.**
   - Code that needs no Apple API goes in a source set shared with web (`nonJvmMain`, below),
     never in `iosMain`.
   - Only Keychain, AVFoundation, UIKit and other Apple frameworks go in `iosMain`.
3. **JVM/Android keep their stack.**
   - OkHttp (Tor routing, `SurgeDns`, connection tuning), Jackson and the JNI secp256k1 stay on
     JVM. Hot paths must not regress.
   - The new implementations sit beside them behind the interfaces that already exist.
4. **Kotlin first, Swift only where Apple requires it.** Swift is used for:
   - the `@main` entry point;
   - app extensions (Notification Service, Share, Broadcast Upload): thin wrappers that call the
     Kotlin framework;
   - SDKs that only ship as Swift packages (WebRTC, Google Cast): bridged through a Kotlin
     interface that Swift implements and hands to the framework at startup.

   Everything else uses `cinterop` to Apple frameworks from `iosMain`.

## Target source-set layout

```
commonMain
├── jvmAndroid ── jvmMain, androidMain        OkHttp, Jackson, JCA, JNI secp256k1
└── nonJvmMain                                 Ktor client, pure-Kotlin actuals   (NEW, shared with web)
    ├── nativeMain ── appleMain ── iosMain     cinterop: Keychain, CryptoKit, AVFoundation, UIKit
    └── wasmJsMain                             browser interop                    (web plan)

skikoMain  = jvmMain + iosMain (+ wasmJsMain, web plan)   commonsUI only, exists today
```

`quartz` already has `nativeMain → appleMain → iosMain` (plus `linuxMain`, `macosMain`).
`nonJvmMain` slots in above `nativeMain`.

## Part A — Groundwork shared with the web app

### A1. Add `nonJvmMain` and move the reusable native code into it

- Create `nonJvmMain` in `quartz`, `commons` and `commonsUI`. It depends on `commonMain`, and
  `nativeMain`/`iosMain` depend on it.
- Move the **30 of 41** `quartz` `nativeMain`/`appleMain` files that use no `platform.*` or
  `cinterop` (checked 2026-10-09):
  - AES-GCM/CBC
  - Ed25519/X25519
  - `OptimizedJsonMapper`, `EventHasherSerializer`, `EventInterner`
  - `LargeCache`, `ConcurrentHashCache`, `ConcurrentMap/Set`, `StripedHashMap`
  - `UBigInt`, `BitSet`, `UrlEncoder`
  - `ScratchLocal`, `MultiplyHigh`, `FieldMulPlatform`
  - `ChessEngine`, `Address`, `CertificateSignature`, `MacInstance`, `DigestInstance`
  - the buffer helpers

  Their only non-trivial import is `kotlin.concurrent.atomics`, which is common in Kotlin 2.4.
  Their crypto comes from crypto-kmp (`com.vitorpamplona.crypto.*`, pure Kotlin off the JVM), so
  it carries to web once crypto-kmp has a wasm target; see the web plan, W1.
- Extend `gradle/kmp-purity.gradle.kts` to scan `src/nonJvmMain` and `src/nonJvmTest`.

### A2. HTTP and WebSockets on Ktor for non-JVM targets

- Add Ktor client to `nonJvmMain`:
  - `ktor-client-core` and `ktor-client-websockets`;
  - the `ktor-client-darwin` engine in `iosMain` and the `ktor-client-js` engine in `wasmJsMain`.
  - License: Apache-2.0. Checked in the `ktor-client-core` 3.6.0 POM on 2026-10-09; recheck
    each new artifact's POM when adding it.
  - Ktor 3.6.0 is already the catalog version (used by `geode`).
- Write one Ktor implementation per transport interface that already exists in `commonMain`
  with an OkHttp implementation:

  | Interface | OkHttp implementation today |
  |---|---|
  | `WebSocket` / `WebsocketBuilder` | `BasicOkHttpWebSocket`, `OkHttpWebSocket` |
  | `HttpRelayTransport` | `OkHttpRelayTransport` |
  | `MintHttpTransport` | `OkHttpMintTransport` |
  | `LnurlHttpTransport`, `LnurlEndpointResolver` | `OkHttpLnurl*` |
  | `Nip05Fetcher` | `OkHttpNip05Fetcher` |
  | `BitcoinExplorer` | `OkHttpBitcoinExplorer` |
  | `RemoteCalendar`, `OtsResolverBuilder` | `OkHttpCalendar`, `OkHttpOtsResolverBuilder` |
  | `TrustProviderHttp` | `OkHttpTrustProviderHttp` |

  The same code runs on iOS and web; only the engine differs.
- Give `IRoleBasedHttpClientBuilder` a real `nonJvmMain` actual. It owns one Ktor `HttpClient`
  per role:
  - relays, money, media, previews;
  - each role later gains Tor routing on iOS (B12).
- Images use `coil-network-ktor3` (iOS + wasm variants published) in `commonsUI/nonJvmMain`.
- Exit criteria:
  - an `iosSimulatorArm64Test` connects to a local `geode`, sends a REQ and receives events;
  - the same test runs on JVM against OkHttp as a parity check.

### A3. Make the shim's graph JVM-free

Move each `commons/jvmAndroid` class listed under "Where we start" to `commonMain`. Use the same
recipe One-UI used for `Account` (cut each seam, then move), and measure first with the closure
method in the One-UI plan ("Method").

Typical cuts:
- `java.io.File` → okio `Path` on `platformFileSystem`.
- `ConcurrentHashMap` → quartz `ConcurrentMap` / `ConcurrentHashCache`.
- `OkHttpClient` constructor parameters → the A2 interfaces, or `IRoleBasedHttpClientBuilder`.
- `java.text` / `java.time` → the `commons` date and number `expect`s, which already have iOS
  actuals.
- `synchronized` → `KmpLock`.

**`AccountCacheState` comes first.** Every shim builds accounts through it. Its blockers:
- OkHttp, `File` and `ConcurrentHashMap`;
- six Marmot stores in `jvmAndroid`;
- `OkHttpMintTransport`.

**`UploadOrchestrator`, `MediaCompressor` and `MediaMetadata` are in `commons/jvmMain`.**
- Split the orchestration, which is platform-free, from the codecs. Image and video re-encode
  becomes a `MediaTranscoder` port per platform.

**Exit criteria:**
- `desktopApp/…/desktop/app/` imports no `commons/jvmAndroid` class except the OkHttp transport
  implementations it chooses to inject.
- A `commons` iOS test builds an `Account` from a stored nsec and loads its home feed against
  `geode`.

### A4. Finish moving the last JVM-only screens into `commonMain`

- **Markdown.** `RenderMarkdown`, `RenderContentAsMarkdown` and `MarkdownMediaRenderer` use the
  JitPack fork `com.github.vitorpamplona.compose-richtext`, which is JVM/Android-only. That
  blocks the Git screens, `CordnGroupChat` and long-form articles on iOS and web.
  **Decided 2026-10-09:** the maintainer publishes iOS and wasm targets from the fork, so the
  renderer behaves the same everywhere. These files then move to `commonMain` unchanged.
- **The Android-registered destinations.** Move them to `commonsUI` with their screens, behind
  slots or `expect`s where they touch Android:
  - Pure UI: `UpdateReactionType`, `ResourceUsage` (the data source becomes a port),
    `NowPlayingSettings`, `ConnectedAppDetail`.
  - Need new ports: `ShareNoteAsImage` (rendering to a bitmap needs a "save/share image" port),
    `ScanQrImage` (a "decode QR from image" port), `NewHlsVideo` (the `MediaTranscoder` from A3),
    `MyFitness` (a health-data port), `WebApp` / `NostrApp` (a web-view port; see B9).
- **QR encoding.** `encodeQrMatrix` uses ZXing on JVM and returns `null` on iOS. Write one
  pure-Kotlin QR encoder in `commonMain` and use it on every platform. That deletes an `expect`,
  and ZXing stays only where Android scans codes.
- **Nests screens.** Move them from `commonsUI/jvmAndroid` to `commonMain`, behind the existing
  `NestViewModel` interface. Audio itself is B11.
- **PDF.** `PdfFetcher` and `PdfPageRenderer` become a `PdfRenderer` port: PDFKit on iOS, PDFBox
  on desktop, Android's `PdfRenderer` on Android.

### A5. Coroutines: one IO dispatcher name for every target

- Kotlin/Native has `Dispatchers.IO`, so iOS needs no change.
- Web does not have it, so web plan W1 owns this change. Do it during Part A anyway: it is a
  single mechanical PR, and doing it early keeps iOS and web on the same `commonMain`.

## Part B — The iOS app

### B1. Build and app skeleton

- **The shim module.** Add a Gradle KMP module `:iosApp` with `iosArm64` and
  `iosSimulatorArm64`.
  - `src/iosMain` is the shim. It mirrors `desktopApp/…/desktop/app/`: `IosAppModules`,
    `IosAppRoot`, `IosAppServices`, `IosAppPlatform`, `IosAccountViewModelHost`,
    `IosLocalCacheHost`, `IosNotePlatform`, `IosRichTextPlatform`.
  - It produces a static framework (`AmethystKit`) and exports
    `fun MainViewController(): UIViewController`, which wraps `ComposeUIViewController { AmethystApp(…) }`.
- **The Xcode project.** It lives at `iosApp/xcode/`. A Swift `@main` hosts the controller.
  - Build through `embedAndSignAppleFrameworkForXcode`.
  - No CocoaPods. Swift-package SDKs are added in Xcode.
- **App lifecycle.** Scene phase → the app's lifecycle owner, so the `AccountViewModel`
  always-on subscriptions pause and resume the way they do on Android.
- **CI.**
  - A macOS job builds `:iosApp` for the simulator and runs `commons` + `commonsUI`
    `iosSimulatorArm64Test` beside the existing `test-quartz-ios`.
  - It also builds the Xcode project unsigned.

### B2. Keys, accounts, biometrics

- **`SecureKeyStorage` on Keychain.**
  - Uses `kSecClassGenericPassword` with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`.
  - The Keychain access group is shared with the Notification Service Extension (B6).
  - `StoreAccountSessionStore` (shared after A3) keeps the roster through DataStore on the app
    container's okio path.
- **`DeviceAuthenticator`** uses LocalAuthentication (Face ID / Touch ID).
- **`HideFromScreenshots`** uses the secure-text-field layer trick. It blanks the content in
  screenshots and recordings.

### B3. First vertical slice and the performance gate

- **Scope.**
  - Login: nsec, `ncryptsec`, read-only npub, and NIP-46 bunker (set
    `AppPlatform.supportsRemoteSignerLogin = true`, like desktop).
  - Home, profiles, threads, notifications, search and the composer.
  - Reactions, reposts and DMs (NIP-17).
  - Zaps: LNURL and NWC in-app; external wallets through `lightning:` URLs via
    `WalletAppLauncher`.
- **Performance gate. Do not start B4 until it passes.**
  - Load a realistic `LocalCache` of 10k+ notes and 2k+ users from a recorded corpus, so the
    numbers can be re-run.
  - Measure on a mid-range iPhone:
    - feed scroll frame time (Compose iOS);
    - event ingest throughput: parse + verify + index, where the pure-Kotlin JSON mapper and
      native secp256k1 replace Jackson and the JNI library;
    - steady-state memory, against iOS jetsam limits;
    - Kotlin/Native GC pause times.
  - Compare against Android on similar hardware. Fixes for gaps go into shared code, so
    everyone benefits. Examples:
    - a faster `OptimizedJsonMapper` in `nonJvmMain`;
    - caching that bounds `LocalCache` growth (`LargeSoftCache.ios.kt` exists).

### B4. Note media and playback (largest single area)

Android's `service/playback` is 63 files and 9.1k lines on media3.

- **Player.** `IosNotePlatform.VideoView` / `FullscreenVideoView` / audio renderers use
  `AVPlayer` in a `UIKitView`.
- **Player pool.** Mirrors the Android pool's limits, so a feed never holds more than N
  `AVPlayer`s.
- **Formats.** HLS is native.
- **Background audio.** Uses the `AVAudioSession` playback category, the `audio` background
  mode, and `MPNowPlayingInfoCenter` + `MPRemoteCommandCenter` (the Android `nowPlaying` service).
- **Picture-in-picture.** `AVPictureInPictureController` on the player layer.
- **Video disk cache.** `AVAssetResourceLoaderDelegate` serving from the shared Blossom/disk
  cache, or prefetch with `AVAssetDownloadTask`.
- **GIFs and animated images.** Coil animated decoding, if the iOS decoder supports it. If not,
  frame extraction with ImageIO in `AnimatedImageAutoPlay.ios`.
- **Casting.** AirPlay through `AVRoutePickerView`; Google Cast in B13.

### B5. Media capture and uploads

- **Pickers.**
  - `GallerySelect` / `GallerySelectSingle` → `PHPickerViewController`.
  - `FileSelect` / `DocumentSelectSingle` → `UIDocumentPickerViewController`.
- **Camera.** `TakePicture` / `TakeVideo` → `UIImagePickerController`, or an AVCapture screen
  for in-app capture. `canCaptureFromCamera = true`.
- **`MediaTranscoder` port (A3).**
  - Images: ImageIO re-encode with EXIF/GPS stripping.
  - Video: `AVAssetExportSession` presets.
  - HLS for `NewHlsVideo`: `AVAssetWriter` segmenting.
- **Uploads.** Blossom, NIP-96 and NIP-95 run through the shared orchestrator (A3) on the A2
  transports.
- **Microphone.** `RecordVoiceButton` / `RecordAudioBox` use `AVAudioRecorder`, with Opus or
  AAC to match Android's format.

### B6. Notifications

- **What changes.** Android keeps sockets open to the inbox and DM relays
  (`NotificationRelayService`, see `PULL_NOTIFICATION.md`). iOS cannot run that. iOS uses
  server push only.
- **Server work (outside this repo).** `push.amethyst.social` sends to Apple's APNs directly
  (decided 2026-10-09), not through FCM. It needs the APNs auth key (`.p8`) and both push
  types: `alert` for notifications and `voip` for calls (B10).
  - Registration reuses `RegisterAccounts.kt`'s payload with an APNs token.
  - Document the coverage loss: relays the push server does not watch are only caught when the
    app opens.
- **Notification Service Extension (Swift wrapper + `AmethystKit`).**
  - Decrypts NIP-59 gift wraps with the key from the shared Keychain group.
  - Enriches with names and pictures from a small shared cache.
  - Must fit the extension's memory and time budget, so it uses the Kotlin event parser
    without `LocalCache`.
  - Extract the parts of `PushWrapDecryptor`, `NotificationEnricher`,
    `EventNotificationConsumer` and the renderers that are not Android-specific into `commons`
    first. Android then uses the same code.
- **Actions.**
  - Inline reply → `UNTextInputNotificationAction` (Android's `NotificationReplyReceiver`).
  - Conversation grouping → thread identifiers (Android's `ConversationShortcuts`).
- **Catch-up.** `BGAppRefreshTask` runs the logic of `NotificationCatchUpWorker`. Timing is
  best effort.
- **NWC payment notifications.** `NwcPaymentNotificationWatcher` needs a live socket, so it
  works while the app is open and through push otherwise.

### B7. Links, sharing, clipboard

- **Incoming links.**
  - Register the `nostr:` URL scheme.
  - Universal links for the web app's domain, once the web plan picks one.
  - Both route through the same intent router `AndroidAppRoot` uses. Move its parsing to
    `commons`.
- **Share extension.** It hands text, images and video to the app through an App Group
  container. `OnIncomingShare` actual → the `ShareTarget` routing (new post, DM, highlight,
  picture, short, video, scan QR).
- **Outgoing share.** `UIActivityViewController` (`TextSharer`, `FileSharer`). `ShareNoteAsImage`
  renders through the A4 port.
- **Clipboard.** `UIPasteboard`. Sensitive text uses local-only, expiring items.

### B8. Signers and connected apps

- **NIP-55.** There is no equivalent: no intents. The `ExternalSignerLoginButton` slot stays
  empty, and onboarding points to NIP-46.
- **Amethyst as a bunker for other apps (`connectedApps`).**
  - It serves requests while the app is in the foreground.
  - Optionally, a push from the bunker relay wakes the app for a consent notification. This
    needs server support; decide in B8.
  - The consent UI is shared (`connectedApps/consent` moves with A4).

### B9. In-app browser, napplets and nSites

- **In-app browser with NIP-07.** Android's browser (`nappletHost/…/NappletBrowserActivity`,
  `NappletBrowserService`) injects a `window.nostr` shim into every page and frame
  (`addDocumentStartJavaScript` for all origins). The shim talks to the broker through
  `addWebMessageListener`. iOS has the same capability:
  - `WKUserScript` at `.atDocumentStart` with `forMainFrameOnly = false` injects the shim
    (`BrowserWebTools.browserStartScript` moves to `commons`, so both platforms inject the same
    JS);
  - `WKScriptMessageHandlerWithReply` answers the calls, and the message's `frameInfo`
    identifies the requesting origin.

  Arbitrary websites therefore get NIP-07 on iOS just as on Android.
- **Shared broker.** The protocol and broker already live in `commons/…/napplet/`
  (`NappletBroker`, capabilities, held requests). Keep the broker shared.
- **iOS sandbox.** iOS replaces Android's `:napplet` process and Messenger IPC with `WKWebView`.
  Its web content already runs out of process.
  - `WKScriptMessageHandler` carries the bridge.
  - `WKURLSchemeHandler` serves the nSite and napplet files, like `nappletHost`'s content server.
- **What moves out of `nappletHost`.** The host-side logic that is not WebView-specific moves to
  `commons`, so Android and iOS share it.
- **App Store.** Review against guideline 4.7 (HTML5 mini-apps): they must not expose native
  platform APIs. Signing and relay access are app features, not native APIs, but write the case
  down before submission.

### B10. Calls (WebRTC)

- **WebRTC.** The iOS build of WebRTC comes as a Swift package. Check its license (the upstream
  WebRTC license is BSD-3-Clause). It sits behind a Kotlin `CallMediaEngine` interface that
  Swift implements.
- **Shared call logic.** Signalling and state, from `WebRtcCallSession` and
  `WebRtcPeerSessionAdapter`, move to `commons` so both platforms share them.
- **CallKit** for the system call UI and audio routing. Incoming calls arrive as APNs `voip`
  pushes from `push.amethyst.social` (B6).
- **Incoming calls while the app is closed.** These need PushKit VoIP pushes, which the push
  server must send (B6). Apple requires every VoIP push to report a CallKit call.
- **Screen share.** A ReplayKit Broadcast Upload Extension.

### B11. Audio rooms (`quic` + `nestsClient`)

- **`:quic` iOS actuals.**
  - UDP: `NWConnection` (Network.framework) or POSIX sockets via `cinterop`.
  - AEAD: AES-GCM and ChaCha20-Poly1305 from crypto-kmp's pure-Kotlin code
    (`com.vitorpamplona.crypto.aes`, `.chacha`), which is constant-time but slower than hardware AES, so measure it
    on the packet path; else CryptoKit through a small Swift wrapper.
  - The TLS state machine and codecs are already in `commonMain`.
- **`:nestsClient` iOS actuals.**
  - Opus encode/decode: AudioToolbox if it handles Opus well enough, else libopus
    (BSD-3-Clause) through `cinterop`.
  - Capture and playback with `AVAudioEngine`.
- **Order.** The moq-lite listener path first, then the speaker path. Same order as the
  production Android path.
- **Background audio.** Uses the `audio` background mode, with an `AVAudioSession` audit before
  submission.

### B12. Tor

- **Library.** `kmp-tor` runtime 2.6.0 and `resource-noexec-tor` 409.5.0 publish `ios_arm64`
  and `ios_simulator_arm64` variants (checked in their Gradle metadata on 2026-10-09). On iOS,
  Tor runs in-process, because iOS forbids starting a separate executable.
- **Open question: routing traffic through it.** Can a Ktor engine on iOS send traffic through
  Tor's SOCKS port?
  - `NSURLSession` (Darwin engine): SOCKS support on iOS is the question. Check the iOS 17
    `proxyConfigurations` API.
  - CIO engine: no SOCKS support.

  Spike it at the start of B12. Without it, Tor on iOS covers only what can be routed.
- **The rest is shared.** The relay-evaluation and per-role routing logic (`TorRelayState`,
  `torEvaluatorFlow`) is shared after A3.

### B13. Remaining platform features

| Android | iOS |
|---|---|
| Google Cast (`play` flavour) | Google Cast iOS SDK (Swift package) behind `AppPlatform.isCastingAvailable` |
| TTS (`service/tts`) | `AVSpeechSynthesizer` |
| Location, geohash, "around me" | CoreLocation (`rememberCoarseLocationPermission`, `LocationPreviewMap`, `LoadCityName`) |
| Calendar (`service/calendar`, `rememberPhoneCalendar`) | EventKit |
| Workouts / Health Connect | HealthKit, behind the A4 health port |
| ML Kit translation + language ID (`play`) | Apple Translation framework + NaturalLanguage, behind `TranslationPlatform` |
| ML Kit GenAI proofread/rewrite/describe (`play`) | Apple Foundation Models on devices that have it; hidden otherwise |
| PoW (`service/pow`) | Runs in the foreground; `BGProcessingTask` resumes it, best effort |
| Scheduled posts (WorkManager) | **Hidden on iOS** (decided 2026-10-09). `AccountViewModelHost.scheduledPostStore` stays `null`, which `ShortNotePostViewModel` already treats as "no scheduling". A new `AppPlatform.supportsScheduledPosts = false` hides the drawer entry, the account-sheet count and the scheduled-posts screen; `AppServices.scheduledPostStore` gets an empty implementation |
| Resource usage / battery ledger | Memory and network counters only; iOS exposes no per-app battery figures |
| Connectivity callbacks | `NWPathMonitor` |
| `KeepScreenBrightAndAwake` | `UIApplication.isIdleTimerDisabled` |
| QR scanner (`QrCodeScannerDialog`) | `AVCaptureMetadataOutput` |
| `phonePrefersMiles`, languages, number and date formats | Already have iOS actuals; verify output against Android |

### B14. iPad and Mac

- The shared `ScreenLayoutSpec` already picks the rail or the permanent drawer on wide
  windows, and docks notifications at 1200dp+.
- iPad adds:
  - hardware-keyboard shortcuts, reusing the desktop shortcut table once it is shared;
  - pointer hover;
  - Stage Manager window sizes.
- Mac: ship the iPad build as "Designed for iPad" on Apple silicon at no extra cost. No
  Catalyst target; desktop stays the JVM app.

### B15. Release

- **App Store review checklist.** Prepare it before building the matching features:
  - **Two separate payment rules.** Quoted from Apple's guidelines page, read 2026-10-09:
    - **3.2.1(vii), gifts. Applies to every zap, on any rail.** *"Apps may enable individual
      users to give a monetary gift to another individual without using in-app purchase,
      provided that (a) the gift is a completely optional choice by the giver, and (b) 100% of
      the funds go to the receiver of the gift. However, a gift that is connected to or
      associated at any point in time with receiving digital content or services must use
      in-app purchase."* This is what Apple raised with Damus in 2023: a zap on a post can be
      read as paying for content. Zaps are optional and the recipient gets 100%, but they are
      attached to notes. Prepare the rationale, and a per-region flag for zaps on content as a
      fallback.
    - **3.1.5(i), wallets. Applies to features where the app holds the funds.** *"Apps may
      facilitate virtual currency storage, provided they are offered by developers enrolled as
      an organization."* This covers:
      - the NIP-60 Cashu wallet: the app holds the ecash proofs;
      - the NIP-BC on-chain wallet (`OnchainWalletState`): one Taproot address derived from the
        account key, with the app signing the spends.

      It does not cover LNURL zaps paid by an external wallet, or NWC: there the funds stay in
      the user's own wallet and the app only sends payment requests. Under an individual
      account, ship with both wallets hidden.
  - **Napplets** (4.7), see B9.
  - **User-generated content** (1.2): a EULA at sign-up, plus report, block and mute that are
    easy to find. They exist; check the iOS onboarding shows them.
  - **Privacy manifest** (`NSPrivacyAccessedAPITypes`) and the encryption export declaration.
- **Distribution.** TestFlight internal → external → App Store.
- **Localization.** Comes from Compose resources. Add `InfoPlist.strings` for the permission
  prompts.

## What iOS does not allow, and the design used instead

| Android feature | iOS limit | Design here |
|---|---|---|
| Always-on notification service | No background sockets | APNs through the push server + Notification Service Extension (B6) |
| NIP-55 signer apps; Amethyst signing for other apps in the background | No intents; no background serving | NIP-46 both ways, foreground-served (B8) |
| Scheduled posts at an exact time | Background tasks run when iOS decides | Hidden on iOS (B13) |
| Incoming calls while closed | Needs CallKit + PushKit | VoIP push from the push server (B10) |

## Order and gates

1. **A1 → A2 → A3**, plus B1 and B2 in parallel once A2 lands.
2. **B3 slice. Gate:** the performance numbers meet the bar the maintainer sets.
3. **A4 and A5** run alongside B4–B7.
4. **B4 → B5 → B6 → B7**: media, uploads, notifications, links. After these, the app can go to
   TestFlight for daily use.
5. **B8 → B9 → B10 → B11 → B12 → B13**, in any order the maintainer prefers. These are
   independent areas.
6. **B14, B15.**

## Risks

- **Kotlin/Native performance and memory** with a large `LocalCache` (the B3 gate). Fixes land
  in shared code.
- **Compose iOS feel.** Text selection, IME behaviour, scroll physics and VoiceOver. Test on
  devices from B3 on. Report gaps upstream rather than forking.
- **Push server work is outside this repo** and blocks B6 and B10.
- **App Store review** (B15). Zaps on content may need a per-region flag (3.2.1(vii)); the
  Cashu and on-chain wallets need an organization account (3.1.5(i)).
- **Tor routing on iOS** may be partial (B12).

## Open questions

- An organization or individual Apple developer account? This decides whether the Cashu and
  on-chain wallets can ship (B15).

Decided 2026-10-09:
- push goes to APNs directly (B6);
- the richtext fork gains iOS and wasm targets (A4);
- scheduled posts are hidden on iOS (B13).
