Files
flamenet-messenger-android/PARITY.md
T
Paul HittandClaude Opus 5.5 db59a61d52 Open a stored account from the phone, and let the relay catch up after
DESIGN 47. A start with stored credentials waited on the relay for the
contact list and the groups before leaving the spinner: up to its call
timeout twice with the network down, and then without any groups, since
those came only from the relay. The chats now open from what is on the
phone, the stored contacts and the groups as last listed, which the
phone now keeps, and the relay's lists replace them when it answers.
The saved Today page already opened from disk. Sign-in still shows only
with no stored credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:53:22 -04:00

75 KiB
Raw Blame History

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 yes yes Android verified against RFC 5869 / NIST vectors
Ed25519 yes yes RFC 8032 vectors, on the JVM and on device; see the provider note below
X3DH v1 yes yes cross-engine verified
Double Ratchet yes yes cross-engine verified, including reordering and loss
Safety numbers yes yes cross-engine verified
Transparency log (KeyLog) yes yes canonical leaf + inclusion proofs, cross-engine verified
Account KDF / fnid yes yes both pinned to the published vector file
Sealed sender yes yes cross-engine verified, and the relay records no sender
Content envelopes (§6.6–§6.9) yes yes attachments, replies, reactions, groups, calls, one codec
Attachment encryption yes yes verified end to end; relay holds only ciphertext
Envelope + wire codec yes yes cross-engine verified at the envelope level
Session archive / restore yes yes cross-engine verified, in-flight messages included
Device identity + prekey rotation yes yes retired prekeys retained past the relay window
Vault (encryption at rest) yes yes Android Keystore, AES-GCM, record name bound into the AAD

| ML-KEM-768 (PQ agreement) | yes | yes | cross-engine verified, see below | | ML-DSA-65 (PQ identity) | yes | yes | see below |

These two rows said and , and both were wrong, understating, which is the same direction relay#45 warns about and the same one that had the comparison page scoring a competitor unfairly. The note that used to sit here said iOS's PQ files were inert because CryptoKit exposes ML-KEM only on iOS 26 while the app targeted 16, that Android had no ML-KEM at any API level, and so "neither client does post-quantum anything today". None of that is true now, and the middle claim was never quite the point.

  • Android uses BouncyCastle's MLKEMExtractor / MLKEMGenerator. FIPS 203, a real implementation shipped in the engine. There is no platform ML-KEM on Android at any API level, which is what the old note was reaching for, and it is why the engine carries its own rather than a reason it has none.
  • iOS uses CryptoKit's MLKEM768, and it is unconditional: project.yml sets the deployment target to 26.0, so the runtime gate that made those files inert is gone. PQKEM.swift says so at the top.

Corrected by a run, not by reading the source. The cross-engine harness (test/interop-swift-kotlin.mjs) establishes it end to end: "Android picks v3 for an iPhone peer", "the first Android envelope carries a PQ ratchet step", "the iPhone agrees the session is v3", and "a v3 session survives being archived and restored mid-conversation", with the same four in the other direction.

The honest limit, which has not changed: post-quantum protection counts only once both ends have it, so today that means two Android phones, or an Android phone and an iPhone running a build that has not shipped yet.


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 yes
Sign out (erases the vault key) yes
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 AccountKdf.localAccountSalt, pinned to the published vector, and wired into sign-in for a LOCAL relay
Multi-homing (publish + read home records) publish, read, verify and the serial floor, see below

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 transcript carries a mid; long-press gives a five-emoji row, one emoji per person
Edit a sent message (§6.15) inbound edits apply to the message they name; Edit in the long-press menu, own messages only, bubble marked edited
Drafts that survive a relaunch sealed in the vault, not SharedPreferences
Forwarding (§6.16) fwd reaches the transcript and the bubble; Forward offers people and groups, and carries no trail back to the source
Group message ids and quotes (§6.9) transcript stores both; sent and queued copies share one id
Mentions (§6.18) codec, an @ picker in the group composer, and ids recorded at pick time rather than re-derived from the text
Per-message timers (§6.17) combining rule and per-message pruning; composer control in progress
Search inside messages scanned, never indexed
Search inside messages scanned, never indexed, no plaintext index at rest
Pinned message (§6.19) inbound pins stored per thread sealed in the vault; Pin/Unpin in the menu and a banner above the thread
Photos send via the system picker, render inline
Voice notes (record + play) AAC/MP4, tap to record, tap to send
Stories, and seen-state sent per-contact, expired bytes deleted
Stories: text-only, lifetime, reactions, replies, mute a menu on + rather than straight to the picker; lifetime rides inside the sealed envelope
Groups: create, list, members, leave members editable after creation
Group text and images one upload, one envelope per member
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) this row was stale: sealed per thread under a Keystore key, read straight from disk; see below
Presence (read and set) shown from the relay, never inferred
Buddy list, add by username yes
Remove, block, unblock yes
Member lookup look up before adding; adding is gated on it
Buddy commands — retired; not a feature any more (owner)
Voice calls (WebRTC, DTLS-SRTP, TURN) P2P-first; no TURN deployed
Notifications list, read state yes
Services (AIM/ICQ via flamenet-oscar) — retired; not a goal for this app (owner)

History at rest was marked and it has been built the whole time, the third stale row in this file. Every read goes to disk: Session.history calls Vault.history, which decrypts the record. There is no in-memory copy to be fooled by.

What Android does, against what iOS's E2EHistoryStore does:

iOS Android
Cipher AES-256-GCM AES-256-GCM
Key Keychain, ThisDeviceOnly Android Keystore, never leaves the process at all
Bound to its slot one file, nothing to confuse the record NAME is the AAD, so a blob cannot be moved between slots
Shape one snapshot for everything one record per thread
Off backups excluded from iCloud allowBackup="false", and a Keystore key is unbackupable anyway
At rest, locked .completeFileProtection Credential-Encrypted storage: filesDir is unreadable until the first unlock after boot
Plain sign-out keeps history keeps history
Erase this device eraseLocalData() forgetEverything() and the Keystore key is deleted

Two honest differences rather than a claim of equivalence. Android's per-record AAD is a binding iOS's single file does not need; iOS's .completeFileProtection is a per-file assertion, while Android's equivalent is a property of the storage volume rather than of this app's request. Neither gap favours one platform overall.

