Commit Graph
100 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 965216439f The chrome reaches the glass at both ends
Two window insets were being paid twice or not at all, and both showed as
the bar looking cut off.

At the top the signed-in frame took the Scaffold's inset AND the header
applied `statusBarsPadding` of its own, so the screen started below the
status bar, a pale strip sat above the navy, and the header's own text was
pushed down a second time. The frame no longer takes the top inset; the
header paints its navy behind the status bar, which is the whole reason it
insets its contents.

At the bottom the strip stopped above the gesture bar and left the page
ground showing under it. The navy now runs into that area and the row of
tabs sits above it, which is what a bottom bar does.

1474 unit tests green, and this is checked on the emulator rather than
only in the JVM renders: the renders have no window insets in them at all,
so neither of these could have shown up there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:45:36 -04:00
Paul HittandClaude Opus 5 79aefbe707 The screens nobody had rendered get their margins back
Going flush to the screen edges was right for chrome, bands and rows, and
wrong for every screen I had not looked at. Calls, the contact detail and
the story feed were all laid out assuming the 20dp frame that is gone, so
their content sat hard against both sides of the phone and their trailing
controls ran off the right edge entirely: the call-back button on a call
row was half a button, and the contact rail's three actions were clipped.

Calls rows and alert rows are `PortalRow` now, like the conversation list.
`DetailRow` carries its own gutter. The story feed and its empty state do
too.

Two bands on the contact screen were unlabelled strips. They say what they
are over now, which is what a band is for.

`SectionBand` in ChatScreens duplicated `PortalBand` from the day
PortalChrome was added; I wrote the second one and left the first. There
is one band again, and the 19 call sites point at it.

1474 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:11:07 -04:00
Paul HittandClaude Opus 5 5bbc328076 The portal identity reaches the screens, not just the tokens
0.26 carried the palette, the 2dp corners and the gradient helpers, but
every screen still drew a plain headline and sat inside a 20dp margin, so
the app looked like the old one in new colours. This is the structural
half.

PortalChrome.kt is the new furniture: the navy screen header with the
FLAMENET eyebrow, the maroon header a conversation wears because a
conversation is the Messenger channel, the ruled section band, the squared
avatar tile, the gold unread count, the full width list row, and the
raised control face.

Signed in, the screen is now flush to its edges. Chrome, bands and rows
are full bleed on the portal; a margin around them turns each one into a
card, which is the single change that would undo the rest. Prose asks for
its margin back with PortalBody.

Buttons and chips are drawn rather than configured. Material takes a flat
container colour, and a flat fill beside a beveled face is the seam that
gives a half done restyle away.

Three defects found while looking at the renders:

  Mute and Report on a story were painted Palette.Mist, the pale page
  ground used as ink, so they were nearly invisible on white.

  The story action row overflowed and broke Report across two lines
  mid-word. It wraps now.

  The Stories tab icon was Icons.Outlined.Circle, a placeholder that drew
  an empty ring naming nothing, and the one round shape left in a squared
  interface.

The screenshot harness no longer pads its full screen shots, because the
app does not, and photographing a layout the app does not have is how a
suite goes green while proving nothing. A shot of the tab bar is new: it
was rewritten for the portal chrome and nothing rendered it.

1474 unit tests green across engine, client, obfs4 and app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:44:18 -04:00
Paul HittandClaude Opus 4.8 b1073125ba The reply round-trip can be pointed at a real answering peer
aReplyFromSomebodyElseAppearsInTheThread needs somebody replying as the
contact. That is the interop Peer in autoreply mode. The handle is a
runner argument now, defaulting to the standing peerjvmone, so the case
can target a throwaway peer created for one run.

Proven green this way: build interop with :interop:installDist, create a
peer on relay1, run autoreply, and run this case alone against it. It
must run ALONE. One autoreply peer pins the first fresh account it sees
and drops a second, so the two ChatFlow methods cannot share one peer in
a single invocation, which is why the whole class run leaves this case
red while the case on its own passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-17 09:06:06 -04:00
Paul HittandClaude Fable 5.1 ee8465fa34 The on-device suites run again, and the alerts inbox has a door
The instrumented suites had not had a green run since 2026-08-26. Since
then the composer hides send behind a camera until there is text, the
status chips moved to the Account screen, contacts became circles with
the profile behind a long-press, the stories tab posts through new-story,
alerts left the bar, and the app moved inside imePadding() so a keyboard
shortens the screen. Every helper that tapped a control by its tag
without scrolling to it was tapping outside the window.

The suites now scroll before they tap, assert send after typing, look for
the status chips and the device id on the Account screen, long-press a
circle for the profile, and post a story from the stories tab. Sign-up
takes a bare host in instrumented.sh; a URL became https://https:// and
every test skipped.

One product change. Alerts left the bar on 2026-08-28 with the note that
the screen survives for the tap-through from Account, and that tap-through
was never built, so the inbox had no way in. It is a row on Account now,
counting unread the way the bar badge does.

CrudSurfaceTest 4 of 4, FeatureUseTest 4 of 4, ChatFlowTest add-and-send
green on the emulator against relay1. The reply case needs something
answering as the fixture account and stays open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 08:29:24 -04:00
Paul HittandClaude Fable 5.1 4f0f90b4cd The e2e pin moves in step with the iPhone app
Both apps pinned the same flamenet-e2e commit, from before the backup
payload vector widened, and this one's CI has been checking the engine
against that old vector while a developer's machine reads the sibling
checkout at HEAD. The pin moves to HEAD; engine and client pass with the
vector variables pointed there the way the job points them, 1105 of
1105.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 07:39:32 -04:00
Paul HittandClaude Fable 5.1 a4d7390681 The secret sweep runs in CI, on every merge request and push to main
It ran by hand until now, and a by-hand gate is one that rots: the e2e
copy sat red for nine days after a formatter moved a marker, and nothing
said so. The job is the same script the release runbook already names,
called from the repository root, and it takes seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 07:26:55 -04:00
Paul HittandClaude Fable 5.1 4cfc044198 A failed revoke on block is shown, and a failed revoke on sign-out is finished next time
Two security operations discarded their result, the same two the iPhone
had. One commit because both wires live in the view model.

Block: the token revoke was a nested runCatching whose value nobody read.
The block must not fail because the follow-up did, and that reasoning was
right; making the follow-up silent was not. A revoke that fails leaves the
blocked person holding capabilities that deliver sealed to this inbox, the
opposite of what "Blocked" now says. Kept the block, surfaced the failure,
and said that blocking again retries it.

Sign-out: the device revoke was discarded on the line before the keys that
authorise it are destroyed, so a failure was unrecoverable and invisible.
What it leaves is a dead device row the relay fans every envelope to and
every sender spends a prekey on; one was found this morning with 42
undelivered. The device id is now kept in preferences, not the vault,
because the vault is destroyed a line later and a note there would not
survive. Keyed by account, retried on the next sign-in to that account,
cleared only when the relay accepts.

The helper's tests round-trip the note, keep it account-scoped, and wipe
filesDir, which is what destroy does to storage, to prove the note lives
elsewhere. The view-model wiring is covered by compile and the suite;
there is no view-model harness, so the two wires are verified by reading.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 06:24:29 -04:00
Paul HittandClaude Fable 5.1 6c4289ee42 Off the record: edits, reactions and pins work, and none of them reach disk
`record()` and `history()` knew an off-the-record thread lives in memory.
`applyEdit` and `applyReaction` did not. They read `vault.history`, found
nothing there for such a thread, and returned false, so an edit or a
reaction to an off-the-record message was silently dropped. Not leaked,
dropped: the better of the two faults, and still a broken feature.

`applyPin` went the other way and wrote the pin to the vault regardless, a
record on disk naming the thread for a conversation whose one promise is
that nothing reaches disk.

One helper now mutates a thread's lines where they actually live, memory
or vault, and both mutators use it; a pin on an off-the-record thread is
held beside the thread in memory. The comment on `record()` claiming a
single gate was enough for the writes it knew about, and these were the
writes it did not.

