# Android ↔ iOS parity What the iOS app does, what Android does, and what is left. Read from the sources listed, not from memory — every row names the iOS file it came from so it can be re-checked rather than trusted. **Standing rule: this file is the definition of done for the Android app.** A row moves to ✅ when the behaviour exists *and* something tests it, not when the screen renders. --- ## Where things stand | | iOS | Android | |---|---|---| | Protocol engine | `Sources/E2E`, 15 files | `engine`, 10 files | | App layer | ~9,700 lines, 45 files | accounts, contacts, chat, photos | | Tests | XCUITest + JS interop | 114 unit + 37 cross-engine + 15 on-device + live round trips | | Ships to | nobody (no signing team) | a debug APK that installs and runs | Android's engine is **verified against the web engine in both directions** (`engine/test/interop-kotlin.mjs`), which is a stronger claim than the iOS app could make for most of its history. What Android has no version of at all is everything above the protocol. --- ## Protocol engine | Feature | iOS | Android | Note | |---|:--:|:--:|---| | X25519, HKDF, AES-GCM | ✅ | ✅ | Android verified against RFC 5869 / NIST vectors | | Ed25519 | ✅ | ✅ | RFC 8032 vectors, on the JVM **and on device**; see the provider note below | | X3DH v1 | ✅ | ✅ | cross-engine verified | | Double Ratchet | ✅ | ✅ | cross-engine verified, including reordering and loss | | Safety numbers | ✅ | ✅ | cross-engine verified | | Transparency log (`KeyLog`) | ✅ | ✅ | canonical leaf + inclusion proofs, cross-engine verified | | Account KDF / fnid | ✅ | ✅ | both pinned to the published vector file | | Sealed sender | ✅ | ⚪ | `E2E/SealedSender.swift` | | Content envelopes (§6.6–§6.9) | ✅ | ✅ | attachments, replies, reactions, groups, calls — one codec | | Attachment encryption | ✅ | ✅ | verified end to end; relay holds only ciphertext | | Envelope + wire codec | ✅ | ✅ | cross-engine verified at the envelope level | | Session archive / restore | ✅ | ✅ | cross-engine verified, in-flight messages included | | Device identity + prekey rotation | ✅ | ✅ | retired prekeys retained past the relay window | | Vault (encryption at rest) | ✅ | ✅ | Android Keystore, AES-GCM, record name bound into the AAD | | ML-KEM-768 (PQ agreement) | ⚠️ | ⚪ | see below | | ML-DSA-65 (PQ identity) | ⚠️ | ⚪ | see below | ⚠️ **The post-quantum gap is not what it looks like.** iOS has `PQKEM.swift` and `PQSign.swift`, and they are inert: CryptoKit exposes ML-KEM only on iOS 26 and the app targets iOS 16, so no shipped iOS build does post-quantum anything. Android has no platform ML-KEM at any API level. So the two clients are *currently equal in effect* and the honest parity target is v1/v2 negotiation that degrades cleanly — which the Android engine already does, and the interop harness asserts. Closing this properly is a separate decision (implement, or take a vetted library) and it should be made once, for both clients. --- ## Accounts and identity From `Sources/Auth/*` and `Sources/Views/Sign*`. | Feature | Android | |---|:--:| | Sign up — local self-hosted | ✅ verified against `relay1` through the UI | | Sign up — managed / shared self-hosted | 🟡 built, not yet exercised end to end | | Sign in, session restore | ✅ | | Sign out (erases the vault key) | ✅ | | Password reset request + confirm | ✅ on the sign-in screen | | Change password | ✅ signs the device out, because that is what it does | | Delete account | ✅ confirms in place, erases the phone too | | Relay picker / `SiteConfig` equivalent | ⚪ | | Deterministic local-relay KDF salt | ⚪ | | Multi-homing (publish + read home records) | ⚪ | ⚠️ Sign-up is where the three deployment modes become visible to a user, and each derives its salt differently. Getting this wrong does not error — it derives a *different account* from the same passphrase. --- ## Messaging From `E2E/E2EInbox.swift`, which is the real feature list. | Feature | Android | |---|:--:| | Send / receive text | ✅ verified app ↔ separate program, through a real relay | | Inbox polling | ✅ every 5 s while open; no push channel exists | | Multi-device fan-out | ✅ one envelope per device, partial delivery reported | | History at rest | ✅ encrypted, keyed by person not device | | Session restore across restarts | ✅ credential and device both survive | | Reactions | 🟡 codec done, no UI | | Photos | ✅ send via the system picker, render inline | | Voice notes (record + play) | ⚪ | | Stories, and seen-state | ⚪ | | Groups: create, list, members, leave | ⚪ | | Group text and images | ⚪ | | Delivery keys + rotation | ⚪ | | Trust state, verification, identity-change acceptance | ⚪ | | Key-log enforcement and head gossip | ⚪ | | Clear thread | ✅ keeps the session, so the chat still works | | History at rest (encrypted, device-only key) | ⚪ | | Presence (read and set) | ✅ shown from the relay, never inferred | | Buddy list, add by username | ✅ | | Remove, block, unblock | ✅ | | Member lookup | ⚪ | | Buddy commands | ⚪ | | Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ | | Notifications list, read state | ✅ | | Services (AIM/ICQ via `flamenet-oscar`) | ⚪ | ⚠️ Calls need a WebRTC dependency on Android as they do on iOS. That is the one place parity forces a third-party library rather than a choice about one. --- ## Settings Every key in `Views/Settings/SettingsKeys.swift` has to exist, because each one is a promise the UI already makes: push on/off, per-kind notification toggles, in-app sounds, previews, read receipts, last-seen, online status, app lock and its grace period, send-on-return, timestamps, larger type, appearance, recent emoji, photo auto-download. App lock also needs a lock screen that covers **everything including sheets**, and obscures on backgrounding rather than only on return. --- ## Build order Each step is useless without the one before it, which is why they are in this order and not a nicer-looking one. 1. ~~**Session layer** — envelope codec, device identity, archive/restore.~~ ✅ Cross-engine verified at the envelope level, restarts included. 2. ~~**`:client`** — REST, relay auth, device registration, prekey upload, inbox polling, attachments, groups, transparency log.~~ ✅ 13 tests against a real HTTP server, plus an opt-in check against the deployed relay. 3. ~~**Vault** — Android Keystore, encryption at rest.~~ ✅ 4. ~~**`:app` scaffolding** — Compose, sign-in/up/out, on-device tests.~~ ✅ 5. ~~**Buddy list and chat.**~~ ✅ 6. **Voice notes, groups, stories, calls** — in that order. 7. **More instrumented tests** — the JVM tests prove nothing about Conscrypt. This engine has already been bitten once by exactly that. --- ## Two findings worth keeping **Android has no ordinary Ed25519 provider.** Measured on API 35, not assumed: `KeyPairGenerator.Ed25519` and `KeyFactory.Ed25519` are served by `AndroidKeyStore` and by nothing else, and Keystore only ever makes keys it keeps. Every Ed25519 key here is one we must hold — a device identity in our own vault, an account key derived from a passphrase — so the JCA route cannot work by construction. It compiled, passed 81 JVM tests, and threw `IllegalStateException: Not initialized` on a phone. The engine now uses BouncyCastle's `math.ec.rfc8032.Ed25519` directly: its one real dependency, and it is there because the platform leaves a hole. `ProviderProbeTest` records the measurement so nobody has to guess again. **XDH is fine** — Conscrypt serves it from API 33, which is why `minSdk` is 33. **The wire shapes were wrong three times, and each was silent.** `prekeys` not `opks` (relay answered 200 and stored nothing); the send body names the recipient once at the top, not per envelope (400, at least loud); the buddy list uses `id`/`presence`/`screen_name`, not `user_id`/`status`. None of these were findable without a real relay, which is why the live tests exist and why they are opt-in rather than absent. ## The CRUD rule **Every capability must be reachable from the UI, with its full create / read / update / delete surface.** Owner standing instruction, and it exists because features kept landing in `:client` with a live test proving them and no door for a person to walk through — remove a contact, block someone, clear a thread, change a password. `CrudSurfaceTest` walks the app and asserts the controls exist; it checks reachability rather than behaviour, because behaviour is what the live tests already cover and the door is what was missing. It found two real gaps on its first run: sign-out was reachable only from the contact list rather than from Account, and several controls sat below the fold. ## Known flakiness ⚠️ **The emulator's network drops occasionally** — seen twice as "Failed to connect" and "Software caused connection abort" on sign-up, with the relay reachable from the host at the same moment. A rerun succeeds. Worth knowing before treating a red instrumented run as a regression. ⚠️ **Instrumented tests share a process and a vault.** The Compose rule gives each test a fresh Activity, but the app spends its first moments reading the vault behind a spinner. A reset that checks for a signed-in control immediately finds nothing, concludes "already signed out", and then the session restores underneath it — so the suite passes one test at a time and fails as a set. Wait for the app to settle before deciding anything. ⚠️ **Two peer processes on one account race for the inbox.** The one that polls first takes the message and the other reports zero, which reads as "nothing was sent". Run a single `autoreply` at a time.