Commit Graph
296 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 350b50c357 Putting the app away is not leaving the call, and erasing asks first
Two things a person could do by accident.

Swiping out of Recents hung up a live call. The `onTaskRemoved` that did
it was written when removing the task took the service with it, so the
call was over regardless and the envelope only saved the far end a few
seconds of talking to nobody. `stopWithTask="false"` is what keeps this
phone reachable, and it means the service now survives the swipe -- so
the call survived it too, and killing it there dropped a call for no
visible reason from a gesture nobody associates with hanging up. The
teardown that really is a teardown still tells the far end.

Erasing an unreadable vault was one tap on a screen the app can reach by
itself at launch, and it deletes every message this phone holds with no
way back. Keys that will not open here are not worthless: the device they
came from still opens them. Behind a confirmation now, as on iOS.

The test that pinned the old hang-up behaviour is rewritten to pin the
new one from both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:45:39 -04:00
Paul HittandClaude Opus 5 c2f9889bd9 Revoke presence on block, keep the key pools alive, release SAF grants
Four things this client was not doing.

Presence: a per-contact token lets its holder ask the relay whether this
account is online, and the relay answers while the published digest set
carries it. Block and remove never touched that set. They do now, and the
publish no longer skips an empty set, which meant revoking the last
contact's token never reached the relay at all.

One-time prekeys: twenty were published at registration and nothing ever
topped the pool up. Once they were spent, a stranger's first message had
no prekey to take. Replenished from the poll, rate-gated to an hour.

Signed prekey: the rotation existed and nothing called it, so this device
kept one signed prekey for its whole life. Also driven from the poll, on
its own much longer clock.

SAF: deleting a linked file dropped the row and kept the persisted read
grant, so the app held access to files the user believed it had let go of.
The vault now hands back the bookmarks it dropped and the app releases them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:28:53 -04:00
Paul HittandClaude Opus 5 2ea825f5cc 0.28, versionCode 28
The rounded bubbles, released.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:37:01 -04:00
Paul HittandClaude Opus 5 c48c2ee5ba Round the bubbles, keeping the corner that points at the author
The conversation read as blocky, which it was: bubbleRadius was 2.dp,
so a bubble was a rectangle.

14.dp now, with bubbleTail left at 2, so one corner stays square and
points at whoever wrote the message: sent bubbles square at the bottom
right, received at the top left. A bubble rounded on all four corners
is the shape every other messenger draws, and the corner that does not
round is what keeps this one recognisable.

`bubbleShape` is the Compose counterpart of iOS `BubbleShape`, which
already drew the asymmetric corner and was also turned down to 2. Both
apps move together and to the same numbers; they are meant to be the
same conversation.

The rest of the app stays squared on purpose, and both files say so, so
this does not get read later as drift and "fixed" by rounding the lists
and bands to match.

1748 unit tests green. The 4 red in TheCrashListOpensAReportTest are
the release variant's Robolectric activity lookup and fail identically
with these two files stashed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:00:38 -04:00
Paul HittandClaude Opus 5 d871560324 No bold in code comments
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:44:35 -04:00
Paul HittandClaude Opus 5 9a45d437e2 De-dash the manifest, proguard rules and resource comments
Comment prose in XML and .pro files, same rule as the Kotlin. PARITY.md
table rows keep theirs: a leading dash in a cell is typography, not a
prose splice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:42:25 -04:00
Paul HittandClaude Opus 5 c26368b493 Comments describe the code, not the edit that produced it
"This used to be drawn inside SignedIn", "nulling current first (as this
used to)", "this used to report chat.peerId". Each keeps the reason it
carried, written as the property rather than the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:18:18 -04:00
Paul HittandClaude Opus 5 47ecffea39 Groups and recents sit on the surface, like everything beside them
On the Messages screen the CHATS rows were white cards with a rule under
each, and the GROUPS row and the RECENT strip sat straight on the page
with no surface behind them and no inset at the leading edge. Three
sections of one list, two of them looking unfinished next to the third.

The group row was a bare `Row` carrying only vertical padding, while a
chat row is a `PortalRow`, which supplies the surface, the horizontal
inset, the row height and the hairline. Its own comment already said the
two kinds of row are meant to read as one list; now they do, and the
hand-rolled `Divider()` under it goes, because PortalRow draws that.