Falsified one at a time: each old behaviour restored fails exactly its own
test and no other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 05:22:44 -04:00
Paul HittandClaude Fable 5.1 4235754d0c Unread badges survive a restart
The unread markers were a Set in the view model's UI state and nowhere
else. Kill the app with unread conversations and every badge was gone at
the next launch, whether or not anything had been read. The same fault as
the story reactions were: a mark somebody has not yet dealt with is
theirs until they deal with it, not until the process ends.

The vault keeps the set now. It is seeded from there when the signed-in
state is built, and written from `update()`, the one chokepoint every
state change passes through, whenever the set differs from before. That
is one write for the four sites that mutate it today and for any fifth
one, rather than four writes that a fifth could forget. Synchronous on
purpose: it is a short list of ids, and dispatching it would let two quick
updates land out of order.

Coverage, honestly: the vault round trip is tested in both directions,
including that a cleared set stays cleared. The view model chokepoint is
covered by compile and the full suite; there is no view model harness
beyond a screenshot fixture, so the seed and the write are verified by
reading rather than by a failing test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:44:23 -04:00
Paul HittandClaude Opus 5 ae618cd1f3 A story reply is kept on this side, and says it was about a story
Two faults, neither of which raised anything.

Replying to somebody's story sent the message and wrote nothing here, so
the person who typed it could not see what they had said. The iPhone has
recorded its own copy since story replies existed. The local copy is
written now, whether or not the send reached anybody: what somebody typed
is theirs either way.

And `Content.repliesToStory` had been parsed, unit tested and read by
NOTHING since the day it was added. It stopped at the parser, so an
arriving reply filed as an ordinary one quoting a `mid` that matches no
message here, because a story key is not a message id and the bubble had
nothing to resolve. Wiring it took four links: `record` writes `reStory`,
the ingest passes it, `Line` surfaces it, and the export carries it in
both directions under the name the other engine already uses.

That last one is its own lesson. The tests around it asserted the parser
set the field correctly, and it did. None of them asked whether anybody
read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:30:09 -04:00
Paul HittandClaude Opus 5 ca6265e7a6 The watched mark is no longer ours alone
This engine has written `seen` into the shared shape all along, on the
grounds that the iPhone had no unread mark on a story. That turned out to
be wrong in a way worth recording: the iPhone HAS the mark and was not
writing it, locally or to a backup, so the ring around every contact
relit at each launch and a restore through it lost the lot.

The iPhone writes it now, so the comment claiming the field is ours alone
is false and is replaced, and `stories[].seen` moves out of the vector's
divergence list and into the canonical set.

No behaviour change here. The code was already right; only what it said
about the other engine was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:17:32 -04:00
Paul HittandClaude Opus 5 3607a85459 A federated thread travels under its address, not this app's key for it
This app keeps one `threads` map and namespaces it with prefixes: `group:`
for a group, `addr:` for somebody on another relay. `toShared` strips the
group one and never stripped the address one, so a backup written here
said `addr:ada@relay.example`, which is not an address. The iPhone writes
the bare one.

Neither side could read the other's federated conversations, and the
damaging direction is iPhone to Android: a bare address arriving here
lands in a map whose unprefixed keys are user threads.

Same mistake as the story id one commit ago, one field over, and the group
prefix beside it was already being handled correctly, which makes it a
slip rather than a decision. An internal storage detail does not belong in
a shared shape.

Coming back in, the prefix goes on unless the file already carries it, so
an older backup does not end up with two.

One test was pinning the bug: it asserted the shared shape contained the
prefixed key. It now asserts the bare address, and that the prefix is
absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:09:06 -04:00
Paul HittandClaude Opus 5 88397a8bda A story is identified by its key, never by the other device's row number
The two engines both had a notion of "this story, wherever it is read",
and kept it in different fields. This app put it in `id`. The iPhone
numbers its rows locally in `id` and kept the shared identity in `key`.
One name, two meanings, so a story travelling between the two phones came
back unrecognisable and the import filed it as a second copy of itself.

`key` is the identity now and `id` is written by neither engine. A local
handle has no business in a shared shape.

`id` is still read as a fallback for files written before this was
settled, and only when it is a STRING. A number there is the writing
device's row handle: stringifying it produced a confident, meaningless
identity, which is the original bug reached by another path. A story with
neither falls back to its post time, the same value the iPhone falls back
to, so the two still land together.

Two tests were pinning the old contract rather than testing it. One
asserted the internal id came from the shared `id` when both were present;
the other was called `a numeric story id from the iphone becomes a usable
name`, which is the bug stated as a requirement. Both rewritten, and the
new sibling covering the post-time fallback is what caught the numeric
case above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:47:35 -04:00
Paul HittandClaude Opus 5 1049c1d118 Pins survive a backup, and a pin keeps what this app cannot show
The vector's canonical set just widened to every field both engines carry,
and pins was the one this app failed: it never read or wrote them, so a
pinned message was lost crossing in either direction and nothing said so.

A pin is kept whole now, at and by included, though this app shows
neither. Dropping a field an engine cannot render turns a backup into a
lossy hop: restore an iPhone through an Android phone and the pins come
back stripped of who pinned them and when. The vault keeps the record
rather than just the mid, so setting a pin here no longer discards what
travelled with it.

Import merges into gaps, like the drafts beside it: a pin set on THIS
phone is the current one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:40:15 -04:00
Paul HittandClaude Opus 5 5fda2954c1 The story audience keeps its meaning when it crosses to an iPhone
This app stores the audience mode as its enum name, EXCEPT, and the iPhone
writes and reads it lower case. The export started carrying the field one
commit ago, so a backup moving between the two phones would have had the
mode dropped in silence.

Dropped is not neutral here. An audience that fails to restore is an
account back on everyone, which shows stories to the people somebody had
deliberately left out, and nothing on screen would have said so.

Converted in both directions where the ok/connected rename already lives,
and asserted in both directions, because a one-way test would pass on two
Android phones and still lose this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:12:40 -04:00
Paul HittandClaude Opus 5 f9463d8ba6 The export carries the choices somebody made, not only their messages
Following the SPEC 16.3 rule the same sweep just wrote down. The export
held threads, groups, calls, stories, the roster and the group keys, and
left behind the story audience, muted authors, the story archive, drafts,
polls in flight and the 8.9c presence tokens. Restoring on a new phone
lost every one of them silently, which looks exactly like a person who
never set them.

They travel under the names the iPhone already writes, so a backup moves
between the two phones rather than only between two Android ones. The
presence tokens travel as the two per-contact maps alone, never the
delivery record around them, which holds this account's own key and 16.1
excludes.

Import merges into gaps rather than overwriting: a draft typed on THIS
phone is the newer thought, and a presence token this device already
holds is the live one the relay knows the digest of, so an older copy
from a file would make this account invisible to that contact until they
issued another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:10:47 -04:00
Paul HittandClaude Opus 5 fd3a52b35c Story reactions survive a restart, and ride the backup
They lived in a map in memory. An author watched a tally empty itself
overnight and the reactor watched their own reaction disappear, and there
was nothing on screen to say either had happened. The iPhone was fixed
yesterday; this is the other half.

The vault keeps them now, loaded when the session opens and written on
every change, withdrawals included. The export carries them too, in the
same shape the iPhone reads, so a tally survives a move between the two
phones as well as a restart. Import merges per reactor rather than per
story: two devices can hold different halves of one story's tally, and
taking a story's map wholesale would drop the other half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:06:19 -04:00
Paul HittandClaude Opus 5 38e31fc5ee A story reaches this account's own devices, so reactions have somewhere to land
Posting sent the story to the audience and saved it locally. Nothing went
to this account's other devices, so a story posted on the phone did not
exist on the tablet at all.

