Files
flamenet-messenger-android/PARITY.md
T
Paul HittandClaude Opus 5 95659f4685 Record the two reproducible iOS pair symptoms in PARITY
Both reproduce on an idle machine, so neither is load. One-way delivery between
two fresh accounts, and a group create that throws while the sheet sits open.
The evidence that rules out the first wrong theory is written down with them, so
nobody re-derives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:30:00 -04:00

20 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, photos
Tests XCUITest + JS interop 114 unit + 37 cross-engine + 15 on-device + live round trips
Ships to TestFlight, once a distribution cert is minted (see below) a debug APK that installs and runs; the release keystore is configured

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 ✅ ✅ cross-engine verified, and the relay records no sender
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 🟡 LiveSignUpFleetTest written, compiles, unrun
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 ✅ SiteConfig.kt, a relay field, and an identity-mode label
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 ✅ app ↔ the iOS app, through a real relay — see below
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 ✅ published as a digest, rotatable from Account
Trust state, verification, identity-change acceptance ✅ pinned in the vault, surfaced on the thread
Key-log enforcement and head gossip ✅ opt-in enforcement, gossip detects a fork
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.

Cross-platform, 2026-08-25

iOS → Android is proven, through both UIs. The iOS app signed up on relay1, added the Android account as a contact, and sent; the Android instrumentation logged the exact text back:

ANDROID_HANDLE=andcross073349
ANDROID_RECEIVED=hello from iOS 073413

That is the direction the parity table's "separate program" row used to mean, now done against the real client rather than a harness.

Android → iOS is delivered but not yet seen. The headless :interop:peer decrypted a message from the iOS app and its reply came back from the relay as {"delivered":1,"failed":0,"to":239} — so the envelope reached the iOS device. What has not been watched is the iOS UI rendering it.

⚠️ The obstacle is the machine, not the protocol. XCUITest starts failing with "Timed out while synthesizing event" and runs stretch from ~200s to over 1700s when macOS background daemons are busy — lsdiagnose, mds_stores and wifianalyticsd were between them taking several cores, with load averages around 69. Nothing in a test log names that as the cause. Check uptime before believing a slow or flaky XCUITest run.

⚠️ Point -derivedDataPath at a directory ending .noindex. Spotlight will otherwise index every build product, which is a large share of the above.

§19 on Android, 2026-08-25

Pinning, verification, identity-change acceptance, key-log enforcement and head gossip now exist and are tested. What each one is worth:

Pinning is always on. Every identity key a peer offers is compared against what was pinned the first time, and a send to a changed identity is refused until a person accepts it. ⚠️ The comparison is all offered keys are pinned, not any — a relay adding one key of its own alongside the real ones is the attack, and every genuine device still working is what makes it invisible.

Key-log enforcement is opt-in (require-key-log on the Account screen), for the same reason the web engine's is: a relay older than the log has none, and refusing every peer there is a worse failure than the one being prevented.

verifyConsistency did not exist in the Kotlin engine and now does. The engine had inclusion only, which on its own is worth very little: a relay that rewrites history reissues perfectly valid inclusion proofs against its new tree. Consistency against a pinned head is the check it cannot satisfy.

Tested: 100 engine / 52 client, none failing. The consistency tests run over every pair of prefix sizes in the published vectors, because the traversal has a special case when the old size is a power of two — an implementation that gets it wrong still passes at 3→5 and fails at 2→5. The refusals are tested explicitly: a shrinking head, two roots at one size, a tampered proof node, a served key absent from the log, an inclusion proof for somebody else's leaf, and a rewritten log that cannot be reconciled with a pinned root.

✅ Exercised against relay1, 2026-08-25 — LiveKeyLogTest, opt-in via FN_LIVE_FLEET. Against the real 231-leaf log: a key the relay genuinely published verifies; a substituted key is refused; a second check reconciles the pinned head with a grown tree using proofs the relay generated; a root we never saw at our pinned size is a fork; and the send path itself refuses on a bad pin while NOT refusing an honestly published key with enforcement on.

✅ Contacts carry the peer's global address, and enforcement uses it.

The relay files a leaf under an account's fnid once it has one, and under local:<audience>/<user_id> before that. The subject is inside the hashed bytes, so a verifier that does not know the address cannot recompute the leaf — which made an honest key from such an account unverifiable. That was a false positive on a real conversation, which is how a security setting gets switched off and left off.

The address is now resolved once from the contact's handle (GET /e2e/account/identity/{handle}, unauthenticated) and pinned beside their identity keys, exactly as the keys are. enforceKeyLog recomputes against the pinned address by default.

