Commit Graph
585 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 edfb8ea48a Keep off-the-record drafts out of the vault
`Vault.saveDraft` wrote the draft text under the thread id with no
off-the-record check, so a half-typed message in an off-the-record chat
sat on disk under a key naming the person it was for.

This is the iOS finding from 2026-09-17 on the other platform. That one
covered iOS drafts and pins and Android pins, edits and reactions;
Android drafts were the half nobody went back for.

Turning the mode on removes an existing draft rather than leaving it
behind, since the mode can be set after something was typed. iOS gets the
same result a different way: its filter runs at the save and the whole
map is rewritten each time.

The filter is on the write, with `saveOutbox` and `saveUnreadThreads`,
for the reason those give: a later caller cannot forget it.

Falsified: without it two of the four tests go red, report XML
`tests="4" skipped="0"`. Engine 719 and client 473 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:58:27 -04:00
Paul Hitt bafb44352c Merge branch 'fix/off-the-record-outbox' into 'main'
Keep off-the-record messages out of the outbox on disk

See merge request paulhitt/flamenet-messenger-android!143
2026-09-22 11:53:29 +00:00
Paul HittandClaude Opus 5 fd7841e002 Keep off-the-record messages out of the outbox on disk
An outbox entry carries `to` and `text`. That is the message, not a note
about it, and `saveOutbox` writes the whole queue to the vault on every
enqueue, so an off-the-record send queued for later was on disk in
plaintext the moment it was typed. §6.28 says the conversation lives in
memory while the app is open and is gone when it closes.

Two paths reach it: `Session.scheduleSend` for a send with a time on it,
and `Session.enqueue` for one the recipient could not be handed yet.
Neither checked, and neither had to: the filter belongs on the write, the
same place and for the same reason as `saveUnreadThreads`.

A group entry carries `thread` and goes to a member whose 1:1 thread may
not exist, and a group has no off-the-record setting, so it passes
through.

Dropping a queued send is the promise rather than the cost of it. The
thread is not kept either, so a message that survived would arrive into a
conversation the app no longer has, and it retries for as long as the app
is open, which is as long as the conversation exists.

Falsified: without the filter two of the four tests go red, report XML
`tests="4" skipped="0"`. Engine 711 and client 469 green, with a
flamenet-e2e checkout linked beside the worktree so the vector tests can
find the published vectors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:46:29 -04:00
Paul Hitt 934589cc06 Merge branch 'fix/off-the-record-unread-marks' into 'main'
Keep off-the-record unread marks out of the vault

See merge request paulhitt/flamenet-messenger-android!142
2026-09-22 11:44:44 +00:00
Paul HittandClaude Opus 5 2cf20e761a Keep off-the-record unread marks out of the vault
`MessengerViewModel.update` persists the unread set through
`Vault.saveUnreadThreads` on every change, unfiltered. A thread id for a
user conversation is `user:<id>`, so an off-the-record conversation with
something unread put the peer's id on disk, and it came back at the next
launch. `saveOffTheRecord` says the setting is kept and only the
setting.

The filter goes in `saveUnreadThreads` rather than at the call site, for
the reason the call site gives for being a chokepoint: a later caller
cannot forget it. The vault already answers `offTheRecord(threadId)` for
exactly that key, so the check was available and unused.

Nothing is lost. An off-the-record thread holds no lines at rest, so a
restored mark would badge a conversation with nothing in it.

iOS had the same fault on `unreadPeers` and is fixed in ios!221. Both
are the September pins-and-drafts finding again, on the one dictionary
each side had left.

Falsified: dropping the filter turns two of the four tests red, and the
report XML says `tests="4" skipped="0"` in both directions, since gradle
will report success having run nothing.

Engine 711 and client 469, both green. The 55 engine failures a worktree
shows first are `vectors not found`: the vector tests resolve the
published vectors through a sibling `flamenet-e2e` checkout, which a
worktree has no path to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:27:49 -04:00
Paul Hitt 9103ede6bc Merge branch 'fix/deleting-an-account-tells-the-account-service' into 'main'
Tell the account service when an account is deleted

See merge request paulhitt/flamenet-messenger-android!141
2026-09-21 23:55:49 +00:00
Paul HittandClaude Opus 5 377ab65efe Drop the Flamenet Files half of the account delete
Files was folded into the messenger, so the account service issues
pseudonyms for one product and a delete can no longer half-succeed.