Reactions do go to every device the author has. The other device received
all of them and had no story to attach them to, so they sat in a map
nothing reads and the person watching saw a story drawing no response. The
only way out anybody found was to stop using the second device.

Each posted story is now mirrored to this account as well, carrying the
same story key so one story stays one story across the devices. Best
effort and outside the audience count: an account with one device has
nowhere to send it and that is still a successful post.

On the way in, a story from this account is filed as this account's own,
and only from a proven sender, or an unproven envelope claiming our id
could put a stranger's story on our own strip and show us its reactions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:57:30 -04:00
Paul HittandClaude Opus 5 13d49fed8e A stale presence token stops reporting people offline
Same fault the iPhone had. While the relay still keeps a contact graph there
are two ways to be granted a view of somebody, and a token that has gone stale
answers offline for a contact the graph says is online. The merge took the
token's answer outright; it takes the more visible of the two now.

Once the relay keeps no graph the token is the only source and stands alone,
including when it says offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:31:36 -04:00
Paul HittandClaude Opus 5 55d8442ccc Believe the relay about its own roster, including an empty one
Same as the iPhone. Yesterday's guard refused every empty list and stranded a
removed last contact on every other device; the relay says which kind of empty
it means now, so the rule is simply whether the relay is the one holding the
roster.

MessengerApi.roster() carries both halves; buddies() stays for callers that
only want the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:15:26 -04:00
Paul HittandClaude Opus 5 dafb4f8adb The roster guard is a decision that can be run, not grepped
Same as the iPhone. The rule lives in shouldAdoptRoster and the test asks it
rather than searching Session.kt for a line: empty never replaces held, empty
onto empty is allowed, anything non-empty always lands. A guard that refused
everything would have passed the old source-reading test and broken the app.

The ordering test stays, since where the guard sits relative to the write is a
real property: after it, the empty list is saved on the way past and the
fallback reads it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:45:35 -04:00
Paul HittandClaude Opus 5 73c7b7baef Report the app version to the relay
Android sent no User-Agent at all, so a relay could not tell an old build from
a new one, which is the check that has to pass before the contact graph can be
removed.

The version is set on the client module by the app layer at launch, because the
app layer is the one that knows it: this module is a library with no BuildConfig
of the app's own. Unset it reports "0", which a relay reads as an unknown build
rather than a recent one, so forgetting to set it fails safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:14:26 -04:00
Paul HittandClaude Opus 5 e48f726c0f An empty roster from the relay never erases the one stored
Same guard as the iPhone. A relay that has stopped keeping the contact graph
answers with an empty list, and mirroring that over the stored roster would
erase somebody's contacts on the first poll after the switch was thrown, or on
any transient fault that answered empty.

The guard sits before the mirror is written, or the empty list would be saved
on the way past and the fallback would read it back. A test pins that ordering
rather than only the presence of the check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:04:35 -04:00
Paul HittandClaude Opus 5 cf811d1bee Publishing the roster tags retries until the relay agrees
Same fault the iPhone had. The dirty flag was cleared before the attempt, so a
refusal was lost for good: the token was already stored, nothing asked again,
and that contact would never have seen a presence.

The set is compared against what the relay last accepted instead, and
republished until the two agree. The route replaces rather than merges, so
repeating a set that already landed changes nothing, which is what makes
retrying free.

Verified on the live relay: both accounts publish their own digest, and the
digests differ, because a token is per pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:18:45 -04:00
Paul HittandClaude Opus 5 89474a1fc7 0.26, and a test for the wire the relay and this client agree on
Both halves of 8.9c were written in different repositories on the same
afternoon, and the way that goes wrong is a field name: this client posts
"digests" and the relay reads "tags", both suites stay green, and presence
silently never works. RosterTagWireTest asserts the exact bytes against a mock
server, including the route paths and the token-keyed answer shape.

It also pins two behaviours worth keeping: no tokens means no request at all,
because a relay asked about nothing still costs a round trip and a rate-limit
unit, and a token the relay does not recognise comes back offline rather than
missing, since "offline" and "no answer" are different things to whatever
merges this with a roster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:40:15 -04:00
Paul HittandClaude Opus 5 7ebd519942 Presence without handing the relay a contact graph
Spec 8.9c, the Android half, matching iOS. Each contact gets a random 32-byte
token, sent over the encrypted channel on the delivery-key envelope, which is
already the per-contact one. The relay is given only SHA-256 of it, and
presenting the token is what reads a presence.

Per pair rather than one shared secret, so taking one contact off the list
costs the others nothing. That is the difference from the sealed-sender
delivery key, where cutting somebody off replaces a key everybody holds.

Where a token is held its answer wins; a contact with no token yet keeps
whatever the roster said, so a half-migrated pair does not read as offline
while both ends catch up.

The HTTP stays out of the engine. Conversations has the vault and the e2e
surface but not the mobile one, so it mints the token and raises a flag, the
same arrangement avatarsUpdated already uses, and Session publishes.

A star inside a block comment opens a nested one in Kotlin, which is how a
doc comment naming a route path took out the whole file. The path is described
rather than written now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:33:08 -04:00
Paul HittandClaude Opus 5 18d62551d8 0.25
versionCode 25, versionName 0.25, equal as they have been since 24.

Published APK sha256 33474c03bf5ee35a1ef33a0c112b53d6212f82fc72faf07f15247b4c4c502cd7,
signed with the same certificate as every release before it,
791fc826d572a70abfaf7db7fa7784dad0f86f204bb313f0d75ee676df8d3a1d. The outgoing
0.24 is kept at apk-rollback/flamenet-messenger-0.24-vc24.apk.

Proved rather than assumed: five strings this release introduces are present in
the new APK's dex and absent from the one the site was serving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:16:00 -04:00
Paul HittandClaude Opus 5 be03175996 ios#24, the Android half: the app can see its own crashes
Android 0.7 crashed on answering a call and it took somebody noticing. There
was nothing in the tree that read exit reasons, so a crash was something a
person had to report in prose.

ApplicationExitInfo is Android's own record: no library, no network call, no
identifier, the same shape of answer MetricKit is on the iPhone. It is stricter
here in one way. MetricKit delivers a payload once and it is gone, so that side
keeps a file; Android hands back the same list every time it is asked, so the
system is the store and this app writes nothing at all.

Ordinary exits are dropped rather than greyed out. A list of "you swiped the
app away" buries the one line somebody came to the screen for. REASON_SIGNALED
goes with them: in practice it is the system or a debugger sending SIGKILL, and
a real native crash arrives as REASON_CRASH_NATIVE.

setProcessStateSummary at launch stamps the running build into the 128 bytes
Android returns with an exit record, so a report can say which build died. By
the time anybody reads one the app has usually been updated.

Saved through the system file picker, not a share sheet. There is no
FileProvider here and a trace is too big to travel as intent text: past a few
hundred kilobytes that throws, which would fail on exactly the largest and most
useful report. Saving is also how a history backup leaves this app.

The dialog's layout came from the test. With everything in one scrolling column
the address and the save button sat below the explanation and off the bottom of
a small screen, so the two things somebody opened it to do were the two they had
to go looking for. Only the trace scrolls now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:40:58 -04:00
Paul HittandClaude Opus 5 7b4251c5d6 The keyboard stops hiding the messages
The app sits inside an imePadding(), because windowSoftInputMode=adjustResize
stopped doing anything at targetSdk 35. So an opening keyboard SHORTENS the
message list rather than covering it, and a LazyColumn holds its offset from
the top: the newest messages slide up out of the shorter viewport and end up
behind the composer. The keyboard hid exactly the messages somebody opened it
to answer.

The list follows the inset now. Keyed on its height rather than on a
visible/hidden flag, and scrolling without animation, because the inset arrives
as a series of values while the keyboard slides and one animated jump fired at
the start of that lands short by however much the viewport shrank afterwards.

