The story features existed in the codec and nowhere a person could reach them. + went straight to the file chooser, so a story of words was unreachable even though the engine could carry one; there was no way to react, reply, mute, or see what anybody had said back. + is a menu now. Reactions are a fixed row of five — a story is glanced at and scrolled past, and anything that takes two taps to say "nice" does not get used. Your own story shows the tally instead, because you cannot react to yourself and a row of dead buttons is worse than none. Mentions get an `@` picker in the group composer, matching iOS down to the rule that matters: the id is recorded when somebody is PICKED, never re-derived from the text at send time. Deriving it would mean typing a name reached that person whether or not they were chosen, and renaming a contact would silently unmention them. Only the ones still named in the body are sent, so picking somebody and then deleting their name doesn't ring them about a message that no longer mentions them. A group member who isn't in your contacts has no name to show, so they appear under their id. Un-mentionable would be worse than ugly — it would mean a group where some people cannot be reached. Mentions on Android are carried and rendered but override nothing: there is no per-conversation notification mute here to override yet. 640 unit tests green across app, engine and client. PARITY.md updated — mentions and the story features move to ✅, and Files and Wallet get rows saying plainly that Android has none of either. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
811 lines
45 KiB
Markdown
811 lines
45 KiB
Markdown
# Android ↔ iOS parity
|
||
|
||
What the iOS app does, what Android does, and what is left. Read from the
|
||
sources listed, not from memory — every row names the iOS file it came from so
|
||
it can be re-checked rather than trusted.
|
||
|
||
**Standing rule: this file is the definition of done for the Android app.** A
|
||
row moves to ✅ when the behaviour exists *and* something tests it, not when the
|
||
screen renders.
|
||
|
||
---
|
||
|
||
## Where things stand
|
||
|
||
| | iOS | Android |
|
||
|---|---|---|
|
||
| Protocol engine | `Sources/E2E`, 15 files | `engine`, 10 files |
|
||
| App layer | ~9,700 lines, 45 files | accounts, contacts, chat, photos |
|
||
| Tests | XCUITest + JS interop | 114 unit + 37 cross-engine + 15 on-device + live round trips |
|
||
| Ships to | 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 — blocked on the transcript carrying a `mid` |
|
||
| Edit a sent message (§6.15) | 🟡 codec done, in progress — an inbound edit is recognised and not drawn as a duplicate bubble; applying it needs the same `mid` |
|
||
| Drafts that survive a relaunch | ✅ sealed in the vault, not SharedPreferences |
|
||
| Forwarding (§6.16) | 🟡 codec done, in progress — an inbound forward is recognised and shown as one; originating needs #10 |
|
||
| 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 |
|
||
| Reactions | 🟡 codec done, no UI |
|
||
| Search inside messages | ✅ scanned, never indexed — no plaintext index at rest |
|
||
| Pinned message (§6.19) | 🟡 codec done, in progress — an inbound pin is recognised and not drawn as an empty bubble |
|
||
| 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) | ⚪ |
|
||
| Presence (read and set) | ✅ shown from the relay, never inferred |
|
||
| Buddy list, add by username | ✅ |
|
||
| Remove, block, unblock | ✅ |
|
||
| Member lookup | ✅ look up before adding; adding is gated on it |
|
||
| Buddy commands | — retired; not a feature any more (owner, 2026-08-25) |
|
||
| Voice calls (WebRTC, DTLS-SRTP, TURN) | ✅ P2P-first; ⚠️ no TURN deployed |
|
||
| Notifications list, read state | ✅ |
|
||
| Services (AIM/ICQ via `flamenet-oscar`) | ⚪ |
|
||
|
||
✅ 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.
|
||
|
||
---
|
||
|
||
## Files and Wallet
|
||
|
||
From `Sources/Views/Files/*` and `Sources/Wallet/*`, both of which moved into
|
||
the messenger rather than staying separate apps.
|
||
|
||
| Feature | Android |
|
||
|---|:--:|
|
||
| Local vault: add, organise, delete | ⚪ |
|
||
| Bell–LaPadula labels and clearance | ⚪ |
|
||
| Share a file into a chat or group | ⚪ |
|
||
| Wallet: BIP-39 seed, create and restore | ⚪ |
|
||
| Wallet: fresh receive address per receive | ⚪ |
|
||
| Wallet: balance and transactions from a node | ⚪ |
|
||
|
||
⚠️ Nothing here exists on Android yet. The iOS side is built and tested; the
|
||
engine work it rests on (BIP-39, SLIP-0010, Base58, the label encoding) is
|
||
Swift-only and has no Kotlin counterpart, so this is the largest remaining gap
|
||
and it is a build, not a port of a screen.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
### Narrowed 2026-08-25: 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 genuine 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
|
||
|
||
2026-08-25, 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 ✅
|
||
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 2026-08-25
|
||
|
||
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 genuinely 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.
|
||
## Text and link stories (§6.21, ios#32)
|
||
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 in order 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.
|