The rule this branch was built around, keep the shared account alive
while Flamenet Files still holds an identity, can never fire now. So
`deleteAccount` returns nothing instead of reporting which of two
outcomes happened, the screen no longer has a sentence explaining that
somebody's Flamenet account survived because Files was using it, and the
test for that case is gone. The endpoint half is in
flamenet-files!10.

What stays is what was right for other reasons: the passphrase is proven
before anything is deleted, the relay goes first, and the first tap asks
the relay where the account lives rather than guessing.

Copy: the sign-in screen said "One Flamenet account, shared with Flamenet
Files", which is now a promise about a product that does not exist. It
says the account works across relays, which is the thing that is
actually true and the only reason the shared service exists.

The signing comment in app/build.gradle.kts keeps its mention. Early
releases really did go out as `CN=Flamenet Files`, an Android signing key
is permanent, and that is history somebody needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:32:21 -04:00
Paul HittandClaude Opus 5 0cc14f92e7 Tell the account service when an account is deleted
Deleting an account removed the relay's copy and stopped there. On a
relay that uses the shared accounts, that is half the account: the row at
accounts.flamenet.io stayed, the handle stayed taken, and the person
stayed registered with a service they believed they had left. Found on
2026-09-21 by creating an account against production from the emulator
and deleting it: the relay row went, the account service row did not.

So the delete makes the second call. The endpoint is new, in
enclave!fix/deleting-an-account-tells-the-account-service, and that has to
be deployed before a build carrying this ships or the second call answers
404.

Three things decide the shape:

The relay goes first. Reversed, a failure in between leaves an account
that can no longer sign in, because a shared account signs in AT the
account service, and so an account nobody can ever finish deleting. This
way round strands a row, and a stranded row can still be cleared.

The passphrase is checked BEFORE anything is deleted, by signing in. A
typo is the likeliest thing to go wrong, and finding out afterwards would
mean the messenger account is already gone, the Flamenet account is not,
and the person has been signed out of the app that was offering to remove
either.

The first tap deletes nothing: it asks the relay where the account lives.
The vault records the relay and nothing about the account service, and
`site` tracks whatever was last typed into the relay box, which after a
restore can describe a different server. Guessing is how the account
service came to be skipped in the first place.

What the screen says now matches what happens, including the case where
the Flamenet account is kept because Files is using it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:15:18 -04:00
Paul HittandClaude Opus 5 2c22ffed75 Stop blaming the server for failures that never reached it
describeFailure had one sentence for everything it could not name: 'Something
went wrong reaching that server.' That is a claim about the network, and most
of what lands in the catch-all never touched the network.

Seen while testing the released 0.29 build on an emulator. Sign-up against a
relay that answered 200 to signup, token, device registration and identity put
that sentence on the screen. The account had been created. The person is told
to check a connection that is working, and the one action the sentence implies,
tapping again, is the one least likely to help. It is also what produced the
duplicate token request in the relay's access log.

Same shape as the relay-pin bug fixed in 80bb4c4: a specific failure arriving
as a shrug about the network, and the fix is the same. Say what is known.
RelayException.Transport now has its own branch and is the only place allowed
to blame the connection, because it is the only one that knows. The catch-all
says the failure was on this phone, and carries the cause's own sentence when
there is a readable one, refusing anything that looks like a class name.

Seven tests, falsified: restoring the old sentence turns three of them red.
App unit suite 633 green.

Not fixed here: WHY that sign-up failed locally after a 200. The message was
hiding it, and now it will say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 15:06:38 -04:00
Paul HittandClaude Opus 5 f8efd4d12c 0.29, versionCode 29
Post-quantum authentication, released. Android now publishes an ML-DSA-65
identity alongside its Ed25519 one, verifies a peer's, and refuses a relay
that strips or substitutes it. Thirteen commits since 0.28, also carrying
group and multi-picture stories, remote erase, invite by link, held refused
envelopes, and a server identity change that says which server.

Two things this release does NOT do, both deliberate. A device restored from
a backup older than this stays classical and is not given an identity behind
the user's back, so existing installs gain one only by re-registering. And
nothing in the app tells a user whether a given conversation is
post-quantum authenticated or classical; that needs surfacing and does not
exist yet.