⚠️ Pinned, not looked up per check. Re-reading it each time would let a relay that wants to substitute a key simply also change the name it files it under, and every verification would agree with the new one. First contact is still trust-on-first-use — taking the relay's word once is not a check; what it buys is that every later answer has to match. The safety number covers the first sighting and nothing here replaces it.

🔴 A changed address is an identity change, and for a stronger reason than a changed key: an fnid is derived from the account key, so a new address is not a new device — it is a different account key, which is to say a different person.

Proven against relay1 in three states, because only all three make it legible: refused with nothing pinned, accepted once the contact carries the right address, and refused again with a wrong one — so what makes it pass is the address being right, not the check having been softened. A changed address is asserted to come back CHANGED against the real relay too.

⚠️ A relay that is unreachable pins nothing rather than pinning "no address". Baking that in would be permanent, and the next successful lookup would then read as the address changing — the alarm, for a network blip.

Sealed sender, 2026-08-25

✅ The relay no longer learns who sent a message. Verified against relay1: a sealed submission is accepted unauthenticated, the stored row carries from_user = 0, and the recipient still reads it with the sender recovered from inside the seal.

Cross-engine verified in both directions against the normative web engine (engine/test/interop-kotlin.mjs, 43 assertions) — a seal made by Kotlin opens in JS and vice versa, the inner envelope survives, and a seal addressed to one device does not open on another.

🔴 The seal is confidentiality, never authentication. The recipient's IK_dh is published to the world, so anyone can produce a well-formed seal claiming to be from anybody. The claimed sender selects a session and nothing more; the ratchet opening it is what makes it a fact. There is a test that forges a sender on purpose so that nobody later "tidies" the naming into something that looks authoritative.

⚠️ The first message to a new contact cannot be sealed, and Sent.sealed reports which it was rather than letting the UI imply otherwise. Sealing needs the recipient's delivery key, and that key travels inside an E2E message — so there has to be a prior exchange. Claiming anonymity for that first message would be lying about the one thing the feature is for.

⚠️ Rotation is the only lever against a sealed spammer, and it is blunt. There is no sender to block, so the recipient replaces the delivery key and every contact loses sealed access until the next time they are written to. Reachable from Account; the copy says so.

The relay stores only SHA-256(key) — the key itself reaches contacts inside E2E messages and never passes through the relay from its owner, which is the only reason an unauthenticated submission can be authorised at all.

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.

Signing, on both sides (2026-08-25)

iOS was pinned to the wrong Apple team, and that — not a missing membership — is what made TestFlight unreachable. project.yml carried DEVELOPMENT_TEAM: "2W7DA3A8W2", the free personal team attached to the iCloud address on the build Mac. Meanwhile an App Store provisioning profile for net.flamenet.messenger already exists, issued to team M5923Y84P5 — the team that ships hittfitness and shepherdgiving — and valid until 2027-06-04. A personal team cannot produce a distribution build at all, so every archive would have failed at signing while naming the certificate rather than the team ID that put it out of reach. The team is now M5923Y84P5.

⚠️ Still outstanding: no distribution certificate in the build Mac's keychain. security find-identity -v -p codesigning returns exactly one identity, the personal-team development one. Signing in to Xcode with the App Store Connect account mints the distribution certificate and the matching profile; nothing in this repository can do it, and nothing here should try.

Android needs none of that. The release signingConfig reads ~/.flamenet-android-signing.properties — present on the build Mac since 2026-08-23 — so :app:assembleRelease produces a signed APK that can go straight to a tester. That asymmetry is why Android reaches testers first, and it has nothing to do with which app is more finished.

Two reproducible symptoms in the iOS pair (2026-08-25)

Found running TwoSimulatorUITests against relay1. Both reproduce with the machine idle, so neither is the load artifact they were first taken for.

1. One-way delivery between two fresh accounts. Alpha receives bravo's message — test1 passes on alpha's side, 113s. Bravo never shows alpha's, across a full three-minute wait, on two separate runs. The direction is stable.

Already ruled out: E2EInbox.transmit throws before recording anything when the recipient has no device, and send appends to the thread only after transmit returns. So alpha's line existing in alpha's thread is positive evidence the relay accepted envelopes addressed to bravo's device. Bravo therefore either never fetched them or could not open them. ⚠️ The first reading of this — "the app records messages it could not deliver" — was wrong, and reading E2EInbox is what disproved it. Do not act on that theory.

2. Creating a group does not produce a group. The sheet stayed open past two minutes; the tree captured at failure shows Home, the buddy row present, and the Groups section reading "No groups yet. Start one with your buddies." createGroup appends to groups on success and groups is @Published, so a successful create would have rendered a row. It did not — the create threw.

⚠️ Both concern sending to the second account. Suggestive, not a diagnosis. The next run needs the thread dump (added) plus the relay's own view of what is queued for that device.