The test reads the source, because exercising this needs a real IME and a JVM
test has none. It guards the follow against deletion; it does not prove the
behaviour, which takes a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:30:27 -04:00
Paul HittandClaude Opus 5 55d7feb302 A Donate row on the account screen
Opens flamenetmessenger.com/donate in the browser. Nothing is taken in the app,
nothing is unlocked by giving and nothing is withheld without it.

The address is the project's site rather than the signed-in relay, which is the
opposite of the rule the page links follow. Those open the server you are on,
because a self-hosted relay's operator writes the terms that bind its users. A
donation is not that: their users paying their operator's hosting bill is not
what the row offers, and a self-hosted relay serves that page with no form on
it unless its operator configured a processor.

openDownload gained a plainly named sibling, openLink, since a donation link is
not a download. The old name stays and delegates, so the update path is
untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:10:24 -04:00
Paul HittandClaude Opus 5 59988a3790 The launcher icon fits inside a round mask
The adaptive-icon foreground was sized so the mark was 69 percent of the
canvas wide, against a safe zone of 72 of 108. That reads as a fit and is
not one: the safe zone is a CIRCLE, so what has to fit is the mark's
diagonal, which came to 86 of 108. A launcher with a round mask cut the
flame off at both ends, which is the thing the comment in ic_launcher.xml
says must not happen.

The mark is also the whole emblem now rather than a crop that sheared the
arc at both sides before it ever reached a mask. Regenerated by the relay
repository's Scripts/make-brand-assets.py, which now measures the diagonal;
every density comes out between 65 and 66 of 108.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:16:18 -04:00
Paul HittandClaude Opus 5 645896ce96 0.24, and the two version numbers are one number now
Android carries a versionCode, which decides whether an install counts as
an upgrade, and a versionName, which is what a person reads. They had been
one apart since 0.11 was built and never published: the build after it went
out as code 12 before the name was settled, and a code can never come back
down. So the download page said 0.22 while every number underneath said 23,
and somebody comparing the page against their own app had to know which of
the two was ordering and which was a label.

This release closes it by skipping the name 0.23. Code 24 with name 0.24,
and from here they move together and match. Skipping a name costs nothing,
because a name is a label; skipping a code would be the thing that cannot
be undone. A test reads both out of the build file and fails if they ever
name different releases, or if the code drops to or below the published 23.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:54:02 -04:00
Paul HittandClaude Fable 5.1 a8ec0bab63 A suspended account is told so
The relay can now suspend an account, and it refuses one with a single
sentence at every door. That sentence has to reach the person: nothing
else they do will work until the suspension is lifted, and "that isn't
allowed on this server" sends them reinstalling. A 403 whose body names
account_suspended shows the relay's sentence; every other 403 reads as
before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:02:04 -04:00
Paul HittandClaude Fable 5.1 3932e43153 A call keeps the microphone when the screen locks
On Android 14 and later a foreground service has to declare the
microphone, and for video the camera, or the system silences them the
moment the app leaves the foreground. The only service here declared
remoteMessaging, for the poll loop. So a call whose phone locked, or
whose person switched apps, went quiet at the far end while the screen
on this one still said the call was up. The app targets 35 and runs on
nothing older than 33, so every phone it ships to behaves this way.

The service re-declares itself with the microphone type when call media
starts, camera too for video, and back to the plain declaration when the
call ends. It is done the moment the audio track exists rather than at
connect, because the microphone is open from there. If the system
refuses the extra types, the plain declaration keeps the poll loop alive
rather than crashing the service mid-call. A source-reading test pins
the manifest, the service and both call-side hooks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 08:10:24 -04:00
Paul HittandClaude Fable 5.1 e25cd413be Sending a picture to somebody on another relay no longer crashes the app
A conversation with a person on another server has an address and no
peer id, and only the text path knows how to reach it. The picture path
asserted the peer id anyway, and the attach button is offered on every
conversation, so one tap on a picture in that thread was a crash. Files
and stickers did not crash: they did nothing, which is the same tap with
less honesty.

All four paths now resolve the peer the same way, and the three that
cannot go there refuse with one sentence that says so, in place of the
generic "Something went wrong reaching that server". A source-reading
test pins the absence of the assertion and the presence of the refusal,
ignoring comments, and checks the sentence a person is shown.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 07:01:49 -04:00
Paul HittandClaude Opus 5 04d790e713 Android 0.22, versionCode 23, published
The download page said 0.21 and the owner asked why it did not say 22. It now
says 0.22, and so does the app, because the build behind it really is 0.22.

WHAT THIS RELEASE IS, PLAINLY

Nothing in it behaves differently from 0.21. Proven rather than assumed: the
0.21 APK on the site and this build were pulled apart and compared, and they
carry the same 8733 strings, the same 7543 classes and methods, and the same
timeout fix (const-wide 40, TimeUnit.SECONDS) that made calls collect in under
a second. The release note on the site says exactly that, so nobody reads a
changelog that promises them something they already have.

AND WHAT WAS TAKEN OUT BEFORE IT SHIPPED

The one real difference was mine. The build carried the `fnmsg-poll` line I
added to the view model while chasing the held-poll bug, two strings that were
in this tree and not in 0.21: "vm cycle " and "got=". Diagnostic logging earns
its place while a bug is being hunted and not afterwards, and it does not
belong in a build a stranger installs. The service keeps its own poll line,
which was already shipped in 0.21 and is unchanged here.

After removing it the string pools and symbol tables match 0.21 exactly.

The comment above the version numbers claimed they would line up. They cannot:
the code was ahead before the name was settled and a code is never reused. It
now says what is true, that they stay one apart and move together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:39:31 -04:00
Paul HittandClaude Opus 5 7ccec9e3d1 Every held poll on Android was failing at ten seconds
`heldHttp` raised `callTimeout` to the hold plus twenty and left `readTimeout`
at OkHttp's default of TEN SECONDS. A held poll asks the relay to say nothing
for up to twenty. So every one died at ten with a SocketTimeoutException, and
the generous call timeout above it never got the chance to matter.

Nothing said so. The failure was swallowed into a retry, the loop looked busy,
and the only trace was a number on the relay: this device took an average of
112 seconds to collect what was waiting for it, against 5 for an iPhone. A
message written into a connection this end had already abandoned waited for
the next poll instead of arriving.

That is why calling an iPhone took forever, why "connecting..." never
resolved, and why the call then ended: the answer was on the relay the whole
time and the dial guard gave up at forty-five seconds waiting for a fetch that
could not succeed.

Measured on the device, before and after, once the loop was made to say what
it was doing:

    vm cycle 10125ms held=20s call=false failed=SocketTimeoutException
    vm cycle 20304ms held=20s call=false got=0

TWO POLLS, AND I FIXED THE ONE THAT WAS SWITCHED OFF

`MessengerService` and the view model both poll, and the activity stops the
service on the way in -- "exactly one poller at a time". Placing a call is
exactly when the app is on screen, so the service is stopped and the view
model is the poller. The twenty-second hold during a call was fixed in the
service first. The loop that was running kept it.

Both ask for no hold during a call now.

AND THE LOOP CAN SAY WHAT IT COST

One line per cycle: how long it took, how long it was allowed to hold, whether
a call was up, and what came back or what failed. Two rounds were spent
reading an empty trace as "the loop is not running" when it was "the loop has
no voice", and the bug above was invisible for as long as it was because a
failing poll and a quiet one look identical from outside the process.

1619 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:19:02 -04:00
Paul HittandClaude Opus 5 7d8b721a49 Android 0.19, versionCode 21, published
versionCode 21 rather than 20, and the bump was not bookkeeping. The website
was already serving versionCode 20 as 0.18, so shipping today's build under
that number would have reused a code: nobody already on 0.18 would ever be
offered this, and this file's own rule is that a code is never reused.

Signed with the same certificate as every build before it, checked against
the outgoing APK rather than assumed -- 791fc826..., CN=Flamenet Messenger --
because a mismatch there makes the update uninstallable for everybody who
already has the app, and it is found out by users rather than by us.

