The app now holds a conversation. A message typed into it reaches a different
program through the real relay, that program answers, and the answer appears in
the thread -- verified three times, end to end, encrypted the whole way.
96 JVM tests, 37 cross-engine, 11 on-device, plus live round trips against the
throwaway fleet.
What landed: a fan-out send that reaches every device a person has, inbox
polling, message history encrypted at rest, contacts, presence, and a session
that survives a restart. Plus `:interop:peer` -- a Flamenet account you can
drive from a shell, so the correspondent on the other end is not this app
talking to itself.
Three wire shapes were wrong, and only a real relay could say so:
- `prekeys`, not `opks`. The relay answered 200 and stored nothing, so the
device was published and could not be started with.
- The send body names the recipient once at the top, not per envelope.
- The buddy list is `id`/`presence`/`screen_name`, not `user_id`/`status`.
The second and third at least failed loudly. The first did not, which is why
the live tests exist.
Decisions worth stating:
A send returns partial success rather than throwing. One of someone's three
devices being unreachable must not stop the message reaching the other two, and
the UI says "delivered to 2 of 3" rather than "sent".
The inbox watermark advances per message, after it is decrypted AND stored --
never once for the batch, which would lose everything after a crash. An
undecryptable message advances it too: it is never going to decrypt, and
leaving it would re-fail on every poll for ever.
A sent line is recorded only once a device took it. Writing it first produces a
thread showing messages the recipient never got, which is worse than an error --
the sender believes they said something.
Opening a thread marks it read locally and tells the relay nothing. A read
receipt is a disclosure, and this product does not make one by default.
History is keyed by person, not device. Someone's phone and tablet are one
conversation, and filing by device would split it into threads each holding
half the story.
Also improves how instrumented failures report: the assertion now prints what
the screen was showing. That turned a bare 90-second timeout into "No value for
user_id" and then "Failed to connect to relay1" -- one a bug, one the emulator's
network, and previously indistinguishable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.1 KiB
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 |
| Tests | XCUITest + JS interop | 96 unit + 37 cross-engine + 11 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 |
| 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 |
| Attachment encryption | ✅ | ⚪ | E2E/E2EAttachment.swift |
| 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 | ⚪ |
| Change password | ⚪ |
| Delete account | ⚪ |
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 | ⚪ |
| Photos | ⚪ |
| 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 / all threads / caches | ⚪ |
| History at rest (encrypted, device-only key) | ⚪ |
| Presence (read) | ✅ shown from the relay, never inferred |
| Buddy list, add by username | ✅ |
| Remove, block | ⚪ built in :client, no UI |
| 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.
Session layer — envelope codec, device identity, archive/restore.✅ Cross-engine verified at the envelope level, restarts included.✅ 13 tests against a real HTTP server, plus an opt-in check against the deployed relay.:client— REST, relay auth, device registration, prekey upload, inbox polling, attachments, groups, transparency log.Vault — Android Keystore, encryption at rest.✅✅:appscaffolding — Compose, sign-in/up/out, on-device tests.Buddy list and chat.✅- Attachments, voice notes, groups, stories, calls — in that order.
- 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.
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.
⚠️ 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.