The security page still says Android publishes no ik_pq. It is edited when
this is live in Play and not before, per the checklist in flamenet-e2e
docs/SECTION-20-ANDROID-RELEASE.md. A page describing a build nobody has yet
is wrong for however long the rollout takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:30:46 -04:00
Paul HittandClaude Opus 5 26a14fb55a engine: Android verifies and pins a peer's section 20 identity
The receive half. PeerBundle now carries ik_pq and spk_pq_sig, PQIdentity
decides what to do with them, and Conversations enforces it where a session is
established.

The three refusals match the web engine and iOS: PQDowngradeException when a
device that was seen with an identity appears without one, PQVerificationException
when the signature does not verify, and PQIdentityChangedException when the
identity is not the pinned one.

The pin is the SHA-256 of ik_pq, not a boolean, because section 20.5 is
explicit about why: a relay that substituted its own ML-DSA key would satisfy
a boolean pin and the substitution would go unnoticed. Same fingerprint the
web engine stores, so it means the same thing on both.

Absent to present is allowed and present to absent is refused. That asymmetry
is section 20.2 and 20.5 together: a device that has never published an
identity is classical and may start later, and one that has may never stop.

Enforced on the new-session path only, because section 20.5 says the pin is
consulted when a session is established, which is the only moment a downgrade
can be attempted; an existing session never refetches a bundle. A bundle that
never opens a session therefore cannot move a pin.

PQIdentity.check is a pure function of the pin and the bundle, so every case
is testable without a vault or a relay. Thirteen cases, most of them a relay
misbehaving: stripping, substituting, a bent signature, an identity with no
signature, and a signature over a different device's prekey.

Falsified: disabling the two refusals turns the suite red on exactly the two
tests that cover them. Engine 707 green, client 469 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:26:12 -04:00
Paul HittandClaude Opus 5 54ce831897 engine: Android publishes a section 20 post-quantum identity
The DeviceIdentity half. A new device now mints a 32-byte ML-DSA-65 identity
seed, archives it, and publishes ik_pq and spk_pq_sig in the registration
bundle.

spk_pq_sig covers EXACTLY the bytes spk_sig covers, the raw X25519 signed
prekey, because section 20.1 requires a verifier to check one message under
two schemes rather than two messages under two schemes. Signing anything else
would have verified against ourselves and failed against the web engine and
iOS, which is the failure this whole section is designed to prevent.

Published before the v2 early return, not after, because section 20.2 makes a
post-quantum identity independent of post-quantum key agreement. Putting it
after would have tied the two together against the spec.

The archive key is the web engine's ikPQSeed, so an export crosses platforms.
An archive written before this restores as classical-only and is deliberately
NOT given an identity behind the user's back: the reference implementation
mints only on create, and a device that starts publishing an identity it did
not have is a decision to take on purpose.

Proven rather than assumed, both directions across implementations:
BouncyCastle and the web engine derive the same public key from the same seed,
each verifies a signature the other made, and each refuses the other's
tampered signature. Ten wiring tests, ten primitive tests, engine suite 694
green.

Still not done: nothing VERIFIES a peer's ik_pq yet, and there is no pinning,
so this publishes an identity that no Android device checks. That is the
receive half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:22:21 -04:00
Paul HittandClaude Opus 5 fd386259f6 engine: ML-DSA-65 for Android, the primitive and its vectors
Section 20 post-quantum authentication shipped on the web engine and iOS.
Android had nothing, so it published no ik_pq, and section 20.2 makes an
absent identity a supported state rather than an error. Every conversation
with an Android device therefore fell back to classical authentication
SILENTLY. This is the first half of closing that.

PQSign is ML-DSA-65 on BouncyCastle 1.79, which is already a dependency and
already carries the sibling mlkem package PQKEM uses. Seeded key generation
via MLDSAPrivateKeyParameters(params, seed): 32 bytes persisted, the signing
key rederived on demand, so a post-quantum identity lives in the existing
vault without a new storage format.

The load-bearing test derives the published seed's public key and compares it
byte for byte against protocol-v1.json. FIPS 204 keygen is deterministic in
the seed, so a device that derived a different key would publish a different
identity on restore and every contact would read it as a substitution.
Signatures are deliberately not pinned because ML-DSA signing is randomised;
verification is checked against the public key instead.

Falsified rather than assumed: flipping one bit of the derived key turns the
suite red on two assertions including that one. Engine suite is 684 tests
green. The test reads FLAMENET_VECTORS, which is the name CI exports; an
invented name would have passed here and found nothing there.

