mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 15:48:36 +00:00
Quartz's SHA, HMAC, HKDF, AES, ChaCha20/Poly1305, X25519, Ed25519, digest and secure-random code moved to vitorpamplona/crypto-kmp, which quic-kmp now uses too, so quic no longer pulls in all of Quartz. Quartz depends on com.vitorpamplona.crypto:crypto 1.0.0 as api; every import moves to com.vitorpamplona.crypto.* with no shims. secp256k1 stays in Quartz. What stayed in Quartz or its callers: - NIP-44's 76-byte HKDF expansion (Hkdf.fastExpand, now MessageKey in nip44Encryption/crypto/Nip44MessageKey.kt). - AESGCM.copyUsingUTF8Nonce, the Blossom workaround, as a private extension in EncryptedBlobInterceptor. - NostrCipher is now crypto-kmp's SymmetricCipher. quic-kmp 2.0.0 drops its Quartz dependency and logs through QuicLog, so QuicLogBridge (commons jvmAndroid) routes those lines into Quartz's Log, installed next to Log.minLevel on Android, desktop and amy. Lines below Log.minLevel are skipped before their message is built. -PcryptoKmpDir=../crypto-kmp builds a local crypto-kmp checkout, like -PquicKmpDir does for quic. Needs crypto-kmp 1.0.0 and quic-kmp 2.0.0 on Maven Central before CI can resolve them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FiX3CsiEsvjJ7zHNHD3Kk
543 lines
29 KiB
Markdown
543 lines
29 KiB
Markdown
---
|
||
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).
|