Vault.history prunes expired messages on the way out, so disappearing messages are enforced on READ rather than by a timer nothing runs. It rewrites the record only when something actually expired, a read is not a write.

Calls are built, on io.github.webrtc-sdk:android, the one third-party library in this app, and unavoidable: the alternative is hand-rolling ICE, DTLS-SRTP and an audio pipeline.

Peer to peer by default. ICE tries a direct path first and only falls back to TURN when both ends are behind NAT that refuses one. Signalling rides the ratchet, so the SDP, codecs and every candidate address, is end-to-end encrypted; a relay that could read it would learn both parties' addresses even on a call it never carries.

A direct call shows the other person your IP address. That is what makes it direct. The app says so before the FIRST call and waits for an answer, once, not every time, because a prompt people see repeatedly is one they stop reading.

No TURN server is deployed (FN_TURN_SECRET / FN_TURN_HOST unset), so /e2e/turn answers 503 and calls are P2P-or-fail. Handled rather than hidden: STUN alone still connects through most NATs, and the call banner says plainly that this server has no relay so a call only connects if a direct path exists.

WebRTC's native libraries dominate the APK: ~41 MB across four ABIs, of which x86/x86_64 (~24 MB) exist only for emulators. Release is restricted to arm64-v8a and armeabi-v7a; debug keeps all four so it still runs on an emulator.


Hardening

The security work iOS did as ios#19/#39/#41/#45. Android had none of it, and two platforms disagreeing about a security guarantee is how one of them ends up being the one that leaks.

Feature iOS Android Note
Back off on repeated app-lock attempts yes yes ios#41 — 3 free attempts, then 30s doubling to a 5-minute ceiling
Handle a wrong clock yes yes ios#45, skew learned free from the Date header; expiry takes the LATER of device and relay
Zero key material after use yes partial ios#39, done, but the JVM guarantee is weaker than iOS; see below
Pin the relay's identity yes yes ios#19. Android already pinned the key and drew the wrong conclusion from a change; see below

The back-off is not a defence of the data. It slows a person holding an unlocked phone. The vault and the ratchet keys are protected by the Android keystore, and no amount of back-off changes that, the same wording AppLock itself uses, kept identical on both platforms so neither can be read as promising more.

The clock rule is an asymmetry, not a correction. A story disappearing after an hour is a promise the SENDER made, enforced on the READER's device, so a reader whose clock runs behind keeps a message the sender was told would be gone, and nobody involved can tell. Expiry therefore takes the LATER of the device's clock and the relay's: a device set back cannot extend a message's life, and neither can a relay that lies about the time, because the other source overrules it. The remaining failure, expiring early, costs the reader something they were about to lose anyway.

Android needs synchronisation iOS does not. The Swift version is nonisolated(unsafe) static var because its writers sit on one actor; here the observation is written from whichever OkHttp thread finished a call and read from the UI thread, so the two fields are @Volatile and updated together under a lock. A torn read of "skew from now, timestamp from an hour ago" would age out an observation just made, or trust one that had not been.

The relay pin was not a missing feature on Android, it was a wrong conclusion. Trust.verifyHeadSignature already pinned issuerPublicKey the first time a signed log head arrived. When the key later changed, the signature stopped verifying and the only conclusion available at that point was KeyLogForkException, so a rotation somebody performed on purpose was reported to the user as the relay showing two different histories.

Those need different responses. A fork means nothing the relay says can be trusted. A rotation means somebody ran a command, or somebody else is answering on that address, and only a person can tell which. RelayIdentityChangedException is deliberately not a KeyLogForkException, and forkDetected is not set, an alarm people cannot act on is one they learn to dismiss, which would cost the real fork detection its credibility too.

Zeroing is where Android cannot match iOS, and the row says for that reason rather than being rounded up to . iOS has memset_s, specified not to be optimised away, and iOS does not page application memory to disk. Neither holds here: a compacting collector can relocate a ByteArray and leave the bytes at the old address untouched, Android has had compressed swap on by default for years, and Arrays.fill is merely "not eliminated in practice" rather than guaranteed. There is no way to pin a Java array, so this is a ceiling on what any implementation here can offer, not a gap to close later.

The claim Android supports is therefore narrower and is written that way in SecureBytes: a derived key is not left sitting in the heap after use. Not "the key is gone".

One divergence from iOS, and it is a bug the Swift version cannot have. iOS computes 30 * pow(2, steps) in a Double, which saturates to infinity and still caps correctly. Kotlin shifting a Long does not: 30L shl 60 wraps NEGATIVE, and minOf(negative, cap) returns the negative, a wait in the past, which reads as no wait at all and removes the whole feature at exactly the point somebody is grinding attempts. The steps are clamped before the shift, and AppLockBackoffTest fails without the clamp.


Multi-homing (§20.4)

Both rows above were and both were stale: AccountKdf.localAccountSalt is pinned to the published vector and wired into sign-in for a local relay, and MailboxHoming / AccountIdentity.publishHomes have shipped the publish and read halves for a while. The document was describing a gap that had closed.

What was missing was on the READING side, on BOTH clients. An older home record is signed, the account holder published it, before they added a second relay, so a relay that wants to be somebody's only mailbox does not have to forge anything: it serves the record it saw first, that record verifies perfectly, and the sender delivers to one relay believing it is delivering to all of them.

The relay refuses a lower serial on write. Nothing refused one on read: verifiedRelays here and verifyHomes on iOS both checked the signature and stopped. Fixed on both (Android this MR, iOS ios!116) by remembering the highest serial seen per address and refusing anything below it.

The floor rises only for a record that verified, otherwise a hostile relay could raise it with a forged Int.MAX_VALUE and lock out every later genuine record, turning the rollback defence into a denial of service against the account holder.

Neither client claims an attack when it refuses. A relay that has fallen behind serves an old record for honest reasons, and nothing in a client can tell that from the other case.


Inbound control envelopes