The outgoing build is kept as apk-rollback/flamenet-messenger-0.18-vc20.apk,
outside the served directory. Android never downgrades, so that copy is a
record rather than an undo: once a code is public the only way out is a
higher one.

`scripts/release-manifest.sh` refused to write the update manifest until the
releases page named Android 0.19, which is exactly what it is for. The notes
went up first.

Carries what landed today: a call signal is no longer lost to whichever poll
loop wins, a call polls without a twenty second hold, every envelope carries
an id so a repeat is recognised as one, reporting in a group actually sends,
Report and Block are in the conversation menu, and closing the app during a
call ends it for the other person.

sha256 38ba0a251deb2f26a3c3bf63d47a7e2c64520326f602a40cad2d2ae7be564e80

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:20:15 -04:00
Paul HittandClaude Opus 5 5b3865c06a Reporting somebody in a group sent nothing at all
The menu item was offered on any message somebody else sent, group included.
The sheet's send handler was `chat.peerId?.let { … }`, and a group has no
peerId: `openGroup` never sets one. So the form opened, a reason was chosen,
Send was tapped, the sheet closed, and the block ran zero times. Nothing sent,
nothing said, no error.

Silent, on the surface where somebody is most likely to need it: a group is
where a person who is not your contact can reach you. Block in the same sheet
was dead identically, and the sheet was handed the GROUP's name, so it offered
to "Block Family chat".

It reports the author now, worked out the same way the list already works it
out to decide whose avatar to draw. Where the author genuinely cannot be
resolved it says so, because a form that swallows what somebody typed is the
bug, not a smaller version of it.

REPORTING AND BLOCKING WERE BOTH UNREACHABLE FROM A CONVERSATION

Reporting lived only behind a press and hold on a message. That is the right
place to report one message and it stays there, but nobody finds it by
looking -- the person who wrote this app went looking, following the safety
page, and could not find it. Block was only ever on the banner a stranger
gets, so once somebody had been kept as a contact there was no way to block
them from the conversation at all.

Both are in the conversation menu now, first, above four headings of
preferences, because somebody opening that menu for this reason is not in the
mood to read past disappearing messages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:58:27 -04:00
Paul HittandClaude Opus 5 5945a87189 No call signal is lost to whichever poll loop happens to win
Two loops poll one mailbox: the service's, which keeps it watched while
nothing is on screen, and the view model's. An envelope goes to whichever
asks first -- the relay marks it delivered and does not hand it out again.

The service's handler knew what to do with an offer and an end and had
`else -> Unit` for everything else, so an ANSWER it happened to fetch was
thrown away. Measured, not inferred: an iPhone answered, the relay accepted
the answer, this side logged the envelope decrypting, and the call manager's
own "call signal in" line never appeared. The signal reached the process and
stopped one layer short of the call. The caller then sat on "Calling..." for
the full forty-five seconds and gave up, every time the race went that way.
An END fared no better: it cleared a notification and left a live call
running.

A call now registers itself so either loop can reach it, and the service
forwards every signal after its `when` rather than inside a branch of it --
in a branch it would cover only the actions somebody remembered, which is the
bug restated, so there is a test holding it outside.

A CALL CANNOT WAIT ON A TWENTY SECOND HOLD

The same loop asked the relay to hold every request open for twenty seconds.
Right for messages, wrong for the two signals somebody is watching a screen
for. iOS polls hard for the length of a call and this never had the same
thing, so an answer sent at once was accepted twenty seconds later: offer at
:07.9, answer accepted at :28.5, with the relay holding it the whole time.
It polls with no hold at a one second cadence while a call is up, and goes
back to the hold when there is not one. The same pair now measures 2.8s.

ENVELOPES THAT ARRIVE TWICE

`hasSeenEnvelope` is guarded on the id being present and nothing sent one on
the ordinary route, so it was asked about nothing. One offer on the relay was
delivered twice 2.4 seconds apart and the second was answered "busy" by the
phone ringing for the first. Every send carries an id now, with a default on
the field so a new call site cannot forget one, and an offer for the call this
phone is already on is no longer mistaken for a second caller.

CLOSING THE APP DURING A CALL

`onTaskRemoved` is what fires when the app is swiped out of Recents, and it
runs while the process is still alive, which is the point: by `onDestroy`
there may be no time left to reach the network. On a thread with a bounded
join rather than a coroutine, because the scope is cancelled a moment later
and a launched coroutine would be killed before the envelope left.

1606 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:13:41 -04:00
Paul HittandClaude Opus 5 74429be153 An end wakes a phone; it does not ring one
The matching half of the iOS fix, and this side is where the ghost was
actually made for an iPhone receiving an Android hangup.

iOS gives no choice: every VoIP push must report a call to CallKit or the app
is killed. Ends were marked urgent, urgent means a VoIP push, so every hangup
and every busy sent from here arrived on an iPhone as an incoming call.

Ends were marked for a real reason: hanging up before the other phone was
picked up left it ringing for a caller who had gone. What that missed is that
the phone which needs to hear a hangup is, by definition, ringing, and
therefore awake: it has a call up, the system keeps it running for one, it is
already polling, and the relay answers a held poll through Redis in under half
a second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 09:33:37 -04:00
Paul HittandClaude Opus 5 393205ca85 Take the e2e spec update
The pin was nine commits behind, all of them spec: off the record, anonymous
polls, handing a call to another device, the private-reaction flag and the
call-reaction kind, a call answered on one device stopping the others, and
the harness fix that reads a page's stylesheet href rather than naming the
file.

No vector moved, so AccountKdfTest, PQVectorTest and PublishedVectorsTest
check the same bytes they did before. This accepts no protocol change.

flamenetmessenger pins the same commit and moves in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:14:56 -04:00
Paul HittandClaude Opus 5 3fae491d10 A poll shows the moment the group opens
Opening a group built the chat without its polls, so one waiting on your vote
was blank until the next poll tick came round, up to five seconds later. A
poll waiting on your vote is often the reason somebody opened the group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:30:02 -04:00
Paul HittandClaude Opus 5 c4b9c5c89d Android 0.18
versionCode 20, because 19 is already public and Android refuses to install
anything at or below a code it already has.

Carries polls nobody can attribute and handing a call to another of your own
devices, on top of the call fixes that went out in 0.17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:14:57 -04:00
Paul HittandClaude Opus 5 d9813bd40a Hand a call to another of your devices
On a call on the phone, walk to the desk, take it on the tablet, and keep
talking. The other person hears a short gap and nothing else.

This rides the channel that already exists: the device that answers a call
tells this account's others to stop ringing. Handoff is that channel carrying
two more signals. Nothing new reaches the relay.

Every rule here is about not breaking a call that is currently working.

The peer is never hung up on. No end is sent to them at any point in either
direction. An end would make the far end drop the call and redial, which is
the failure this replaces.

The order in `takeHandoff` is load-bearing and has a test on it: the peer gets
the offer first, this account's other devices are told second. The device that
had the call is still carrying it until that line, so a failure while taking
it leaves somebody holding a working call rather than nobody.

An offer nobody takes lapses at both ends rather than being withdrawn. A
withdrawal envelope can be lost and leave a device believing an offer still
stands; a timeout cannot.

And whether the far end can take a renegotiating offer is known before any of
this starts, from a flag both ends set during call setup. A build that
predates handoff sets nothing, absent reads as no, and the control is absent
rather than disabled.

An offer for the call already in progress is answered where it stands: no
ring, no missed call filed, and the call screen stays.

13 tests. 639 in the engine, 425 in the client, 210 in the app, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:59:52 -04:00
Paul HittandClaude Opus 5 315e6d328f Polls where nobody can tell who voted for what
The secure-aggregation core has been in the engine since it was written and
nothing used it: there was no way to ask a poll, vote in one, or see a
result. This is the rest.