NOT yet wired: DeviceIdentity still mints no identity seed and no bundle
carries ik_pq or spk_pq_sig, so nothing changes on the wire yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:18:34 -04:00
Paul HittandClaude Opus 5 07f256c2ec Wire group stories and multi-picture stories (§6.24, §6.23)
Both were built and neither was reachable.

§6.24, group stories. The receive side has been complete for a long
time: an arriving story carries its gid and the gid is checked against
the group's roster before it is believed. `postGroupStory` had no
callers, so nothing could ever produce one. The composer offers the
groups this account is in as destinations now.

A group REPLACES the audience rather than filtering it. Running a story
audience over a group's roster would quietly drop the members somebody
excluded from their contact stories, which is not what posting to a
group means, and the branch runs before the contact check so a person
with no contacts at all can still post to a group they are in.

§6.23, a sequence posted as one story. Android was further behind: the
receive side stored `set` and the feed never read it, and
`Conversations.postStory` never passed one to the encoder, so this
phone could not make a sequence and drew an iPhone's three-picture set
as three separate stories from the same person, one after another.

The feed groups through `Vault.groupedStories`, which already existed
and had no callers outside its own test, and says how many pictures a
card stands for. The sender shares one set id across the items and puts
the caption on the first only: a sequence has one thing to say and
repeating it under every picture reads as a stutter. The poster's own
copies carry the same id, so they see the sequence their contacts will.

Capped at ten, matching iOS. Each item is sealed, uploaded and sent to
every recipient separately, so a set of forty is forty uploads and the
person waiting cannot tell it from a stuck app.

Falsifying this found a duplicate: I had added a second `groupedStories`
as an instance method beside the companion one that was already there.
It compiled, the feed resolved to the original, and mine was dead the
moment it was written. Removed, and the one case my test had that the
existing `StorySetTest` did not now lives in that file instead of a
parallel one.

engine 674, client 469, app 626, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:19:25 -04:00
Paul HittandClaude Opus 5 5b8177fba4 Wrap the erase dialog's handlers the way ktlint asks
Seven findings, all on lines the commit before this one added: two
semicolon-joined statements in onClick lambdas and a chained call that
wanted a newline. No behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:58:27 -04:00
Paul HittandClaude Opus 5 ef1a8138aa Remote erase on Android (§6.26)
Android had no part of this: no type, no route, no fetch. The relay's
half was complete and iOS had the crypto, so a lost Android phone was
the one that could not be erased at all.

DeviceWipe in the engine, signing and verifying the same canonical
string as the iOS half, `fnwipe:v1:<device>:<seconds>`. A vector in
the tests takes a signature the SWIFT implementation produced and
verifies it here, and the iOS suite takes one produced here. Not a
comparison of the two signatures: CryptoKit randomises Ed25519 signing
and this does not, so they never match and never need to. What interop
needs is that each side verifies what the other signed.

The poll asks once a session and stops there if it erased, rather than
carrying on into a vault it has just emptied. The account public key it
checks against comes out of the address this device already stored, not
out of anything the relay just said.

Issuing is on the Devices card and asks for the passphrase, which is
what somebody holding the phone does not have. A passphrase that does
not derive this account's published address is refused before anything
reaches the relay. The dialog shows DeviceWipe.HONEST_DESCRIPTION,
kept word for word with the iOS copy: two apps describing one guarantee
differently is how somebody ends up believing the stronger sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:56:01 -04:00
Paul HittandClaude Fable 5.1 9bc8cdcb23 Invite a friend, as a link
The relay has minted invite codes since relay#43; the app had no door to
them. Account settings now has "Invite a friend": it asks the relay for a
code, shows it large, and shares flamenetmessenger.com/join/<code>, the
page that shows who sent it, the store buttons and the code to type if
sign-up asks for one.

InviteLink.forCode repairs what a person did to a code on the way so the
link carries what the relay hashed. MessengerApi.mintInvite posts to
account/vouch on the relay root with the app credential. RateLimited now
carries the relay's own sentence when it gave one, so "You already have 5
unused invites" reaches the screen instead of "rate limited".

Five client tests; the whole client suite is green at 469.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 11:47:07 -04:00
Paul HittandClaude Opus 5 80bb4c4e58 Say which server changed its identity, instead of shrugging at the network
The relay pin exists to catch one thing: the server signing with an
issuer key this app never agreed to. It caught it and threw an
exception carrying a warning written for a person to read, naming the
host and giving both readings the app cannot tell apart -- a rotation
done on purpose, and something else answering in its place.

describeFailure had a branch for a forked key log and one for a
contact's changed key, and none for this, so it fell through to
"Something went wrong reaching that server." The alarm arrived as a
shrug about the network, and the carefully written warning was
discarded on the way.

Found looking for the Android half of the iOS relay-identity banner
added earlier today. Android does raise it; nothing said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:21:41 -04:00
Paul HittandClaude Opus 5 cc4f5d8844 Hold refused envelopes on Android too
The same data loss as the iOS side, in the other direction. When a
contact reinstalls, every message they send before this phone accepts
their new keys was destroyed: the poll advances its watermark whatever
happens, so the relay marked them delivered and forgot them, and the
catch in ingest swallowed the refusal and moved on. Tapping "Accept new
keys" brought nothing back. The sender saw delivered messages and this
side saw an empty thread under a security banner.

The identity gate is right and stays. The discard is what changes.

A refused envelope is now set aside as it arrived, still ciphertext, in
the sealed vault so it survives the app being closed -- which is the
ordinary way this happens. Bounded at a hundred per sender and thirty
days, expired on the way in so it needs no timer that a rarely-opened
app would never run. Blocking or removing a contact drops their hold
rather than keeping a removed contact's messages.

Two extractions were needed to make a replay possible, and both are
worth having on their own. The per-envelope work in the poll loop is
now `consume`, so a held envelope can go through exactly the path a
fresh one does; the replay calls it directly rather than through the
de-duplicating check at the top of the loop, which has already seen
every one of those envelope ids and would deliver nothing while
reporting success. And everything `receive` does with a batch of
decrypted messages is now `absorb`, because a replay that stopped at
the decrypt would write nothing and show nothing, which is this same
bug one layer further in.

The banner says how many are waiting, and accepting re-reads the thread
it just filled rather than only re-badging the trust state.

engine 663, client 464, app 582 across both variants, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:16:50 -04:00
Paul HittandClaude Opus 5 86da4cc8f4 Run the release variant in CI, and stop hand-listing what cannot run there
CI ran :app:testDebugUnitTest and nothing else, so the release variant
of the app suite was never exercised anywhere. Four tests in it have
been failing since the day they were written: they take an Activity
rule, ComponentActivity is declared by ui-test-manifest which is a
debugImplementation dependency, and the release variant's merged
manifest has no such activity. Green board, red suite.

The exclusion that covers this existed and was a hardcoded name. It
said ScreenshotTest, the next test to take an Activity rule arrived
months later, and nobody thinks about a list in a build file. It reads
the names off the sources now, so a test opts itself in by the rule it
declares.

Both variants run here now, 296 debug and 276 release, all green. The
gate's floors move with them: 663 / 453 / 75 / 572, and 1580 overall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:00:31 -04:00
Paul HittandClaude Opus 5 7f121ab456 Delete nine functions nothing calls
Each verified by bare-word grep across app, client and engine, not by a
counter: the twenty-five WebRTC, BiometricPrompt and capturer callbacks
that also show zero Kotlin-side references are invoked by the SDK and
are not dead.

  refreshGroups        Session calls relay.listGroups directly
  filesGoUp            the breadcrumb bar is how Files goes up here
  clearStoryArchive
  WalletChain.setEndpoint
  RelayAccounts.verifyEmail   a wrapper; the path is posted elsewhere
  Vault.visibleStories        Session filters mutes inline at 1665
  Vault.forgetWallet          KeystoreVault.destroy covers it
  Vault.forgetFilesIdentity   same

postGroupStory looked dead by the same measure and is not being
deleted: it is the only implementation of group stories on this side,
so removing it would drop a spec feature rather than tidy up after one.
Whether to wire it to a screen or take it out is a decision, not a
cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:52:10 -04:00
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 a336401d80 Keep built APKs and test screenshots out of the repo
Two 24MB rollback APKs and a screenshot reached a commit through a
broad `git add`. The rollback series is archived on the box at
/srv/prod/messenger/apk-rollback, which now holds the full run from 0.6
to 0.25.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:51:10 -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 9c5aec7f71 De-dash the README line
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:42:39 -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 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