The app never asked the engine whether an envelope belonged in a thread. Content.isRenderable has existed since §6.15 and Session's inbound loop did not consult it, so three things arrived from the iPhone app and were filed as ordinary bubbles:

  • an edit carries its new wording in text, so it drew as a duplicate beside the typo it was meant to replace, a correction that made the thread worse;
  • a pin carries only an id, so it drew as a blank bubble;
  • and any control envelope from a newer peer would have drawn a bubble full of JSON, the failure isControl was written to prevent, defeated by nobody calling it.

All three now branch before filing, and the last is guarded by isRenderable itself rather than by a list of kinds this build happens to know: an allowlist is only correct until the other platform adds one.

An inbound edit is applied only when it comes from the person whose message it is. Without that check any contact sharing a session could rewrite anybody's words in the reader's transcript, a better attack than sending a message of their own, because the words would carry somebody else's name.


Test safety

Feature iOS Android Note
Live suites cannot run by accident yes yes Android was already ahead here, every live suite gated on assumeTrue, and LiveTarget.refuseProduction refuses a production host outright
No credential written into the source yes yes both fixed together, ios#62
A guard that fails the build if either regresses yes yes Tests/UITestSafetyTests.swift, client/.../TestSafetyTest.kt

iOS was creating real accounts on production on every plain test run, with a literal password from a public repository; Android's equivalent suites were properly gated but still carried const val PASSPHRASE = "a-long-enough-passphrase" and used it to sign up on accounts.flamenet.io whenever somebody opted in. The gate stops it running by accident. It does not stop the password being written down.

Both now read the passphrase from the environment with no fallback, and an unconfigured run reports SKIPPED rather than signing up with a value anybody can read. FN_LIVE_PASSPHRASE on Android, TEST_RUNNER_FN_DEMO_PASSPHRASE on iOS.

Both guards are source scans, and both had to be narrowed before they were worth keeping, the Swift one flagged an email default because "password" appeared in a trailing comment, the Kotlin one flagged four suites that sign up against a MockWebServer, which is the correct way to test sign-up. A guard with standing false positives is one somebody switches off.


The 2 September revert

Twenty-five Android merge requests landed that evening and aebf41e reverted all of them, 5,016 lines across 41 files, for the same reason iOS was reverted an hour earlier: the branches had never existed together, and resolving them mechanically produced code that compiled and passed while being wrong.

All twenty-five are back, reinstated one at a time on feat/files-and-wallet-parity, each built and tested before the next was started. Six of them could not be cherry-picked at all: the originals were cut from a tree whose story features looked different from the versions rebuilt in the meantime, so applying them whole would have left two implementations of mute, text stories and story replies under different names, no compile error, and exactly the kind of divergence that rots. Those were taken by hand, feature by feature.

One deliberate divergence from the originals: the mention wire key stays men, not ment, and mentions are List<Int> rather than List<Long>. That is what the iOS engine speaks and what the two were verified interoperating on; switching to match the reverted commits would have broken iOS silently.


Files and Wallet