`PollBook` is the sequencing, deliberately the same shape as the iOS one. A
poll crosses between the two engines, so a rule enforced on one side and not
the other is a poll that tallies differently depending on who is looking at
it. No sum is published until every member has voted: a member who has not
voted has handed nobody a piece, so nobody holds a complete set, and a sum
published early is noise rather than a partial answer.

Asking a poll sends the same envelope to the group. Voting does not: each
piece is addressed to the member it belongs to and sent to them alone. That
is the one group envelope in the app which is not the same bytes to
everybody, and a test pins it, because sending one share to the group would
let any member subtract it from the tally and read the vote, with the counts
still coming out right and nothing at runtime to notice.

Kept in the vault, or a poll could never finish: it waits for everybody, and
the pieces gathered would be gone the first time the app closed.

The card says what the maths actually gives. Everyone else would have to work
together to know how one person voted, never that nobody can know. A poll of
two is not anonymous at all, because the other person holds a piece of the
vote and sees the tally, so the word is not offered for a pair and the card
says why.

22 new tests. 647 in the engine and 430 in the client, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:10:01 -04:00
Paul HittandClaude Opus 5 6766b39165 A call that is over never rings again
Envelopes are fetched in batches, so the caller's hang-up is regularly
decrypted before their offer. The end was checked against the call in
progress and, when it matched nothing, dropped and forgotten. The offer
behind it then rang a moment later for a call that was already over.
Answering that ring did nothing, because nobody was on the other end, and
the next queued offer arrived behind it.

An end is now filed whether or not it matches the call in progress, and
every signal is checked against that before it can ring, connect, or wait
on anything. The call this device ends is filed the same way, so a late
signal from the peer cannot restart it.

The memory is bounded oldest-first at 200. A phone left running for weeks
must not keep every call id it has ever seen.

A stale offer is filed too: a second delivery of the same dead offer used
to record a second missed call for one ring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:13:46 -04:00
Paul HittandClaude Opus 5 c031532dc3 The ring stops when the caller hangs up, and two things on screen
Only a call OFFER was marked time-critical, so only an offer got the VoIP
push that reaches a locked, suspended phone. Hanging up sent the end as
ordinary traffic, which a sleeping phone does not wake for, and it went on
ringing for a caller who had already gone. The end of a call is as
time-critical as its start. The rule moves to the companion so the test can
ask it what it decides rather than read how it is written.

The sign-in screen showed a letter in a box where the app has a mark of its
own. It now shows the artwork and the navy the launcher icon uses, so the
screen someone lands on matches the icon they tapped.

And setting up Files asked for the passphrase twice. A second box saying
"type it again" is how you confirm a secret somebody is inventing on that
screen; this is the passphrase they already sign in with, which the
paragraph above the box says in as many words. Asking twice only suggested
it was a different one. iOS asks once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:00:25 -04:00
Paul HittandClaude Opus 5 0c161bdccb Version 0.17
What this carries, all of it merged since 0.16: a call answered on one
of your devices stops the others ringing, reactions meant for one person
inside a group, reactions during a call, and conversations held off the
record by both phones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 08:06:05 -04:00
Paul HittandClaude Opus 5 1541407c3c Anonymous polls: the envelopes
Three kinds (§6.29): the poll itself, one piece of one vote, and the sum
a member publishes. A piece goes to a single member and to nobody else,
which is what makes a vote unattributable; a sum is what everybody sees.

All three are marked as control traffic. They are deliberately not in the
CONTROL kind set, because that check returns a bare envelope before the
specialised branches and these carry structure worth reading, but without
the flag they satisfied every renderable test and would have drawn as
empty bubbles.

The poll carries the member set it is counted over, hashed. Pieces split
across a different set do not add up, and that should be visible rather
than arriving as a wrong tally.

Also adds call-reaction to the known kinds. It went into the call actions
when reactions during a call were built and never here, so an arriving
one was classed as needing a newer client. It is the same file as this
change, and a parallel branch for one line would only collide with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 22:21:12 -04:00
Paul HittandClaude Opus 5 c367d3e223 Anonymous polls: the arithmetic
A vote is never sent to anybody. It is split into random pieces that sum
to it, one per member, and each member publishes only the sum of the
pieces they were handed. Adding those sums gives the counts.

Every member other than the voter would have to collude to recover one
vote. That is the guarantee, and it is not the same as nobody being able
to know, so the interface will have to say the true thing rather than the
shorter one.

A missing publisher does not undercount, it gives a wrong answer: the
total keeps a random number in it. That is why a poll waits for everybody
rather than showing what it has, and a test pins it so the waiting is not
mistaken later for timidity.

Tested by doing the arithmetic rather than by reading the source that
does it: the counts, that no single piece is the vote, and that all but
one of the pieces reveal nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 22:16:38 -04:00
Paul HittandClaude Opus 5 f325b1c6ae Off-the-record conversations: the control
A switch in the conversation's own menu, and a line under the header
saying so while it is on.

The switch asks; it does not assert. The state shown is re-read from what
the peer has echoed, and the echo arrives during a poll rather than while
the menu is open, so the open conversation re-reads it there too. Without
that the banner would claim the conversation was off the record from the
moment it was asked for.

Group and remote-address conversations have no peer id, and this is a
one-to-one setting, so it reads false for them rather than pretending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:43:14 -04:00
Paul HittandClaude Opus 5 ce7f50230b Off-the-record conversations: the mechanism
A thread held in memory and written to disk by neither side. Close the
app and it is gone: no file to seize, no backup to leak.

It is a proposal, not a switch. The device turning it on sends the
control envelope and keeps writing until the peer echoes it back. A build
that does not know the kind never echoes, so the thread stays on the
record on both sides rather than on one of them, which is worse than not
having the feature because both people would believe the first thing.

Every message reaches disk through one call, so one gate is enough, and a
test pins that count so a second write path cannot quietly walk around
it. Ratchet state is still persisted: messages stop decrypting without
it, and it is not message content.

The control for it comes next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:31:24 -04:00
Paul HittandClaude Opus 5 bfd86f9817 Say which side of a private reaction you are on
The chip read "private" to everybody who could see it, which says the
least useful thing to each of them. The person who sent it is now told
who saw it, and the author is told that nobody else did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:10:52 -04:00
Paul HittandClaude Opus 5 f147bbf6ae Reactions during a call
Tap a reaction while somebody is talking and it shows on their screen
for a moment. Agreement without interrupting, on a voice call as well as
video.

The signal rides the ratchet like every other call signal, is accepted
only for the call in progress and only once it is up, and is recorded
nowhere: when the call ends there is no trace of it. An older build
ignores the action it does not know.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:07:31 -04:00
Paul HittandClaude Opus 5 8037a299b5 Private reactions: react to one person inside a group
React to a message in a group and only its author sees it. A quiet note
without turning it into an event for everybody else.

The reaction is sent to the author and to nobody else, which is the whole
guarantee: asking the other members' apps not to display it would be a
request, and a request is not privacy. The envelope carries a flag so the
author's app can label what it got, not to ask anyone to hide anything.

Private reactions are kept apart from the public tally on both devices
that hold them, and drawn with a label, so one is never mistaken for
something the group watched arrive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:57:31 -04:00
Paul HittandClaude Opus 5 43baac80b7 Stop the ring on this account's other devices when one answers
The answer goes to the caller only, so a second device on the same
account kept ringing for its full minute after the first picked up, and
then filed the call as missed. The device that answers or declines now
sends call-end to its own account's other devices, reason
answered-elsewhere or declined-elsewhere. A device still ringing for that
call stops and files nothing; one already on the call ignores it.

A send to this account's own devices carries no delivery key or key-log
prelude and is not refused for going unsealed. Its failures are logged
and never touch the call on this device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:50:59 -04:00
Paul HittandClaude Opus 5 945897a742 Clear the app before every on-device run, as !122 said it did
!122 described this change and did not contain it. Its commit carries
only SignUpFlowTest.kt; the script edit stayed in the working tree,
because the file is tracked as `scripts/` and was staged as `Scripts/`,
the same file on a case-insensitive disk. The on-device run that passed
used the uncommitted script, so the pass was real and main never had
the fix behind it.

