Commit Graph
195 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 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>
2026-09-07 13:37:35 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 12:44:55 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 12:37:03 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 12:26:42 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 12:13:36 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 11:54:25 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 11:21:33 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 11:09:04 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 10:04:57 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 09:28:44 -04:00
Paul Hitt dd275939e3 Merge branch 'feat/android-report-a-story' into 'main'
Report a story on Android, which had no way to

See merge request paulhitt/flamenet-messenger-android!87
2026-09-07 01:56:06 +00:00
Paul HittandClaude Opus 5 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>
2026-09-06 21:46:00 -04:00
Paul HittandClaude Opus 5 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>
2026-09-06 21:18:04 -04:00
Paul HittandClaude Opus 5 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>
2026-09-06 21:06:14 -04:00
Paul HittandClaude Opus 5 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>
2026-09-06 20:37:57 -04:00
Paul HittandClaude Opus 5 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>
2026-09-06 19:56:58 -04:00
Paul HittandClaude Opus 5 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>
2026-09-06 19:07:15 -04:00
Paul HittandClaude Opus 5 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>
2026-09-06 15:12:12 -04:00
Paul Hitt 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.
2026-09-06 06:51:25 -04:00
Paul Hitt 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.
2026-09-05 16:15:29 -04:00
Paul Hitt 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.
2026-09-05 15:41:49 -04:00
Paul Hitt 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.
2026-09-05 14:28:38 -04:00
Paul Hitt 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.
2026-09-05 14:12:56 -04:00
Paul Hitt 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.
2026-09-05 14:08:27 -04:00
Paul Hitt 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.
2026-09-05 12:02:34 -04:00
Paul Hitt 76c548c727 Merge branch 'fix/files-uses-your-flamenet-passphrase' into 'main'
Files asks for your Flamenet passphrase, not a second secret

See merge request paulhitt/flamenet-messenger-android!69
2026-09-05 14:44:03 +00:00
Paul HittandClaude Opus 5 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>
2026-09-05 10:42:56 -04:00
Paul HittandClaude Opus 5 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>
2026-09-05 09:50:45 -04:00
Paul HittandClaude Opus 5 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>
2026-09-04 22:06:57 -04:00
Paul HittandClaude Opus 5 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>
2026-09-04 20:41:40 -04:00
Paul HittandClaude Opus 5 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>
2026-09-04 19:17:38 -04:00
Paul HittandClaude Opus 5 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>
2026-09-04 18:36:21 -04:00
Paul HittandClaude Opus 5 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>
2026-09-04 18:29:30 -04:00
Paul HittandClaude Opus 5 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>
2026-09-04 15:34:29 -04:00
Paul HittandClaude Opus 5 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>
2026-09-04 15:22:56 -04:00
Paul HittandClaude Opus 5 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>
2026-09-04 14:48:56 -04:00
Paul HittandClaude Opus 5 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>
2026-09-04 14:07:22 -04:00
Paul HittandClaude Opus 5 455cdcd818 Edit, forward and pin from the conversation (§6.15/§6.16/§6.19)
The outbound half of the three controls whose codecs were already back. The
long-press menu gains Edit, Pin/Unpin and Forward; the thread gains a pinned
banner; a bubble says `edited` and `Forwarded`.

🔴 An edit that silently replaces the words is worse than the typo. The reader
has no way to tell a correction from their own misremembering, and no way to
know the message they replied to is not the message that is there now. So the
bubble is marked, on both ends, and the edit sheet says plainly that everyone in
the conversation sees the new wording and a mark on their copy.

🔴 A forward carries the words and NOTHING about where they came from — no
author, no thread, no quote — and the test asserts the ABSENCE of a reference
rather than only the presence of the flag. The obvious "improvement", carrying
a reference so the reader can see the original, would republish a private
message's author to somebody who was never in that conversation. It also gets a
NEW message id: reusing the original's would make the copy a target for edits
and reactions aimed at the original, one of which would silently rewrite the
other.