From Sources/Views/Files/* and Sources/Wallet/*, both of which moved into the messenger rather than staying separate apps.

Feature Android
Files engine: names, key wrapping, chunked content pinned to the browser's published vectors, not to the Swift engine
Files engine: KDF and identity record an identity written by the browser unlocks here
Local vault: add, organise, delete index, folders, both storage modes, bulk delete with its report
Files screen: set up, unlock, browse, add, folders, delete, promote verified on a device: set up, encrypt, lock, cold-start reload, link via SAF
The security lattice (labels, clearance) encoding and clearance in Kotlin, pinned to independently-computed vectors. Not Bell–LaPadula: the -property is deliberately unenforced on both clients; see android#15
Share a file into a chat or group both directions; proven live: a Restricted document crossed two vaults through a real relay with its label intact
Wallet engine: BIP-39, Base58, SLIP-0010 pinned to the published vectors
Wallet engine: address derivation and signing BIP-44 path, Base58 address, and the engine's own RFC 8032 signs, no API-33 floor
Wallet: BIP-39 seed, create and restore create, import, restore, sealed at rest, with the phrase shown once and explained
Wallet: fresh receive address per receive counter in the vault; the screen says why the address changes
Wallet: balance and transactions from a node both, with unread drawn as an em dash and never as zero

The device run found a bug no JVM test could have. Android's bundled org.json coerces a JSON null to the four-character string "null"; the Maven org.json the tests run against returns "". Every nullable field in the vault index therefore passed on a laptop and broke on a phone:

  • parentID threw Invalid UUID string: null, so a vault holding any top-level file failed to load at all. Adding the first file worked, because it was still in memory; reopening the app lost everything, with an error nobody could act on.
  • wrappedContentKey was worse because it did NOT throw, "null" is valid base64, so the node came back with a plausible three-byte content key and simply refused to decrypt, for ever.
  • bookmark produced a reference pointing at "null", permanently missing.

Absence is now decided by JSONObject.isNull, never by comparing a decoded string to a sentinel, and the literal "null" is rejected too. Two regression tests reproduce the device's behaviour on the JVM by writing the literal string the platform hands over, feeding a real JSON null would pass with the broken code and prove nothing.

Worth a sweep. optString on a nullable field is wrong everywhere in this app, not only here, and it fails only on the platform it ships to.

What the device run covered (emulator, API 35): set-up with the passphrase, a wrong passphrase refused, the SAF OpenDocument picker with the display name pre-filled, encrypting into the vault at a chosen classification, lock and unlock, and a cold-start reload of the vault. On disk: the blob begins ENCL\x01, both wrapped keys carry version 0x11 and the name 0x01, the filename is not in the clear, and the plaintext is absent from the entire app container. Linking a file works and reports "Nothing was copied".

The persistable read grant survives. Checked after a device reboot, a full emulator cold boot AND an app update: dumpsys activity permissions shows persistable=0x1 persisted=0x1 for the document, and the row reads through to the file rather than reporting it moved. Promoting a link to the vault also works on device, the bookmark is dropped from the index, the original file is left untouched where its owner put it, and the byte count corrects from the provider's reported size to the real plaintext length.

The device run found a second bug: promoting a link appeared to do nothing. Node.equals compared only the id, written to dodge ByteArray identity comparison, so a promoted row compared EQUAL to the reference it replaced. The state object holding it compared equal too, StateFlow conflated the update away, and the screen never changed: the file vanished from Linked and did not appear in the vault until the app was restarted, which reads as losing somebody's file. Every field is now compared, with contentEquals for the byte arrays.

Neither of the two device bugs was catchable by this suite as it stood. The tests read the vault; the failures were in what the vault handed a StateFlow and in what the platform's JSON handed the decoder.

Android asks for a Files passphrase, exactly as iOS does, and this was a decision rather than a port. The Android wallet sits behind the Keystore-sealed vault alone, so an unlocked phone opens it. Files does not: the identity record is encrypted under a key stretched from a passphrase this app deliberately keeps no copy of. The Keystore seal around it stays as the second layer. Dropping the inner one for convenience would be a real reduction and not a refactor, a vault that opens because the phone is open is a folder with a lock drawn on it.

Files is NOT a mandatory access control system, and nothing should say it is. The sharing path forced the question android#15 asked, and the answer was already written on the wire format: the label is a MARKING, not a permission. The content key travels in the same envelope as the file, by design, because that is what makes sending work, so no client-side refusal is a control. There is no trusted computing base: the person holds every key, on a device they control, running a client they could modify. A suggestion dressed as a control is worse than no control, because it is the one people plan around.

What Files does provide, and what may be claimed: cryptographic read separation (no label key, no plaintext, enforced by mathematics, not by a check), and a classification that travels with a file and is preserved on the recipient's phone. Bell–LaPadula's -property is deliberately absent on both clients, and the test in LabelTest asserts that absence so it fails loudly if one platform implements it alone.

The Files crypto is held to vectors/files-v1.json, which the JavaScript engine in the browser produced, a third implementation, not the Swift one. Checking Kotlin against Swift would only establish that the two agree, and two implementations agreeing on the wrong answer is how somebody's files stop opening when they change phones. The file is copied byte for byte from the iOS repository rather than regenerated here, because a vector file regenerated by the implementation under test proves nothing at all.

Two of those vectors were additionally recomputed from scratch in Python before any Kotlin ran against them, struct.pack('>hQ') for the label encoding, hmac.new(...).digest() for the name digests. The café case matches under NFC and not under NFD, which is what makes the normalisation form pinned rather than assumed: a platform normalising differently does not error, it just fails to find a file somebody filed on another device.

The tampering tests (truncation, reordering) each prove the surviving bytes are still authentic under their original index and end-flag. Without that second half, "it threw" would be all they established, and any bug at all makes a mangled blob throw.

The wallet primitives are now in Kotlin and pinned to the STANDARDS, not to the Swift engine. Bip39, Base58 and HdKey are held to BIP-39's own vectors, SLIP-0010's own ed25519 vectors and the published word-list SHA-256, the same things the Swift side is held to. Checking one implementation against the other would only prove they agree, and two implementations agreeing on the wrong answer is exactly how a wallet ends up empty: a phrase written down on an iPhone has to restore on an Android handset, and on somebody else's software years from now.

Every failure in that file is silent in production. A wrong checksum bit, a lost leading zero, a missed NFKD, none of them error. They derive a different, perfectly valid, empty wallet.

Two Kotlin-specific decisions, both money-relevant:

  • PBKDF2 is hand-written over Mac, not SecretKeyFactory. The JCA path takes a CharArray and the provider decides the encoding, AccountKdf already carries that caveat for the account KDF. For a wallet the consequence is worse than a failed sign-in: a phrase containing anything outside ASCII would stretch differently under a different provider and derive a different empty wallet with no error anywhere.
  • Base58 is a digit loop, not BigInteger. The obvious Kotlin version is BigInteger(1, bytes).toString(58) remapped, and it silently drops leading zero bytes, encoding two different keys to one address.

Android can SIGN, and iOS's constraint does not apply here. AccountKdf notes that a raw Ed25519 seed needs EdECPrivateKeySpec, which Android exposes only from API 33, but the engine carries its own RFC 8032 (Rfc8032), so HdAccount.signingSeed produces a key that signs on any supported device. The wallet is not blocked on a platform floor.

A wallet is not derivable from the account, and that is asserted rather than intended. The entropy is fresh randomness, never the passphrase, the handle, or the Flamenet identity. Deriving it from the passphrase would be convenient, would need no backup at all, and would tie every payment somebody ever received to their handle on a public ledger for ever. The chain does not forget, so there is no undoing it later.

The record stores the ENTROPY, never the seed. The seed is a one-way function of the phrase, so a record holding it would open the wallet and be unable to produce the words, somebody who lost their phrase but still had their account could spend, and could never write it down again or move elsewhere. Self-custody in name only.

Erasing the device destroys the record; the phrase is then the only copy. Plain sign-out keeps it, and so does a password change (which signs out plainly), signOut(erasingDevice = true) and deleteAccount do not. That is what a recovery phrase is for, and it has to be said at the moment the phrase is shown rather than discovered afterwards.

The node is somebody else's server and is trusted with nothing. It is asked what a public address holds and handed transactions that are already signed; it never sees a key. What it can do is lie about a balance or withhold a history, so no answer is treated as authoritative beyond "this is what that node said". And which node somebody points at is a privacy decision the app has to state: asking a public endpoint for an address's balance ties that address to this device's IP in somebody's logs.

An unread balance is drawn as an em dash, never as 0.00. Null means this app could not read the balance; zero means the account is empty. Drawing the first as the second tells somebody their money is gone, on every flaky connection, which is exactly when they are most likely to believe it. Same rule for the activity list: "not read yet" and "nothing yet" are different sentences.

Reached from Account, not the tab bar. The bar is four icons by design and Alerts was taken OUT of it in August for being a fifth; a wallet people open occasionally does not earn the slot that decision refused.

A JSON-RPC refusal arrives with HTTP 200. A client checking only the status code carries on with a body that has no result, fails somewhere less obvious, and throws the node's own explanation away on the route, the difference between "insufficient funds for rent exemption" on screen and a parse error nobody can act on.

A Solana address has no checksum, and looksLikeAddress says "well formed", never "correct". A single mistyped character that still decodes to 32 bytes is a perfectly valid address nobody holds the key for; money sent there is gone, and neither the client nor the network will catch it. There is a passing test that finds such a near-miss, so the limit is recorded rather than implied and it fails if the format ever gains a checksum, which is when the warnings around typed addresses can be revisited.


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

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

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 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, LiveKeyLogTest, opt-in via FN_LIVE_FLEET. Against the real 231-leaf log: a key the relay 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

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

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

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.

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

Narrowed: it is the RECEIVE side, and only when both sides opened first

The headless peer tool settles the direction. qz89925 (user 402), a real account driven from a shell, was sent to by the iOS app:

CrossPlatformUITests/testAMessageReachesANonIOSClient   passed (39.2s)
peer poll → {"messages":[{"text":"","from_user":403},
                         {"text":"hello from iOS to the peer tool","from_user":403}]}

iOS → non-iOS delivery works. The sender encrypts correctly and the relay delivers. (The empty first message is the §14.4 delivery-key prelude, as designed.) So neither the send path nor the relay explains the two-simulator failure.

What differs in the two-simulator case is that both sides initiate a session with each other at the same time, each signs up, opens the chat, and sends before either has received anything. The peer tool never initiates, which is exactly why it works.

Disproved, and narrowed further (same day)

The existing != nil theory is WRONG. The suspect was E2EInbox.swift:995, where ingest returns true, consume the envelope, rather than holding the cursor, whenever the recipient already has its own session. Two things kill it:

  1. ratchet is nil there only when the envelope is not type prekey. An inbound prekey envelope always builds a session and never reaches that guard, so it cannot drop a first message.
  2. Measured. testARoundTripWithANonIOSClient passed in 54.2s: iOS signed up, sent first, creating its own outgoing session, and then received and rendered the peer's reply.
peer add qrt90210    → {"added":"qrt90210"}
peer send            → {"delivered":1,"failed":0,"to":404}
iOS round trip       → passed (54.234s)

So non-iOS → iOS works even when the iOS client already holds an outgoing session to that device. Both directions are proven in isolation.

What is left is simultaneous initiation. In the round trip the peer received iOS's prekey and established its session before replying, so both sides ended up on one chain. In the two-simulator case both sign up, both open the chat, and both send before either has processed the other's prekey: so each creates its own session and then meets a message on a different chain.

Leading hypothesis, not confirmed: E2EInbox.swift:1006 calls saveRatchet on the session built from an inbound prekey without archiving the session it replaces. saveArchivedRatchet appears exactly once in the file, at line 914, and that call only re-saves an already-archived ratchet, nothing ever archives a live session before overwriting it. The archived-ratchet fallback at line 911 exists precisely for "messages sent on the old chain before the peer reset", which is this situation, but it can only help if something was archived.

To confirm, instrument both clients' ratchet ids across a concurrent send. Do not patch on this reasoning alone: the file's history includes an advance-unconditionally bug that lost messages silently, and a careless fix here trades one silent loss for another.

Why confirming it is expensive

A two-simulator run advances roughly one second of test time per minute of wall clock, and this is not machine load, measured with the host healthy (38 runnable processes, load 27). The cost is Capturing element debug description: XCUITest snapshots the entire accessibility tree on every query, and this app's home screen carries a stories strip, a buddy list, a groups section and a tab bar.

A DM run takes 20-40 minutes per side. The group phase has never completed inside a sitting. Budget hours, not minutes: or give the pair a trimmed screen to work against before running it.

Reproduced cheaply, and now fixed, verified against relay1

two :interop:peer accounts, no simulators, no UI. Neither polls before sending, which is the simultaneous-initiation case.

Before (retired-session retention alone):

p1/p2 send   delivered:1, failed:0    both first messages arrive   yes
p1/p2 send   delivered:1, failed:0
p1/p2 poll   {"messages":[]}  {"messages":[]}                      both lost

After:

p1/p2 poll   second-from-Q2 / second-from-Q1                       both arrive

What the first attempt got wrong, and it is worth remembering. Retaining the retired session was necessary; storing the WRONG OBJECT made it useless. DoubleRatchet is a final class on iOS and E2ESession is a class on Android, so the failed decrypt attempt that proves the peer is on another chain mutates the very instance it was handed: skipped keys consumed, chain stepped. Archiving that instance stores a chain that can no longer open anything, which is indistinguishable from not archiving at all. The copy in the vault was never handed to a decrypt, so it is the pristine one: re-read it before retiring it.

Second thing the first attempt got wrong: which slot the winner belongs in is not the same question as which chain opened it. When the RETIRED chain is the one that opens an envelope, it must be written back to the retired slot, promoting it to current swaps the two and discards the chain we are sending on. Each branch now writes back deliberately.

Reproduce in ~2 minutes rather than the 20-40 a simulator pair costs:

peer q1.json create <relay> <h1>;  peer q2.json create <relay> <h2>
peer q1.json add <h2>;             peer q2.json add <h1>
peer q1.json send <h2> a;          peer q2.json send <h1> b     # neither polled
peer q1.json poll;                 peer q2.json poll
peer q1.json send <h2> c;          peer q2.json send <h1> d     # the exchange that used to die
peer q1.json poll;                 peer q2.json poll

iOS TestFlight readiness, checked

Verified against the BUILT PRODUCT, not the source tree, the difference matters, and caught one thing that would have bounced the upload.

xcodebuild archive, generic/platform=iOS succeeded, arm64, WebRTC embedded
Unit tests 38 passed, 0 failures
PrivacyInfo.xcprivacy added, confirmed present in the .app bundle
UILaunchScreen present (empty dict is the modern form)
Bundle / version / min OS net.flamenet.messenger, 0.1.0 (1), iOS 16.0
Usage strings mic + Face ID; no camera use, so none required
App icon 1024×1024
ATS fully on, no dev exception left behind
Third-party SDK manifest WebRTC ships its own (no ITMS-91061)
Trackers none, grepped for firebase/analytics/crashlytics/sentry/etc.

The privacy manifest was a hard blocker. Apple requires one for any app touching a "required reason" API, and this app uses UserDefaults in five files mute and pin flags, the push toggle, the chosen relay. Without it the upload is rejected ITMS-91053 before it ever reaches TestFlight. The first archive silently omitted it because the file was added after the project was generated: check the bundle, not the repo.

Two things a human must settle before submitting:

  • NSPrivacyCollectedDataTypes is a legal declaration and must match the privacy label in App Store Connect. Written from what the code sends; confirm it.
  • CURRENT_PROJECT_VERSION is 1. Fine once; every later upload needs a bump.

plutil -extract WRITES BACK IN PLACE unless given -o -. Using it to "inspect" an archive destroyed that archive's Info.plist down to 3 bytes. Use plutil -p to read.

Still unproven: the feature surface end-to-end on a device, voice notes, calls, groups, stories. Unit tests and an archive do not cover those, and testers will be the first to exercise them.

Muted story authors (ios#33)

Both platforms hide one contact's stories without blocking them. Local on each device: no envelope, no relay call, and the author is never told.

Stored in the vault, not shared preferences. Who somebody has muted is a statement about their social graph, and the point of the vault is that such a statement is not readable by anything that reaches the file. iOS puts it in the sealed history snapshot for the same reason.

A muted contact's stories are still received and still stored. The filter runs on the way out to the strip, Vault.visibleStories, and saveStories runs before it. Dropping them on receipt would make unmuting show an empty strip until their next post, so muting would quietly mean something other than it says.

Your own stories are never hidden, even if your own id reaches the muted set from a restored backup. Hiding somebody's own posts from them reads as the posts having failed to send.

Screenshot notices (§6.27, ios#36)

A disappearing-message timer promises the message goes away; a screenshot breaks that silently. It cannot be prevented on either platform, so it is reported. Both platforms refuse to let the absence of a notice mean anything. The device that took the screenshot is the device that reports it, so a modified build, a second phone photographing the screen, or the platform's own recorder produce nothing at all. A notice means one was probably taken; no notice means nothing. Neither platform counts notices, a running total would be read as a measurement, and its zero as "nobody screenshotted", which is exactly the inference §6.27 forbids. The platforms differ, and the copy says so rather than hiding it. iOS reports a screenshot to the foreground app reliably. Android has no callback at all before API 34, and the one added there does not cover screen recording. So the disclaimer says "you are told when their app can tell", and the Android text adds its own limitation explicitly. Claiming a symmetry that does not exist would be worse than the gap, because somebody would rely on it. Only where a timer is set, on both. Everywhere else this would be a general surveillance feature rather than the narrow one it is. The timer check runs BEFORE the rate-limit check on both, so a suppression window cannot make an untimed conversation start reporting. Last-notice times are in memory on both. Persisting them would store who screenshotted whom, on disk, to enforce a five-second window.

A story may carry no attachment; the caption is then the whole story. Both platforms encode, decode, store, render and post one. The decode sits BEFORE the attachment branch on both. That branch requires att for kind: "story"; a text story failing it falls through to the ordinary-message path and appears as a chat bubble in the conversation that never expires, both of the things §6.6 says a story must not be. Each engine has a test named for exactly that. ref is nullable/optional rather than a sentinel, so nothing can read a blob id off a story that has no blob. Both platforms therefore need a key for seen-marks and de-duplication: with no blob id, a text story is named by what cannot repeat for one author, when it was posted and what it said. Android's stored record omits ref entirely rather than writing an empty object; a reload would otherwise resurrect a blob that fails to fetch. expiresAt folds the two cases so no caller has to know which kind it holds. A text story has no blob, so nothing dies at the server's retention purge. Client-enforced expiry is the whole mechanism rather than a backstop to one, and both composers say so instead of implying more.

Saving your own story (ios#34)

Both platforms can write a story you posted yourself out to the device gallery before it expires. Local only; no protocol change. Only ever your own, and the rule lives in the engine: Vault.isSaveable on Android, E2EInbox.isSaveable on iOS, rather than in the button. Saving somebody else's story keeps a copy of something they were told would disappear, and a guard living in a view is one refactor away from being lost. Both are tested from both directions. Neither asks for broad storage access. Android writes through MediaStore, which needs no runtime grant; iOS asks for .addOnly authorisation. Requesting read access to perform a write would be a much larger ask than the feature. iOS needs NSPhotoLibraryAddUsageDescription: without it the app is terminated on first save rather than warned. Android has no equivalent trap. Both messages say the saved copy has left the disappearing model, and where it went. "Saved" alone would let somebody assume the expiry still covered it.

Story replies (§6.20, ios#30)

A reply to a story is an ordinary direct message to its author carrying sre.sid. Stories gain no comment thread and no viewer list. sre carries the id and nothing else: no caption, no thumbnail, no blob. This diverges from §6.9's re on purpose: a quoted message may be one the recipient never had, but a story's author had it by definition, so the only way they no longer hold it is that their own timer removed it. Sending a copy back would resurrect it at a moment they did not choose. Content.text returns raw bytes when it has nothing to carry, so storyReplyId had to join that condition. Left out, a reply with no message id and no quote would go out as plain text and arrive with the reference silently dropped, on exactly the path a story reply normally takes. The disclosure comes before the first reply, on both platforms. Replying tells the author you watched, which stories otherwise never reveal. Neither platform sends anything automatic when a story is merely opened.

Private story archive (ios#54)

Your own stories, kept past their expiry. Local only; no protocol change. This keeps something the interface told everybody would disappear, so the shape is the argument for it being acceptable: off unless switched on, labelled as what it is rather than as a feature, and only ever your own. Vault.archive / E2EInbox.archive refuse anything not the author's own, the callers filter too, but these are the guards that survive a caller being rewritten. Both are tested from both directions. Archiving happens at the moment expiry is applied, which is the only moment it can. iOS catches it on restore (a story past its expiry is never loaded into stories); Android catches it in Session.stories() before saveStories drops it. Both run repeatedly, so the fold is idempotent, otherwise one long-expired story would be appended on every pass. The archive is not swept by the expiry rule. Everything in it is expired by definition, so reusing saveStories would empty it on the next write. Android has a separate record and a test named for exactly that. Android injects the setting into Session as a lambda rather than reading preferences in the engine layer, which has no Android dependency. An absent value reads as OFF on both platforms.

Multi-item stories (§6.23, ios#53)

Several pictures as one sequence. Each item is an ordinary story envelope carrying a set that names the sequence, its position and its size. One envelope per item, not one carrying an array. The array is the obvious encoding and degrades badly: a client predating §6.23 requires a single att, fails that guard, and renders the whole set as a message in the conversation. Separate envelopes degrade to what they actually are, several stories from one person, in order, which every existing build already draws. Expiry is per set, and the earliest stamp wins. Items are required to carry the same exp, but a sender can write different ones; if the latest won, the difference would keep part of a sequence alive after the rest had gone. That decay is confusing to look at and a disclosure, the gaps reveal the order the author added things. Both engines have setExpiry and a test for it. A zero means "never" and is skipped rather than read as "expires at the epoch", which would kill the whole set. Grouping renders what arrived. n is the sender's claim and any item can fail to arrive, so a set is never held back or shown as incomplete. Stories with no set are keyed so two of them cannot collide into one group.

Story reactions (§6.22, ios#52)

One tap, sealed to the author like a story reply. Decoded before §6.9's reaction path on both platforms. That path requires re; a story reaction carries sre instead. A story is not in the conversation and has no mid there, so one carrying re would fold onto whatever message happened to share that id, or onto nothing. Falling through would leave it unhandled and drawn as an empty line in the thread. Both engines test it in both directions, including that an ordinary thread reaction is untouched. Its own disclosure and its own stored key on both, reactDisclosureSeen, not the reply one. A reaction discloses exactly what a reply discloses; what differs is deliberation. A reply is typed; a reaction is one tap on something already filling the screen, so an account told about replies has been warned about the considered case and not the careless one. Neither platform puts the control on a gesture that advances or dismisses a story. An acknowledgement delivered by a mis-tap is a disclosure the person did not make.

Stories posted to a group (§6.24, ios#55)

An ordinary story envelope carrying gid, fanned out to the members and rendered in the story surface. The group's roster is the audience; the sender's story audience does NOT apply. Two different lists making two different statements, who may see this account's stories, and who is in a conversation, and they will disagree. The roster wins: a group story that silently dropped members would be a group message part of the group cannot see. Both platforms expose groupStoryReach, which returns the overlap rather than a warning, who is excluded by the story audience, and who is not a contact at all, counted separately, since a non-contact was never on that list. The story branch runs BEFORE group routing on both platforms. Falling through would render a story as a message in the group's conversation: a story that is a message, and one that never expires out of the thread. Android adopts the group label only from an actual member. Its roster check guards group messages and runs after the story branch, so without this a sender could attach the id of a group they are not in and have their story shown as having come from it. The label is dropped to null rather than the story refused, the story is legitimately theirs; only the claim about where it was posted is unverified. exp is the ordinary story lifetime on both. Group membership does not extend it.

Report or block from a story (§6.25, ios#56)

Blocking existed in a conversation and nowhere near a story, the surface a stranger is most likely to reach somebody through. Reporting did not exist at all, on either platform or in the relay. A report is the one thing either app sends that the server can read. Everything else is sealed, and the relay cannot open a story, so a report about one is only actionable if the reporter hands over what they saw. Both platforms state that before sending, in those words: what leaves the device, who can read it, and that it cannot be undone. Declining to include a copy is offered on both, and says plainly there may then be nothing to act on. Both send only what they were passed. Neither reaches into the thread for surrounding context, §6.25 forbids including anything the reporter did not choose to include, and a helpful "we also attached the conversation" would be exactly the wrong kind of help. Blocking and reporting are offered together and never merged, on both. Blocking is the reporter's own decision and takes effect now; a report is a request to somebody else and nothing may come of it. A combined control would make one of the two invisible. The reasons are enumerated to match the relay, with a test on each platform asserting the lists are equal, a reason the relay rejects is a report somebody believed they had filed. CSAM is its own reason on both rather than folded into abuse: it carries a different obligation, and an operator cannot triage what nobody can name.

Sealed-to-unsealed fallback (§14.5, ios#16)

Sealing hides which account sent a message from the relay. When the peer's delivery key or capability is missing, the send still goes, end-to-end encrypted as always, but unsealed, and the relay learns the sender. Both platforms now count every outcome and surface the share; both offer refusing rather than degrading, off by default. The copy distinguishes sender from content on both, with tests asserting it. Somebody who reads "sealed" as "encrypted" makes the wrong decision in both directions, relaxed when it is off, and alarmed when it degrades on a message the server could never read anyway. Aggregate counts only. Per-contact counts would be more useful and would also build a record of who this account talks to and how often, the precise thing sealing exists to keep off a server, kept instead on the device. Zero sends reports as "nothing sent yet", never a reassuring 0%. Refusing costs nothing that was already spent. On both platforms the gate sits where sealed is false exactly when no capability was taken, so refusing cannot burn a token per attempt and drain the contact's batch. Android injects the policy and the counter into Conversations/Session as lambdas, those modules have no Android dependency, and wires them in ONE helper, because two sign-in paths building a session and only one applying the policy is how a setting silently stops working on half the app.

Everything 0.32 adds, with the DESIGN.md item that decided it where there is one. Each row names the iOS file to re-check it against, and the Android test that holds it. iOS was read on its release/0-32-0 branch at 1230277f on 2026-10-04.

Feature iOS Android Note
Sticker packs in FlameNet's art yes yes iOS StickerPack.swift reads the same manifests; Android StickerPack.kt, every pack under assets/stickers
A sticker or a card to a group, queued for the members nothing reached yes yes iOS E2EInbox.sendGroupSticker, sendGroupCard; Android Session.sendGroupSticker, sendGroupCard
Greeting cards (§6.31), sealed until a day yes yes iOS ChatCard.swift, CardViews.swift; Android CardUi.kt, CardArt.kt, CardsAndEventsInASessionTest
Events and answers (§6.32) yes yes iOS ChatEvent.swift, EventViews.swift; Android ChatEvents.kt (engine), EventUi.kt, ChatEventsTest
A rev 1 that takes over from a stand-in organizer is redrawn and never notifies (DESIGN 36) yes yes iOS ChatEvent.fold returns .settled; Android ChatEvents.fold returns SETTLED
Event and game ids: any non-empty string, a space included (DESIGN 36) yes yes iOS ChatEvent.isValid, ChatGame.swift; Android Content.Event, Content.Game, ChatGames.fromRow
An event runs 1 to 31 days, read and written (DESIGN 45) yes yes iOS ChatEvent.dayCount; Android Content.Event.clampDays, the event composer
A card, an event or a game follows the chat's timer; its own secs is ignored (DESIGN 46) yes yes iOS E2EAttachment.swift; Android Content.lifetimeSeconds, CardEventEnvelopeTest
Any ctl but 0, false or null makes an envelope control (DESIGN 43) yes yes iOS E2EAttachment.swift; Android Content.isControl, ControlFlagInteropTest
Games in a one-to-one chat (§6.33): chess, checkers, reversi, tic-tac-toe yes yes iOS ChatGame.swift, GameViews.swift, FlamenetGames; Android :games, ChatGames.kt, GameUi.kt; both engines pinned to games-v1.json
A gmove with no log or an empty one, and a gend with no log, are dropped (DESIGN 37) yes yes iOS GamePart.init; Android ChatGames.receive, ChatGamesTest
A draw accept counts only at the whole known log (DESIGN 37) yes yes iOS GameState.ending; Android ChatGames.applyEnding
A start or a move that carries r is still one; a field a kind does not use never drops it (DESIGN 44) yes yes iOS GameWire reads r leniently; Android Content.Game.fromJson, GameEnvelopeTest
"Sam invited you to play chess", with a sound, and the chat is unread (DESIGN 29) yes yes iOS GameWords.invited, E2EInbox.receiveGame; Android Session.Notice.GameInvite, GamesInASessionTest
A move, a draw offer or an ending is said but never unread (DESIGN 29) yes yes iOS GameWords.isQuiet; Android Session.takeGameNews: an ending with a sound, a move or an offer without
Boards turn round for the second player, tic-tac-toe never (DESIGN 30) yes yes iOS GameViews.swift; Android ChatGames.turnedRound, GameScreensTest
Games and events in the shared backup (§18) yes yes iOS E2EBackupPayload.swift; Android BackupPayload.kt
One-to-one threads backed up under the user id yes yes iOS E2EBackupPayload.swift keys threads by user id; Android BackupPayload.kt, BackupThreadKeyTest
Link previews on incoming messages yes yes iOS LinkPreview.swift, Message.swift; Android Session.kt, IncomingLinkPreviewTest
The Today tab beside Messages (§8.12) yes yes iOS TodayFile.swift, TodayStore.swift, TodayView.swift; Android TodayFile.kt, TodayFeed.kt (client), TodayModel.kt, TodayScreen.kt
Today fetch: once an hour, five minutes after a failure, an ETag only when it is the hash of the cached file (DESIGN 19) yes yes iOS TodayStore.swift; Android TodayFeedTest
Today strictness: no redirects, a market needs a name and a non-empty last and nothing else, v is the integer 1 as written (DESIGN 38) yes yes iOS TodayFile.swift (TodayJSON.exactInteger); Android TodayFile.verify, TodayFileTest
The Today file cited as §8.12, not §8.11 (DESIGN 34) yes yes Android TodayIsSection812Test
Today bounds: a figure 24 characters, five notes of 200, cc two letters, tz 64, and an unknown zone shows the days as given (DESIGN 42) yes yes iOS TodayFile.swift, TodayFormat.swift; Android TodayFile.kt, TodayText.ahead, TodayFileTest, TodayTextTest
The small-web directory and "Send to a chat" (§6.12) yes yes iOS SmallWebDirectory.swift, DirectoryView.swift; Android Directory.kt, DirectoryScreen.kt, DirectoryShare.kt
Hello links (§6.34): the address card, the app link, the scanners yes yes iOS HelloLink.swift, MyAddressSection.swift, CardScanner.swift; Android HelloLink.kt, AddressCard.kt, ScanAddress.kt
A hello link's path is exactly /hi or /es/hi: no trailing slash, no query (DESIGN 35) yes yes iOS HelloLink.swift; Android HelloLink.isHead, HelloLinkTest
A hello link's host is ASCII, an address has no spaces, a fragment is 400 characters at most (DESIGN 41) yes yes iOS HelloLink.canonicalAddress, ContactCard.swift; Android HelloLink.canonical, ContactCard.decode (typed or pasted), HelloLinkTest, ContactCardTest
Every scanner binds by fnid; an older card's ?u= is ignored (DESIGN 32) yes yes iOS CardScanner.swift; Android prefillFor, HomeScanFillsInTheAddressTest
Parental controls hide the Today tab and the directory (DESIGN 31) yes yes iOS ParentalControls.swift, MainTabView.swift; Android ParentalControls.todayHidden, NavDestinationsTest
Sign-out and a lost-device wipe clear the Today file and the weather picks, and a fetch still on its way is dropped when it returns (DESIGN 32) yes yes iOS TodayStore.forgetOnThisPhone, TodayStore.generation; Android TodayData.forget, the TodayFeed epoch, TodayDataIsClearedTest, TodayFeedTest
Edits, reactions, pins and story reactions go with the relay's silent flag and never notify (owner, 0.32) partly yes iOS E2EInbox.warrantsAlert at d7d0c83e wakes for any envelope with text, an edit among them, and the owner has approved making all four silent there; Android Conversations.warrantsAlert, every kind pinned in SilentKindsTest
A stored account opens to its chats and the saved Today page with the relay out of reach, and connects after (DESIGN 47) partly yes iOS is being changed to do so; Android Session.storedRoster, Session.storedGroups, catchUpAfterStart, StoredStartStateTest
A snoozed chat holds every notification, not only a game's no yes Owner, 0.32. iOS NotificationManager.postIncomingMessage checks mute and never snooze, though AttentionSummary says a snoozed chat "stops asking"; Android NotificationSettings.due, ASnoozedChatHoldsItsNotificationsTest
A group photo that reached nobody is queued, and leaves pending when it goes yes yes iOS E2EInbox.sendGroupImage; Android Session.sendGroupImage, OutboxTest
A photo, voice note or file to one person, and a file to a group, that reached nobody is queued and leaves pending when it goes (owner, 0.32) no yes iOS E2EInbox.sendAttachment and sendFile stop with an error when the person has no device, and nothing waits; iOS has no group file send; Android Session.sendUploaded, Session.sendGroupFile, OutboxTest
Event, card, poll and stories feed secondary text reads in the dark theme yes yes iOS Theme.secondaryText has a dark value; Android uses the theme's onSurfaceVariant, as the game card does; CardsReadInTheDarkTest