The recents strip is a LazyRow with no background. It keeps its full
bleed content padding, so the tiles still scroll under the screen edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:23:33 -04:00
Paul HittandClaude Opus 5 8f92f3c368 The Send button gets a name
Every other icon-only control in the composer row takes its label from
`IconAction(label =)`. Send is a bare Button whose Icon passed
`contentDescription = null`, so the single most important control in the
app had no name: TalkBack announced "button", and it was absent from an
accessibility dump altogether, which is also how it came to be reported
as a missing Send button while it sat there working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 09:31:58 -04:00
Paul HittandClaude Opus 5 223d40e88e Reactions and votes stop failing silently
`reactToStory` ran its work inside `runCatching` and threw the result
away. A reaction that never reached the relay looked exactly like one that
did: the strip redrew, nothing said otherwise, and the emoji was simply
not there afterwards. That is "I react to a story and the reaction doesn't
take", reported and until now unexplained.

`reactToMessage` and `votePoll` do the same thing, so they get the same
treatment. `replyToStory`, sitting immediately below the story reaction,
has always handled its failure properly; these three were the ones that
did not.

There are more of these in this file. They are not all bugs, plenty are
genuinely fire-and-forget, and a heuristic sweep gets it wrong in both
directions: mine first reported `addBuddy` as unhandled when its handler
is fifteen lines down, then missed `reactToStory` because it saw the
neighbouring reply's handler. These three were read rather than matched.

1474 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 07:30:19 -04:00
Paul HittandClaude Opus 5 91a46b4d2a A call asks for the microphone, which nothing on that path ever did
A call is a microphone the moment it starts, and no call path asked for
one. `CAMERA` was requested for video calls; `RECORD_AUDIO` was requested
only by the voice-note button. So anybody who had never recorded a voice
note had never granted it, and every call they placed or answered hit:

  SecurityException: Starting FGS with type microphone ...
  requires android.permission.RECORD_AUDIO

The throw is caught and logged, and the call then carries on with no
microphone. The far end hears nothing and it ends looking like the call
"just went away", which is exactly how it has been described. A clean
install placing its first call reproduces it every time.

`rememberMicThen` asks first, and a refusal is respected rather than
worked around: the call is not placed, because a call nobody can hear is
worse than no call. Placing from a conversation and both answer paths go
through it.

Also: an error raised anywhere but Home was invisible. `phase.error` was
drawn inside `SignedIn`, which is the Home tab and nothing else, so a
failure on Stories, Messages, Calls, Files, Wallet or Account explained
itself to an empty room. It moves up beside the call banner, which is
already there for the same reason: it belongs wherever the person is
standing when it happens.

Verified on the emulator with RECORD_AUDIO denied: tapping Call now
raises the system prompt where it used to proceed silently, and after
granting, the call runs with no SecurityException.

1474 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 07:12:23 -04:00
Paul HittandClaude Opus 5 611a16d0b3 The poller survives a swipe, which is whether the phone can be reached
There is no push on Android, by choice: no Firebase, no Play Services,
nothing that needs a Google account to receive a message. MessengerService
IS the delivery path, so whether it is running is whether the phone can be
reached at all.

It was declared without `stopWithTask`, which defaults to true, so swiping
the app out of Recents destroyed it. Nothing brought it back: START_STICKY
covers a process the system killed for memory, not a task the user removed.
The phone then went silent until somebody opened the app again.

From the outside that is a phone that does not ring, does not receive, and
collects everything in a burst when it is next opened, which is exactly
what a week of "Android answers then hangs up", "the call just goes away"
and "the story never appeared" looks like. Nobody would guess that closing
the app from Recents is what did it.

Observed on a real handset: last poll 11:08, forty-two envelopes still
waiting on the relay thirteen hours later, while the iPhone on the same
account was current. iOS was never affected because APNs wakes it.

Verified in the compiled APK, not just the source: aapt2 shows
`android:stopWithTask=false` on the service in the installed manifest.

1474 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:53:26 -04:00
Paul HittandClaude Opus 5 616e23c16c The devices section stops printing a cap it does not know
Same as the iOS screen: it said "N of 3 registered" while the cap is the
relay's `FN_MAX_DEVICES`. Production runs six, so an account at the limit
read "6 of 3 registered" on the screen people are sent to when they are
told they have too many devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:41:51 -04:00
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 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 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 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