The fix itself is unchanged from what !122 described. The uninstall used
to run only when the emulator was already up, on the reasoning that a
cold boot starts clean. A cold boot starts with no snapshot; the disk
image keeps whatever was installed. An AVD used to try a release build
kept that build, and the debug test APK could not install over it:
INSTALL_FAILED_UPDATE_INCOMPATIBLE, zero tests run. It now clears on
both paths and refuses to continue if anything survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:14:44 -04:00
Paul HittandClaude Opus 5 8f0fd77dae Guard the second-device call fix where Android CI actually runs it
The iPhone test reads this repo's CallManager too, but only when the two
checkouts sit side by side under the names it expects, which no CI build
directory has, so its Android half returns early there. This is the copy
that runs on every Android pipeline. Proven able to fail: against the
code before the fix, both checks fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:14:43 -04:00
Paul HittandClaude Opus 5 ae84b949a3 A second device's ring no longer hangs up a call answered on the first
With one account signed in on two devices, both ring. Answer on one and
the other keeps ringing, because nothing tells it; when its ring guard
fired it sent the caller an end with reason "timeout", and the caller
hung up the call in progress.

Two halves, both needed.

The ring guard now releases the ring locally and sends nothing. The
caller gives up on its own dial timer long before this fires, so the
message was redundant, and on a shared account it was destructive.

The end handler did not check the call id at all: any end, for any call,
hung up whatever call was current. It now requires the call id to match,
and once a call has connected it ignores an end whose reason means the
ring was never picked up, declined, busy or timeout. Those can only have
come from another device that was ringing: somebody silencing it, or a
build already in people's hands that still announces its timeout. A
plain hang-up carries no such reason and still ends the call.

