Files
flamenet-messenger-android/PARITY.md
T
Paul HittandClaude Opus 5 e65d1d987b Android: photos, and a door for every capability
Owner feedback, and it was fair: capabilities kept landing in `:client` with a
live test proving them and no way for a person to use them. Remove a contact.
Block someone. Clear a thread. Change a password. Read a notification. All
built, none reachable.

So this is the CRUD pass, plus attachments.

New in the UI: contact actions (block, unblock, clear chat, remove), a presence
picker, a notifications screen with per-item and mark-all-read, an account
screen with display name, password change and account deletion, password reset
on the sign-in screen, bottom navigation, and photos -- sent through the system
picker, rendered inline.

`CrudSurfaceTest` walks the app and asserts the controls exist. It checks
reachability rather than behaviour, because behaviour is what the live tests
cover and the door is what was missing. It found two real gaps immediately:
sign-out was reachable only from the contact list rather than from Account,
where people look for it, and several controls sat below the fold.

Underneath: the §6.6-§6.9 content envelope, which is one codec for attachments,
replies, reactions, group routing and call signalling. 17 unit tests.

Two things it has to get right, and both are one-line mistakes:

`fnc` must serialise FIRST, because the marker is a prefix test on the bytes. A
JSON library that sorts keys puts `att` first, every envelope it produces reads
as plain text on the far side, and somebody's chat fills with raw JSON. The top
level is assembled by hand.

Control messages travel down the same channel as chat -- the web client sends
`delivery_key`, `kt_head` and `sent` copies this way. A client that renders
every envelope it does not recognise shows those to the user. They are now
parsed, marked unrenderable, and handed to the caller with their payload.

Attachments verified end to end against a real relay, including that the relay
holds only ciphertext. That path was 401 on iOS for months because the
attachment routes sent the account password, which the relay refuses on
`/e2e/…`; this one uses the device-scoped token like everything else, and the
test is what says so.

Also: the instrumented suite passed one test at a time and failed as a set. The
Compose rule gives each test a fresh Activity but the process and vault survive,
so the app spends its first moments reading the vault behind a spinner -- and a
reset that checks for a signed-in control immediately concludes "already signed
out" while the session restores underneath it.

114 JVM tests, 37 cross-engine, 15 on-device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:47:45 -04:00

206 lines
9.6 KiB
Markdown

# 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.