Files
amethyst/commons/plans/2026-10-09-ios-app.md
Claude 9ca2259f76 refactor!: take crypto primitives from crypto-kmp
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
2026-10-20 23:01:13 +00:00

29 KiB
Raw Permalink Blame History

title, type, status, date, owner, consumers, supersedes
title type status date owner consumers supersedes
iOS app: the shared UI on iPhone and iPad, at feature parity with Android feature proposed 2026-10-09 commons commons, commonsUI, quartz, quic, nestsClient, new iosApp 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 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) 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 expects, 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 expects 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 AVPlayers.
  • 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.
  • 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).