⚠️ Only your own message can be edited, checked in `Session` and not only in
the menu. The receiving side enforces the same rule independently, so neither
end is trusting the other. Anyone may PIN, by contrast: a pin is a shared
banner rather than a permission, and restricting it to the author would mean the
useful message in a group is exactly the one nobody but its writer can pin.

⚠️ Edits and pins are applied locally first and rolled back if the send fails,
the same shape `react` already uses. Waiting on a round trip to see your own
correction appear makes it feel broken on a slow connection; leaving one that
exists only on this phone is worse, because you would believe you had fixed it.

⚠️ A forward does not carry an attachment yet. Re-sending a blob means
re-uploading it under a new token, and passing on the ORIGINAL token would hand
the new recipient a capability to a file in a conversation they are not part of.
Text only until that upload exists.

⚠️ The pinned banner resolves the id against the lines on screen, so a pinned
message that was since deleted shows nothing rather than a stale copy. Clearing
a thread clears its pin.

Two things found while wiring it: `Content.text` has no `groupId` parameter, so
a group forward needs `groupText` — a compile error rather than a silent drop.
And the edit/forward state is hoisted above the message list, because a row's
own `remember` dies when it scrolls out and an edit box vanishing mid-typing is
the worst way to lose a correction.

9 more unit tests. App builds, every JVM suite green. The three PARITY rows are
now ✅.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:01:19 -04:00
Paul HittandClaude Opus 5 e0420a00c6 Apply inbound edits and pins instead of filing them as messages (§6.15/#6.16/#6.19)
🔴 This was an interop bug, not a missing feature. The engine has parsed
`editOf`, `pins` and `fwd` for as long as they have existed, and it exposes
`isRenderable` — "does this belong in a thread" — as the single answer.
`Session`'s inbound loop never consulted it.

So three things arrived from the iPhone app and were filed as ordinary bubbles.
An edit carries its new wording in `text`, so it drew as a DUPLICATE beside the
typo it was meant to replace — a correction that made the thread worse than the
mistake did. A pin carries only an id, so it drew as a blank bubble. And a
forward was drawn as the sender's own words.

Worst of the three is the one nobody had hit yet: a control envelope from a
NEWER peer carries `ctl` and a kind this build has never heard of, and would
have drawn a bubble full of JSON in somebody's conversation. That is precisely
the failure `isControl` was written to prevent, defeated by nobody calling it —
and it would have shipped to every older Android install the first time a
control kind was added on iOS. The guard is `isRenderable` rather than a list of
kinds this build knows, because an allowlist is only correct until the other
platform adds one.

What is applied now:

- an edit rewrites the message it names and stamps `edited`, so a bubble can
  say so — silently replacing the text is worse than the typo, because the
  reader cannot tell a correction from their own misremembering;
- a pin is stored per thread, sealed in the vault like the transcript, as an ID
  and never a copy: a copy goes stale against an edit and outlives a deletion;
- `fwd` reaches the transcript so second-hand words are not drawn as the
  sender's own — THAT it was forwarded, never from whom.

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

⚠️ Clearing a thread clears its pin. A banner pointing at a message that was
just cleared reads as the clear not having worked.

⚠️ `fwd` is read as both a boolean and a number, for the same reason `isControl`
accepts both: `org.json`'s `optBoolean` does not coerce a number, and that is
how this class of bug shipped once already.

9 unit tests. App builds, every JVM suite green. The three PARITY rows stay 🟡:
the inbound half is done, originating each still needs the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:53:13 -04:00
Paul HittandClaude Opus 5 f362db0ee7 Handle a wrong clock (ios#45, Android half)
🔴 A wrong device clock breaks a promise made to somebody else. A story
disappearing after an hour is a promise the SENDER made, and it is enforced on
the READER's device — so a reader whose clock runs behind keeps a message the
sender was told would be gone, and nobody involved can tell.

That asymmetry decides the design, and it is the whole of this change. Expiry
takes the LATER of the device's clock and the relay's, never either alone. A
device set back cannot extend a message's life, and a hostile relay claiming an
earlier time cannot either, because the other source overrules it. The
remaining failure — expiring early on a clock set forward — costs the reader
something they were about to lose anyway.

The skew is learned free, from the `Date` header every HTTP response already
carries. Observed in `RelayClient.checked`, which is the one funnel every relay
call passes through, and BEFORE the success check: a phone whose clock is wrong
enough to be refused is exactly the phone that needs to be told why.

Story and disappearing-message expiry on the app side now reads
`DeviceClock.expirySeconds()` rather than `System.currentTimeMillis()`, at all
seven sites.

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

⚠️ `SimpleDateFormat` is built per call rather than shared, because it is not
thread-safe, with a fixed `Locale.US` and GMT: a formatter taking its locale
from the device parses nothing on a phone set to most of the world, and the
failure is silent — the header is simply never understood.

15 unit tests. Proven: replacing the rule with "trust the server" turns the
hostile-relay test and the never-earlier test red. App builds, every JVM suite
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:38:55 -04:00
Paul HittandClaude Opus 5 fffab8e685 Back off on repeated app-lock attempts (ios#41, Android half)
Android had the lock and none of the back-off. iOS has counted failed unlocks
and imposed a growing wait since ios#41; here every attempt was free, so
somebody holding an unlocked phone could work through PINs as fast as the
prompt would reappear. Two platforms disagreeing about a security guarantee is
how one of them ends up being the one that leaks — the same argument that put
the lock on Android in the first place.

Same schedule as iOS: three free attempts, then 30 seconds doubling to a
five-minute ceiling. Both ends matter. A mistyped PIN or a fingerprint that did
not read is ordinary, and making the first slip cost a wait would punish the
owner far more often than anybody else; and an unbounded back-off locks
somebody out of their own messages permanently, which is worse than what it
defends against — no reset, no support desk, and the data is on their phone.

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

🔴 The refusal happens BEFORE the prompt is shown. Letting the system sheet
appear and then discarding the answer would teach somebody that attempts are
free, which is the opposite of the point.

⚠️ Only a genuine wrong answer counts. `onAuthenticationFailed` is a
fingerprint that did not match; `onAuthenticationError` is a cancel, a platform
lockout or a hardware problem. Only the first moves the counter, so dismissing
the prompt cannot back the owner into a wait — and the platform's own lockout is
not counted on top of ours, because two back-offs stacking produce a wait nobody
can predict from either rule.

⚠️ Switching the lock off clears any outstanding wait. Leaving one behind
strands somebody behind a countdown for a screen that no longer appears.

7 unit tests. App builds, every JVM suite green. PARITY.md gains a Hardening
section listing the three that remain (ios#19, #39, #45).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:35:56 -04:00
Paul Hitt eff8cdb493 A 403 is not an expired session
Signing in on a fresh install said "Your session expired. Sign out and back
in to keep going." — advice that cannot work, because signing in again hits
the same rule and says the same thing. There is no way out of that loop from
inside the app.

RelayAccounts mapped 401 and 403 to the same Unauthorized. They are not the
same: 401 means the token was refused and signing in again IS the fix; 403
means a rule refused this — too many devices, a retired device, an
unverified address — and the relay writes a sentence naming which one and
what to do about it. RelayClient already made this distinction; RelayAccounts
did not, so which client a path happened to use decided whether its refusals
were legible.

The 403 sentence now reaches the screen instead of "That isn't allowed on
this server", which replaced an answer somebody could act on with one they
could not. Only when it is really a sentence: a JSON fragment or a bare code
keeps the generic line rather than putting a payload on screen.

Predates tonight's merges — checked against fa92581 — and is why a sign-in
failure could not be diagnosed from the device.
2026-09-03 19:22:27 -04:00
Paul Hitt 3f0e36fffc Count the sealed-to-unsealed fallback, and offer to refuse it (ios#16)
🔴 **Off by default, and that is the honest default rather than the lazy
one.** An unsealed send is still end-to-end encrypted; what leaks is
that this account sent something to that one. Refusing turns a metadata
leak into an undelivered message, which for most people is the worse
outcome — so the default degrades and says so, and the refusal is there
for the people who would rather a message did not go than go
attributable.

The counters answer a question nobody could ask before. `Sent.sealed`
says whether one send was sealed; the record says how often this happens
to you, which is what somebody actually needs to decide whether the
switch is worth throwing.

⚠️ Refusing costs nothing: `sealed` is false exactly when neither a
capability nor a key was found, so there is no token to hand back.

⚠️ Both the policy and the counters are wired in ONE helper called from
both sign-in paths. Two call sites building a `Session` and only one
applying the policy is how a setting silently stops working on one of
them.

Taken by hand rather than cherry-picked — the original also rewrote a
ChatScreens that has since changed underneath it. The settings switch
for it is UI work still to come.
2026-09-03 19:22:26 -04:00
Paul Hitt a0bc8e68d2 Report a story to whoever runs the server (§6.25)
The reporting path, taken by hand — the original commit also rewrote 318
lines of ChatScreens against a story feed that no longer looks like that.
The UI control comes next; this is the part underneath it.

🔴 **A report is the one thing in this app that leaves readable.** The
relay cannot open a sealed story, so a report about one is only
actionable if the reporter hands over what they saw. That is the point
and the cost, and it means the consent has to name the consequence —
`reportStory` does not ask, and must never be reached from a control
that does not.

⚠️ It sends only what it is passed, and never reaches into the thread
for surrounding context. §6.25 forbids including anything the reporter
did not choose to include.

⚠️ The reasons are enumerated to match the relay. A reason the relay
rejects is a report somebody believed they had filed.

The message afterwards says what will and will not happen: whoever runs
the server sees it, and you are not told what they do. Promising an
outcome we cannot observe would be worse than saying nothing.
2026-09-03 19:20:37 -04:00
Paul Hitt 32e2f6b5df Post a story to a group (§6.24)
Taken by hand, like the two before it — the original was cut from a tree
whose text stories and story replies differ from this branch's.

🔴 The group's roster is the audience, and the story audience does NOT
apply. Those are two different lists making two different statements —
who may see this account's stories, and who is in a conversation — and
they will disagree. The roster wins, because a story addressed to a
group that silently dropped members would be a group message part of the
group cannot see.

`groupStoryReach` returns that overlap rather than a warning. A sender
who believes their story audience still applies is being misled by the
interface, and a disclosure that cannot name the consequence is not one.
The two categories deliberately do not double-count: somebody who is not
a contact was never on that list to be excluded from.

🔴 On receipt, a claimed group id is checked against our own roster. A
story is caught before the group-message branch, so without that check a
sender could attach the id of a group they are not in and have their
story shown as having come from it. The claim is dropped to null rather
than the story refused — the story is legitimately theirs; only the
claim about where it was posted is unverified.

⚠️ `gid` names the audience, never a destination. Rendering a group
story into the group's messages would make a story into a message, and
one that never expires out of it.
2026-09-03 19:19:37 -04:00
Paul Hitt bee63e0ef7 Keep your own stories in a private archive
Reinstates ios#54, taken by hand rather than cherry-picked. The original
commit was cut from a tree where story mute, text stories and story
replies all looked different from the versions rebuilt on this branch
today, so applying it whole would have left two implementations of each
under different names — no compile error, and exactly the kind of
divergence that rots quietly.

What came across is the archive and nothing else: the `archive()` rule,
the store, the sweep, and the setting.

🔴 The rule refuses anything not the author's own, and it does so in the
engine as well as at the caller. An archive holding somebody else's
expired story breaks the promise the app made to them, and "it is
private to you" does not repair that.

⚠️ The sweep runs at the one moment it can. Expired stories leave on
write, so if they are not caught before `saveStories` they are gone —
which is correct for everybody else's and wrong for your own.

`archiveEnabled` is injected rather than read from a context, because
this layer has no Android dependency and an unset value has to read as
OFF. This is a feature that keeps something the interface said would
disappear, so it stays off until somebody asks for it.

Vault story serialisation is now shared between the live set and the
archive rather than written twice.
2026-09-03 19:13:56 -04:00
Paul HittandClaude Opus 4.8 9ca2c352e7 Save a story you posted yourself, before it expires
Android half of ios#34. A story disappears from its author's own device
too, so there was no way to keep something you made.

🔴 Only ever your own, and the rule is `Vault.isSaveable` rather than a
condition on the menu item. Saving somebody else's story keeps a copy of
something they were told would disappear — the opposite of what the
feature promises them — and a guard living in a composable is one
refactor away from being lost. Tested from both directions.

⚠️ Written through MediaStore, so no storage permission is asked for. An
app inserting its own entry needs no runtime grant on any version this
app supports; asking for WRITE_EXTERNAL_STORAGE in order to save one
picture would request read access to everything the person has, to
perform a write.

⚠️ The message says the copy has left the disappearing model and names
where it went. "Saved" alone would be true and useless — somebody not
told that may reasonably assume the expiry still covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:11:21 -04:00
Paul HittandClaude Opus 4.8 968a88789a Search inside messages, across every conversation
🔴 There is no index, and that is a decision rather than an omission. An
index derived from decrypted history is plaintext by another name: it
would have to be sealed as carefully as the vault, taken by
forgetEverything, and kept consistent with the threads — a second copy
of everything anybody ever wrote, with its own ways to go wrong. This
reads what already exists and throws the plaintext away.

⚠️ It costs more here than on iOS, and the code says so. The iPhone app
keeps the whole decrypted history in memory; Android decrypts each
thread on demand. Hence a two-character minimum, a bound, a 250ms
debounce, and Dispatchers.IO.

🔴 Folding is length-preserving on purpose: the snippet slices the
ORIGINAL string at an index found in the folded one, so a fold that
dropped characters would cut in the wrong place — and for an accented
character, mid-character. There is a test with accents before the match.

Accent-insensitive because somebody typing "cafe" means "café", and a
search that knows the difference quietly fails in every language with
accents — Spanish is next on the localisation list.

Refs paulhitt/flamenet-messenger-ios#25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:09:49 -04:00
Paul HittandClaude Opus 4.8 fc8d40ecf6 A timer on one message, not just the conversation (§6.17)
The rule that keeps this from making the product worse: the shorter
timer always wins. Without it a sender could defeat the recipient's
conversation timer by putting a long secs on every message — turning a
setting the recipient chose, on their own device, into something the
other party overrules.

🔴 pruneExpired returned early when the CONVERSATION had no timer, so a
timed message in an untimed thread would have been kept for ever — the
feature working on the sending phone and doing nothing on the receiving
one. Pruning is per message now.

⚠️ Content.text returns raw bytes with no envelope when it has nothing
to carry, so the timer had to join that decision or it would be dropped
and the message would live for ever.

The sender's own copy expires too: a timer that only cleared the
recipient's phone would leave the message with the person who set it.

Refs paulhitt/flamenet-messenger-ios#29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:09:10 -04:00
Paul HittandClaude Opus 4.8 edd53bc771 Tell somebody when their disappearing message is screenshotted
Android half of ios#36, spec §6.27. `shot` joins the control kinds, so a
build that does not know it stays quiet rather than rendering JSON.

🔴 The care is in refusing to let the absence of a notice mean anything.
The device that took the screenshot reports it, so a modified build, a
second phone, or a screen recorder produce nothing. A notice means one
was probably taken; no notice means NOTHING, and nothing counts them.

⚠️ Android's half of the asymmetry is stated rather than hidden. There is
no screenshot callback at all before API 34, and the one added there does
not cover screen recording — so the disclaimer says that, in those words.
Claiming a symmetry with iOS that does not exist would be worse than the
gap, because somebody would rely on it.

⚠️ Only where a timer is set, and the timer check comes before the
rate-limit check so a suppression window cannot make an untimed
conversation start reporting.

⚠️ Last-notice times are in memory. Persisting them would store who
screenshotted whom on disk to enforce a five-second window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:08:44 -04:00