Two gaps, one in each direction. Android could ENCODE an address and had no way to DERIVE one, so it could not show yours and nobody on another relay could be given it -- the cross-relay work was iOS-only in practice. The engine was missing `localAccountSalt` and a way to get both sign-in secrets from one stretch; asking for the account key separately re-runs 650,000 PBKDF2 iterations for a value already in hand. The salt is pinned against the JavaScript engine, which is normative -- a Kotlin test asserting what Kotlin computes would prove only that Kotlin is self-consistent, and the failure to guard against is three engines disagreeing, which shows up as one person having two addresses depending on which phone they used. The address is stored in the vault because it can only be derived at sign-in: the passphrase exists for that one moment and is never kept, so a restored session had nothing to recompute from. Caught by the empty state saying so on the emulator rather than showing a blank. A QR encoder, written rather than depended on. Android has no encoder in the platform, and this is one fully specified algorithm for one fixed job -- turning a short ASCII address into a bitmap. It is verified by DECODING: thirteen codes, every version from 1 to 10 at exact capacity, read back by CoreImage's detector, which shares no line with it. That found the bug that mattered. The symbol was structurally perfect -- finders, timing, alignment, data placement and mask all confirmed correct against a reference symbol -- and nothing could read it, because the fifteen format bits were written least-significant first. It fails identically at every version with no clue where. The Account screen was a flat stack of fields, buttons and paragraphs with dividers between them, so a security setting carried the same weight as a text box and "Delete account" the same as "Save display name". Cards now, matching the Contacts screen that already had them, with the destructive pair on their own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flamenet Messenger — Android
Nothing here is an app yet. This is the engine, and it is deliberately first.
An Android client needs the protocol implemented a third time. That third
implementation is the one that finds the places where the specification says
less than the two existing engines quietly agreed on — but only if it is
written against the spec and the published vectors rather than against a
reading of our own Swift and JavaScript. So the engine comes first, it is a
plain Kotlin/JVM library with no Android in it at all, and it is held to
engine/test/vectors/protocol-v1.json — the same file the other two engines
are checked against, read from that one location rather than copied here.
./gradlew :engine:test
No Android SDK, no emulator, no device. It runs anywhere a JVM does, which is also what lets it run in CI on Linux.
What the engine covers today
| Area | Spec | State |
|---|---|---|
| Safety numbers | §7 | Done — fingerprints and conversation ordering match the published vectors |
| Key transparency verification | §19 | Done — RFC 6962 leaf/node hashing, unbalanced split, bottom-up audit paths |
| Byte helpers | — | Unsigned comparison, constant-time equality |
What it does not cover
Listed rather than left to be discovered, because a table of green ticks with the hard parts quietly missing is worse than no table:
| Area | Spec | State |
|---|---|---|
| X25519 / Ed25519 | §3 | Not started — needs a provider that works on both JVM and Android |
| X3DH key agreement | §4 | Not started |
| Double Ratchet | §6 | Not started |
| Sealed sender | §12 | Not started |
| ML-KEM-768 hybrid | §11 | Not started |
| ML-DSA-65 identities | §20 | Not started |
| Relay client | §8 | Not started |
| The app itself | — | Not started |
Until those exist, Android is honestly marked "not started" on the website, and a conversation gets the protocol's guarantees only when both ends are iOS.
Why the vectors matter more than the code
Two implementations that agree may both be wrong in the same way, especially when one was written while reading the other. The audit-path ordering in §19 is the worked example: a top-down reading of a bottom-up path produces the correct root for every left-spine index, so it passes any test that only checks index 0. The vector file pins a right-descending index for that reason, and the Kotlin tests assert both that the published path verifies and that the same path reversed does not.
If this engine ever disagrees with the file, the interesting question is which of the three is wrong — not this one by default.