51e9f0132370702e9450336bddb4bdded2c83239
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
51e9f01323 |
Do not stop reading the inbox while a call is up
The handover stops this app's polling loop when the screen goes away, because the background service takes over. That is right for messages and wrong for a call already in progress: the loop is the only thing that routes `call-end` into the manager owning the call screen. The service polls too, but it turns offers into notifications and knows nothing about a call that is already up. So a call ended by the far end while this app was off screen had nowhere to land. The screen stayed, still offering to answer, until the dial guard gave up forty-five seconds later. Seen on a device: a call placed at 13:04:13, no `call-end` ever logged, ICE closed at 13:04:58. Polling now refuses to pause while a call is dialling, ringing, connecting or active. And a backstop for when the signal is genuinely lost. ICE DISCONNECTED was a no-op, so nothing noticed a far end that simply vanished. It now arms an eight second grace, cancelled if the connection recovers, because WebRTC blips are ordinary. A call whose audio has stopped is over whatever the signalling says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
63f2758d46 |
Accept on the call notification answers the call
The notification's Accept button has carried EXTRA_ANSWER_CALL since it was written, and nothing ever read it. Pressing Accept did what any other tap did: it opened the app. Answering was left to the next turn of the polling loop, which refuses a parked offer older than the caller's patience, so a call taken a little late opened the app onto nothing and looked like it had dropped the moment you answered it. MainActivity reads the extra now, on a cold start and on a warm one, and answers at once rather than waiting for a tick. The notification also goes away on its own when the offer it shows has expired. Before, it could outlive the offer by the gap between service sweeps, which is longer since the loop started holding its poll open, and during that gap it was a button that could only disappoint. Seen on the emulator: Accept pressed, app opened, nothing on screen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
9ca58202a6 |
A ring that ends, and only one thing reading the inbox
Two bugs found by calling a real emulator and watching what it did. An unanswered ring never ended. Dialling and connecting each had a timeout and ringing did not, so a call nobody picked up stayed RINGING until the app was killed. Nothing showed it, because the notification can be brushed off the top of the screen while the state behind it survives, and the phone then answered every later caller "busy" while looking completely idle. A guard now releases it after sixty seconds, deliberately longer than the caller's own forty-five, so this end gives up on its own only when their hang-up never arrived. It hangs up with "timeout", which the far end already renders as "No answer"; an invented word would have shown a flat "Call ended". And the handover was half-built. MainActivity starts the background service when the screen stops and stops it when the screen starts, under a comment reading "exactly one poller at a time". Only the service half existed. The view model's loop kept running beside it, both sessions read the same inbox, and an envelope is consumed once, so whichever asked first won it. When the view model won a call offer the phone rang inside a UI nobody was looking at, and the service, the only half that can post a notification, never saw the call. Seen on a device: the offer arrived, CallManager moved to RINGING, and the only notification posted was the service's own "running" notice. That is why calls did not announce themselves with the app off screen. The loop now stops on ON_STOP and starts again on ON_START, so the comment is finally true. Verified on the emulator against relay1: an unanswered offer at 12:51:07 released itself at 12:52:07 and told the caller, where before it would have refused every call after it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba3f380aa2 |
Android 0.14, versionCode 16
Both, as always: the code is what tells an existing install a newer build exists, the name is what a person reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d735b79b86 |
Keep the token when only the proof was spent, and review keys at the call
Two things the iPhone got today and this did not. A proof of possession is single use, and the request that spends one is frequently not a request this code made: OkHttp retries across a connection failure with the headers it already built, which is what a phone moving between wi-fi and mobile data produces. The relay refuses the replay, correctly, and now says so with its own code rather than a bare "unauthorized". Reading that as a token problem meant discarding a working token and minting another before carrying on, and during a call that lands in front of the signalling. The request is signed again either way; only the token is kept now. A relay too old to name the code still says so in words, so this works against production as it stands. And ios#73. A call refused by the trust gate already named the reason and already offered a button. The button opened the conversation, which is the same gap one screen along: the reason the conversation banner does not cover this case is that a call placed from the Calls tab, or answered from a notification, never passes through a conversation. Sending somebody to one to find out why their call failed is the complaint rather than the cure. So the review is a dialog at the failure. It carries both readings the chat banner carries, the ordinary one and the one that matters, and it puts the safety number on screen rather than behind another tap, because comparing the number is what actually settles the question. The old test said accepting must not be offered here, on the argument that a hurried decision is better made elsewhere. The issue argues the other way and is right: an extra screen in front of the same tap slows nobody down, it just costs somebody the explanation. The requirement moves rather than disappears, from "no accept button" to "no accept button without the sentence saying what accepting costs". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
84b00135d5 |
Android 0.13, versionCode 15
Both, as always: the code is what tells an existing install a newer build exists, the name is what a person reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
df598a3961 |
Say when Android is refusing to let a call ring
The notification was built correctly all along: CATEGORY_CALL, PRIORITY_MAX, ongoing, autoCancel off, full-screen intent. Android was refusing it. USE_FULL_SCREEN_INTENT: default; rejectTime=+2m52s ago Since Android 14 that permission is auto-granted only to apps the system already treats as calling apps. Declared and not granted, an incoming call is demoted to an ordinary heads-up: about two seconds at the top, then gone, which reads as the call notification being broken. The system refuses silently, so nothing anywhere said so. Notification settings now checks canUseFullScreenIntent and offers the one screen that can grant it. The fuller fix is a self-managed ConnectionService, which makes this a calling app in the system's eyes and needs no permission prompt at all. That is a larger change than telling somebody the truth today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a70787e960 |
Hand CI the backup vectors it was never given
The §16 backup port shipped with a vector suite that finds its input by walking relative paths out of the checkout. That works on a developer's machine, where flamenet-e2e sits beside this repo, and cannot work on the runner, where it does not. The suite fails rather than skips, deliberately, so it said so: nine BackupVectorTest failures on every pipeline since the port merged, on main included. Two things were missing, and both had to be fixed or the gate would have stayed red. The pin predates the vectors, so `unit-tests` cloned a flamenet-e2e that has no backup-v1.json in it. Bumped; nothing else under test/vectors moved between the two commits, so no other suite changes what it checks. And neither job exported FLAMENET_BACKUP_VECTORS, so even the job that clones HEAD, which did have the file, never pointed the suite at it. It is exported in both now, and named in the pre-flight check, which is there precisely so a missing file reads as a missing file rather than a protocol bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6584d313ca |
Ask the relay to hold a poll open
The relay has always been willing to hold an inbox request until something arrives, and the Android client has never asked it to. The constant naming the hold was declared in MessengerService and read nowhere, and the comment next to the 500 ms gap already described a long poll that was not happening. What that cost, measured against production: two devices sent 1,177 inbox requests in eight minutes, every one of them held open by the relay for about 30 ms and answered "nothing". Every message and every call signal waited out the gap to the next poll. Someone had already noticed the effect on calls and shortened the gap to a second while one was being set up, which made the polling denser without making the signal arrive any sooner. So `wait` reaches the wire. The signed-in mailbox is asked to hold; a backup home never is, because two held reads in a row would put a backup mailbox's latency in front of the one nearly all mail arrives at. A held request needs longer than the ordinary call timeout, so it gets a clone of the client with room for the hold. Both loops now pace on whether the relay held the request rather than on a fixed gap: a poll that took a while was itself the wait, and the next one goes straight back out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f7ddb263e5 |
Areas can be chosen, and named
The engine has always carried compartments and both apps sealed them in, honoured them in clearance checks and rendered them on a received file. No screen let anybody set one, so a label was half-settable: how sensitive, but never which part of the business. The public page promised both. The bits travel with the file. The names do not: they are this organisation's words, so a file from somebody else shows its areas as numbers, which is correct rather than a gap. Nothing is named for you. The site says areas are whatever your organisation already calls them, so this is where you say so. Forgetting a name does not free the bit. A file already filed under it still carries it, and handing that bit to a different area would silently relabel that file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e4daaa6a35 |
Android 0.12, versionCode 14
Release notes landed on the relay first, which is where the gate lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5d6350d157 |
Encrypted backups in Kotlin, against the published vectors
Spec §16, the third engine, checked against pinned bytes rather than against either of the other two. Reuses the module's own primitives: FilesKdf for PBKDF2 and Aead for GCM. Aead returns nonce || ciphertext || tag and §16 keeps the nonce in its own field, so the prefix comes off here; what remains is what WebCrypto calls ct. The AAD is built by interpolation, not by a JSON writer, because it has to match the other engines down to key order. A JSONObject would be the obvious choice and would silently disagree, and the AAD is what stops a file being edited into deriving a cheaper key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c893824b62 |
The instrumented suites can get past sign-up again
Two of the six suites tapped submit without filling the age gate. Submit is
disabled until birth-year holds four digits, and performClick on a disabled
Compose node does not throw: it does nothing. Each run then failed up to two
minutes later looking for a screen the tap never reached, blaming whatever
it had waited for.
A JVM test now holds it: any suite that taps submit must fill birth-year, so
the next one written cannot quietly do the same thing.
android#21 also reports that nav-contacts and nav-account no longer exist
anywhere in the app. They do. They are declared in NavDestinations.tabs and
applied through a variable, so a grep for the literal testTag("nav-" finds
nothing, which reads as the navigation having been rebuilt without them. A
third test pins that too, so the claim cannot be made again from a grep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
daff2b9a05 |
Link the site from the README
None of the four repository READMEs linked to flamenetmessenger.com. The only occurrence of the domain was inside a security contact address, which is not a link and is not what somebody arriving at the repository needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7bd836636b |
The level picker explains itself, as it does on iOS
This screen showed chips and no explanation; iOS has carried one all along, under a comment claiming both apps use the same words. They now do. The last sentence is the boundary: a level decides who can open a file, not where it can be put. Neither app stops somebody filing sensitive material somewhere low, and saying so is the honest half of android#15. Also repairs a comment the emoji sweep truncated, which left the test saying 'the -property is NOT enforced'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
41cdef5e07 |
docs: drop dated self-corrections
Remove dates and "this used to say X" framing from prose and comments. What the tree does now is the only thing that matters; the history is in git. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
db9ca2b442 |
Mark a call offer time-critical, so it rings
Reported from a physical iPhone: an incoming call showed no notification and no full-screen call. Android never sent `urgent` on any envelope. The relay cannot work out that an envelope is a call: it holds ciphertext, and an offer and a birthday message are the same bytes to it. So §6.14 makes the sender say, and only an urgent envelope gets a VoIP push. Only a VoIP push rings a locked, suspended iPhone; anything else is a "New message" banner, or nothing at all when the phone is locked. So every call placed from Android reached an iPhone as an ordinary notification, and the call screen never appeared. iOS has set this since the flag existed (`ringsAPhone` in E2EInbox), which is why calls the other way round work. The detection was already here and used for the opposite question: `warrantsAlert` identifies an OFFER to decide whether to make a sound. Now `ringsAPhone` asks the same thing to decide whether to ring, and the two stay separate, an answer or a hangup must not ring a phone for a call that is already over. 4 new tests. 1221 tests pass, counted from the XML. ⚠️ Not yet observed ringing a real locked iPhone; that needs two handsets and is the check to run before believing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3d9f18f922 |
Offer the review when a key change blocks a call
ios#73. Android already named the reason: a call signal refused with IdentityChangedException hung up and said "Their security code changed". But it then told the person to go and find the conversation themselves, and the banner that compares and accepts lives in the conversation header, which a call started from the Calls tab or answered from a notification never passes through. So the failure described the cure without offering it. The blocked peer is now carried on the phase rather than only named in a sentence, and the failure draws a "Review their security code" button that opens that conversation and clears the failure. Compare and accept stay behind the existing banner. A second accept button, phrased on a call screen in a hurry, is the thing the issue's wording requirement exists to prevent. 4 new tests. 1217 tests pass, counted from the XML. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
23f4022b4a |
Put the parity table's status column back, as words
My emoji sweep emptied the iOS and Android columns of every table in
this file. The check and cross marks were the values, not decoration:
this document exists to say which features exist on which client, and
with those cells blank it said nothing at all. It is also the file the
weekly parity scrub reads.
98 marks were removed by commit
|
||
|
|
788be4259e |
Merge main and re-derive the formatting pass
Conflicting files take main's version and ktlint and the comment pipeline are re-run on them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
b938d161ba | Merge main | ||
|
|
2fa430f21c |
API-style comments and ktlint formatting
Every comment is reduced to its first sentence plus @param, @return and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied with its default (official Kotlin) style across all modules; 262 files reformatted. The 264 remaining ktlint findings are naming and filename rules, which are code changes, and are left alone. Comments and formatting only. 1208 tests pass, counted from the XML. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8a7f40fb13 |
Second pass with the avoid-ai-writing detector
The skill's detector scored the README, PARITY.md and the extracted
comment prose. Fixed what it flagged: hollow intensifiers, "in order
to", "additionally", "the whole point", bold sentences in docs, and
bold that had wrapped across comment lines. Comment prose scores a
maximum of 4 out of 100 across the repo.
One sweep rule mangled four comments ("which is the entire point of X"
lost its clause); repaired, and the rule now keeps the clause.
Comments and strings only. 1208 tests pass, counted from the XML.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
4dd544083c |
Remove machine-writing tells from comments and docs
Emoji markers, markdown bold inside code comments, and em-dashes are gone from source comments, string literals, scripts, Gradle files and the markdown docs. Counted before and after: emoji markers 2680 -> 1 (the emoji picker's own list, which is the feature) em-dashes 2490 -> 9 (table placeholders and fenced code) Two warning glyphs in user-facing strings in ChatScreens.kt are removed as well; the sentences say the same thing without them. Two backtick test names carried an em-dash and now carry a comma. Nothing else changed. 1208 tests pass, counted from the result XML. The three obfs4 interop tests are opted out with OBFS4_INTEROP_OPTOUT=1 for this run, as their own message instructs when no bridge is set up. CONTRIBUTING gets a four-line rule so it does not grow back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
4a8cb64c65 |
Turn photos the right way up, and lift the chat off the keyboard
Two reports from a real phone. **A photo auto-rotated.** BitmapFactory does not apply EXIF orientation, and every image this app sends is re-encoded to strip EXIF — which is right, because that block carries the GPS coordinates the shot was taken at. The same block carries the tag saying which way is up, and a phone camera rarely writes upright pixels: it writes landscape pixels plus "turn this 90°". Decoding ignored that, the re-encode dropped the tag, and the only thing that knew which way was up was gone. The tag is now read and applied to the pixels before it is discarded, on all three paths: preparing to send, scrubbing metadata, and rendering an image received from a peer. The mirrored tags are handled too — a front camera writes them, and a selfie coming back flipped is the same bug facing sideways. 🔴 iOS is NOT affected, which is why this survived: UIImage(data:) applies orientation as it decodes, so the identical strip-and-re-encode is safe there. Same design, opposite platform default — and the reason "works fine on iOS" said nothing about Android. **The keyboard covered what was being typed into.** The manifest has said windowSoftInputMode="adjustResize" all along, and that stopped meaning anything at targetSdk 35: Android 15 forces apps targeting it edge-to-edge, and an edge-to-edge window does not resize for the IME. So the line has been a no-op on every device this ships to (minSdk 33). The root now consumes the IME inset with imePadding() — at the root rather than per screen, because every screen with a text field has this and one added later would too. 5 new tests, including one that fails if the manifest line ever becomes the only thing relied on again. 1213 tests green, counted from the result XML rather than from BUILD SUCCESSFUL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
053444cdd9 |
Say why delivery failed, instead of only logging it (ios#21, #22, #23)
The Android half, matching the iOS change.
**The give-up rule (ios#22).** `flushOutbox` used
`runCatching { … }.getOrNull()`, which threw the exception away — so the loop
could not tell a contact who is merely offline from one whose identity keys
changed, and retried both for ever while the bubble went on promising
"will send". The failure is caught now and classified: a refusal from the
trust layer stops, marks the bubble FAILED and records why. Everything else
still retries for ever, deliberately — a contact who is offline or has no
device yet gets the message whenever they return.
⚠️ Classified on the exception TYPE, never on message text. A string match
would break the first time somebody reworded an error, and would break
silently: the queue would simply go back to retrying for ever.
**The reading (ios#21, #23).** `DeliveryDiagnostics` counts envelopes seen,
drops by reason, session repairs and the last send failure, per conversation.
Three or more repairs against one peer is flagged — a client that repeatedly
repairs is a symptom, not a cure.
🔴 **Shape only.** Refusal messages name what was wrong, which sometimes
means naming a device id: "device ce0c0fb4 has published keys, but none
match". Those are normalised before being counted, so the reading is safe to
screenshot and the map cannot grow a row per device id. ⚠️ Short hex ids like
`9f0e` slip past the obvious rules; the test covers them, and covers that
"base64" survives unmangled.
The new outbox test was checked by disabling the give-up rule: it fails
without it.
581 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
20f320ac51 |
Revert the purple/dark theme, back to the pre-purple palette
Matches the iOS revert. Unpicks the nine commits that took Android from the
site-derived palette to purple-into-black dark-only: the purple ground, the
darkening passes, the single dark theme, the three-stop gradient, white text
boxes and white icons.
Two reconciliations, both because the surrounding work was worth keeping:
* `MainActivity`'s root `Surface` goes back to the plain one. The
transparent-Surface trap it documented -- Material deriving
`contentColor` from `contentColorFor(Transparent)`, getting Unspecified,
and falling back to BLACK text -- only existed because the Surface was
transparent to let a gradient through. No gradient, no trap.
* `CallScreen` painted `groundBrush()` to avoid stamping a flat rectangle
over that gradient. Back to `colorScheme.background`, since there is no
longer a gradient to clash with.
`PaletteMatchesIOSTest` goes with the theme it was written to hold in place.
574 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d91dea3fd0 |
Publish as 0.11, with the code still climbing
0.11 was built on 2026-09-07 and never published, so the public jumped 0.10 -> 0.12 and skipped a number. This closes that gap in the NAME, which is the part a person reads. The code does not go back to 11. Android refuses an APK whose versionCode is lower than the installed one, and 12 is already published -- so code 11 would be uninstallable for anyone who took it, with no way out but an uninstall that wipes their vault. The code is ordering; the name is the label. They are allowed to differ, and a comment says why so it does not get "corrected" later. Verified: 0.11 (code 13) installs over 0.12 (code 12) with no downgrade refusal, session intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d4469aba16 |
Android 0.12
0.11 was built and never published, so this carries everything since 0.10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
409925c2f5 |
Drop the rings behind the call avatar
A pair of translucent `primary` circles stood in for the kit's animation. Against the gradient they read as banding -- two hard edges around the one thing on screen -- which is what "the box around the center elements" was. The avatar carries the centre on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6ed7931b8b |
Read the contact's address from the directory, and name the refusals
The send path fetches full bundles only when a device needs a session
established, so it does not drain one-time prekeys on every message. The
contact's address rides with those bundles -- so every later send had no
address, computed the `local:` key-log subject instead of `fnid:`, matched
no leaf, and refused its own send. flamenet-relay!82 puts the address on
the directory; this reads it.
Two bugs the wiring exposed:
* `rememberFnid` silently overwrote a DIFFERENT address, the one thing
its own doc comment says it must never do. An fnid is derived from the
account key, so a new address is a different account, not a new device.
It went unnoticed while only the once-per-conversation bundle path fed
it; the directory feeds it every send. It marks CHANGED now.
* `guardIdentity` read the pinned address and passed it back into the
comparison against itself, so "a different address is a different
person" could never fire on a send. It compares the served one now.
And the copy: only `IdentityChangedException` was named, so
`KeyNotLoggedException` and `KeyLogForkException` reached the screen as
"Something went wrong reaching that server." -- sending a person to check
their signal for a decision this phone had made.
Also: the call screen painted a flat `colorScheme.background` rectangle
over the app's gradient, which read as a blue box around the call area.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
75d7113ad3 |
Compose a reply (android#16)
The receiving half already worked. `Content.Reference` was in use for reactions, an inbound reply was recognised and rendered, and `Session.Line` already had an `about` field to hold one. What was missing was any way to START one: no Reply in the long-press menu, no composer state, and `Session.send` had nowhere to put a reference. So this is narrow, as the ticket said: `Session.send(replyTo:)` passes the reference to `Content.text(about =)` and records it locally, a Reply item sits above Forward in the menu, and a dismissible strip above the composer says who is being answered. 🔴 **The preview travels with the reply, and dropping it to save bytes is not a free optimisation.** A recipient who joined the thread later or cleared their history has never seen the message being answered — "> [missing]" is worse than showing what was said. A test says so rather than a comment. ⚠️ **Visible before it is sent.** A reply that only appears in the sent message is a mode nobody can see they are in, and the commonest way to answer the wrong message is not to know you were answering one. ⚠️ **1:1 only, and the menu item is ABSENT rather than disabled in a group.** The group send path does not carry a reply yet; accepting one there and dropping it would be a half-feature that looks whole. The same reasoning the file already applies to Edit and Pin on messages written before ids existed. Three wire tests. One of them pins that a message which is NOT a reply still goes out as a bare body — adding this must not have turned every plain message into an envelope an older peer reads differently. ⚠️ Not included, and its own piece: tapping the quote to jump to the message it names. That needs a scroll-to-id in the transcript list. 158 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ca0d3f781d |
A photo sent as a file loses its location too (android#17)
Matches ios!146, format list included. A photo sent through the picker had its EXIF removed — `ImagePrep` decodes to a bitmap and re-encodes, so the output is pixels. The same photo saved into Files and sent from the vault kept it: GPS, capture time, device serial. Somebody who had learned "Flamenet strips location from my photos" had learned something true of one button and false of the one beside it. 🔴 **Both doors, not one.** `sendFile` and `sendGroupFile` sit two functions apart and had the identical gap. A leak fixed on one of two identical doors is a leak. 🔴 **Sniffed by magic bytes, not by name.** The vault stores no MIME type, and a file name is a guess in both directions. ⚠️ **Only images.** "Send this exact file" is what a file send means; a scrubber that mangles somebody's PDF is worse than the leak it was fixing. Undecodable bytes are returned as they came, for the same reason. Five JVM tests, covering the DECISION rather than the pixels — `BitmapFactory` needs a device, so the re-encode belongs to the instrumented suite, and iOS covers the round trip with a real GPS-tagged JPEG. The decision is where the bug was: the old code never asked the question at all. ⚠️ Two of the tests exist because a sloppier sniff passes the obvious ones: a WAV file shares RIFF with WebP, and an MP4 shares the `ftyp` box with HEIC. Falsified by matching only the RIFF header — "a WAV was mistaken for an image". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
17495659eb |
Icons and initials are white, not the accent — matches ios!144
🔴 The accent was being used the wrong way round. It is a mid purple chosen to be readable as a FILL with white on top of it; as the INK for a glyph on a dark ground it is a light purple mark on a dark blue field, which reads as a disabled control rather than a button. `Palette.OnDark` is white. Three sets of avatar initials and fourteen accent-tinted icons. ⚠️ The selected tab keeps the accent. Its tint reads `if (selected) MaterialTheme.colorScheme.primary else Palette.SecondaryText` — a conditional, so the sweep did not catch it, and that is the right outcome: the selected tab is the one place the accent as ink is doing a job nothing else does. ⚠️ The accent still fills: primary buttons, badges, the pill's selected state. Ink versus fill, not "purple is gone". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8fa66cd603 |
White text boxes — matches ios!144
An input is the one surface a person types into and reads back character by character, and a dark field on a dark ground gave it the least contrast on the screen at the moment it mattered most. Typed text reads 18.3:1 on white; the placeholder 4.6:1, because a placeholder is still text. ⚠️ **Its own colour, not `SurfaceAlt`.** That fill is shared with incoming bubbles and the tab pill, which stay dark — a white message bubble on this ground would read as somebody else's app. 🔴 **Every colour a field draws has to be named, not just the container.** Material derives label, placeholder, cursor and outline from the colour scheme, which is the DARK scheme here: setting only `containerColor` leaves a light-on-dark placeholder on a white box, and it simply disappears. iOS had the same failure in a different dialect — SwiftUI drew the placeholder and caret for a dark background until the field was given `.environment(\.colorScheme, .light)`. Two frameworks, one mistake: a white island in a dark app has to be told it is an island. One `fieldColors()` in `Theme.kt`, applied to all 34 `OutlinedTextField` call sites. Mechanical and wide rather than a new wrapper composable, because a wrapper would have to be adopted one screen at a time and the ones that were missed would be exactly the ones nobody looks at. ⚠️ The message composer is a `BasicTextField` and stays dark. It is a pill in the chat, not a form field, and turning it white would put a white bar across the bottom of every conversation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b824e626e7 |
Three stops — purple into blue into black — matches ios!144
#3A1E9E purple, #0B3080 blue, #070A1A black, top down. Less purple than the two-stop version, because the purple is a third of the sweep rather than half. 🔴 One card colour against three stops has to lift off all three. #0E1745 looked right against the purple and the blue and measured 1.15:1 against the black end — a card that disappears at the bottom of a long screen. #141C52 lifts 1.41 / 1.33 / 1.23. The palette test checks the middle stop too. A three-stop gradient checked only at its ends is two thirds measured, and the middle is where most of a screen actually sits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
15e678d593 |
Bright, but still dark — matches ios!144
#3A0CA3 to #062C8C, saturation 0.93/0.96 where the old ground was 0.72/0.83. 🔴 Cards go DARKER than the ground, not lighter. Against a vivid ground a raised card has to be lighter than a saturated violet, and secondary text then falls below AA on it — every candidate measured did. Recessed panels invert it: the gradient is the bright thing, content sits in dark wells. card #190B4E (1.47:1 vs ground) · field #2A1875 · secondary #B4BAD4 avatar #31207F · danger #FF819F (was #FF6B92, 4.45:1 on the ground) ⚠️ The section band is a gap now, not a fill. It was 16pt of `surfaceVariant`, which on the light theme was a barely-there grey seam — on the dark theme it became a light purple slab drawing more attention than the sections it was dividing. The panels either side of it are the separator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
28d10bec4d |
The recovery button did not recover, and half the text went black
Two bugs from testing on the emulator, one of them mine from this morning. ## "Erase and start fresh" erased nothing 🔴 The `VaultUnreadable` screen — shown when the keys on this phone cannot be opened — offered exactly one action, and it called `model::signOut`. `signOut` defaults to `erasingDevice = false`: the door that deliberately KEEPS the device so coming back is silent. So it set the phase to SignedOut, left the unreadable vault exactly where it was, and the next `restore()` opened it, failed again, and came straight back to the same screen. ⚠️ It looked like it worked, because the screen changed. That is the whole problem with a recovery action that does not recover: the feedback IS the claim, and the person only finds out when they try to sign in and land back where they started. Which is what happened, twice, to the person testing it. `discardUnreadableVault()` destroys the vault, opens a fresh one, and lands on sign-in with a sentence saying what happened and that contacts will see the security keys change. Deliberately NOT `signOut(erasingDevice = true)`, which would also have worked: that path first tries to revoke the device on the relay using a session that by definition does not exist here, and its comments are about handing a phone on. The keys are already lost; the only question is whether the app can get past them. Three tests, in the same source-scan shape as `DestructiveLabelsTest`. Falsified: putting `model::signOut` back fails with the reason spelled out. ## Black text on the dark ground 🔴 `Surface(color = Color.Transparent)` with no `contentColor`. Material derives it with `contentColorFor(color)`, which has no answer for Transparent and returns Unspecified — so `LocalContentColor` falls back to its own default, which is BLACK. Every `Text` that does not name a colour of its own drew black on the dark ground. ⚠️ It read as "some of the text is hard to read" rather than as a broken theme, because everything that DOES set a colour was fine and only the inheriting ones went dark. That is what made it easy to ship: the screens I looked at longest were the ones that name their colours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c688ed21b4 |
One theme, and it is dark — matches ios!144
Same values, same commit, as the file at the top of both palettes has always
asked for.
ground #241650 purple -> #0B2140 blue, top down
card #302F5C lifts off the ground at 1.29:1
field/bubble #3B3A6E lifts off a card at 1.20:1
ink #EDEFF9 · secondary #A8AEC9 · links #B99BFF
🔴 `onBackground`/`onSurface` are `Ink`, not `Mist`. The dark scheme used to set
them to `Mist`, which was the near-white page ground; `Mist` is the DARK ground
now, so leaving that alone would have been dark text on a dark background. That
is the exact shape of a swap that survives a rename, and it would have looked
like the app failing to draw rather than like a colour being wrong.
🔴 `MessengerTheme` no longer branches. There was a light scheme, a dark scheme,
and a stored preference choosing between them; with one palette the light branch
would draw light-mode Material chrome under dark artwork. `DisplaySettings.appearance`
is left in place so an existing stored value is not a crash — nothing reads it —
and the picker is gone from the Display section, on both platforms.
⚠️ The palette test caught one on the way in: secondary text on the avatar fill
was 4.48:1, two hundredths under. #3E3C7A became #3B3974.
The test now checks every surface text is read on — ground, card, field, avatar
— and that cards and fields visibly lift off what is behind them. A gradient
that passes and a bubble that does not is still an unreadable app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
37143c5b6e |
Darker again — matches ios!144
#C0A4EC to #5A8BCC, dark #170D2E to #050F1E. Same values, same commit. 🔴 Close to the floor for a light theme. One more step (bottom stop #477BC2) puts ink itself at 4.65:1 and leaves secondary text unable to clear 4.5:1 even at pure near-black. Below that, dark text on this ground fails and the answer is a theme flip, not a darker shade. ⚠️ SecondaryText is now #1B1822, very close to Ink at #090710 — contrast is satisfied and hierarchy is not. The palette test measures ratios; it cannot see that two text colours have converged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4444e7e8e2 |
The ground shades from purple into a medium blue
Matches ios!144, value for value: #D6C2F4 to #82ABE0 top down, and #1C1036 to #071426 in dark mode. ⚠️ The blue end is not decoration. The mark is a blue globe with an orange flame, and a purple-only app left the icon as the odd one out — the one thing on screen from a different palette. Shading into blue puts the icon's own colour on the page instead of beside it. 🔴 SecondaryText moves again, for the third step in a row: #413A4D read 4.6:1 on the new bottom stop, inside the margin rather than clear of it. #332E3D reads 5.5:1. The bottom stop always binds and it is where the explanatory copy sits. The palette test checks all of it — both ends of both gradients, and that the two platforms still agree. 4 tests, 0 failures, read from the results XML with its age printed: an earlier run of this suite reported two failures that were stale output from a build that had not recompiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
49854ac222 |
Darker ground, the mark on sign-in, and a dark mode that was never readable
Matches ios!144's second pass, value for value. **Darker.** #E9DDFB → #C6AFF2, a clear step down from the whisper it was. 🔴 That forced a second change: secondary text at #505C69 read 5.0:1 on the old bottom stop and only 3.5:1 on the new one, below AA, and most of the explanatory copy is set in it. #413A4D now — 8.4:1 top, 5.6:1 bottom. 🔴 **And dark mode never had a secondary text colour at all.** It used the light one, which measures **2.41:1** on the dark ground. iOS has had a light/dark pair since it was written; Android used one value for both and nothing noticed — because a dim grey on a dark background looks deliberate. Found by measuring while changing the palette, not by looking. #AFA4C9 now: 7.0:1 on the lightest dark stop, 8.5:1 on the darkest. The palette test covers the dark grounds too, so it cannot come back. **The mark replaces the 'F' box** on the sign-in screen, which is also the sign-up screen here — `registering` is a flag on one composable, so both get it. **The launcher icon was cropped.** The adaptive foreground had the mark at 72% of the canvas, and the guaranteed-visible area is a 66dp CIRCLE: a mark that is widest at its vertical middle gets clipped at exactly the circle's widest point. 59% now, and it is measured rather than eyeballed — compositing the foreground under both a circle and a squircle mask clips **0 artwork pixels**. ⚠️ `R` needed an explicit import: the namespace is `io.flamenet.messenger` and this code is in `io.flamenet.messenger.app`, so the generated class is not in scope by package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
563e9efed7 |
The same purple ground, and a test that checks it stays the same
iOS moved to a purple gradient in ios!144. This is the other half, in the same values, plus the thing that has been missing since both files were written. 🔴 **`Theme.swift` has always opened with "one palette, two apps — change a value here and change it there in the same commit", and nothing enforced it.** Its own comment records what happened last time: the file claimed the two were matched while iOS was indigo and Android was ember. A promise in a comment is a promise nobody keeps, because nothing goes red when it is broken. `PaletteMatchesIOSTest` reads `../flamenetmessenger/Sources/Theme/Theme.swift` and compares the hex values for the accent, both gradient stops, and the deep accent. ⚠️ It SKIPS when the iOS checkout is not beside this one — a red test on a machine that only has Android would train people to ignore it. It also measures contrast, because "still readable" is the actual requirement: - white on the button gradient's LIGHT end must clear 4.5:1 (it is 5.50:1) - ink and secondary text must clear 4.5:1 on BOTH ends of the page gradient (18.6/14.8 and 8.7/6.9) Secondary text is the one that fails first — most of the explanatory copy on these screens is set in it. Falsified both ways: putting the old blue back in `Accent` fails with "primary differs: iOS says 7A3FF2, Android says 2298FF", and lightening the gradient's top stop fails with "white on the gradient's light end is 2.28:1". Mechanics worth knowing: the ground is painted ONCE on the root `Surface`, with `Scaffold(containerColor = Transparent)` under it — Scaffold's default container colour paints straight over a gradient, and a gradient applied per screen restarts at every navigation, which reads as the background flickering. The button's gradient is on the modifier with a transparent `Button`, clipped to the same shape or the corners show square. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
832cbf9214 |
Files and Wallet are bar destinations, like iOS (android)
Both apps had both features. iOS carried them in its tab bar; Android had them as buttons most of the way down the Account screen, behind a comment arguing the kit's bar is four icons and "a wallet people open occasionally does not earn the slot". What that produced: somebody testing the Android build reported Files and Wallet as missing. That is a fair reading of a feature you have to scroll past a dozen settings sections to find, and two apps disagreeing about where a feature lives is worse than either answer. Seven destinations now, in iOS's order — Home, Messages, Calls, Stories, Files, Wallet, Account. The Account sections are gone rather than kept alongside: two routes to one screen is two things to keep in step, and the bar is the one people look at. 🔴 **Tapping a tab calls `navigateTo`, not `showScreen`.** Wallet and Files each read the vault before they have anything to draw, so a bare `showScreen` lands somebody on an empty screen with nothing loading and no error. That is now one method with the loading in it, so no future caller can navigate past it. 🔴 **The bar's contents are data now, and a test reads them.** It was a hand-written run of `NavButton` calls, so nothing could assert what was in it — a destination could vanish in a refactor and no test would go red. That is exactly how this divergence survived. `NavDestinations.tabs()` returns the list; five tests pin the order, the tags the instrumented suite looks for, the accessibility labels, and that supervision removes Stories and only Stories. ⚠️ The kit's artboard is four icons and this is seven. Deliberate departure, said in the comment rather than left looking like an oversight. Verified from `test-results/*.xml` — 5 tests, 0 failures — not from BUILD SUCCESSFUL, which prints no test names for `:app`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
845254a58c |
The launcher icon is the product's mark
The adaptive icon's foreground was a vector — a speech bubble with a flame knocked out of it — drawn before there was a logo. There is one now, so the launcher shows it. 🔴 **A bitmap replaces the vector, and that is a real trade.** The artwork is a painted globe with gradients and a flame arc; no path describes it. Five density buckets are generated instead, mdpi through xxxhdpi, by the relay repo's `Scripts/make-brand-assets.py`. Do not edit the PNGs — the script overwrites them, and one hand-edited density is an icon that looks different on one class of phone and nowhere else. ⚠️ Content sits inside the adaptive-icon safe zone at 72% of the canvas, so a circular mask cannot cut the flame arc. Checked against both a circle and a squircle mask before this landed, not assumed. ⚠️ `<monochrome>` still points at the old vector silhouette, deliberately. A themed icon is tinted flat by the launcher, so the colour artwork cannot serve as one, and the alpha silhouette of a filled globe is a disc. The existing speech-bubble shape is the right kind of thing for that slot and is still on brand; a silhouette drawn from the new mark is separate work. `minSdk` is 33, so there are no legacy square mipmaps to keep in step. Verified with `:app:processReleaseResources`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
900c91ca6f |
Report a story on Android, which had no way to (ios#12, android#12)
Apple's Guideline 1.2 and Play's user-generated-content policy both require a way to report. Android had the whole stack — `submitReport` on the client, `Session.reportStory`, `MessengerViewModel.reportStory` — and no screen called any of it. For those rules, a capability with no way in is the same as no capability, and a story is the most public thing this app carries, which is exactly the surface they are aimed at. Message reporting was already wired and complete. I nearly rebuilt it from scratch: a truncated grep showed no hits, I concluded Android had no reporting at all, and got as far as a second `ReportSheet` before finding the existing one. Discarded. The lesson is the boring one — `head -8` on a grep is not evidence of absence. Reuses that sheet rather than adding a second. Two would be two places for the consent wording to drift apart, and the consent is the feature: sending a copy hands the operator something readable, which is the one thing this app otherwise never does. It was `private` to `ChatScreens`, which is why the stories feed could not reach it; widened to `internal`. ⚠️ Not offered on your own story. Reporting yourself to the operator is not something anybody means to do, and offering it invites a misfire. ⚠️ The author's name is passed in from `StoryCard` rather than resolved again. It has the buddy list and has already done it, and two resolutions of the same name are two chances to disagree about who is being reported. 1179 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1b5e7068e4 |
Display settings, and one gap that is a missing feature rather than a toggle
The rest of what the parity scrub found on iOS and not here: show whether contacts are online, show the time on messages, larger message text, and light/dark/system. All four are wired to the thing they name — the presence dot, the timestamp above a bubble, the body size, and the theme — because a switch that stores a value and changes nothing is the failure this whole exercise keeps turning up. ⚠️ **There is no "last seen" setting, and that is deliberate.** Android does not show last seen anywhere; the only `seenAt` in the app is about missed calls. iOS does show it. So the gap there is a missing FEATURE, and adding a switch for something the app never draws would be exactly the decoration the scrub exists to catch. Written down in `DisplaySettings` so the next person does not "fix" it by adding one. 🔴 **Named for what they do.** iOS shipped "Show online status" on a Privacy & Security screen, where every messenger uses those words for the reciprocal thing — it only ever hid other people's dots from your own phone. That was renamed today in ios!140; this is named that way from the start, and the section says out loud that contacts are still told when you are online whichever way it is set. Two details worth the words: - The base body size is **16sp because the kit says so** for a message body, and `ChatScreens` says so at the point it draws one. A larger- type switch that restyled every message when turned OFF would be a worse bug than the absence it fixes. Pinned by a test. - With timestamps off the `Text` is omitted rather than drawn empty. An empty `Text` still takes its line height, so the setting would have left a blank gap above every message. 1183 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0e5460da41 |
Notification settings, which Android never had
The parity scrub found eight preferences on iOS with no Android
counterpart. This is the notification group, and it is the one worth
doing first, because "show who it is from" is a privacy control rather
than a preference: it decides whether a name reaches a lock screen.
`MessengerService` had already written down what was missing, at the only
place it could —
// Count only, until there is a setting for it.
— so notifications said "New message" or "New messages in 3 chats" and
nothing else. That is still the DEFAULT. Nobody acquires a name on their
lock screen by installing an update; somebody who wants one asks.
Four switches: notify at all, new messages, show who it is from, and
sound while the app is open. Same defaults as iOS, which is the part that
must not drift — the storage differs because the platforms differ, but a
person carrying both phones should not find them behaving differently.
⚠️ Several waiting threads are counted and never named, even with the
setting on. Naming one of three is arbitrary and naming all three turns a
notification into a contact list on a lock screen. A thread that cannot
be resolved to a person — a group, a cross-relay peer — falls back to the
count, because a wrong name is worse than no name.
🔴 **The first version of the test was worthless and passed.** It
reimplemented the rules against a fake and asserted nothing about the
code that ships — two identically-wrong ends agreeing, the same shape
that made the protocol vector tests necessary. The decision now lives in
pure functions on the companion object, taking the preference as an
argument, because a `Context` is not available in a JVM unit test and the
logic had to leave the class that needs one. The test calls those.
Checked by breaking it: make the text ignore the preference and exactly
one test reddens — the one asserting no name reaches the lock screen.
1179 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9a20c06eda |
Give sharing a bridge a way in
`shareBridge` shipped in the last change with nothing calling it. The receiving half was complete — an offer arrives, is shown, is accepted by a button — and the sending half existed only as a method. Half a feature, and the half that was missing is the one the whole argument rests on: a bridge nobody can hand out is a bridge nobody has. The Account screen now lists contacts under the bridge, with a Send beside each, and only when a bridge is actually configured. Sharing from a screen with nothing set would have posted an empty line onto somebody else's settings screen. ⚠️ The button says "Sent" afterwards but does not disable. An operator rotates a bridge and the same person needs the new line; a control that never re-arms would make that impossible from here. And "Sent" is not persisted — whether somebody still has a working bridge is not something this phone can know, so it is a confirmation for this sitting rather than a record. The copy says who learns what, because neither party is nothing: whoever runs the bridge sees one more person connecting, and the contact learns you use it. 1175 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1fd61ce124 |
Hand a contact the bridge you use (§6.19)
A bridge only works while whoever wants to block it does not have the line. Every ordinary way of distributing one — a web page, an email, a channel — is a place a censor can go and collect them, which is why Tor needs BridgeDB, moat and domain-fronted rendezvous and still gets its bridges enumerated. This app already has the thing that solves it: an encrypted channel between people who know each other. A line handed over that rail is invisible to the relay, and enumerating it means infiltrating one friendship at a time. 🔴 **An offer, never a setting.** A bridge line tells a phone to route every relay connection through a machine somebody else chose. Applying one on arrival would let a contact — or whoever has taken their account — move another person's traffic onto a host they control. They could not read it, because the ratchet does not care what carries it, but they would learn who is online and when, and could cut them off at will. So the transport stores it and a button applies it, pressed by the person it was sent to. The test that holds that line asserts storing an offer writes ONLY the offers record. Make `saveBridgeOffer` also write a configured bridge and it reddens, alone, while every other test in the file still passes — which is what makes it worth having. The line is shown in full on the screen, not hidden behind "Accept". Somebody may want to check the fingerprint against another source before routing their traffic through it, and they cannot check what they cannot see. A line that arrived broken says which part is wrong, using the same validator the paste field uses, rather than failing when accepted. ⚠️ **`"ctl":1`, so 0.10 swallows it.** A build that has never heard of this kind consumes the envelope and shows nothing. The alternative — a bubble full of JSON in somebody's conversation — is the exact bug the `ctl` flag was introduced to stop, and it would have shipped to every older install. Asserted on the wire as the NUMBER one, because iOS decodes it as a number and a boolean fails the whole envelope's decode rather than merely failing to set the flag. 1175 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b3daf0eb0f |
Android 0.11
versionCode 11, versionName 0.11. 0.10 published 2026-09-05 18:12 (`39ccfbde…`) and everything below has merged since, so a rebuild at code 10 would have reached nobody — Android refuses an APK whose versionCode is not higher than the installed one, and the two builds would have been different bytes under the same number. User-visible in this build: - **A bridge, for networks that block Flamenet.** Account → Network takes a bridge line and carries relay traffic inside obfs4 to it. Off unless a line is pasted, validated before it is stored, and the screen reports whether a handshake has actually completed rather than what is saved. - **Sign-up could sit on "Checking what this server uses…" forever.** The relay probe had no deadline and no way to report failure, so a server that never answered left the form on that sentence permanently, with no error and nothing to act on. The rest is internals: the Kotlin obfs4 port itself, proven against real lyrebird in both directions, and CI that now actually runs the module's ~100 tests and the twenty protocol vector tests that had never passed there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
625b4516c7 |
Pair the e2e pin with a loud advisory at HEAD
I pinned the vector clone earlier today on flamenet-relay#56's evidence, and stopped a step short. flamenet-relay did not: it pins AND keeps `conformance-head` / `doc-pages-head` running against HEAD with `allow_failure: true`, and its `.e2e-ref.md` says why in a sentence I had read and not acted on — "a relay tested against a pinned old harness is tested against a pinned old spec, and that argument still holds." A pin alone is worse than HEAD for exactly that reason. HEAD couples this board to another repository's merges; a bare pin means the vectors can move and this suite goes on checking last month's protocol, reporting green, with nothing anywhere saying so. The first failure is loud and wrong; the second is quiet and wrong, and quiet is worse in a conformance suite whose whole job is to notice a divergence. So: `.e2e-ref` holds the commit, `unit-tests` checks against it and blocks, and `vectors-head` runs the same vector suites against HEAD and is allowed to fail. Being behind is loud and non-blocking. Being broken is still a hard stop. Verified it would actually notice, rather than assuming — pin and HEAD are the same commit today, so a green advisory run proves nothing. With one expected value in `account-kdf-v1.json` mutated, `AccountKdfTest` reddens. `.e2e-ref.md` records the procedure and the deadlock story behind it. ⚠️ `vectors-head` runs `:engine:test` only. The rest of the suite has nothing to say about whether flamenet-e2e moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a93efeb3ac |
Pin the vector clone, rather than reddening on someone else's merge
I cloned flamenet-e2e at tip an hour ago and wrote down the reasoning: if the published protocol changes, this suite SHOULD go red without a commit here, because that is a real divergence and not a stale pin. flamenet-relay#56 is the write-up of what that costs in practice. The same pattern there — a job cloning flamenet-e2e at HEAD — turned relay's main red in a pipeline nobody was watching, blocked six MRs sitting on merge-when-pipeline-succeeds, and was misdiagnosed as runner starvation before anyone found the cause. It fired twice in one evening. The person whose merge did it was working in a different project and had no reason to look. The argument for tip is real but it is the smaller one. A protocol divergence that surfaces as a surprise failure in an unrelated repo does not get read as a protocol divergence — it gets read as flaky CI, which is how relay#56 went for three hours. A pin makes the same event a deliberate bump here, reviewed by somebody who is actually thinking about the protocol at the time. Pinned at 9775fbfe (2026-09-05). Move it when the protocol moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
934704d784 |
Fetch the protocol vectors in CI, which never had them
Turning off the build cache exposed twenty failures that had been there all along: AccountKdfTest, PQVectorTest and PublishedVectorsTest look for a sibling `flamenet-e2e` checkout. That exists on a developer's machine and has never existed in a build directory, so every one of them has been failing in CI since it was written — and CI reported green, because the cache was replaying passes computed here. These are the cross-implementation conformance tests. They are what says this engine derives the same keys as the one iPhone runs; twenty of them silently not running is the worst possible thing to find in the suite that guarantees the two platforms can talk. They fail rather than skip when the file is missing — deliberately, exactly so this could not hide. It still hid, one layer up, in a cache. `flamenet-e2e` is public, so the job shallow-clones it and points the three environment variables at the result. The paths are checked before the suite runs: a variable pointing at nothing would put the tests back where they were, and the failure would read as a protocol bug rather than a missing file. Cloned at its tip rather than pinned. The vectors are normative and shared with iOS, so if the published protocol changes this suite SHOULD go red without a commit here — that is a real divergence, not a stale pin. Verified the variables are actually honoured, rather than assuming: with one pointed at an empty JSON file the test reddens even though the sibling checkout is present. 539 engine tests pass against the cloned vectors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
902e16cf92 |
CI never ran the obfs4 suite, and replayed the others from a local cache
Two holes, found by reading the trace of the pipeline that had just merged android!78 rather than trusting its green. `:obfs4:test` was not in the task list. The module shipped in !77 with ~100 tests — the Elligator2 port, the framing, the ntor handshake, the socket factory — and not one of them has ever run in CI. A break anywhere in the transport would have merged green. The bridge-line parser added in !78 was in the same position. The rest ran, but not in this pipeline. The runner is tagged `mac-local`, so it is the developer's own machine and shares Gradle's build cache with local runs. Every test task came back FROM-CACHE — the whole job finished in 5 seconds — which means CI replayed results it never computed. The outcomes were real, they were just mine from ten minutes earlier. That is not independent verification, and nothing in the log distinguishes it: a cached pass and a real pass both print "BUILD SUCCESSFUL". `--rerun-tasks` now forces execution. And the job asserted nothing. It ended on gradle's exit status, which is the same shape of mistake the iOS gate was carrying when it ended in `grep ... || exit 1` and went green on a red suite. `:app` makes it worse by printing no per-test names at all, so a filter matching nothing looks exactly like 145 passing tests. So the gate reads the results XML: per-module floors at roughly 90% of today's counts, zero failures, and a total. Old results are deleted first — the runner reuses its build directory, and XML from a previous pipeline would otherwise be counted by the very check meant to notice a suite had stopped running. Hiding one module's results reddens it; that is how the floors were checked. 1162 tests across the four modules, 0 failures, 44 tasks executed and none from cache. ⚠️ The interop tests are opted out here and only here — they need a real lyrebird from the flamenet-elligator2 checkout, which is not in the build directory. They fail rather than skip when unconfigured, deliberately, so the opt-out is written down where it can be seen. Run `scripts/obfs4-interop.sh` before merging obfs4 changes: every other test in that module compares against recorded vectors or against itself, and two identically-wrong ends agree perfectly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6cd7a0d5dc |
A settings screen for the bridge line, and a path for it to reach
Two gaps, and the second was the bigger one. obfs4 was reachable from nothing. The socket-factory seam landed in !77, but every client in the app takes `defaultHttpClient()`'s null default — `RelayClient`, `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`, `RelayDiscovery` and the background service, ten sites in all, several from default parameters evaluated at field-initialisation time. The transport had been built, tested and proven against real lyrebird in both directions, and no user could switch it on. Threading a bridge through ten constructors would have worked until an eleventh was added, and the eleventh would connect directly, successfully, with nothing to show that it had. A missed site does not fail; it silently does not protect. So the factory is installed unconditionally and consults `Obfs4Routing` per socket. With no bridge it delegates to the platform's own factory, which is what OkHttp would have used anyway — nothing changes for the overwhelming majority who never set one. Pooled connections are the trap underneath that. OkHttp keeps a connection alive across requests, so one opened through the old bridge serves requests for minutes after the user switches — or after they turn a bridge OFF, which is the dangerous direction. The pool is shared so `Obfs4Routing.use` can empty it. The other gap is the one flagged when the wiring merged: a malformed line parses to nothing, nothing means no bridge, and no bridge is a direct connection. So `BridgeLine.parse` reports which part is wrong, and `Obfs4Settings` stores only what it accepted. Java's decoder made that worse here than on iOS. `fromCert` appended `==`, which THROWS for most truncation lengths — 20, 29, 30, 50 and 51 bytes among them — so a short paste came back as unreadable gibberish while iOS, whose decoder is padding-lenient, called the same paste "cut short". That is the likeliest real mistake by a distance: bridge lines wrap in every chat app and terminal, and copying only the first line is ordinary. Decoding the bare string works at every length and lets the size check give the honest answer. Both forms produce identical bytes for a real 52-byte cert, so nothing that worked before changes. The messages are the same sentences iOS shows, pinned by a test. The two apps are handed the same lines by the same operators, and one support answer should cover both. None of them says obfs4, base64 or node id — whoever reaches this screen was handed a line and told to paste it, and an error in the protocol's vocabulary reads as "you are not qualified to fix this". `iat-mode` 1 and 2 are a caveat, not a rejection: they ask the client to space its writes and the bridge does not enforce them, so such a line connects and works. Refusing it would take a working bridge away from somebody who has no other one. The status line reads a handshake counter, never the stored preference. A saved bridge that is not there leaves the app connecting directly, and only a completed handshake can tell you otherwise — so a bridge that has carried nothing says exactly that, and the words "protected", "secure", "hidden" and "safe" are asserted absent from it. Live interop re-run against real lyrebird after the factory change: both directions still pass. 448 unit tests green, 145 in the app, and reverting the unconditional factory reddens the routing test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
11f0fe9fee |
Count handshakes at the bridge, so the socket-factory test proves something
This fix was made when the iOS end-to-end wiring test was found passing while BYPASSED, and the same flaw was confirmed here — but it never got committed, so the assertion has been sitting in a working tree while the merged test went on proving nothing. The test fetches `http://127.0.0.1:<upstream>/probe`. That URL points at the upstream directly, so a request that ignored the socket factory entirely reaches the same server and returns the same body. Asserting on the response proved the HTTP server worked; it said nothing at all about whether obfs4 was in the path. The bridge now counts sessions that completed a handshake, and the test asserts the count rose. Removing the socket factory reddens it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dd412b8680 |
obfs4 for Android, part 7: the OkHttp seam, off by default
RelayClient.defaultHttpClient(bridge) now carries every request inside obfs4 when a bridge is configured. The parameter defaults to null, which is the shipping configuration: nothing changes for anyone who does not ask for it. A real HTTP request now travels through obfs4 end to end via OkHttp. That test is deliberately Kotlin on both ends, and says so: the wire format is already pinned to real lyrebird in both directions by InteropTest. What is unproven until here is the plumbing -- that the handshake happens before any stream is handed out, that bytes arriving alongside the handshake are not dropped, and that a frame decoding to nothing does not read as end-of-stream. Each of those loses data without producing a wrong byte anywhere. The handshake runs inside connect(), not on first write. OkHttp starts writing a request the moment it has a connected socket, so a deferred handshake would interleave with request bytes and desynchronise the frame decoder on the first request of every connection. Obfs4Socket ignores the endpoint OkHttp asks for. That endpoint is the RELAY's address; the socket goes to the bridge, which proxies on. Connecting to the endpoint would defeat the entire transport while appearing to work. All five SocketFactory overloads are implemented. Leaving any of them returning a plain socket means some path silently bypasses the transport -- and the traffic still works, which is exactly why nobody would notice. A wrong bridge key fails as a refusal rather than hanging. obfs4 bridges stay silent for anyone they cannot verify, so without the timeout this is an app that never finishes a request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a7ce010403 |
obfs4 for Android, part 6: live interop with real lyrebird, both directions
scripts/obfs4-interop.sh builds a real lyrebird bridge and a real lyrebird client from the flamenet-elligator2 checkout and runs the Kotlin port against both. The bridge accepts the Kotlin client's handshake and echoes data; lyrebird's own client accepts the Kotlin bridge and its echo. 61 tests. Both directions are needed, and the falsification shows why: swapping the framing key halves by role leaves the CLIENT test green -- a client takes the first half either way -- and fails only the reverse one. Encoding the epoch hour as binary rather than a decimal string fails both. The probe-resistance test, which asserts the bridge stays silent for garbage, passes throughout, as it should. Both clients are pinned to the SAME reference rather than to each other. Two independent implementations agreeing with lyrebird is a real statement; agreeing with each other is not, and would hide anything they share. The runner's name check earned its keep twice in ten minutes. First it reported failure while all three tests had passed, because Gradle prints `name() PASSED` with parentheses and the pattern omitted them. Then, with that fixed, it caught the tests not running AT ALL: Gradle's up-to-date check does not know the Go counterparty changed, so a second run skips the task silently. Hence --rerun-tasks. An exit code alone would have called both of those green. The tests never skip quietly: absent configuration is a hard failure unless OBFS4_INTEROP_OPTOUT=1 is set explicitly. Verified both ways -- the bare run fails, the opted-out run passes 61. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ac5802a839 |
obfs4 for Android, part 5: handshake messages, packets, and the connection
The protocol is now complete in Kotlin, short of a socket. 58 tests. Handshake messages are checked against RECORDINGS, not computed values: the vectors are real lyrebird-to-lyrebird handshakes captured off a pipe, because the message carries random padding chosen inside lyrebird and a MAC over the current epoch hour. The padding turns out to be recoverable -- it sits between a fixed-length prefix and the mark -- so rebuilding from the recovered representative, padding and hour must reproduce the recording byte for byte. That makes these a test of generation, not only of parsing, and both directions pass. The epoch hour is a parameter, never read from the clock inside the library, because a MAC with a hidden clock dependency cannot be tested against a recording. The asymmetry that catches people is preserved: the server searches only the TAIL for the mark, because a client cannot send anything valid past its MAC; the client must SUBSTRING-search, because a real server sends framed data immediately after its handshake. The symmetric reading fails against every real bridge. GoRandom reproduces Go's math/rand quirks exactly -- float64 dividing an int63 by 2^63 rather than the cleaner form Go rejected, and perm's useless first swap -- so the packet-length distribution derived from a seed matches lyrebird's. That buys indistinguishability, NOT interoperability: padding is padding, and any distribution talks to a real bridge. What a different one costs is looking like other obfs4 traffic, which is the entire point. The client handshake returns leftover BYTES rather than an offset, carrying forward the fix the Swift port needed: an offset indexes a buffer accumulated across all calls, which a caller feeding one socket read at a time does not have, and it only misfires when the server handshake spans two reads -- routine over a network, almost never over loopback. HONEST GAP: the connection layer here is self-consistency-tested only. Everything below it is checked against lyrebird's bytes, but two identically-wrong ends agree perfectly. The Swift port has a live interop test against a real lyrebird bridge; this one does not yet, and that is the next thing that matters -- more than the OkHttp seam. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
799886f35c |
obfs4 for Android, part 4: framing
Whole runs of frames now match lyrebird byte for byte and decode back byte-at-a-time. 33 tests. XSalsa20 and Poly1305 both come from BouncyCastle. The Swift port hand-writes both because CryptoKit's AEADs are AES-GCM and ChaChaPoly and it exposes neither primitive; Poly1305 especially is a MAC, so a bug in it is a forgery rather than a wrong answer, and taking a vetted implementation is the right call wherever one exists. Added standalone Poly1305 and Salsa20 vector tests, because the comment above them claimed those vectors sat beside the frame ones and nothing was using them. They do not test BouncyCastle's arithmetic -- that is vetted -- they test that it is being CALLED correctly, key and parameter setup being exactly where a wrapper goes wrong. The frame tests run a sequence, not a single frame: the nonce counter and the DRBG both advance per frame, so a one-frame test passes for an implementation whose counter never increments and whose length mask is recomputed from scratch each time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
430f167fc0 |
obfs4 for Android, part 3: SipHash, the length DRBG, and ntor
64/64 handshakes agree with lyrebird on KEY_SEED, AUTH and KDF output, from both the client and the server side. 23 tests. SipHash is the one primitive hand-written here despite BouncyCastle shipping one, and that is worth justifying because everything else in this module is BouncyCastle's. BC's SipHash is a Mac: doFinal RESETS. obfs4's DRBG writes eight bytes, finalises, writes eight more and finalises again -- without ever resetting. Emulating that with a resetting MAC means re-hashing the whole history per block, O(n^2) in the frames on a connection. So the state machine is written out, and `sum` finalises a COPY. The DRBG is checked ten blocks deep, not one. obfs4's comment says each block is a fresh H(K, previous); the code never resets, so blocks are cumulative. The two readings agree on block one and diverge afterwards -- an implementation built from the comment decodes the first frame of a connection perfectly and then fails. ntor carries the same trap the Swift port hit: the transcript is B | B | X | Y | PROTOID | ID, with B twice, while the comment directly above that code claims ID | B | Y | X | PROTOID. Both orderings produce a well-formed 32-byte KEY_SEED. Only one interoperates. HMAC via javax.crypto, HKDF via BouncyCastle's HKDFBytesGenerator, X25519 via the ladder from part 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
44e06adf92 |
obfs4 for Android, part 2: the cofactor-dirty scalar multiplication
256/256 private keys now produce lyrebird's exact public key, and the whole keypair -- private key to public key and representative -- matches end to end. 13 tests. BouncyCastle's X25519.scalarMult cannot be used for this: it clamps internally and runs 255 bits, while the dirty variant needs the COMBINED scalar fed to a 256-bit ladder with no re-clamping. The combined scalar can exceed 2^255 once lsb*L is added, so a 255-bit ladder silently drops the top bit for a fraction of keys. Only the ladder and the scalar handling are written out; the field arithmetic underneath is still BouncyCastle's. The danger here is that the wrong answer looks entirely fine. A clean X25519 public key agrees on shared secrets and round-trips; it is just confined to the prime-order subgroup and therefore distinguishable, and clamping the private key before the call is all it takes to get one. So there is a structural test beside the byte comparison: the dirty key must differ from the clean key exactly when the low three bits of the private key are non-zero, with both arms asserted to actually occur so neither passes vacuously. cswap here uses an explicit mask rather than any BouncyCastle conditional. Nothing in this module assumes those take a boolean-shaped condition, after X25519Field.cmov was found to do nothing at all when handed a plain 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
db35c7ce55 |
obfs4 for Android, part 1: Elligator2, against the same vectors as Swift
A new `:obfs4` module, a plain JVM library like `:engine` so its tests run without an emulator -- which matters here because the only tests that mean anything are vector comparisons against lyrebird. Far smaller than the Swift port, for a reason worth recording: the Swift implementation hand-writes SipHash-2-4, XSalsa20, Poly1305 and its own field arithmetic because CryptoKit exposes none of them. The JVM does not have that hole. BouncyCastle -- already a dependency of :engine, for the same class of reason -- has X25519Field, XSalsa20Engine, Poly1305 and SipHash, vetted and constant-time where it matters. So the two clients do NOT share an implementation. They share the vectors and the live interop harness, which is what actually keeps them honest: two independent implementations agreeing with the same reference is a stronger statement than one agreeing with itself twice. Two things BouncyCastle would not do, both found by vectors rather than by reading. X25519Field.sqrtRatioVar is not a drop-in. Elligator needs the root even when the ratio is NOT square -- it is sqrt(i*u/v) then, and the algorithm uses it to pick a branch rather than to detect an error. BC only promises a usable root in the square case, and handing its output to the map produced zeros for 120 of 256 vectors. The exponent chain and branch selection are written out here; the field arithmetic underneath is still BouncyCastle's. X25519Field.cmov did nothing at all with a condition of 1. Both tweak parities produced the SAME representative -- a map that still round-trips, still looks uniform, and only ever emits one of the two valid answers. The failing assertion said "wrong bytes"; it took a diagnostic printing both candidates to see they were identical. Replaced with an explicit mask so the convention is stated rather than assumed. Behind no setting yet because nothing calls it: this is the crypto foundation only. The transport, the OkHttp socket factory and the user-facing switch come next, and the switch defaults to off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
945978972a |
The on-device suite catches up with the app
🔴 **Nine defects, all of them in the test, none in the app.** These suites were written before the tab split, the age gate and the two sign-out doors, and had not run since. Each staleness hid behind the one before it, and every error message blamed the wrong thing. 1. **The age gate was never filled.** `submit` is `enabled = … && (!registering || birthYear.length == 4)`, and `performClick()` on a disabled Compose node does not throw — it does nothing. 2. **`submit` was below the fold.** Adding that field pushed the button off a 1080x2400 screen. An off-screen node still reports ENABLED — that is a state check, not a visibility one — so the gesture reached nothing. Proved by logging inside the app: `signIn` was never called, through 450 seconds of waiting. 3. **The peer's name was typed into a button.** `new-contact` is the FIELD; `lookup-contact` is the Look-up button. 4. **The lookup step was skipped**, so `confirm-contact` never appeared — the app refuses to turn a typed string straight into a contact, because a typo would silently become a stranger. 5. **`buddy-<id>` vs `buddy-circle-<id>`.** Home shows the circles row. The suite reported "never became a contact" about a contact on screen. 6. **RECORD_AUDIO was never granted.** The mic dialog is not in the app's Compose tree, so the failure read "No compose hierarchies found" — as though the app had died rather than a dialog covering it. 7. **Sign-out was a no-op**: the row needed scrolling to, AND the destructive rows confirm in place, so one tap only armed it. 8. **It used the wrong door.** A plain sign-out KEEPS the device and credential by design — right for a person, wrong for a suite whose next test wants a different account. `sign-out-erase` is the one that clears the vault. 9. **Groups and stories are on their own tabs**, and `stories` is the strip on Home, not the Stories tab, which carries `new-story`. ⚠️ `signOutIfSignedIn` no longer wraps itself in `runCatching`. Swallowing that failure is what made the damage appear three screens later in a different test. ⚠️ The two waits that span a relay POLL — a contact reaching the roster, a group reaching the list — are 150s. They are the only two that flake, because neither is refreshed by the response to its own write. All four tests pass. Not yet all in one run: the add-contact lookup is throttled per window, and four tests share it. |
||
|
|
77a7f3981c |
The instrumented suite could not sign up, and said nothing about it
🔴 **Sign-up requires a year of birth and no instrumented test filled it.**
`submit` is `enabled = … && (!registering || birthYear.length == 4)`, so
the button was DISABLED — and `performClick()` on a disabled Compose node
does not throw, it does nothing. Every sign-up in this suite has been
tapping a dead button since the age gate landed, failing two minutes later
with 'nav-contacts did not appear' and no error anywhere on screen.
Nothing was wrong with the app. The suite simply stopped being able to
create an account, and a failing instrumented suite reads as a flaky one.
Also fixes the skip counter in scripts/instrumented.sh: it grepped a bare
SKIPPED, which matches Gradle's own `Task … SKIPPED` lines, so a verdict
written to stop a skip being read as a pass was inventing skips instead.
|
||
|
|
1a7c25386d |
Allow the test fleet in the instrumented runner, refuse only production
relay1/relay2 are what the fleet is FOR, and the suites need https because Android blocks cleartext — so a local http relay cannot serve them at all. Refusing both left no way to run the suite as designed. Production stays refused outright. The test fleet is allowed with a loud line saying accounts will be created on it, because they will be. |
||
|
|
d8d48d8651 |
"Checking what this server uses…" was also the failure state
🔴 **The relay probe had no deadline and no way to say it failed.** `discovered` is null both while the probe is in flight and after it has come back with nothing, so `identityDescription` shows "Checking what this server uses…" in both cases. A relay that never answers leaves the sign-up form on that sentence permanently — no error, no retry, nothing to act on. Found by the instrumented suite on a clean emulator: nine of ten tests failed with `nav-contacts did not appear within 120000ms`, and the screen dump showed the form still checking. ⚠️ **The reason it never answered is worth writing down.** The app was pointed at `http://10.0.2.2:18810`, and Android has blocked cleartext HTTP by default since API 28 — there is no `usesCleartextTraffic` and no network security config here. The request never left the device. 🔴 That refusal is CORRECT and is not what changed. `SiteConfig` already says why: a bare hostname becoming `http://` would hand key material to anyone on the path "and it would work, which is the worst outcome." What was wrong is that it failed silently, so somebody self-hosting who omits the scheme gets a dead screen and no idea why. Now: bounded at fifteen seconds — far longer than a healthy relay needs — and a failed probe is a distinct state that says "Couldn't reach that server. Check the address — and note it has to be https, not http." Same defect as android#19 one screen earlier, which I fixed this morning without noticing the probe had it too. Also adds `scripts/instrumented.sh`, which closes two ways an on-device run silently points at production: the suites DEFAULT to live hosts when no relay is named, and a saved emulator snapshot restores whatever the AVD was last doing — observed today as a snapshot signed in as `fnreviewbeta` against relay.flamenet.io, under a run told to use a local relay. The script has no default, refuses both live hosts by name, cold boots with `-no-snapshot-load`, and exits 3 on a skip because a skip is not a pass. |
||
|
|
9bba9d2555 |
Report a message, not just a story (android#20)
**The relay has always accepted `kind: "message"`. Nothing ever sent one.** Reporting existed on stories only — so the surface where unsolicited contact, spam and harassment actually arrive had no way to tell anybody. The person being messaged by a stranger had Block, which protects them and tells whoever runs the server nothing, so the account moves on to the next person. 🔴 **Android had `reportStory` wired all the way to the relay and no screen anywhere calling it.** The plumbing was complete and unreachable. This is the first report UI on this platform, and the sheet is not story-specific, so stories can use it too. 🔴 **The consent is the feature; the form is scaffolding.** Everything else this app sends is sealed and a report is not: the relay cannot open a message, so a report is actionable only if the reporter hands over what they saw. §6.25 requires the client to say so, and it says so in these words — what leaves the device, who can read it, and that it cannot be taken back. Turning the copy off states what that costs rather than quietly weakening the report. ⚠️ Block is offered beside it and never merged into it. A block takes effect immediately and is the reporter's own decision; a report is a request to somebody who may do nothing, and the sheet says which is which. ⚠️ One message travels, never the thread around it — §6.25 forbids including anything the reporter did not choose to. ⚠️ Report is absent, not disabled, on your own messages, and on the stranger banner it sits below both buttons rather than beside them. Most first messages are somebody you gave your address to; leading with Report would frame every one of them as a threat. ⚠️ A relay may decline reports entirely (`reports_not_accepted`, 501). That is a property of the server, not a mistake the reporter made, and the message does not read like one. |
||
|
|
1594ce84f6 |
Android 0.10 — published
sha256 39ccfbde…, signed CN=Flamenet Messenger with 791fc826… — the same
certificate as every build since 0.3, so 0.9 installs are offered this
rather than refused.
⚠️ APK into downloads/ BEFORE the manifest, and the served copy hashed
over HTTPS rather than trusted as copied. A manifest that lands first
announces a version the file is not, and every client checking in the gap
downloads the old one and rejects it against the new hash.
Second release through the notes gate, and it did its job unprompted:
`release-manifest.sh` read 0.10 back out of the APK and confirmed
`Resources/releases.json` names it before writing a byte.
|
||
|
|
d8df4a8dbf |
Sign-in that cannot finish now says so, and a missing log leaf heals
**android#19 — sign-in sat for four minutes and said nothing.** The failure path was always correct: anything `authenticate` throws is reported. What it could not report was a call that never RETURNS — observed 2026-09-05 with the requests demonstrably going out and the screen unchanged. Nothing was thrown, so nothing was shown. ⚠️ Sixty seconds, not ten. Sign-in derives a key from the passphrase and then talks to two services; an emulator or an older handset is legitimately slow at the first part. This is a backstop for "never", not a latency budget. **android#6 — a failed key-log append was permanent.** `signIn` calls `registerDevice`; `restore` did not. So a device registers exactly once, and if the transparency-log append did not land — a slow write, a relay mid-restart — it stays registered with no leaf, and every peer that enforces the log refuses it forever with an error that reads as an accusation of tampering. Relaunching did not help, which is the cruel part: the one thing anybody would try was the one thing that could not work. Restore now checks for a leaf and republishes if there is none. ⚠️ Checks first. Registering unconditionally would publish twenty fresh prekeys every launch and churn the log for nothing — iOS's `registerDeviceIfNeeded` tops up only when low for the same reason. Silent on failure, because this repairs a state almost nobody is in. **android#17 — a photo sent as a file keeps its GPS.** Same as ios#69 and the same answer: the picker strips through ImagePrep and MediaScrub, `Session.sendFile` passes the vault's bytes straight through, and the send screen now says which path you are on rather than leaving the safer one looking like a shortcut. |
||
|
|
581967289a |
Android 0.9 — published
🔴 **0.9, not 0.10.** The tree said 0.10 because "Put the app back to the last state that worked" bumped past 9 without publishing anything, so 9 belonged to nothing and the live manifest still said 8. Going back to 9 keeps the published line contiguous; the only rule that matters here is that `versionCode` never goes backwards from what is INSTALLED, and nothing carrying 10 was ever served. Published: sha256 b86219dc…, signed CN=Flamenet Messenger with 791fc826…, the same certificate as every build since 0.3 — an update installs over 0.8 rather than being refused. ⚠️ The APK went into downloads/ BEFORE the manifest, and the served copy was hashed over HTTPS rather than trusted as copied. A manifest that lands first announces a version the file is not, and every client that checks in the gap downloads 0.8 and rejects it against 0.9's hash. This is the first release through the notes gate that landed one commit earlier: `release-manifest.sh` refused to emit anything until `Resources/releases.json` in the relay named Android 0.9. |
||
|
|
ad7c63ff4f |
An APK no longer ships without a word about what changed in it
Publishing was three steps done by hand, and only two of them left a trace. The manifest and the APK could go out with nothing anywhere saying what was in the build — which is how 0.8 shipped with notes and 0.9 shipped with none. The gate goes on the manifest rather than on a checklist, because the manifest is the step that cannot be skipped: it is what the in-app updater reads, so an APK published without it offers nobody an update. A checklist item is skipped on exactly the busy evening the notes matter most. 🔴 It matches on the `versionName` read out of the APK, never a number typed here — the same reason this script already reads the version rather than accepting one. The two cannot disagree. The check itself lives in the relay repository (Scripts/covers-android.py), because that is what serves the /releases page and defines the format. Parsing the JSON here would leave a second copy of the schema to drift out of step with the first, and a drifted gate is a gate that passes everything. ⚠️ FN_SKIP_RELEASE_NOTES=1 still publishes, and says so loudly on stderr — a skipped gate that printed nothing would be a gate nobody remembered turning off. A missing relay checkout is a refusal, not a pass, and names FN_RELAY_DIR. Proven on a real APK in all four states: covered (manifest written, valid JSON), uncovered (refused, and stdout stays EMPTY so no half-written manifest lands), skipped, and no checkout. Every message goes to stderr, because this script's stdout is the manifest itself. |
||
|
|
571c18571f |
Files asks for your Flamenet passphrase, not a second secret
Reported: "on android, the files passcode should just reuse the account like ios does." 🔴 **The difference was entirely in the words, and iOS has been right all along.** iOS says "your passphrase" and takes whatever is typed, so a person naturally uses their Flamenet one and it simply works. Android said the files were "encrypted with a passphrase of their own", labelled the field "Choose a passphrase", and later asked for "your Files passphrase" — three separate places telling somebody to invent, and then remember, a second secret. ⚠️ **Forgetting it is a one-way door.** The label keys are derived from the identity that passphrase protects and exist nowhere else, so a forgotten Files passphrase makes every vaulted file permanently unreadable. Sending people off to mint an extra secret for no reason put them one lapse of memory from that, and the app already has a paragraph explaining the consequence. ⚠️ **Nothing in the derivation changed, deliberately.** It is still a free-choice string protecting the identity record, so anyone who already set a different one keeps opening their files with it exactly as before. Making the account passphrase the ACTUAL key would be a migration with permanent data loss on the far side of any mistake, and it is not what was asked for. That also means the copy IS the feature here, so a copy guard is the only guard there can be. Proven by reverting the wording: both tests go red naming the file and the phrase. 1194 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
97961974dd |
Say when a call was refused because the key changed (android#20)
Reported from a live session: "the calls wouldn't connect because the keys were changed. it wasn't obvious why the call failed." Confirmed by the reporter — accepting the new key made everything work. 🔴 **It was not obvious because nothing said it.** The call's signalling lambda was `runCatching { session?.sendCallSignal(...) }` with no `onFailure`, so `IdentityChangedException` vanished. That exception is the send path REFUSING to encrypt to an unreviewed identity — TOFU working exactly as designed — and swallowing it meant the offer never left while the screen sat in DIALLING until it timed out. ⚠️ **`describe()` had no case for it either**, so anywhere it WAS caught the person got "Something went wrong reaching that server." The server was fine. That sentence sent somebody hunting a network problem that did not exist, which is the specific thing the neighbouring branches in that function exist to prevent — they all say what to DO. ⚠️ **And the fix is on a screen the caller is not looking at.** Accepting a changed key is a banner in the CHAT; somebody calling from the Calls tab never sees it. So the message names the conversation, not just the problem. This is a parity gap, not a new idea. iOS has named this on the call path since it was built — "Their security code changed", with the review opened — and its comment describes this exact bug: "sent someone hunting network problems when the actual cause was their contact reinstalling". `describeFailure` moves to file scope so the mapping can be tested without an `Application`. It never touched instance state, so that is a move rather than a rewrite — the alternative was a Robolectric run to assert a string. Proven by removing the branch: all three tests go red reporting the old sentence verbatim, "Something went wrong reaching that server." 1196 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2764145063 |
Expose the security lattice to the interop driver (relay#49)
relay#49 requires that labels and security levels be kept as Messenger and Files converge. Both engines have implemented §1's lattice independently since the Files apps shipped, each pins its own vectors, and nothing has ever compared the two. 🔴 **The ten-byte encoding is authenticated data for every construction that uses a label**, so a platform disagreement does not warn. It produces a decryption failure, or — worse — plaintext that quietly belongs to the wrong sensitivity. Each side passing its own vectors is necessary and not sufficient; two engines can pass their own and still disagree. `label_encode` reports the mask as a STRING. Bit 63 makes it a negative Long, and a JSON number would arrive on the other side as a double and lose the low bits — turning the one compartment most likely to be mis-shifted into the one this check cannot see. The matching harness stage is flamenet-e2e!28. Proven by breaking it rather than by watching it pass: dropping `compartments.containsAll(...)` from `dominates` — the classic "compares the level and forgets the areas" bug — turned exactly four checks red, all of them compartment cases, each reporting iOS and Android disagreeing. That bug behaves correctly on every case built from levels alone, which is why it needs two engines to catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8f474ce0b9 |
Give the Kotlin engine content commands, so the layer can be checked (e2e#4)
The interop driver dispatched 19 commands — device, initiate, encrypt, seal, log_leaf — and not one touched a content envelope. Swift has had `content_encode`/`content_parse` for a while; Android has never been checked against any other implementation at this layer. 🔴 **This is the half that most needed it.** SPEC.md §6.10 says so in as many words, after the `ctl:true` bug: "no vector covers this envelope. It is the standing argument for the interop harness." That bug was Android writing `"ctl":true` where iOS decoded a number — and the cost was not a missed flag but a failed decode that dropped the whole message onto the plaintext path, while each engine's own tests passed on both sides. ⚠️ The two parsers fail DIFFERENTLY, which is why a round-trip on either proves so little. This engine reads field by field through `org.json`, whose bundled Android build coerces a JSON null to the string "null" where Maven's returns "". iOS decodes all-or-nothing through `Codable`, so one bad field downgrades the entire envelope to a raw-JSON bubble. The same additive change degrades differently on each platform. `content_parse` also reports `isControl`, so the harness can check §6.10's rule across engines instead of each engine checking itself. The matching harness stage is flamenet-e2e!27. Proven by breaking it: making this engine read the SPEC's `ment` instead of the shipped `men` turned "iPhone mentions parse on Android" red — the exact drift that has been invisible since the key was chosen. 1190 tests still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c2d7f1f65 |
"Unsend" did not unsend. Say what the button does (android#18)
The message menu labelled the delete `if (line.mine) "Unsend" else "Delete for me"`, and both branches call `deleteMessage` → `Session.deleteMessage` → `vault.deleteLine`. That is the whole implementation. No envelope leaves the phone; the recipient's copy stays exactly where it is. 🔴 **Worse than an overclaiming page**, which is why this got a test and not just an edit. A page that overclaims can be read sceptically. A button that reports success cannot: the feedback IS the claim, and it arrives at the moment the person is most relieved to believe it. "Delete for me" was already the other half of that ternary, and it was always the accurate half. ⚠️ Two tests, not one, because the first is worthless without the second: a source scan that walks the wrong directory finds no files and passes every assertion in it. `the scan actually reaches the chat screen` pins that the walk reaches the file the bug was on. The walk climbs to the module root rather than resolving a relative path, for the same reason. Proved by breaking it, not by watching it pass: label reverted → FAILED, naming ChatScreens.kt line ~1564 restored → 2 tests, 0 failures 1190 tests pass across the suite. ⚠️ This is ios#57 on the other platform, fixed there in the same pass. A real retract is a new `kind` on rails that already exist — §6.15's edit machinery is implemented and tested on both clients. But even then it is a request, not a guarantee: a client can ignore it and the message may already have been read. Whatever the button eventually says must not promise more than "we asked them to remove it." Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
56ec122405 |
Prove a group pin resolves, so a spec claim can be settled (relay#46)
The published spec says a group pin "is carried correctly on the wire but cannot resolve yet, because a group transcript does not record" a message id. `Session.sendGroup` mints a mid and passes it to `record(...)`, so the stated reason is already false — but relay#46's rule is that a claim is flipped by a run, not by reading the code, and nothing exercised the group case specifically. Five tests, on a group thread rather than a 1:1 one, because that is what the spec singles out: the transcript carries an id, the pin resolves to the message it names, and a group pin does not leak into a DM. ⚠️ One of them is about the design rather than the plumbing. The pin stores an ID and never a copy, so the banner follows an edit instead of quoting words that are no longer in the conversation — a stored copy would go stale the moment the message changed and would outlive it being deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
28169a648f |
PARITY: post-quantum is shipped on both clients, not missing on either
Both rows said ⚪/⚠️ and the note under them said "neither client does post-quantum anything today". That was wrong in the understating direction — the same direction relay#45 warns about, and the same one that had the comparison page scoring Signal unfairly in our favour. - **Android** uses BouncyCastle's ML-KEM (FIPS 203), shipped in the engine. The old note's "no ML-KEM at any API level" was about the PLATFORM, which is true and is why the engine carries its own — not a reason it has none. - **iOS** uses CryptoKit `MLKEM768`, unconditional since `project.yml` moved the deployment target to 26.0. The runtime gate that made those files inert is gone, and `PQKEM.swift` says so at the top. ⚠️ Corrected by a run rather than by reading the source, which is the rule this file keeps to. `test/interop-swift-kotlin.mjs` establishes it end to end in both directions: "Android picks v3 for an iPhone peer", "the first Android envelope carries a PQ ratchet step", "the iPhone agrees the session is v3", "a v3 session survives being archived and restored mid-conversation". ⚠️ The honest limit is unchanged and kept: it counts only once BOTH ends have it, so today that means two Android phones — or an Android phone and an iPhone running a build that has not shipped. Found while correcting relay#11, where I went in expecting to find our own cells overstated and found the opposite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c01d1ba32b |
Fix: ./gradlew test now passes on a clean tree (android#5)
Confirmed exactly as filed: 15 red `ScreenshotTest`s, all in the RELEASE variant, all "Unable to resolve activity for Intent". The diagnosis in the issue was right — `androidx.compose.ui:ui-test-manifest` is a `debugImplementation`, and it is what declares the `ComponentActivity` those tests launch, so the release variant's merged manifest has no such activity. ⚠️ **The tempting fix is to add that dependency to release as well. Do not.** It exists to inject a test-only activity into the manifest, so putting it on `releaseImplementation` would ship that into the APK. The tests belong to the variant that can run them. ⚠️ Excluded by name rather than by disabling `testReleaseUnitTest` wholesale — everything else still runs in both variants, so a genuine release-variant difference would still be caught. ⚠️ And the first green was not evidence: `clean` followed by `test` reported BUILD SUCCESSFUL in 3 seconds, which is far too fast to have run anything. Re-run with `--rerun-tasks` and counted: debug 129, release 114, JVM modules 933, 0 failures. The 15-test gap between the variants is exactly the excluded screenshot tests, which is the shape that proves the exclusion did what it says rather than taking the suite with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
327da06ed3 |
Prove an inbound edit is applied, so a spec claim can be corrected (relay#46)
The published spec says the Android client *"cannot yet apply one, because its transcript does not record a `mid` for each line"*, and that the same gap blocks replies and reactions. The transcript DOES record a mid, and `MessageReactionTest` already folds a reaction onto a message by id — so the stated reason is false and the consequence for §6.9 is false with it. ⚠️ But relay#46's rule is the point of this commit: **do not flip a claim because the code looks present; flip it because a run proved it, and say which run.** `applyEdit` was implemented and wired with nothing exercising it — `InboundControlTest` covers the codec and says in its own header that the app-side application needs a Vault and is out of scope there. This is that run, written so the correction has something behind it. Five tests, and the two that matter most are refusals: an edit from somebody who did not write the message, and an edit of our own message claiming another author. Without the author check a contact could rewrite another person's words in a transcript, which reads to the reader as the sender having said something they never said. Both proved by removing that check and watching exactly those two go red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
46fb23dbe1 |
Expired messages no longer leave their plaintext on disk (android#11)
`attachmentBytes` wrote DECRYPTED bytes to `cacheDir/att-<id>` and nothing ever removed them. So a disappearing message expired, the bubble went, and the photo or voice note stayed on disk indefinitely — the exact failure the feature exists to prevent. Spec §6.11: attachments MUST be purged with the message that referenced them. 🔴 **A sweep, not a purge at each deletion point, and that changed what the fix had to be.** FOUR paths drop a row — message expiry, story expiry, delete-for-me and clear-thread — so threading "here is what I removed" out of each would still miss anything a future path drops, and would never reach the orphans already on disk. Asking instead "what is still referenced?" is complete by construction and self-healing. 🔴 **Refs are found by SHAPE.** An attachment reference serialises with `id`, `key` and `digest` together and nothing else in the vault has all three, so `attachmentIdsInUse` walks every record and matches on that. A hand-written list of stores is wrong the day somebody adds a store and does not update it — and the sweep would then delete attachments still in use, which is far worse than the leak. ⚠️ **That is not hypothetical: enumerating by hand gets it wrong today.** The obvious sources are history and stories. The vault ALSO holds refs in the story archive, in per-contact avatars and in this account's own avatar. A sweep built on the obvious two would have deleted every avatar on the device the first time it ran, and blanked the user's own story archive — which is the one thing that archive exists to prevent. Three more safety properties, each with a test that fails without it: fails closed when the in-use set cannot be built (a partial answer is what turns a cleanup into data loss); a grace period so a sweep racing a fetch cannot delete bytes on their way to the screen; and a throttle inside the function, since it reads every vault record and is called from the poll loop. ⚠️ The cost is that a file survives to the next sweep rather than going at the instant of expiry — named in the code rather than left implied. 17 tests. Every guard proved by breaking it. ⚠️ One did NOT hold at first: removing the `/` -> `_` filename mangling broke nothing, because every id in the suite was slash-free — the guard was untested by the tests meant to be guarding it. Added the case; it now fails as it should. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0e70c78e98 |
PARITY: the file round trip is proven live, not merely written
`LiveFileTransferTest` was committed unrun, with the caveat that an unrun test proves nothing. It has now run against relay1 on the same image production runs: a Restricted document left one vault, crossed a real relay, and arrived in another's with its classification and both compartments intact. Ran alongside it, all green: 6 LiveConversationTest, 7 LiveKeyLogTest, 2 LiveSealedSenderTest, LiveSignUpTest, LiveRelayTest and the self-hosted LiveShapesTest paths — 31 live tests in total. ⚠️ The managed-account paths stay SKIPPED. They need FN_LIVE_MANAGED, which signs up against accounts.flamenet.io — production, and `LiveTarget` refuses it by design. Those accounts are real and persist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ead31053a4 |
A live test for the file round trip, and four published passwords
`LiveFileTransferTest` covers the one seam nothing else does: a document leaving one vault, crossing a real relay, and arriving in another's with its label intact. `FileShareWireTest` proves the envelope's shape and `LocalVaultTest` proves the vault at both ends; neither puts a byte on a wire. ⚠️ **Written, compiles, UNRUN** — the same state `LiveSignUpFleetTest` is in, and the same caveat applies: an unrun test proves nothing. It creates accounts, so it needs a throwaway relay and an explicit decision to point it at one. 🔴 **Writing it surfaced four published passwords and a guard that could not see them.** `TestSafetyTest` exists to stop a credential being written into a suite that creates real accounts. It matched `val X = "…"` only. So: - `LiveConversationTest` passed the literal straight as an ARGUMENT, twice — invisible to the check. - `LiveSignUpTest`, `LiveKeyLogTest` and `LiveSealedSenderTest` wrote `password = "…"` on its own line inside a multi-line argument list, the most ordinary Kotlin formatting there is — also invisible. I copied the same literal into the new suite before noticing, which is how I found it. **A guard that passes while the thing it guards against is present in the tree is worse than no guard, because it is read as evidence.** All five call sites now read `LiveTarget.passphrase()` from the environment with no fallback, and announce a SKIP when it is unset. The check itself took two wrong turns worth recording: - Skipping every named argument, to avoid flagging `product = "messenger"` — which let the three `password = "…"` suites through. Trading one blind spot for another is not a fix; a named argument is now judged by its NAME. - Widening the sign-up match to a few lines around the call — which flagged forty test tags, assertion messages and message bodies as credentials. That is how a check stops being read, so it is same-line only, and rule 1 covers the multi-line case properly. "secret" was dropped from the credential names for the same reason: test prose is full of it, and a marker string called `secret` is a fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e6913ae60e |
PARITY: history at rest was already built — the third stale row
Marked ⚪ and implemented the whole time. Every read goes to disk: `Session.history` calls `Vault.history`, which decrypts the record. There is no in-memory copy to be fooled by. Checked against iOS's `E2EHistoryStore` rather than assumed. Same cipher, same posture, two honest differences recorded rather than papered over: - 🟢 Android binds the RECORD NAME into the AAD, so a sealed blob cannot be moved between slots. iOS keeps one snapshot file and does not need that — but would, the day it splits. - iOS's `.completeFileProtection` is a per-file assertion. Android's equivalent is Credential-Encrypted storage, a property of the volume rather than of this app's request. Neither favours one platform overall, and both clients agree on the behaviour that matters: a plain sign-out KEEPS history, and erasing the device destroys it — on Android by wiping every record and deleting the Keystore key, so what is left is unopenable as well as gone. ⚠️ Also recorded: `Vault.history` prunes expired messages on the way out, so disappearing messages are enforced on READ rather than by a timer nothing runs — and it rewrites the record only when something actually expired, so a read is not a write. Services (AIM/ICQ) retired rather than left as a gap: not a goal for this app (owner, 2026-09-04). That leaves only the two post-quantum rows, and the note above them already says why they are not a gap in effect: iOS's PQ files are inert because CryptoKit exposes ML-KEM on iOS 26 and the app targets 16, so neither client does post-quantum anything today. Closing it properly is one decision for both clients, not an Android task. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0a4709dd0e |
Fix: promoting a link to the vault appeared to do nothing
🔴 `Node.equals` compared only the id. It was written that way to dodge a real trap — a data class compares `ByteArray` by identity, so the generated equals calls two identical rows different — and dodging it broke a whole feature. Promoting a link keeps the node's id and changes its `storage`. So the new row compared EQUAL to the one it replaced, the state object holding it compared equal too, `StateFlow` conflated the update away, and Compose never saw it. On screen the file vanished from Linked and did not appear in the vault until the app was restarted. That reads as losing somebody's file. Every field is compared now, with `contentEquals` for the four byte arrays — the identity trap handled rather than avoided. ⚠️ **This suite could not have caught it, and the new tests say why.** Everything here reads the vault, and the vault was right the whole time: the index on disk had both rows, and a fresh load rendered both. The failure was in what the vault handed a state holder that conflates on equality. So the tests assert that property directly — a promoted row must not equal its old self, an unchanged row read twice must, and every mutable field must be visible in equality. Found by watching it on an emulator. Confirmed by restoring the old equals and watching both go red, then re-verified on device: the promoted row now appears immediately with no restart, and its size corrects from the provider's reported 4 kB to the real 14 kB plaintext length — which is itself proof the row genuinely updated. Also verified on device while here: a persistable read grant survives a reboot, a full cold boot and an app update (`persistable=0x1 persisted=0x1`), promoting drops the bookmark from the index, and the person's own file is left untouched where they put it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2d05bbe7c4 |
Fix: a contact with no address was pinned as the string "null"
The same platform difference as the vault index, in shipped code, with a worse consequence. 🔴 `saveTrust` wrote `"fnid": null` explicitly when a contact's global address was not yet known. Android's bundled `org.json` reads a JSON null back as the four-character string `"null"` — a non-null value that is not an address. What that caused on a phone: - `Trust.pin` compares a newly offered fnid against the pinned one and treats a difference as a different person. `"null" != "fnid:…"`, so the FIRST TIME a real address arrived the contact was marked **CHANGED** — the "security keys changed" warning, which is supposed to mean an attack, firing on an ordinary first sighting. The vault's own comment about minting a fresh device warns about exactly this shape of false alarm. - `rememberIdentity` returns early once `fnid != null`, so the real address was never resolved and the record stayed wrong permanently. - `pinnedFnid` fed `"null"` into key-log verification, which recomputes a leaf against the address a contact was accepted under — every check against the wrong subject. Two halves, because records written the old way are already on phones: the writer now omits the key entirely (an absent key reads as `""` on both platforms, which is what the reader always expected), and the reader rejects the literal `"null"` so existing records repair themselves. ⚠️ The test that matters is the one feeding the reader the literal string. On the JVM a real JSON null reads back as `""` whether the key is omitted or not, so the plain round-trip passes with the bug intact — which is why this survived every suite. Confirmed by restoring the old reader and watching it go red. Found by the sweep flagged in android!54. `JSONObject.NULL` is written in exactly two places in this app, and both were wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
436ce240c7 |
Fix: the vault index failed to load on a real phone
🔴 **Android's bundled `org.json` coerces a JSON `null` to the four-character string `"null"`. The Maven `org.json` the tests run against returns `""`.** Every nullable field in the vault index therefore passed on a laptop and broke on a device: - `parentID` threw `Invalid UUID string: null`, so a vault holding any top-level file failed to load AT ALL. Adding the first file worked, because it was still in memory; reopening the app lost everything, with an error nobody could act on. - `wrappedContentKey` was worse, because it did NOT throw. `"null"` is valid base64, so the node came back with a plausible three-byte content key and simply refused to decrypt, for ever, with nothing to explain it. - `bookmark` produced a reference pointing at `"null"`, permanently missing. Absence is now decided by `JSONObject.isNull` and never by comparing a decoded string to a sentinel. The literal `"null"` is rejected as well: it is not a valid value for any of these fields, and tolerating it costs nothing next to reintroducing this. ⚠️ **The two regression tests write the literal string `"null"` into the index on purpose.** That is exactly what the decoder is handed on a device, so it is what makes a JVM run reproduce the device's behaviour. Feeding a real JSON `null` — which is what the vault actually writes — passes with the broken code and proves nothing. Both were confirmed by restoring the old decoding and watching them go red. Found by running the app (android!52). No JVM test could have caught it, which is an argument for the device run rather than against the tests. ⚠️ `optString` on a nullable field is wrong everywhere in this app, not only here, and it fails only on the platform it ships to. Worth a sweep. Verified on the emulator afterwards: the vault survives a cold start, and on disk the blob begins `ENCL\x01`, both wrapped keys carry version 0x11, the name 0x01, the filename is not in the clear, and the plaintext is absent from the whole app container. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6ec626cde2 |
Share a file into a chat, and save one back — with the label it carries
Both directions: pick a document from the vault inside a conversation, and save a received one into the vault at the classification it arrived with. 🔴 **This is the answer to android#15, and the codebase had already given it.** `Attachment.FILE` has carried this since before the lattice existed: *the label is a MARKING, not a permission — the content key is in the same envelope, so whoever can read the message can read the file.* That settles the ★-property question. The key travels with the file by design, because that is what makes sending work at all; there is no trusted computing base to enforce against, since the person holds every key on a device they control. A client-side refusal would be a suggestion, and **a suggestion dressed as a control is worse than none** — it is the one people plan around. So neither client implements the ★-property, `LabelTest` keeps asserting that absence, and PARITY.md now says plainly that Files is not mandatory access control. What it IS: cryptographic read separation, plus a classification that travels. The picker says so before the tap, in those words. Three things found on the way: 🔴 **A second copy of the vocabulary.** `classificationName` in ChatScreens listed the same four names as `FileLevels`, agreeing by coincidence with nothing making them agree. Rename a level for the Files screen and the chat bubble keeps showing the old word for the same number, in the same conversation, on the same phone. Collapsed to one. 🔴 **Attach and Camera did the same thing.** Both launched the gallery, and there was no way to send a document at all. Attach now opens a small panel — photo, or from Files — which keeps the kit's composer row at its four controls. 🔴 **iOS's picker lists the root only**, so a file inside a folder cannot be sent from a conversation, and nothing says so — it is simply absent, which reads as "it isn't there". `LocalVault.allFiles()` walks the whole tree. Filed as ios#68. ⚠️ The picker is the ONE place this vault hides a row it cannot read. Everywhere else it insists on showing them, because hiding one leaves somebody unable to see anything is there; a picker exists only for choosing, and a row that can never be chosen is furniture. ⚠️ A received file is always saved as an encrypted copy, never a link — there is no file on this phone to point at — and always at the label it arrived with. Re-labelling somebody else's document on receipt would launder its classification, and the number is the only thing that survived the trip. 9 new tests. Each guard proved by breaking the code: writing the label for every attachment kind fails the image test; dropping compartments fails the two that name them; removing the fallback caption fails the older-build test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fbf2c4c531 |
Files on Android: set up, unlock, browse, add, folders, delete, promote
The screen, the Storage Access Framework resolver behind `LocalVault.ReferenceResolver`, and the passphrase-protected identity the vault opens under. 🔴 **Android asks for a Files passphrase, as iOS does, and that was a decision rather than a port.** The Android wallet sits behind the Keystore-sealed vault alone, so an unlocked phone opens it. Files does not: the identity record is encrypted under a key stretched from a passphrase this app deliberately keeps no copy of, with the Keystore seal around it as the second layer. Dropping the inner one for convenience would be a real reduction and not a refactor — a vault that opens because the phone is open is a folder with a lock drawn on it. 🔴 **`OpenDocument`, never `GetContent`.** `GetContent` hands back a URI whose read grant dies with the process, so a linked file works for the rest of the session and is broken on the next launch — and whoever added it watched it work. `filesReference` REFUSES when the grant cannot be persisted and offers to copy the file in instead, rather than filing a row that will be dead tomorrow. 🔴 **Locking drops the identity; it does not hide a screen.** The open vault is a field, deliberately not part of `FilesState` — a state object gets logged, compared and copied into recompositions, none of which should ever have a private key in it. The classification ordinals are pinned by a change-detector test, on purpose: the NUMBER travels with a file and the word is only how it is drawn, so renumbering them would file a Restricted document as Internal on the other phone with nothing erroring and neither screen looking wrong. ⚠️ **Not yet run on a device.** The machine was carrying an iOS simulator at 99% CPU on 8 cores, and booting an emulator on top of that produces a flaky run rather than evidence. Two things need a real device before this is done: the SAF picker with its persistable grant, and whether a linked file still reads after a reboot. Neither can be faked on the JVM. ⚠️ Files is its own destination, not a section of the wallet. They share an unlock, which is true and is an implementation detail; iOS grouped them once and what it produced was somebody pressing "wallet" and arriving at a file list. Full build and all JVM suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3a2c10f7bc |
The local Files vault in Kotlin
The index, folders, both ways of holding a file, and the delete semantics — everything iOS's `LocalVault` does, minus the screen. 🔴 One deliberate divergence, and it is a fix rather than a port. `descendants(of:)` on iOS bounds a cyclic index with `guardRail < 10_000`. That terminates and then returns ten thousand copies of the same two ids — and the screen calling it is the one offering "delete this folder and everything in it", so a corrupt index turns that into an offer to delete 10,000 things. Bounding by what has already been visited terminates AND returns the right answer, with no magic number. Filed as ios#66. The test that found it writes a real cycle into a real index. It matters because the index is a file on disk: the input is not only ever our own writes. ⚠️ The index file is device-local and is NOT an interchange format. The node FIELDS match iOS's exactly so a courier transfer stays a copy rather than a translation; the JSON around them does not. iOS encodes dates as Swift's reference-date doubles, which is an accident of `JSONEncoder` rather than a decision, and pinning Kotlin to it would promote an accident to a format. Anybody building vault migration should move nodes through a declared wire shape, not by copying this file. 🔴 `ReferenceResolver` is required with no default. A no-op default would make every reference row read as "missing" in an app that simply forgot to wire it up — a vault that looks like it has lost half its contents, with nothing anywhere saying why. Two tests worth naming: - **Nothing on disk contains the plaintext.** A round trip passes just as happily if `add` wrote the file out unencrypted and read it straight back, so the bytes are searched for across the whole container, and the index is checked for the filename. - **Deleting a reference row does not reach the person's storage at all.** Asserting only that their file survived would be a test that cannot fail: the delete path holds no call to remove it, and the protection is structural — the resolver has no way to delete anything. Asserting zero resolver calls is the property that constrains future code, and it was verified by routing the delete path through the resolver and watching it go red. 29 tests. Each guard proved by breaking the code: the label key salted with the level alone, a node key wrapped to its parent's label, and a bulk delete that ignores unselected survivors each fail exactly the test that names them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2a3187308d |
The Files vault crypto in Kotlin, pinned to the browser's vectors
Names, key wrapping, chunked content, the KDF and the identity record — everything needed to open a Files vault written by any other FlameNet client. 🔴 Held to `vectors/files-v1.json`, produced by the JAVASCRIPT engine in the browser. Not to the Swift engine. Checking Kotlin against Swift would establish only that the two agree, and two implementations agreeing on the wrong answer is how somebody's files stop opening when they change phones. The file is copied byte for byte from the iOS repository rather than regenerated, because a vector file regenerated by the implementation under test proves nothing. Two vectors were recomputed from scratch in Python before any Kotlin ran against them — `struct.pack('>hQ')` for the label encoding and `hmac.new(...).digest()` for the name digests. The `café` case matches under NFC and NOT under NFD, which is what pins the normalisation form rather than assuming it: a platform normalising differently does not error, it silently fails to find a file filed on another device. Each guard was proven by breaking the code and watching the right test go red: normalisation removed → the composed/decomposed digest test; the wrap salt halves swapped → the browser's label-key grant; the end-flag or the chunk index dropped from the authenticated data → the browser's file stops decrypting at all. ⚠️ The truncation and reordering tests each go on to show the surviving bytes are still authentic under their ORIGINAL index and end-flag. Without that second half "it threw" is all they would establish, and any bug at all makes a mangled blob throw. Two notes on the platform: - PBKDF2 is hand-written on `Mac`, as `Bip39.pbkdf2HmacSha512` already is. The JCA path takes a `CharArray` and lets the provider decide the encoding; the reference implementations take bytes. A passphrase outside ASCII would otherwise stretch differently under a different provider and the record simply would not open, with nothing to say why. - The public half of an identity is recovered by agreeing against the X25519 base point, because the JCA will not derive one from an XDH private key. That it returns 32 bytes proves nothing, so it is pinned to the vector file's own `identityPublicKey`. 43 tests. `FileContent` rather than `Content`: the messenger's `Content` is a message payload and the two are not interchangeable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6a0dce6d4e |
The Files security lattice in Kotlin (labels, clearance)
`Label` and `Clearance`: the two-dimensional lattice Files is built on, and the ten-byte encoding that is authenticated data for every construction using it. 🔴 The encoding vectors were computed independently, not read off this implementation. A label that encodes differently on two platforms does not warn — it produces a decryption failure, or plaintext that quietly belongs to the wrong sensitivity. A vector taken from the code under test would pin whatever the code happens to do. 🔴 Two dimensions, and the tests are shaped around the failure that READS AS WORKING: an implementation comparing classification and forgetting compartments behaves correctly on every test built from levels alone, right up until somebody sees a compartment they are not in. Domination needs a superset of areas, not an overlap; two labels can be incomparable in both directions. ⚠️ Bit 63 is the sign bit of a Long. Building the mask with an Int shift loses it, and the compartment that vanishes is the one nobody notices until somebody sees a file they are not cleared for. Pinned, along with the all-ones case where a sign-extension mistake still produces plausible-looking bytes. 🔴 **A finding, and a divergence I deliberately did NOT create.** Files implements the simple security property (no read up) and NOT the ★-property (no write down) — on either client. iOS gates `ensureLabelKey` and `use` on `clearance.permits` and nothing else, so Kotlin's `mayWrite` calls the same check, with a comment saying why and a test asserting the current behaviour that will fail loudly the day it changes. Implementing it on Android alone would mean a file that saves on one phone and is refused on the other, with neither person able to tell which rule they had met — worse than the gap, because an apparent bug gets worked around. Filed as android#15 / ios#65 with the trade written out: no-write-down is the half a lattice actually exists for, and it is also the half that is awkward enough to need a stated downgrade path rather than a bare refusal. It interacts with sharing a file into a chat — a write to a place with no label at all — so it needs an answer before that ships. 16 tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d915001dcc |
The wallet screen (Android)
Create a wallet, restore one from a phrase, see the balance and the history, and take a fresh address to be paid at. 🔴 An unread balance is an em dash, never 0.00. Null means this app could not read the balance; zero means the account is empty. Drawing the first as the second tells somebody their money is gone — on every flaky connection, which is exactly when they are most likely to believe it. The same rule governs the activity list: "not read yet" and "nothing yet" are different sentences and the screen says whichever is true. Both cards are always present once a wallet exists, because a screen that hides them until it has something looks broken at the moment somebody wants reassurance nothing went wrong. 🔴 The phrase card sits ABOVE everything about the chain. Somebody who has just made a wallet has one job — write these words down — and putting a balance in front of it invites them to skip it and come back later, which for most people means never. The card says both halves at once: anybody who has these words owns the wallet, and anybody who loses them loses it. Saying only the first makes people hide it; saying only the second makes them screenshot it. ⚠️ Copy is offered for the phrase and is the SECOND option, not the first. It puts the words on a clipboard every other app can read — offered anyway because people who are not given it photograph the screen, which is worse and permanent. The text asks for paper. ⚠️ The receive card says the address changes BEFORE somebody writes one down. A fresh address per payment is the one linkability a wallet can prevent, and an address that silently changed would read as a bug to anybody who had saved it. Older addresses keep working, and the card says that too. ⚠️ Reached from Account, not the tab bar. The bar is four icons by design — the kit's artboard is "Bottom Bar/Without Label" — and Alerts was taken OUT of it in August for being a fifth. A wallet people open occasionally does not earn the slot that decision refused. 🔴 An account below the rent-exempt minimum is RECLAIMED by the network, so `spendable` holds back the fee and that minimum: emptying a wallet to the last lamport does not leave it empty, it deletes it. Named constants rather than an expression, so the reason survives. ⚠️ http endpoints are ignored rather than honoured. A balance read over plain http is one anybody on the path can rewrite, and a wallet showing a number an attacker chose is worse than one showing nothing. 8 display tests. App builds, every JVM suite green. Three PARITY wallet rows are now ✅. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b8a440fc4 |
Read a balance and a transaction history from a chain node (Android)
`ChainRpc` asks a node what a public address holds and what has happened to it. The node is trusted with nothing: it never sees a key and cannot move money. What it CAN do is lie about a balance or withhold a history, so nothing here treats an answer as authoritative beyond "this is what that node said". 🔴 A JSON-RPC error arrives with HTTP 200. The transport succeeded; the node said no. A client that checks only the status code carries on with a body that has no `result`, fails somewhere less obvious, and throws away the node's own explanation on the way — the difference between "insufficient funds for rent exemption" on screen and a parse error nobody can act on. Both cases are tested, including an error carrying no message. 🔴 A FAILED transaction still appears in an address's history, and is shown as failed. Not doing that is the worst kind of wrong: somebody would believe a payment went through. `err` is an OBJECT for one class of failure and an ARRAY for another, so the check is "not null" rather than a shape — testing for an object alone would pass one whole class of failure off as a success. Both shapes tested. ⚠️ A body with neither result nor error is malformed, never a balance of zero. Reading a missing result as zero would show somebody an empty wallet that is not empty. ⚠️ Lamports as a Long throughout, and to the edge of the UI. Five SOL already exceeds Int.MAX_VALUE, and converting to a fractional unit early means carrying rounding through code that compares and subtracts — a rounding error in a balance is money. ⚠️ A transaction the node has not dated yet reports no time rather than 1970. It has the transaction and has not assigned a block time; "just now" is the truth and the epoch is not. ⚠️ Which node somebody points at is a privacy decision the app has to state: asking a public RPC endpoint for an address's balance ties that address to this device's IP in somebody else's logs. Running your own node is the only answer that removes it; the default exists so the wallet works at all, not because it is private. 15 tests, against a MockWebServer. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
227c098da7 |
Create, import and store a wallet (Android engine)
`Wallet` makes one, adopts one from a phrase somebody typed, and reads the phrase back; the vault seals the record at rest. 🔴 The property guarded hardest is a NEGATIVE one: a wallet must not be derivable from the account. The entropy is fresh randomness — never the passphrase, the handle, or the Flamenet identity. Deriving it from the passphrase would be convenient, would need no backup at all, and would tie every payment somebody ever received to their handle on a public ledger for ever. The chain does not forget, so there is no undoing it later. A test asserts that eight wallets made in a row share nothing, which is what would catch that shortcut being taken. 🔴 The record stores the ENTROPY, never the seed. The seed is a one-way function of the phrase, so a record holding it would open the wallet and be unable to produce the words: somebody who lost their phrase but still had their account could spend, and could never write it down again or move to another wallet. Self-custody in name only. ⚠️ Erasing the device destroys the record, and the phrase is then the only copy that exists. Checked rather than assumed while writing this: plain sign-out keeps the vault and `updatePassword` uses the plain door, so a password change does NOT cost somebody their wallet; `signOut(erasingDevice = true)` and `deleteAccount` destroy it. That is what a recovery phrase is for, and it has to be said at the moment the phrase is shown rather than discovered afterwards. ⚠️ An imported phrase is validated BEFORE it is stored. One that fails its checksum is refused at import rather than saved and discovered later, when the wallet it names turns out to be empty and nobody can say why. ⚠️ The receive counter is carried across a rewrite rather than reset. Resuming at zero would hand out addresses somebody has already been paid at, undoing the one linkability a wallet can prevent. ⚠️ `SecureRandom()` with no seeding call. Seeding it from a timestamp or a device id would make the wallet reconstructible from things that are not secret — the same failure as deriving it from the passphrase, wearing a different hat. 9 tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2e8c6776e2 |
Derive wallet addresses and sign with them (Android)
`HdAccount` turns a recovery phrase into the BIP-44 path every other Solana wallet uses, the Base58 address somebody can be paid at, and the key that signs for it. 🔴 The property everything else rests on is that the signing key OWNS the address that was displayed — same path, same curve, same code. If those ever disagreed the wallet would sign with a key that does not own the address it showed. The transaction being rejected by the network is the GOOD outcome; the bad one is signing for an address somebody else controls. Tested across five indices, and separately by signing a message and verifying it under the address. 🔴 Android can sign, and iOS's constraint does not apply. AccountKdf notes that a raw Ed25519 seed needs EdECPrivateKeySpec, which Android exposes only from API 33 — but the engine carries its own RFC 8032, so this works on any supported device. The wallet is not blocked on a platform floor. 🔴 A fresh address per receive, by default rather than on request. Reusing one lets anybody watching a public chain group every payment somebody ever received — the single linkability a wallet can actually prevent, because the chain will not forget. ⚠️ `looksLikeAddress` says WELL FORMED, never CORRECT, and the difference is money. A Solana address carries no checksum, so one mistyped character that still decodes to 32 bytes is a perfectly valid address nobody holds the key for. Bitcoin's Base58Check would catch it; this cannot, and neither can the network. There is a passing test that goes and FINDS such a near-miss, so the limit is recorded rather than implied — and it fails if the format ever gains a checksum, which is the point at which the warnings around typed addresses can be revisited. ⚠️ A test bug found by the test failing rather than passing: `Ed25519.verify` takes (publicKey, signature, message) and I had passed (publicKey, message, signature). All three are ByteArray, so it compiled perfectly. Noted in the file — it is a decent argument for asserting the signature verifies at all rather than trusting that a key which derives must be a key that signs. 12 tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ee0170dcc |
Wallet engine primitives in Kotlin: BIP-39, Base58, SLIP-0010
The foundation Files and Wallet rest on. PARITY.md was right that this is a build rather than a port of a screen — these had no Kotlin counterpart at all. 🔴 Pinned to the STANDARDS, not to the Swift engine. BIP-39's own vectors, SLIP-0010's own ed25519 vectors, and the published word-list SHA-256 — the same things the Swift side is held to. Checking one implementation against the other would only prove they agree, and two implementations agreeing on the wrong answer is exactly how a wallet ends up empty: a phrase written down on an iPhone has to restore on an Android handset, and on somebody else's software years from now. Every failure this file guards is SILENT in production. A wrong checksum bit, a lost leading zero, a missed NFKD — none of them error. They derive a different, perfectly valid, empty wallet, and the way somebody finds out is restoring their phrase and seeing nothing there. Two Kotlin-specific decisions, both money-relevant: 🔴 PBKDF2-HMAC-SHA512 is hand-written over `Mac` rather than taken from `SecretKeyFactory("PBKDF2WithHmacSHA512")`. The JCA path takes a CharArray and the provider decides how to encode it — AccountKdf already carries that caveat for the account KDF. For a wallet the consequence is worse than a failed sign-in: a phrase containing anything outside ASCII would stretch to different bytes under a different provider and derive a different, empty, perfectly valid wallet with no error anywhere. Taking the bytes ourselves removes the provider from the decision. (It is also absent below API 26.) 🔴 Base58 is the digit loop, not `BigInteger`. The obvious Kotlin version is `BigInteger(1, bytes).toString(58)` with a remapped alphabet, and it is wrong in the one way that matters: BigInteger discards leading zeros, so a key beginning 0x00 loses a character and encodes to another key's address. ⚠️ One test was WRONG on the first run and the implementation was right. The all-ones 24-word vector carried the seed from a different BIP-39 vector. It was resolved by computing the value independently — PBKDF2-HMAC-SHA512, NFKD, 2048 rounds — rather than by adopting whatever the implementation produced, and the file says so. A pinned value taken from the code under test pins the bug. ⚠️ The vendored word list is byte-identical to the iOS one and verified on every load, not once at build: it ships inside the app and a check that only ran in CI would not notice one that arrived corrupted. 23 tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e4eca84e3 |
Refuse a home record older than one already seen (§20.4, Android half)
Both remaining ⚪ protocol rows turned out to be stale. `AccountKdf.localAccountSalt` is pinned to the published vector and wired into sign-in for a local relay; `MailboxHoming` and `AccountIdentity.publishHomes` have shipped the publish and read halves for a while. PARITY.md was describing a gap that had closed, which is its own kind of wrong — a definition of done that overstates the work left is one people stop reading. 🔴 What was genuinely missing was on the READING side, and on BOTH clients. An older home record is GENUINELY SIGNED — the account holder published it, before they added a second relay. So a relay that wants to be somebody's only mailbox does not have to forge anything: it serves the record it saw first, that record verifies perfectly, and the sender delivers to one relay believing it is delivering to all of them. That is the attack multi-homing exists to survive, and the serial is what was added to stop it. The relay refuses a lower serial on WRITE. Nothing refused one on READ: `verifiedRelays` here and `verifyHomes` on iOS both checked the signature and stopped. Fixed on both — iOS in ios!116, this is the Kotlin half. The highest serial seen per address is remembered in the vault and a lower one is refused. Equal is fine; only strictly lower is a rollback. 🔴 The floor rises only for a record that VERIFIED, via a separate `verifiedSerial`. Otherwise a hostile relay could hand over an unsigned record carrying Int.MAX_VALUE, have the client raise its floor to it, and lock out every later genuine record — turning the rollback defence into a denial of service against the account holder. Tested. ⚠️ Sealed in the vault and keyed by a hash of the address: which addresses this device has looked up is exactly the metadata the vault exists to keep off a stolen phone. ⚠️ The refusal does not claim an attack. A relay that has genuinely fallen behind serves an old record for honest reasons, and nothing in a client can tell that from the other case. 8 unit tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |