An inbound forward is shown as one rather than as the sender's own words. Both flag shapes accepted, like ctl: org.json's optBoolean does not coerce a number, so an engine reading one shape would silently miss every forward the other two send. ⚠️ The subtle one: Content.text returns raw bytes with no envelope at all when there is no id, reply or link to carry. A forward that was not counted in that condition would lose its flag and arrive looking like the sender's own composition — so is part of the test for whether an envelope is needed, not just a field inside it. Codec only. Originating a forward needs the transcript work in #10. Refs paulhitt/flamenet-messenger-ios#28 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
592 lines
30 KiB
Markdown
592 lines
30 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 |
|
||
| Photos | ✅ send via the system picker, render inline |
|
||
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
|
||
| Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted |
|
||
| 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.
|
||
|
||
---
|
||
|
||
## 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.
|