The iPhone gets the same fix, and its test reads this file so the two
cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:13:13 -04:00
Paul HittandClaude Opus 5 4412c0a0fa A second account on the same phone gets its own device (android#22)
Sign out normally, then create a second account, and the app refused it
with "Your session expired. Sign out and back in to keep going." That is
advice nobody can act on: you have already signed out, and the screen
saying it is the one you sign up from.

The cause is one line doing the right thing in the wrong case.
`authenticate` reuses the device identity stored in the vault, and the
comment above it says exactly why: keeping the device is what stops
signing out and back in costing every contact a fresh trust prompt.
Plain sign-out keeps the device on purpose; only the erase door destroys
it.

That reasoning holds for the same account and breaks for a different
one. The second sign-up reused a device that already belonged to the
first account and tried to register it against the new one. The relay
refused, correctly, and the refusal reached the person as a sentence
about a session they did not have.

The stored credential already carries `userId` and `relayBaseUrl`, so
the check is cheap: same relay and same user means the device is ours to
reuse, anything else is somebody else's account on a shared phone and
gets its own device. Which is what "a device belongs to an account"
already meant everywhere else.

Reproduced and fixed on hardware, not argued: `SignUpFlowTest` against
relay1 failed this way on every run before, and both of its tests pass
after. That suite is the regression guard, and !122 is what made it able
to run at all.

JVM suites unchanged: 1294 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:09:43 -04:00
Paul HittandClaude Opus 5 77ca69b327 The on-device sign-up suite runs again, and sign-up was never broken
Five things were wrong, each hidden behind the one in front of it, and
none of them was the app. Sign-up works: the run that finally got
through created `andt34972750` on relay1 and registered a device, and
the Account screen showed both.

1. The emulator still had a RELEASE-signed build on it, so the debug
   test APK could not install: INSTALL_FAILED_UPDATE_INCOMPATIBLE. Zero
   tests ran. The uninstall lived only in the "emulator already up"
   branch, on the reasoning that a cold boot starts clean. A cold boot
   starts with no snapshot; the disk image keeps what was installed.
   The script already PRINTED the leftover package and carried on, which
   is a check nobody acts on. It now clears on both paths and refuses to
   continue if anything is left.

2. The sign-in form scrolls, and `toggle-register` is below the fold.
   A tap aimed off-screen is not an error in Compose, it simply does not
   happen, so the form stayed on sign-in and the test waited thirty
   seconds for a register-only field. Same for `sign-out`, which is the
   last section of a long Account screen.

3. It waited for `device-id` after sign-up. Sign-up lands on Home and
   `device-id` is on Account, so it waited two minutes on a screen that
   was never going to show it and reported that the app had not signed
   in. It had: the dump said "Nobody here yet. Add someone by the
   username they signed up with", which is the empty contact list of an
   account created seconds earlier. It now waits for the nav pill and
   checks the device id on the screen that has it.

4. `performTextInput` appends, and only the relay field was cleared, so
   the second test in the class typed "19901990" into the age gate.
   Submit is enabled on exactly four digits, a disabled button swallows
   a tap, and the wait then blamed a screen the tap never reached. The
   same shape as the age-gate bug this suite was fixed for once already.

`signingOutReturnsToSignIn` now passes, against a real relay, which it
has not done in a long time. The remaining failure is not a test
problem and is filed separately: creating a second account after signing
out is refused with "Your session expired", because `signOut` clears the
session in memory and leaves the stored credential on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:05:17 -04:00
Paul HittandClaude Opus 5 918f424ddd Keep build output out of Spotlight
The module build directories are rewritten every job on a machine that
is also the mac-local runner for both app repos, so indexing them lands
in the merge path for everything. `mds_stores` and `mdworker_shared` sit
at the top of CPU while no build process is near it, and the load
average goes into the hundreds.

Measured on the iPhone repo, same from-scratch build twice: it peaks at
load 12.5 with its output excluded and over a hundred without, and the
indexing work stops accumulating rather than trailing the build by
minutes.

The markers are written by the job rather than committed, because the
directories they belong in are created by the build and removed between
runs. The stable caches outside the repo were excluded on the host
itself: DerivedData, the Android SDK, the runner's builds directory and
the Gradle home, 11 GB between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:01:01 -04:00
Paul HittandClaude Opus 5 19add570cb Check the content envelope against envelopes the iPhone emitted
The content layer had no vectors. Everything from section 6.6 onward was
guarded only by same-code round trips: this engine encodes and parses
through its own codec and would pass unchanged if it and the iPhone
disagreed about every key. That is how SPEC 6.18 specified `ment` for as
long as it has existed while both clients have always sent `men`.

`content-v1.json` holds fifteen envelopes generated by RUNNING the
iPhone's encoders, and this suite parses each one and asserts field by
field. "It did not crash" is what the old tests already proved.

One of them pins something easy to get wrong twice: the iPhone writes
`fwd` as the integer 1, not as `true`, and `optBoolean` does not coerce a
number. A parser reading only the boolean answers false and the message
quietly loses the fact that it was passed on.

The suite also fails if the vector file grows a case nobody asserts on.

Separately, and the reason any of this is trustworthy: the test task did
not track the vector files at all. They arrive by environment variable,
Gradle does not track one as an input, so a vector could change under a
build directory that already had results and the task reported
UP-TO-DATE without running. That is not hypothetical here, it is how
twenty vector tests once passed in CI for weeks without executing, on
this same runner, whose build directory survives between runs. All six
vector files are now declared inputs.

Proven rather than asserted: with the inputs declared, flipping `men` to
`ment` in one vector turns the suite red without `--rerun-tasks`. Before
the fix the same change left it UP-TO-DATE and green.

Full suite: 1294 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:20:17 -04:00
Paul HittandClaude Opus 5 615658b561 Android 0.16, versionCode 18
Three things landed since 0.15 was built, two of which a person will
notice.

Backups are written in the shared format now, so one made on an Android
phone opens on an iPhone and the other way round. Before this each app
sealed its own storage and refused the other's file outright, so moving
between the two lost every conversation.

A file in a chat keeps its name and its label. They reached the far end
correctly, so a received document arrived named; it was the copy kept on
this phone that lost them on the first read back out of storage, which
looks like a damaged file rather than like the app forgetting.

And an expired story's bytes are actually dropped. The history path read
every stored story back as one that never expires, so they stayed on
disk after they stopped showing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:23:14 -04:00
Paul HittandClaude Opus 5 fb68878752 Check the backup payload against the published vector
Both apps were taught one backup shape and nothing checked that they
agreed. Each side's tests asserted against what the OTHER was believed
to write, transcribed by hand from reading its source, which is a test
of one person's reading rather than of interoperability.

`backup-payload-v1.json` in flamenet-e2e is now the authority, and the
bar it sets is exact: decode the canonical payload, encode it again, and
the result must match byte for byte once both are put in a canonical
order. The same vector is checked on the iPhone, so the two are
compared against one file rather than against each other's source.

One real fix came out of writing it. `messageFromShared` wrote
`author` unconditionally, so a one-to-one message that carried no author
came back claiming user zero sent it, and a round trip through this app
changed the file. Storage keeps an author on every message and reads a
missing one as 0, so it is now written only when the file carried one.

The env var is exported in BOTH ci jobs, the pinned one and the one that
runs at vectors HEAD, and added to the gate that checks every vector
file exists before the suite runs. A path that silently does not exist
puts the tests back where they were and the failure reads as a protocol
bug rather than a missing file. `.e2e-ref` moves to the commit that adds
the vector.

Full suite: 1279 tests, no failures. `ci/lint` accepts the pipeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:43:33 -04:00
Paul HittandClaude Opus 5 7ab5c774cb A file in a chat keeps its name and its label
`Ref.toJson` is what the vault persists next to a message, and it never
wrote `filename`, `classification` or `compartments`. They reach the far
end correctly, as additive top-level keys on the envelope, so a received
document ARRIVED named. It was the local copy that lost them, on the
first read back out of storage. A file in a chat therefore had a name
until the app was restarted and never again, which reads as a damaged
file rather than as the app forgetting.

`fromJson` now defaults those three to null rather than to empty.
Passing nothing means "not a wire parse, read what was stored"; passing
a value, including an empty one, means the envelope said so and wins.
Empty defaults could not tell those two apart, and answering "" for both
is exactly what made a stored file nameless.

The same rule fixes `expires`. The history path hands in 0.0, and 0
means "does not expire", so every stored story read back as immortal:
its bytes were never dropped and it rendered forever. A caller that
names an expiry still means it; one that does not now gets what the
vault kept.

They are stored under the same names the shared backup format uses, so
`BackupPayload` carries them straight through. A document restored from
a backup arrived with no name and no label before this, which is worse
than not restoring it.

Four tests on the storage round trip, one on the envelope still winning,
one that an attachment which never had a name does not invent one, and
one carrying a document through a backup whole. Full suite: 1276 tests,
no failures. No new ktlint violations in Content.kt (3 at HEAD, 3 now).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:29:31 -04:00
Paul HittandClaude Opus 5 5a5e41bfa2 Android writes and reads the shared backup format
Section 16 pins the envelope and says nothing about what is sealed, so
each app sealed its own storage verbatim and stamped the file with the
platform that wrote it. Each then refused the other's, in as many words:
"import it on a device of the same kind". Somebody moving between an
Android phone and an iPhone lost every conversation they had.

The iPhone half landed first. This is the other half.

`BackupPayload` translates rather than dumps, which is the entire point.
This app stores a message under the keys its wire format uses (`secs`,
`fwd`, `rx`), keeps one-to-one and group threads in one map under
prefixed ids, hangs the attachment kind beside `att` rather than inside
it, abbreviates the sizes, and holds expiries in unix seconds. The
shared shape spells all of that out and counts in milliseconds.
Exporting storage as-is is what made the two formats incompatible in the
first place, so a copy would have reproduced the bug with more steps.

Federated threads are the one thing that does not fit. This app keys
them by address and the iPhone's history is keyed by numeric user id,
with a decoder that skips anything else, so an address left in `threads`
would be dropped in silence. They travel under `addrThreads` instead: an
iPhone ignores the key, an Android phone gets them back, and nothing
disappears without somebody having decided it should.

Import tells the two formats apart by SHAPE, not by file name, because a
name is whatever the person saving it chose. Section 16 announces itself
with `alg` and `ct`; the older export announces itself with `magic`. A
wrong passphrase on a section 16 file reports a wrong passphrase and
does NOT fall through to the legacy reader, which would have answered
"that file is not a Flamenet history export" for a file that is one and
sent somebody hunting for corruption instead of a typo.

Nothing writes the old format any more, and everything still reads it.

Seventeen tests: the shared side against the key names the iPhone
writes, the round trip back into this app's own storage shape, the unit
conversions in both directions, and a payload transcribed from the
iPhone's encoder. That last one is the weak point and the test says so:
it proves this app reads the shape the iPhone is BELIEVED to write. Only
a vector both apps are built against proves they agree, and
`backup-v1.json` pins the envelope alone, which is exactly how the two
shapes drifted apart.

Full suite: 1271 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:11:35 -04:00
Paul HittandClaude Opus 5 4b5c309a66 Android 0.15, versionCode 17
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>
2026-09-10 15:12:41 -04:00
Paul HittandClaude Opus 5 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>
2026-09-10 14:46:51 -04:00
Paul HittandClaude Fable 5.1 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>
2026-09-10 13:03:04 -04:00
Paul HittandClaude Opus 5 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>
2026-09-10 12:54:21 -04:00
Paul HittandClaude Opus 5 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>
2026-09-10 09:56:37 -04:00
Paul HittandClaude Opus 5 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>
2026-09-10 09:51:55 -04:00
Paul HittandClaude Opus 5 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>
2026-09-10 09:06:32 -04:00
Paul HittandClaude Opus 5 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>
2026-09-10 09:06:20 -04:00
Paul HittandClaude Opus 5 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>
2026-09-10 08:39:25 -04:00
Paul HittandClaude Opus 5 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>
2026-09-10 08:18:34 -04:00
Paul HittandClaude Opus 5 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>
2026-09-10 07:56:05 -04:00
Paul HittandClaude Opus 5 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>
2026-09-09 18:45:16 -04:00
Paul HittandClaude Opus 5 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>
2026-09-09 18:00:24 -04:00
Paul HittandClaude Opus 5 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>
2026-09-09 17:12:40 -04:00
Paul HittandClaude Opus 5 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>
2026-09-09 16:58:55 -04:00
Paul HittandClaude Opus 5 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>
2026-09-09 15:32:00 -04:00
Paul HittandClaude Opus 5 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>
2026-09-09 09:30:32 -04:00
Paul HittandClaude Opus 5 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>
2026-09-09 08:33:12 -04:00
Paul HittandClaude Opus 5 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>
2026-09-09 08:06:22 -04:00
Paul HittandClaude Opus 5 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 4dd5440. They are back as yes, no and
partial, so the "Zero key material after use" row still records that
Android's guarantee is weaker than iOS rather than flattening it to a
tick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:09:06 -04:00
Paul HittandClaude Fable 5.1 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>
2026-09-08 20:22:03 -04:00
Paul Hitt b938d161ba Merge main 2026-09-08 20:21:39 -04:00
Paul HittandClaude Fable 5.1 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>
2026-09-08 20:02:07 -04:00
Paul HittandClaude Fable 5.1 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>
2026-09-08 19:20:07 -04:00
Paul HittandClaude Fable 5.1 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>
2026-09-08 18:52:12 -04:00
Paul HittandClaude Opus 5 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>
2026-09-08 17:50:37 -04:00
Paul HittandClaude Opus 5 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>
2026-09-08 14:11:30 -04:00
Paul HittandClaude Opus 5 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>
2026-09-08 13:30:23 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 19:35:51 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 17:48:44 -04:00
Paul HittandClaude Opus 5 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>
2026-09-07 17:34:53 -04:00