The on-device test now passes. The app, on an emulator, with an obfs4
bridge in force, signs up on relay1, publishes its device, looks up a
contact, adds it and sends a message. Six obfs4 handshakes counted by the
app, five of them before the send, and the relay logged
POST /e2e/messages 200.
What was in the way was not the bridge.
🔴 **signIn discarded work that had already succeeded.** It ran
`withTimeoutOrNull(60s) { withContext(IO) { authenticate(...) } }`.
`authenticate` is blocking socket work, so cancelling the coroutine does
not stop it: the timeout fired, the screen said "The server did not
answer. It may be down, or the address may be wrong", and the
registration carried on and finished behind it. One run left the account
`andb7564513` on relay1 with a published identity and a device while the
person was told the server was unreachable. The relay answered sign-up in
397 ms and the app's next call came 115 seconds later.
Reproduced with no bridge (`-e direct true`), so this was never a bridge
defect. The work now runs in its own `async`, the first 60 seconds still
report, as "Still waiting for the server" rather than as a failure, and a
late success is adopted instead of dropped. After 180 more seconds it
gives up and cancels the work, so a success nobody is waiting for cannot
surprise somebody who moved on.
Spanish for the new string is in values-es.
Two things the test learned to stop believing:
- An outgoing line in the thread is not delivery. The app renders a
message as soon as it is composed, and a run that asserted only the
text reported OK while the relay never saw a POST at all. Delivery is
now the absence of both the `pending` and `undelivered` markers.
- `Obfs4Settings` on main takes `add`, not `save`, and returns
`AddResult`. The branch this was first written against had the older
single-line API.
InstrumentedSuitesCanSignUpTest now accepts `typeIfPresent("birth-year")`
as well as the direct call. relay1 does not ask for a birth year, so a
suite driving it has to fill that field only when the relay puts it on
screen. The rule it enforces is unchanged.
Gate: `:app:testDebugUnitTest` is 671 tests with 267 failures on this
machine, identical with and without this change, measured by stashing it
and re-running. 266 of those are
`IllegalArgumentException: Unsupported class file major version` from a
JDK 26 toolchain, which is this laptop rather than the repository. The
one real failure was the guard above, and it passes now: 3 tests, 0
failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2026-09-28 answers to OTF said bridge users can reach the relay from
anywhere. flamenet-e2e now carries a real message through the live bridge
from Node, which proves the bridge and the relay. It does not touch this
code: Obfs4SocketFactory under the app's own OkHttp, the sign-up, the
device registration and the send.
Two tests, both opt-in, both needing a live bridge.
BridgeTransportProbeTest opens the tunnel by hand and speaks one request
through it. PASSES on an emulator, and the numbers are worth keeping:
one socket plus TLS plus a GET in 1.3 seconds, and four concurrent
tunnels in 1.7 seconds with four handshakes counted and HTTP 200 on all
four. The certificate inside the tunnel is relay1's own. Concurrency was
the first suspect and it is not the problem.
BridgeMessageTest drives the app: bridge in force first, handshake
counter asserted at zero, then sign-up, add a contact, send. It does NOT
pass yet, and the reason is not the bridge.
🔴 **The app strands the person on the sign-up screen with an account
that exists.** After a transient failure during sign-up the screen keeps
saying "The server did not answer. It may be down, or the address may be
wrong", and it never navigates, while the relay's log shows signup 200,
token 200, devices 200, identity 200, buddies 200 and groups 200. The
account `andb7564513` from one such run answers on
`/e2e/account/identity` with a published identity. The person is told the
server is unreachable; their account and device are registered.
Reproduced with `-e direct true`, which runs the same path with no
bridge, so this is an app defect and not a bridge defect. The control
exists for exactly this reason: without it the flakiness looks like a
flaky tunnel.
Two numbers behind it: the relay answered sign-up in 397 ms, and the
app's next call came 115 seconds later. `RelayClient.defaultHttpClient`
sets `callTimeout` to 25 seconds, so the first attempt times out, the UI
latches the error, and the retry that succeeds changes nothing on
screen.
Four things these tests cost, each a silent failure rather than an
error:
- The sign-up form's fields depend on what the relay says about its
identity mode, so filling them before discovery lands types into
fields that then vanish, and the submit button stays disabled.
- `toggle-register` is a toggle. Clicking it blindly put the test into
sign-in mode, where it pressed a button labelled "Sign in" and read
the refusal as "Your session expired".
- The contact row shows the display name, so a peer whose display name
differs from its handle is invisible to a search for the handle.
- The handshake counter has to be zeroed after the sign-out, because
signing out of the previous run is itself a request through the bridge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four Gitea Actions jobs green on a13adb66: secret-sweep, image, unit-tests (671 tests) and vectors-head. The tree compiles and the suites pass; the notification and padding merges still deserve a human read.
StoryTakeDownTest reached for `io.flamenet.e2e.Bytes`, which is internal to
the engine module, so the app's test source set cannot see it. Both that
helper and the client's `Wire.b64` are `Base64.getEncoder().encodeToString`,
which is what the linked-device map is compared against, so the test uses it
directly, as the other app tests do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each of these tests was written on the release branch against behaviour main
has since changed. The production behaviour stands and the expectation moves,
because in every case main's rule is the one the spec states.
InboundControlReachesTheThreadTest: a reaction and a pin were expected in
`absorb`'s return. Sections 6.9 and 6.19 say neither may notify, and main
applies both through `changedQuietly`, so the thread is redrawn and never
marked unread. The two tests now assert an empty unread set and the thread in
`takeQuietlyChanged`.
StoryTakeDownTest: the tablet was registered but never linked. Spec 17.3,
which main added, sends an own-account copy only to devices a person linked,
under the key they were linked with. A device signed in with the password is
a device of the account and nothing more. The test links it.
ASnoozedChatHoldsItsNotificationsTest read MessengerService for
`due(touched, ...)`. The service now takes quiet threads out first
(`val loud = touched - s.quietThreads`), per 14.5 and 17.2, and the test
checks both lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Session.kt declared `arrived` twice, once per side. Main's filter is the one
to keep: its own comment records that filtering on `isRenderable` here made
the reaction, edit and pin branches unreachable, so everything that arrived
was decrypted and then dropped. The release branch's declaration is gone and
the loop uses the broader list, which is what main's fix intended.
StoryFrame.kt was extracted from StoriesScreens and localized in the same
move, so it calls `stringResource(R.string.social_story_image_desc)` without
importing either name. Both imports added. The key is in values and values-es
already; parity is 860 keys per language.
SilentKindsTest pinned "bridge offer" to `Content.bridgeOffer(...)`, which
returned String on the release branch and String? on main, where a blank or
over-long line is refused (§24.4). That typed the whole map String? and the
two assertions stopped compiling. The test line is neither blank nor long, so
it is unwrapped at the fixture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The container job picked `sh -e {0}`, and sh on eclipse-temurin:jammy is
dash. Every `set -euo pipefail` step failed on line 2 with "Illegal option
-o pipefail" before running a command, so unit-tests and vectors-head went
red without compiling anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Translates .gitlab-ci.yml to .gitea/workflows/ci.yml and deletes it. Kept:
secret sweep, the four-module unit run with --rerun-tasks against the
pinned vectors, the XML floors (595/405/68/515, 1580 total), vectors-head
(advisory).
Runs on mac1/mac2 inside a pinned toolchain image (ci/android-ci.Dockerfile:
Temurin 21.0.5, cmdline-tools 11076708, platform 35, build-tools 34.0.0 and
35.0.0, Node 22.11.0), never on pauls-imac. The `image` job pushes the tag
to git.extant2000.com when it is missing.
Vector and README links now point at git.extant2000.com/extant2000.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two real defects in 69e12af, both found by the pipeline rather than by reading.
client/Conversations.kt had an extra `}` on line 425. Writing out both
`sendGroupAttachment` (main) and `uploadAttachment` (the release branch) left
the closing brace of the function git had spliced away. The class body closed
at 425 instead of 3466, so the parser reported `companion object` at 3364 as a
syntax error 3000 lines from the cause.
.gitlab-ci.yml took the release branch's copy whole, which predates five
vector exports main had added: padding-v1, sync-merge-v1, handle-cert-v1,
links-v1 and bridge-offer-v1. BridgeLineVectorTest then failed on a missing
file, reading as a protocol bug. The two sides each added different vectors,
so the resolution is the union, in both the pinned job and vectors-head.
vectors-head runs :obfs4:test again for the same reason: it reads the
bridge-offer vectors from SPEC section 24.
All 15 vector files exist at the pinned e2e ref 4b4042f.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two 0.32 lines had diverged since 28 September. main carried the Spanish
localization (853 strings) and friend-to-friend bridge sharing;
release/android-0-32 carried 65 commits of stories, Today, the shared test
vectors, the shell peer and envelope wake parity. Neither was a superset, so
this is a real merge of 26 conflicting hunks across 15 files.
.e2e-ref now pins flamenet-e2e 4b4042f, the commit that unified the two spec
lines. Before that no single pin covered both sides. .gitlab-ci.yml takes the
release branch's version, as the owner chose.
Conflicts that were decisions, not text:
- Conversations.kt looked like two versions of uploadAttachment. It was not:
that function exists only on the release branch, and git had paired its
doc comment and parameter list with the body of main's sendGroupAttachment.
Resolving side by side would have deleted one function. Both are written
out whole, and the attachment padding of 23.7 and 23.9 is intact.
- MessengerService and Session: main suppressed alerts for this account's own
device copies (14.5, 17.2); theirs added card, event and game notices with
snooze handling. Session keeps the quiet set and the notice bookkeeping and
returns touched + quiet, so quiet threads still count as unread, and the
service subtracts them before anything is raised.
- MainActivity: contact links and hello links are different schemes, so both
handlers stay. Hello runs first, because helloLinkIn matches only its own
scheme and leaves the intent alone, while the contact handler consumes any
VIEW data and would swallow every hello link.
- MessengerViewModel: fetchedBuddies is not in scope at that point, so the
roster comes from the vault as theirs has it, with the status localized.
Nine string keys to match: seven new (the hello-link QR label, the card and
event forward notes, the Today tab and the three attach row labels) and two
reworded, because supervision now hides the Today tab. English and Spanish
both, so parity holds at 879 keys each side.
No Java on the machine this was merged on, so CI is the first compile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DRAFT, needs native review.
Rebased onto bridge/f2f-apps (ebdd2ac). NetworkSection.kt was taken from
the bridge branch whole and its text moved to resources again, so every
bridge behaviour stays as that branch wrote it: several lines with failover,
offers, sharing with a confirmation, scanning a line, and nothing said to go
around a saved bridge.
- 19 new resources: the bridge list, failover and sharing text, the sign-in
"Connect through a bridge" toggle, the four "off while you use a bridge"
notices (calls, wallet, link previews, and the no-bridge failure).
- statusLineText() mirrors the new statusLine() branches, including "none
reached" and "N bridges saved"; the English function stays for its tests.
- describeFailure takes the no-bridge sentence from the view model;
ChainRpc.Failure.OffWhileBridged maps to the wallet notice.
- ScanAddressDialog's default hint and refusal are resources.
- Obfs4Settings.AddResult and AroundTheBridge keep their English for the
tests; the screens show resources.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DRAFT, needs native review.
An emulator run with the app locale set to es-MX, signed up on a local
relay, showed English in four places and one cut-off label:
- The sign-in line about which accounts a server keeps
(SiteConfig.identityDescription) and the Network status line
(statusLine): both now have a resource twin that the screen uses. The
English functions stay because their tests call them without a Context.
- Message and reply-reminder notifications: the NotificationSettings
instance methods read resources; the companion functions the tests pin
are unchanged.
- The presence word ("offline") in the chat header, contact rows and
contact detail now goes through presenceLabel().
- "Todos excepto" was cut to "Todos exc..." on the story audience row. It
is "Todos menos" now, and "Todos" for "All contacts". "7 días" is still
cut, exactly as "7 days" is in English.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DRAFT, needs native review. Nothing in values-es ships until the owner or a
Localization Lab volunteer has read it.
- 841 user-facing strings (823 strings, 18 plurals) moved from Compose and the
view model into res/values/strings_{common,chat,core,main,settings,social}.xml.
English is word for word what the screens said before.
- values-es mirrors every file key for key, neutral Latin American Spanish,
"tú", terms from the /es/ site pages. docs/i18n/glossary-es.md lists them.
- UserFacingErrors.kt maps the client library's fixed-text exceptions to
resources, so a relay refusal reads in the phone's language. Text that
comes from the relay itself stays as sent.
- SpanishStringsTest: every key has Spanish, no orphans, identical
placeholders, a draft marker in every file; short labels over 1.6x are
listed, not failed.
- Source-reading tests read the English through EnglishStrings.withEnglish,
which appends the text of every key a file references.
Left in Kotlin on purpose, because a unit test calls the function with no
Context and pins the English: describeFailure, linkOutcomeText,
NotificationSettings.messageText, SiteConfig.identityDescription,
NetworkSection.statusLine, registeredLine, draftLengthText, quotedPreview,
FileLevels, ReportReason labels, AppDiagnostics summaries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OkHttp skips the resolver for an IP literal, so it had one route and gave
up after the first line; the next line waited for a later request. The
cross-platform run caught it: Android sign-up against a relay at an IP
address sat on a dead first line, because only one discovery probe is made.
An interceptor now retries on a connection-phase failure (refused, no
handshake, TLS failing in the tunnel), once per stored line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SPEC §24.6: a client with bridges turned on must not connect directly.
- Off while a line is saved, saying why in plain words: link previews (the
fetch returns nothing and connects nowhere; the setting is greyed with a
note), calls (refused at place, at answer and where the peer connection is
built; an incoming call is answered busy), and the wallet (the chain client
refuses before connecting; the screen says so).
- Through the bridge now: the update check, which had its own client.
- The anonymous client keeps its own pool, which now empties with the rest
when the bridges change (§24.7).
- The probe the app makes at launch goes to a saved bridge, checked by a
test that fails when the bridges are not loaded at startup. With no bridge
it is unchanged.
- The sign-in UI test saves a dead line first, so its view model never
probes the production server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- "Connect through a bridge" on the sign-in and sign-up screen opens the
bridge panel before any account exists: paste or scan a line, the live
status, remove a line.
- Up to 8 stored lines (migrated from the one-line store), tried one at a
time with the one that last worked first. OkHttp's own route loop does the
trying: while lines are stored the resolver answers every name with one
placeholder per line, so no name is looked up on the local network, and a
refused connection, no handshake within 15 s, or TLS failing inside the
tunnel moves to the next line. accounts.flamenet.io and the relay keep
their own names through the tunnel, so the edge behind the bridge routes
each to its real service. Every line failing never connects directly, and
the error says no bridge could be reached.
- Offers: a gid, a line over 1024 bytes, or a line that is not a string is
dropped; an oversized or blank line is never built. Ports are digits only.
- Held to bridge-offer-v1.json in :engine and :obfs4; .e2e-ref moves to
flamenet-e2e c75b861 and CI exports FLAMENET_BRIDGE_VECTORS.
- Citations of §6.19 for the bridge offer now say §24.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
!161 carried this branch's two vault fixes to main, with the account-switch
test written against main's Vault (group names in place of the client
records 0.32 adds). Both added that test file; this keeps the 0.32 version,
which also checks the handle certificate record. Every other file merged
cleanly: the fixes are the same lines on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e2e!66 merged privacy/sweep-2026-10 (the 0.32 SPEC and vectors this branch
was written against, 5a5b7e7), the Swift interop build list and §7's
post-quantum safety number. PublishedVectorsTest reads `safetyNumberPQ`.
Full suites with the vectors at 50fcc99: engine 759, client 573 (30 live
tests skipped), obfs4 75, app debug 393, app release 352, 0 failures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SPEC 8.12: https://<relay>/add#... used to match only an unverified
*.flamenet.io filter, so Android asked which app to use, or opened the
browser. A second filter with android:autoVerify claims relay.flamenet.io
and relay1.flamenet.io for /add; the relay serves the matching
/.well-known/assetlinks.json (io.flamenet.messenger, the Messenger release
certificate's SHA-256). The wildcard filter stays for every other relay.
The link handler already reads the https form (MainActivity.openLinkIfAsked
to handleLinkIntent).
AContactLinkOpensTheAppTest: red with the old manifest, green now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner's decision, 2026-10-09: iOS's rule (audit H3) is the protocol. On
the iPhone-to-Android pair against a local relay the two screens showed
different numbers for the same accounts (iOS 09614...98887, Android
15508...63939): only iOS folded the ik_pq fingerprints in.
- Trust.safetyNumber folds SHA-256(ik_pq) of both sides in when this
device holds both (SafetyNumber.compute with PQ, the engine already had
it), the Ed25519 number otherwise. safetyNumberCoversPQ says which, and
the screen says it in iOS's words.
- The side that never read the other's bundle (the one that answered a
first message) had no pin for them, so its number could never match.
Conversations.pinPeerPQIdentity reads the contact's bundles once, for
devices whose IK_sig is already trusted, checks and pins their ik_pq
(§20.5); the screen does it when the number is first shown.
- PublishedVectorsTest pins protocol-v1.json `safetyNumberPQ`
(flamenet-e2e 841492d).
FirstContactTest "the safety number covers both post-quantum identities,
as on iOS": red (Ed25519 number), then green, including both screens
matching after the pin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found on the emulator against a local relay: after a plain sign-out of
luke1009 and a sign-up as mira1009 on the same install, mira saw luke's
conversation with lina1009, carried andy1009's handle certificate from two
accounts earlier, and sent her first message to lina on luke's ratchet
session. The iPhone could not open it ("no session for envelope 95 from
a26ac1d7") and mira's first contact never arrived. Main has the same sign-in
path: a different account got a new device identity (android#22) and kept
every other record.
AccountSwitch.forgetPreviousAccountIfSwitching empties the vault when a
different account (another user id or relay) signs in, before its device is
made. The same account signing back in keeps everything, as before. The
last account's device identity was already being replaced at that point,
so nothing forgotten was still usable by it.
AnotherAccountStartsWithNothingTest: 2 red, then 4/4 green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found on the emulator, typing into a new conversation: the app died with
"IllegalStateException: could not commit the vault record" from
Vault.saveDraft on a worker thread. The draft is saved from a coroutine per
keystroke on the IO pool; two writes of one record staged into the same
`<name>.tmp`, the first rename took it, and the second had nothing to
rename and threw, uncaught. Main has the same code.
Each write now stages under its own name, and draft saves run one at a
time and in order, so an older draft cannot land after a newer one or
after the send that cleared it.
TwoWritesOfOneRecordDoNotCrashTest (400 writes from 8 threads): red with
the shared staging name, green now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found on the emulator against a local relay: after a plain sign-out of
luke1009 and a sign-up as mira1009 on the same install, mira saw luke's
conversation with lina1009, carried andy1009's handle certificate from two
accounts earlier, and sent her first message to lina on luke's ratchet
session. The iPhone could not open it ("no session for envelope 95 from
a26ac1d7") and mira's first contact never arrived. Main has the same sign-in
path: a different account got a new device identity (android#22) and kept
every other record.
AccountSwitch.forgetPreviousAccountIfSwitching empties the vault when a
different account (another user id or relay) signs in, before its device is
made. The same account signing back in keeps everything, as before. The
last account's device identity was already being replaced at that point,
so nothing forgotten was still usable by it.
AnotherAccountStartsWithNothingTest: 2 red, then 4/4 green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found on the emulator against a local relay: an install that had been
signed in as andy1009, then iris1009, then luke1009 carried andy1009's
certificate (uid 1) as `hc` on luke's first message to an iPhone. The
iPhone refused it ("prelude carried a handle certificate that does not
verify") and showed luke as "Buddy 3". A plain sign-out keeps the vault,
and ownHandleCert reused any kept certificate that had not expired.
A kept certificate is now reused only when it verifies for this account;
otherwise a fresh one is fetched.
FirstContactTest "a certificate kept for another account is not carried as
mine": red, then green; 16/16.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found on the emulator, typing into a new conversation: the app died with
"IllegalStateException: could not commit the vault record" from
Vault.saveDraft on a worker thread. The draft is saved from a coroutine per
keystroke on the IO pool; two writes of one record staged into the same
`<name>.tmp`, the first rename took it, and the second had nothing to
rename and threw, uncaught. Main has the same code.
Each write now stages under its own name, and draft saves run one at a
time and in order, so an older draft cannot land after a newer one or
after the send that cleared it.
TwoWritesOfOneRecordDoNotCrashTest (400 writes from 8 threads): red with
the shared staging name, green now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found linking an emulator to an iPhone account by the numeric fallback
(17.3), against a local relay:
- The offer arrived and was held, but the banner that asks the person to
compare the number reads held offers only on a refresh, and the poll
refreshed only after a tick that touched a thread. A link offer touches
none. The poll tick now refreshes when the held offers change.
- The existing device sends its snapshot right behind the offer. The new
device, still waiting for the person, dropped it as traffic from a
device it had not linked ("DROPPED sync_state from own device ... which
is not linked"), so the confirmed link held no contacts, blocks or
flags. acceptHeldLink now asks the offering device for its state
(sync_state with want), which both apps answer with a snapshot.
History sent behind a numeric offer is still dropped; only the QR path
backfills history.
LinkingAndSyncTest "the numeric fallback still hands the new device the
account's state" and ALinkOfferShowsWithoutAMessageTest: red, then green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found on the emulator against a local relay (relay main plus the wave 0b
descriptor switches): a new Android account adding an iPhone account by
username failed with "The server's answer about that account could not be
verified, so nothing was sent." The handle certificate (14.5) is checked
against the pinned relay key, and Android pinned that key only when it
first verified a key-log head, which a fresh install had not done yet.
FakeRelay pinned every device by hand, so no test saw it.
Trust.relayKeyPinnedOnFirstSight pins the descriptor's key when nothing is
pinned, on the same RelayIdentityPin verdicts as the head check; a key
that changed still verifies nothing until the person accepts it. iOS pins
on first sight at start-up (checkRelayIdentity).
FirstContactTest: "a fresh install pins the relay key on first sight" red,
then green; "a changed relay key verifies no certificate" green both ways.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full suites had not been run on this branch with --rerun-tasks. With
the vectors given (FLAMENET_*_VECTORS, flamenet-e2e 5a5b7e7):
- 20 client and 6 app tests queue exactly the answers their route needs on
a MockWebServer. Since 0.32 a poll and a directory read first read the
relay descriptor, which took the queued inbox and left the real read
waiting for 10 seconds. NoDescriptor answers the descriptor with 404
(every switch off, the behaviour these tests describe) outside the
queue, and request counts skip it.
- SecondDeviceCallTest looked for `private fun sendPlaintext` and an
`emptyList()` prelude; it is internal now and the preludes are
`if (toSelf) null`.
- AcceptingKeysBringsTheMessagesBackTest counted forgetHeldEnvelopes in
the view model; a block now drops them in Conversations.blockContact
through Session.block.
engine 758/758, client 568 (30 live tests skipped, no relay), obfs4 75
(interop against lyrebird 3/3 via scripts/obfs4-interop.sh), app debug
386/386, app release 345/345.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The relay's switches were applied only in poll(), so a send made before
the session's first poll read the recipient's devices and bundles from
GET /e2e/devices/{id} and /e2e/keys/{id}, naming the target in a URL, on a
relay that offers anonymous reads (spec 8.11). FirstContactTest "reads
about other people name nobody" caught it: it sends without polling and
was red on this branch. iOS reads the switch at each call.
directoryFor and bundlesFor now apply the switches first (cached, so one
descriptor read per session), and the avatar push reads devices through
directoryFor rather than the relay client directly.
New in FirstContactTest: a reusable link reaches the relay as standard
base64 SHA-256 of its 32 bytes, the link carries it as 43 characters of
unpadded base64url, and a sixth is refused and not kept, as
RoutesIntro.setContactLinks requires.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spec 8.12: a one-time link carries a delivery token (21.4) the recipient
minted, and the first message presents it. No introduction is involved.
The Finding you section was hidden unless the relay advertised
`introductions`, which hid one-time links with it. iOS offers them on any
relay that takes delivery tokens.
contactLinkKindsOffered: one-time always, reusable where the relay keeps
them (`contactLinks`). The section shows on every relay.
OneTimeLinksNeedNoIntroductionTest: red (an empty list without
introductions), then green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The relay does not advertise either yet (privacy/wave0-relay offers
padAttachments, perDeviceDigests and, on privacy/web-search, webSearch), so
the two apps have to agree on the names first. iOS 14c2c7ed reads
features.introductions and features.anonymousReads; Android now reads the
same keys. contactLinks, unlisted and webSearch already matched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The screens for part 2 (docs 60 and 61), on top of the client work:
- Requests (14.5, decision DC2): a stranger's conversation sits under
Requests with Accept, Block and Delete, named by a verified certificate
when there is one ("Name confirmed by the server") and otherwise by the
relay's answer, marked unconfirmed. The app no longer adds strangers to
the contact list on the person's behalf. Requests and copies from linked
devices are quiet: they count as unread and never raise a notification.
- Pairs 2-B: acceptance and blocks seeded from the relay once, then held on
the device; blocking is the local block, the batch revoked, the key
rotated and pushed, and the relay's block, with an honest message when a
part of it failed. Star and archive from a long press, starred first,
archived under their own heading. Opening a chat moves its read mark,
which the linked devices get; a thread read on another linked device stops
showing unread here. An owned legacy group is sealed once a session.
- Sent-copies and history (17.2, 17.4) are filed into their threads as
outgoing (or as the original sender's), once, never into an
off-the-record thread.
- Your devices: which are linked, Unlink, Link this device (the QR code for
ten minutes), Scan a device's code (confirmed before anything is sent),
Link by number with the safety number on both screens, an optional
history backfill, and why a linked device left the set (removed by you,
retired by the server, or no longer listed). A held numeric offer shows
on the new device until the person confirms the number there too.
- Finding you (8.12): Listed or Unlisted where the relay offers it, worded
as "people can't find or add you by your username", never "secret"; the
same choice at signup, Listed by default, with an honest message when the
server cannot apply it. One-time and reusable contact links as QR codes,
shared as the https form, removable.
- Contact links open from a scan, from another app (flamenet: and
https://*.flamenet.io/add, not verified app links) or after sign-in if one
arrived before it.
- Web search, its own screen behind features.webSearch.
- The export nudge (DA9): a single-device account with no export in 30 days.
- A send refused for having no sealed way to go says "Could not send.
<name> needs to message you or send you a new link."
UI copy went through the avoid-ai-writing audit (two fixes). LinkingWordsTest
(2). Written while builds are paused for CI: the :app module has not been
compiled since 9322d1e.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Spec 8.9d: when a contact's device list gains a device (Trust records the
addition), the next poll re-sends them our delivery-key prelude with the
presence token we already issued, so a new phone of theirs restored
without a backup can see our presence. Taken after the poll, never inside
a send.
- Spec 17.3: the linked set is checked against the key log at most hourly
from the poll.
- WebSearch and MessengerApi.webSearch for POST /api/flamenet/v1/search
(relay branch privacy/web-search): the query in the body, never the URL;
the relay's limits (1 to 200 characters, pages 1 to 3) checked before
asking; an unavailable index kept apart from "nothing found"; a result
that is not http or https dropped. Shown only with features.webSearch.
WebSearchTest (4). Written while builds are paused for CI; not yet run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Client side of stages C2, C3, D2 and B3 (SPEC 14.5, 8.11, 8.12), each behind
the relay's descriptor switch for it, since none of the relay half is
deployed: features.intro, features.anonReads, features.contactLinks and
features.unlisted (a JSON true only). The relay branch privacy/wave0-relay
does not advertise these four yet; it must before any of this turns on.
- C2: HandleCert verification against the pinned relay key. This account's
own certificate (GET /e2e/handle-cert, read bare or wrapped) rides as `hc`
in the first message's prelude; a contact's is kept only if it verifies
and names that sender. knownHandle says certified or a link's hint.
- C3: introduceByHandle (the intro challenge solved, then POST /e2e/intro)
and introduceByLink (a one-time link's token presented as delivery_token,
bundles read anonymously; a reusable link asks for an intro, which does
not spend it; a link for another relay is refused). A send seals by token,
then key, then intro. With features.intro (or sealed-only asked for),
nothing goes out named: a refused token tries the key, a refused key tries
ONE fresh intro by the link or handle the conversation started from (or
the roster's handle), and otherwise NeedsIntroduction, sending nothing.
Without the switch a first message still goes named, so 0.31 peers and
today's relays keep working.
- D2: AnonymousRelay, a separate OkHttp client (own pool, no cookies, no
Authorization, User-Agent "FlameNet", same obfs4 transport). With
anonReads, directory and bundle reads about others go to /e2e/anon/* with
the presence token they issued us as `cap`; our own devices come from
GET /e2e/devices/me. presenceAnonymously asks in shuffled batches of at
most eight, each after a random pause of up to 20 s.
- B3: createContactLink (one-time: a delivery token uploaded by hash among
the others; reusable: POST /e2e/contact-links by hash, at most five),
contactLinks, revokeContactLink; MessengerApi.discoverable and
setDiscoverable for Listed and Unlisted.
FirstContactTest (12) against FakeRelay, which gained intros, certificates
signed by a relay key the devices pin, the intro challenge and contact
links. Written while builds are paused for CI; not yet run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Section 16.2.1's pending keys, written and read: who was accepted and who is
blocked, stars and archives, read marks (from the sync registers, their only
store), the account delivery key and the delivery-token batches issued.
A restore merges acc, blocked and convFlags as a union (it never unblocks
anybody), read as a maximum per thread, deliveryTokensIssued per contact with
this device's own entry winning, and adopts dk only onto a device with no
key of its own (16.1), unpublished so this device publishes it. What changes
the registers is queued for the linked devices.
BackupAccountStateTest (6) holds the round trip to the vector's `pending`
object exactly, and each merge rule. Written while builds are paused for
CI; not yet run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Client side of stages A2 and A3 and the core of pairs 2-B:
- AccountSync keeps this account's registers (17.6). acc/, blocked/, conv/
and read/ live only there: who was accepted, who is blocked, stars and
archives and read marks are no longer the relay's to remember. Seeding
from the relay happens once and is a union.
- SyncProjection mirrors the other families (roster, gname, gsecret, pin,
audience, muted, pdk, tok, ptok/i, ptok/h, dk, link) onto the stores the
app already had. A device compares what its stores say with the last look
and turns each difference into an op; a change that arrived from a linked
device is written and the look retaken, so it is never echoed, and a
value that does not parse leaves the store alone, so it is never undone.
Off-the-record pins never cross.
- flushSync at the end of every poll: up to 8 ops go as sync envelopes, a
burst as one sync_state; read marks at most once per thread per 30 s. With
nobody linked the queue is dropped. A week away sends a snapshot with
want; 90 days away discards all but link/ first.
- Own-device traffic is sealed to the account under its delivery key.
Everything but a link_offer counts only from a linked device; a link/ add
is honoured only for a device the relay lists on this account under that
key; a link/<self> rm clears this device's set.
- Linking: beginLink (the QR text, a 16-byte secret for ten minutes),
linkDevice (relay must list the device under the shown key, the key log
must not have retired it; then link_offer, a snapshot, optional history,
and the op to the rest of the set), the numeric fallback held until the
person confirms on the new device too, unlinkDevice (the device leaving
is told), refreshLinkedSet (revoke / expire / missing, kept as notices).
The new device adopts the linked set and the account delivery key.
- Sent-copies (17.2) for everything a person sends, one per group fan-out,
never for off-the-record threads; history (17.4) arrives as Received.
- Token slices (21.4, A3): each of a contact's devices gets its own slice of
the batch in its own prelude. The prelude can carry `hc` (14.5).
- Blocking is the local block, the batch revoked, the delivery key rotated
and pushed to every other contact at once. A blocked person's messages are
dropped on arrival. A stranger's message is marked a request; writing to
somebody accepts them. A rekey_request drops sessions with that device and
the batch issued to them.
FakeRelay: an in-memory relay with real mailboxes, sealed-submission
checks, contact links, intros and log entries. LinkingAndSyncTest (24), all
passing; client suite 551 with the two UrgentCallOfferTest source checks
updated for the new send signature (text may be null for a key push).
Not yet run: falsification of these tests, and the :app compile, both held
while builds are paused for CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pieces sections 17.6, 17.3, 8.12 and 14.5 must compute exactly as every
other engine does, before anything uses them:
- SyncRegisters: the register merge (later at wins, a block wins a tie, then
the greater device id as text by UTF-16 unit; read marks only move
forward; ops more than a day ahead dropped; malformed ops ignored whole;
unknown keys kept as ordinary registers), tombstones forgotten after 90
days, snapshot registers merged one by one, chunking at 16 KiB.
- LinkCodes: the device-link code (flamenet:link?d=&k=&s=), both forms of a
contact link (https://<relay>/add#... and flamenet:<fnid>@<relay>?...),
base64url without padding, encodeURIComponent as the reference writes it.
- HandleCert: fnhandle-v1 verification against the pinned relay key, for this
relay, for the account it is about, before exp; `|`, a wrong version and a
uid given as text are refused before any signature check.
MultiDeviceVectorTest (10) checks sync-merge-v1 in every order of every
case's ops and snapshot registers, each order also delivered twice;
handle-cert-v1 valid and invalid; links-v1 encode, decode and refuse.
Falsified: no tie rule for blocks, `|` allowed in a handle, a repeated key
accepted, a short secret accepted; each goes red.
.e2e-ref moves to flamenet-e2e 5a5b7e7 (privacy/sweep-2026-10), which carries
the three files; content-v1 and backup-payload-v1 changed only in their
pending sections, and the whole engine suite passes at the new pin (752).
CI exports the three new variables in unit-tests and vectors-head and gates
on them. That commit must be on the e2e remote before this branch's CI can
pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK is back, so :app builds again: 358 unit tests passed before this
commit with no change needed. The three app-side changes from part 1 had no
test of their own:
- the composer's countdown text (draftLengthText, pulled out of the
composable);
- describeFailure for each Padding.Failure, in the words iOS uses;
- when roster tags are published, and under which device id
(Session.planRosterTags, pulled out of presenceFromTokens).
PaddingReachesTheScreenTest (6). Falsified: the Padding branch removed, the
device id sent on every relay, the countdown starting at 2,000, and an empty
first set published; each goes red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Content wrote strings through org.json's JSONObject.quote, and the two
org.json builds disagree: the phone's escapes every "/" as "\/" and writes
U+2000 to U+20FF as UTF-8, the JVM's (used by the tests) does the opposite.
So a URL cost twice on a phone what iOS counts for it, the composer reached
its limit sooner on Android, and the tests measured a writer no phone runs.
Content and Padding.composerCost now both use the engine's own writer
(Padding.Json.quote), which escapes only what JSON requires and is the same
on both. A "/" is one character, as iOS counts it. Receivers parse JSON and
read either form; the iOS vectors still parse.
New test: what the composer counts is exactly what a text adds to the
envelope, for random runs of quotes, slashes, control characters, dashes,
U+2028 and emoji. Falsified: composerCost back on JSONObject.quote, and
Content back on JSONObject.quote, each go red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SPEC 23.7 says a refusal is surfaced and never swallowed, and comes before
anything is spent. Android checked each member on that member's own session,
so a text that fit one member's classic header and not another's post-quantum
first message reached the first member and was then reported as refused; the
sender's own copy was never written. iOS had the mirror of this (caught per
member, shown as delivered).
- Padding.refuseUnlessItFitsEveryHeader checks a body once against the
largest header an envelope can carry, sealed, as iOS does. sendToGroup and
sendGroupAttachment call it before any member (the caption before the
upload too); the app layer's group fan-out (edits, forwards) through
Conversations.refuseUnlessItFitsEveryMember.
- A Padding.Failure inside the per-device loop (and in the unsealed retry) is
rethrown, not filed in Sent.failed: a group where every device failed that
way read as "not delivered yet". Nothing for that recipient is posted.
- Edits and forwards that are refused for length now say so on screen; the
edit puts the old wording back as before.
AGroupSendIsRefusedWholeTest (5): RED before the fix on three (the classic
member received the text; WrongLength came back as Sent.failed; the caption
case uploaded first). Falsified after: no preflight, rethrow removed, and the
pre-upload check removed each go red on their own test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the relay descriptor says features.perDeviceDigests, this device
publishes its delivery-key digest once more, once per relay host, with a
vault flag set only when the relay accepts it, so a failure is tried again
on the next poll. Without it a device whose digest was overwritten under
the old one-per-account rule would never publish again, and contacts
holding its key would keep falling back to unsealed sending. No delivery
key yet means nothing to republish and no descriptor request. Roster tags
carry device_id on such a relay, so a set replaces this device's own and
not the account's; a change of relay behaviour alone republishes them.
A poll the relay cut short at its byte cap ("more": true, read strictly)
is followed at once from the new cursor without holding, up to 16 pages,
on the home relay and every extra mailbox. Padding makes the cap far
more likely to bite. Same bound as iOS.
Tests: PerDeviceDigestsAndPollMoreTest (9): republished once and only when
the relay says so; a failed republish retried; device_id only when asked
for; features read strictly; a page with more followed at once; a page
without more is the whole inbox; a relay that always says more cannot hold
the poll; only a JSON true counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Everything this device sent to its own account went to every device on
it, so a device signed in by anybody holding the password received story
copies, group names and keys, and the offer to take over a call.
The vault now keeps a linked-device set: device id to the base64 IK_sig it
was linked under. It is sealed like the rest of the vault and never
exported in a backup. It starts empty, and nothing in this release adds to
it: linking is stage A2. A send to this account goes only to devices in
the set, matched on id AND key, so a relay listing a linked id under
another key is not believed. The key is checked twice: against the
directory before anything is fetched, and again in guardIdentity on the
bundle the session is actually opened with, because the key route could
serve the same id under another key; that mismatch is refused. With no
linked device nothing is fetched, so no unlinked device's prekey is spent.
The one exception is by design and matches iOS: a call end saying
answered-elsewhere or declined-elsewhere still goes to every device of the
account, because the caller rang them all. Receiving, a HANDOFF or
HANDOFF_TAKEN from an own device that is not linked is dropped (SPEC 6.30).
Consequence until A2 ships, as on iOS: call handoff, story copies and group
names no longer reach a second device of the same account.
Tests: OwnDeviceTrafficGoesOnlyToLinkedDevicesTest (9): none linked
reaches nobody and fetches no bundle; the linked device and not the
password one; a linked id under another key in the directory, and on the
key route; the stop-ringing signal to every device; any other call signal
and group names linked-only; a handoff from an unlinked own device dropped,
from a linked one delivered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>