Commit Graph
100 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 4.8 72314491fa Mailboxes at more than one relay
Your address already survives a relay disappearing — it comes from your
passphrase, so signing in elsewhere produces the same one. Your mailbox
does not. Mail sitting at a relay that goes away is gone with it, and a
contact holding only that relay's name has nowhere to write.

The relay has fanned out to a signed home record for a while and no
client could publish one, so the feature existed on exactly one side of
the wire. This is the other side, and both halves of it, because
publishing alone would not have worked: a copy sitting in a mailbox
nothing reads is storage, not redundancy. So the poll now reads every
home, with a cursor per relay — envelope ids come from each relay's own
database, and one shared cursor would let the first home's progress
silently skip the second home's mail.

Copies are collapsed by the id the sender stamps once per message.
Without that the second copy reaches a ratchet that has already deleted
its message key, so the symptom would not be a duplicate — it would be a
dropped envelope and a message that looks lost.

Adding a mailbox is several requests because homing somewhere means
having an account there: sign in, register this device so there is
somewhere to deliver, claim the same address so a sender can resolve it,
then publish the record at EVERY home rather than only the new one. A
home that does not know it is one still accepts mail, but a sender
asking it for the record gets a stale list and skips the others — so the
redundancy would quietly depend on which relay the sender happened to
ask. The new relay accepting is what decides the whole operation:
a record every old home took but the new one refused describes a mailbox
that does not exist.

Removal publishes the shorter list first and forgets the credential only
if that worked. The reverse order leaves mail arriving at a mailbox this
device no longer reads, which from the sender's side is indistinguishable
from delivery. The relay being dropped is not asked to accept the new
record — it is the one party with a reason to refuse.

No one-time prekeys are published at a second home. They are only usable
if this device kept the private halves, and publishing keys it would not
offer back makes every session opened with one undecryptable, which is
worse than offering none.

The passphrase is asked for on the screen that needs it and cleared the
moment the attempt ends. The account key seed lives in memory for the
run and is never written down: it signs the mailbox record, which is the
one thing a relay must never be able to do on the account's behalf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 16:06:11 -04:00
Paul HittandClaude Opus 4.8 444ebf0862 Post-quantum key agreement on Android
A session is post-quantum only when both ends are, so with every Android
device registering `proto: 1` the iPhone implementation protected
approximately no real conversation. The property was true of the code
and false of the product. This is the other half.

ML-KEM-768 over Bouncy Castle's low-level `pqc.crypto.mlkem`, the same
way Ed25519 already uses its `rfc8032` class: not the JCA provider,
which is awkward to register on Android and which R8 cannot shrink. The
platform offers nothing here — Conscrypt has no ML-KEM at any API level
— and hand-rolling a lattice KEM to avoid a dependency would be a far
worse trade than the one Ed25519 already made. No new dependency: the
jar was already in the build for Ed25519.

Keys are stored as the 64-byte FIPS 203 seed, never the expanded
decapsulation key — 64 bytes in the vault instead of 2400, and the same
representation iOS persists, which is what lets a history export cross
platforms. That is also the thing most likely to be silently wrong, so
it is checked against the published vector rather than against another
run of our own code: the seed in `pqx3dh-v2.json` derives the public key
the normative JavaScript engine derived, and the full responder side
reproduces the published session key and associated data byte for byte.
Everything else here would pass with an implementation that is
internally consistent and agrees with nobody.

Negotiation refuses in three places, and each is a substitution
somebody would otherwise get for free. A KEM prekey whose signature does
not verify is treated as ABSENT, so a relay handing out a key of its own
drops the session to v1 rather than to its key — the same substitution
`spk_sig` stops classically, and worth more here because it buys the
whole post-quantum half. A `proto` claim with no `caps_sig` reads as v1,
so a relay cannot advertise a v2 device as v1-only and strip the
post-quantum half off every new session. And the one-time id is signed
big-endian and inside the signature, so a renumbered key falls back to
the last-resort one instead of being spent.

Both pools are refilled together, and that is not tidiness. They drain
at the same rate — one key per new session — so a refill that topped up
only the classical pool would let the post-quantum one run dry and push
every later session onto the last-resort key, every one of them sharing
a single KEM key, with nothing anywhere reporting a downgrade.

Mixed fleets work in both directions and a spent prekey is not an error:
a duplicate delivery of a v2 prekey envelope names a one-time key this
device has already consumed, and throwing there would turn a
retransmission into a dead conversation.

Two test fixtures had to say what they are rather than answer by order.
Sending now asks for a transparency-log head on its own, so a queue
matched purely by position handed that request the device listing and
failed on a parse error nowhere near the cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 15:53:34 -04:00
Paul HittandClaude Opus 4.8 91f58956cf Check every key the relay can prove, and reach other relays
Two things this app could describe and not do.

**Key transparency was a switch almost nobody found.** `requireKeyLog`
was off by default, so the log was published, verified in tests, and
checked for approximately no one. The reason it was opt-in — a relay
predating §19 keeps no log, and refusing every peer there is worse than
the attack — argues for skipping relays that CANNOT answer, never for
skipping the ones that can. `enforceKeyLogIfServed` draws that line: ask
the head route once, enforce fully if it answers, skip if it is absent.
The switch survives as the stricter question underneath — refuse a relay
that keeps no log at all — which is a real choice for somebody who knows
their server publishes one.

Two things make that safe rather than merely automatic. The head is
asked for SEPARATELY, because `enforceKeyLog` reads three routes and a
relay wanting to skip the check would otherwise only have to 404 the
middle one. And a relay that has answered before is remembered, so it
cannot publish a log until clients are talking and then stop — logs do
not disappear, and that is a fork rather than an absence. The flag is
its own vault entry rather than the pin, because the pin can arrive
from gossip and would make a logless relay look dishonest.

Both subject forms are now accepted for a leaf. A leaf names `fnid:`
once an account has claimed an address and `local:` before, and the hash
committed to whichever was true when it was written — so a contact who
registered a device and then claimed an address has older leaves under
one form and newer ones under the other. A verifier computing a single
subject would refuse half the network on the day claiming turned on.
Either candidate still requires the relay to have appended a leaf
binding the exact key it served.

**Federation was iOS-only.** The relay has spoken it for a while and so
has iPhone; Android could neither send to an address nor read one that
arrived. Now: the three remote routes, an always-sealed send that
carries our address as the return path, and a receive side that files a
federated message under that address rather than under `from_user`,
which their relay never set. Filing by user id would drop every message
from every other relay into thread 0 — or worse, into a local contact's,
if their row numbers happened to collide.

The address keyspace is kept apart from the local one throughout, and
that is the load-bearing decision. A remote sender is pinned, bound and
threaded by `fnid@relay`; they cannot write a local delivery key or
avatar, cannot post into a group, and cannot ring a phone, because all
four are addressed by a user id that means nothing across the boundary.

Wired end to end rather than left as a capability: the add-contact field
recognises an address and offers to message it — the QR scanner already
prefilled that field and had its address looked up as a handle — and
conversations on other relays get their own section, without which a
reply is unreachable short of retyping 52 characters of base32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 15:40:28 -04:00
Paul HittandClaude Opus 4.8 0a7c112b8b Test the inbound path with real ciphertext
`poll` was at 0%. The tests that cover it — the Live* suites — are opt-in
via FN_LIVE_FLEET and skip in an ordinary run, so the single most
important path in the client contributed nothing to a normal build. That
is worse than having no test, because the file listing says otherwise.

Nothing here is stubbed below the transport: the peer is a real
DeviceIdentity, the envelopes are real ciphertext from a real ratchet, and
only the relay's HTTP is a mock. A decrypt that succeeds here succeeded
for the same reason it does in the field.

The three-chain rule gets the test its comment asks for. This used to be
`existingSession(fromDevice) ?: respond(...)`, which reads as a fallback
and is not one: with a stored session it was the only thing tried, decrypt
threw for a peer on a different chain, and the catch swallowed it. That
happens whenever both sides run X3DH before either has processed the
other's prekey — two people opening the same chat at once, which is
ordinary — so the test stores one chain and then has the peer start a
fresh one.

The C1 receive-side gate is asserted from both sides: a handshake offering
an unpinned key while claiming a pinned contact is dropped, and the same
handshake from the pinned key is accepted. Without the gate, anyone could
hand us a session with their own key and every later message would render
as that contact.

Also that one unreadable envelope does not discard the rest of the page
AND that the watermark still passes it, or a single corrupt message is
retried for ever and the inbox never drains; and that a failed poll
surfaces rather than being reported as an empty inbox, since "no new
messages" and "we could not ask" are different states.

⚠️ One test needed two distinct user ids: two identities claiming one user
is exactly what C1 refuses, so writing it with the default sender makes
the second message vanish — correctly, and confusingly.

Conversations 25.4% -> 43.0%, client 63.2% -> 68.9% lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 11:51:44 -04:00
Paul HittandClaude Opus 4.8 4d8fc5b09c Test the account routes and their error mapping
RelayAccounts was at 0% — every route a person meets before they can send
a single message, sign-up through password reset, including the error
mapping that decides what they are told when it goes wrong.

The error mapping earns as much attention as the happy path. These are
the only failures somebody sees before they have an account, and a 409
shown as "something went wrong" instead of "that name is taken" is the
difference between fixing it in five seconds and giving up. So there are
tests for the taken username keeping the relay's own words, a wrong
password arriving as Unauthorized rather than a generic failure, 429
carrying the relay's Retry-After and still being throttling without one,
a non-JSON error body surviving as readable text, and an unreachable
relay being Transport rather than a rejected password.

Two of my assertions were wrong and the code was right, which is worth
recording. Unauthorized and Unavailable deliberately keep the relay's
text in `body` and give `message` a fixed string, because the type is a
decision rather than a diagnostic — Unauthorized triggers exactly one
retry, and a caller matching on a string to decide that eventually
matches wrong. For 451 the fixed sentence is a product decision too: the
download site is not geo-gated, so somebody can read the page, install
the app, choose a password, tap sign up and be shown "relay returned
451", which mentions nothing about their country and cannot be retried
past. The test now pins that wording.

Also pins the compatibility details: an email goes in the `username`
field because that field has always meant "username or email" and a
client that only sends usernames rejects half the people who typed what
they were asked for; a self-hosted relay's 404 on the shared-session
route is the correct answer rather than a misconfiguration; and the
handle travels beside the token rather than inside it, so a relay cannot
correlate its users with another product's.

RelayAccounts 0% -> 100%, client 57.7% -> 63.2% lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:47:10 -04:00
Paul HittandClaude Opus 4.8 7f7692318f Pin the roster, notification and account decode shapes
Three separate field-name bugs are recorded in comments in MessengerApi,
and all three shipped. The block list read `user_id` where the relay
sends `id`, so the Block button did nothing — and it survived because the
list it produced was always empty, so nothing ever disagreed with it.
Notifications read `kind`/`body`/`read`, names that exist nowhere, for
months: every alert decoded as a blank unread row and the screen looked
permanently empty. The roster route does not use the `user_id` shape the
§8 routes use, and guessing otherwise threw "No value for user_id" at the
exact moment a contact was added.

None of these are hard bugs. They are all one bug: a decoder and a
producer that disagree, where disagreement yields empty rather than an
error, and empty is indistinguishable from "nothing to show yet". Feeding
the relay's real response shape catches every one in a second, and no
such test existed.

Each decode test is therefore paired with a wrong-shape test asserting
the historical mistake yields nothing, so the pairing is load-bearing
rather than decorative. The mutating routes assert method and path too —
POST vs DELETE on blocks, `buddies/7` vs `buddies` for removing one
contact against the roster's one-way door.

Also pins two defaults that are compatibility decisions rather than
conveniences: `accepted` defaults true so a relay predating the field
does not relabel every contact a stranger, and `setStatus` returns what
the relay stored rather than what we asked for, because the relay once
accepted unknown values with 200 and stored nothing.

MessengerApi 35.2% -> 87.2%, client 51.0% -> 57.7% lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:45:32 -04:00
Paul HittandClaude Opus 4.8 6f5c2a7a24 Test the scheduled half of key transparency
`verifyLogConsistencyIfDue` and `consistent` were at 0%, and
`verifyHeadSignature` at 7%. These are what make transparency act rather
than merely record: checking the log only while establishing a session
leaves an established conversation unchecked indefinitely, which is
exactly the conversation worth attacking, so a relay that rewound or
forked between two bundle fetches would go unseen — possibly for ever.

Two failure directions matter equally here and pull against each other.
A check that never fires misses a real fork. A check that treats every
network blip as a fork cries wolf until the alarm means nothing, which is
worse than no alarm. So half of these twenty-five tests assert that
something is NOT reported: an unreachable relay, a failed consistency
fetch, an unsigned head from a self-hoster who cannot sign, a malformed
signature, and a peer who is merely further ahead than we are.

The other half are the refusals: a log that shrank, the same size under a
different root, growth whose consistency proof does not close the gap, a
head signed by a key other than the pinned issuer, and a peer on an
irreconcilable branch. Also that the alarm stands — a relay that forked
once does not become trustworthy because the next request looked fine.

Proofs are computed from a real eight-leaf tree rather than stubbed, so
"the proof verifies" means the same thing here as on the wire.

Trust 73.9% -> 98.1% lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:43:37 -04:00
Paul HittandClaude Opus 4.8 a40cc2d2d4 Measure Android coverage, and round-trip the vault
JaCoCo was not configured on either JVM module, so the Android numbers
were unknown rather than good. :engine and :client are plain Kotlin/JVM
precisely so they can be measured without an emulator; now they are.
Baseline: engine 84.9% lines, client 47.3%.

Vault was the outlier in engine at 53%, and its uncovered half was almost
entirely save/load pairs — credentials, trust pins, the delivery key, the
key-log pins, the roster, avatars, per-line deletion.

That is not an arbitrary gap. It is the exact shape of a bug that has
already shipped in this app twice. `requireKeyLog` was computed, held in
memory and never written down, so a security control the UI offered
silently reverted on every relaunch. `Trust.changed` says the same thing
in its own doc comment: returned by `record()`, persisted by nothing, so
`state()` could never answer CHANGED, the "Accept new keys" button could
never render, and a contact who reinstalled became permanently
unmessageable while the header insisted nothing had changed.

Neither was hard to write. Both were invisible because a save and a load
that disagree still compile and only differ after a restart, which no
test performed. Every test here writes with one Vault and reads with a
second built over the same bytes, because that is the only arrangement in
which the two halves are forced to agree.

Includes the AAD binding the class doc promises: a trust record copied
into another contact's slot must not open, or someone with file access
could attribute a stranger's keys to someone you trust.

Vault 53.0% -> 84.9% lines, engine 84.9% -> 92.3%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:13:26 -04:00
Paul HittandClaude Opus 4.8 0eea69b023 A contact could crash the app with one image
The sending path was careful — read the bounds first, then decode sampled —
but the two places that draw an image RECEIVED from a peer called
`decodeByteArray` with no options at all, decoding at full size. Inbound
attachments are capped at 12 MiB, and 12 MiB of a suitably chosen JPEG is a few
hundred megabytes of ARGB_8888 once expanded: OutOfMemoryError.

The effect is not one crash. The decode happens every time the thread or the
story is opened, so a single message wedges that conversation permanently from
the outside, and the victim cannot get past it to delete anything.

Both render paths now go through `ImagePrep.decodeBounded`, which reads the
bounds before allocating — the same thing the send path has always done. The
story composer's own draft goes through it too: a 50MP photo is just as much
bitmap whoever picked it.

⚠️ A byte-count cap does not bound this. The 12 MiB limit was already in place
and is exactly what made the attack convenient — the constraint that matters is
the decoded dimensions, not the compressed size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 07:56:12 -04:00
Paul HittandClaude Opus 4.8 141f3168a5 Stop publishing the build machine's paths with the F-Droid repo
`fdroid update` writes a status/ directory containing update.json and
running.json, which record the absolute paths of the machine that built the
repo — home directory, username, toolchain locations — and that signing
happened there. My publish script rsynced the whole repo directory, so those
went public and were being served unauthenticated.

That is targeting data for a supply-chain attack against the laptop holding the
Android release key, which is permanent and cannot be rotated without orphaning
every install. fdroid's own deploy command excludes status/; a raw rsync does
not, and I used a raw rsync.

Removed from the live repo and excluded here so it does not come back.

Found by an audit pass, not by anything going wrong — which is the argument for
running them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 07:38:44 -04:00
Paul HittandClaude Opus 4.8 4d55c2da0a Release 0.5 — the key-log setting no longer forgets itself
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 20:04:21 -04:00
Paul HittandClaude Opus 4.8 047003a509 "Require published keys" switched itself off every restart
It was a plain in-memory field on the trust object. The switch set it, nothing
wrote it down, and the phase it was read back from defaulted to false — so
somebody who turned it on was protected until they next closed the app, and
nothing told them it had lapsed. The switch simply read off again.

A security control that quietly disarms itself is worse than one that was never
offered, because the person is relying on it.

iOS has persisted this in UserDefaults all along. Android now stores it beside
the other device preferences, restores it into the trust object at sign-in, and
seeds the phase from the same value so the switch shows what is actually being
enforced rather than disagreeing with it.

Still off by default, which is deliberate and unchanged: a relay older than the
transparency log has no log to prove anything against, and enforcing it there
would refuse every peer for a reason the user cannot fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 19:49:43 -04:00
Paul HittandClaude Opus 4.8 93674d249c Blocking someone showed an error, and could be undone by their next message
Found by finally running the banner I shipped in 0.3 instead of trusting that
it compiled.

`blocked()` read `user_id` from rows the relay emits as `{id, name, slug}` —
the buddy-list shape, not the `user_id` shape the §8 routes use. It threw
`No value for user_id`. Tapping Block worked on the server and then showed the
user an error, which on a safety action is the worst outcome available: it
succeeded and the screen said otherwise.

The reason it survived is the reason it was dangerous. The block list is empty
until somebody blocks their first person, so the map never ran and every test
passed. The first block anybody performed broke it.

🔴 And it did not stop at a message. `adoptStrangers` read the block list with
`.getOrDefault(emptyList())`, so the throw became "nobody is blocked" — and the
next message from someone you had just blocked auto-added them back to your
contacts, defeating the exact property the code comment above it claims to
hold. That now fails closed: if the block list cannot be established, nothing
is adopted and the next poll tries again. A stranger appearing a few seconds
late is cosmetic; re-adding somebody who blocked their way out of a
conversation is not.

The new test uses a NON-EMPTY list, because that is the case that was missing —
an empty-list assertion looks like coverage and exercises no parsing. Confirmed
by reintroducing the bug: the populated case fails, the empty case still
passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 17:37:28 -04:00
Paul HittandClaude Opus 4.8 84b0af1c9a Add a publish script for the F-Droid repository
Our own repo, not a submission to f-droid.org — and the difference is not
cosmetic. f-droid.org rebuilds from source and signs with their key, so an app
already installed under our signature could not be upgraded by it; Android
refuses a differently-signed APK over an existing install. This repo indexes
the APK we already signed, so everybody who sideloaded from the website
upgrades cleanly and then gets updates automatically.

The working directory holds a signing keystore and its password and lives
outside every checkout, because all four repos are public. The script refuses
to run if the keystore is missing rather than re-initialising over a live repo
— replacing the index key makes every client that has added the repository
reject its updates.

Version comes out of the APK via aapt2 rather than being typed, same rule as
the update manifest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 16:29:32 -04:00
Paul HittandClaude Opus 4.8 d350f970ef Release 0.3 — the stranger banner
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 15:51:50 -04:00
Paul HittandClaude Opus 4.8 e345d93640 Show when a thread is with someone you never added
The app adopts anyone who messages you, because a first message from a
non-contact used to be invisible — no row, no name, just an unread badge over
"Nobody here yet". That fix stays: the message still arrives and is still
readable.

What it also did was decide something for the user. Somebody ends up in your
contacts because they wrote to you, and nothing on screen distinguished that
from a person you went and looked up.

So adoption now sends `auto: true` and leaves the acceptance to the person, and
the thread carries a banner naming what happened with the two useful actions in
it. Nothing is gated: delivery is unchanged, the difference is just visible.

`accepted` defaults to true wherever it is not answered — an older relay says
nothing, and reading silence as "stranger" would label every contact on every
older relay.

The copy states what happened rather than warning about it. Most first messages
are from someone you gave your address to, and opening with an alarm would be
wrong more often than right.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 15:47:26 -04:00
Paul HittandClaude Opus 4.8 31551f60f2 Tell a sideloaded app when a newer build exists
Nothing updates this app on its own. It is downloaded as an APK from a web
page, so there is no store to notice a release and no background updater to
install one — a person can run a build from months ago indefinitely, through
every fix that came after it, and never find out. That is the part of
sideloading that is genuinely worse than a store, and this is the only thing
that closes it.

The background check asks the RELAY the install is signed in to, not a fixed
address of ours. Pointing every install at flamenetmessenger.com would mean an
app whose whole claim is that it does not phone home checking in with us on a
timer, from every device, including the self-hosted ones that deliberately
never contact us at all. A relay that publishes no manifest simply produces no
prompt.

"Check for updates" in Account is the other half, and the reason the fallback
to our site exists: pressing it is a question the user asked, so answering
"your relay does not publish updates" when we plainly could is unhelpful. It
tries the relay first and names whichever host answered, so it is never a
silent phone-home. It also distinguishes "no newer build" from "could not
check" — reporting silence as good news is how somebody sits on a build with a
fix in it believing otherwise.

The download URL is built from the host that answered and never read from the
manifest. A `url` field would let any relay send its users to an APK on a host
of its choosing; Android's signature check is a backstop, not a reason to build
the hole in front of it. A test asserts a hostile manifest cannot move it, and
fails if the guard is removed.

versionCode is bumped and `scripts/release-manifest.sh` reads it back out of
the built APK, so the manifest and the artifact cannot drift.

⚠️ Nobody already installed sees this: every build before this one has no
check in it, so the first prompt anybody gets is for the build after this one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 15:02:59 -04:00
Paul HittandClaude Opus 4.8 0e89003cc0 The chat took half the screen, and the status control was unfindable
**Three panels became a menu.** Disappearing messages, snooze, remind-me and
send-later were four blocks of chips stacked above the messages — in a
conversation you had opened to read, they took half the screen. The controls are
behind ⋯ now.

🔴 **What stays on the thread is the STATE, in one line.** A timer both people
are deleting on, a snooze, a pending reminder, a queued message — those are facts
about the conversation, and a fact you must open a menu to discover is one you
will not rely on. Absent entirely when nothing is set, so an ordinary chat
carries no chrome at all.

⚠️ "Send later" only appears when the composer has something in it. Offered
against an empty box it is a control whose only possible effect is nothing.

**Status was at the bottom of Account, under the address card, as four
unlabelled chips.** It is directly under the profile picture now, with a heading
and a line saying what each state does to other people. The person who asked for
the feature could not find it, which is the same as it not being there.

🔴 **And it showed a guess.** The state defaulted to "online" and nothing ever
read the real value, so the screen showed the default rather than what contacts
could see. It loads from the relay now — an empty body on that route is a read,
so no new endpoint was needed.

engine 177, client 96, app 46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:22:19 -04:00
Paul HittandClaude Opus 4.8 96b43f4e43 The secret sweep could not see a new file
`git ls-files` lists only TRACKED files. A brand-new one — which is exactly what
a dropped credential looks like — was invisible to this gate, while `git add -A`
would stage it happily. A planted `sk_live_…` in a new file passed.

It now scans `--cached --others --exclude-standard`: the same set `git add -A`
would stage.

🔴 **My earlier "verified it still fails on a planted key" was testing the wrong
thing.** That probe was `git add -N`'d first, which made it tracked, so it
exercised the path that already worked and reported confidence in the path that
did not. A verification that quietly changes the conditions is worse than none —
it is why this survived being checked.

Also allowlists the two VoIP test fixtures (`alert-token`, `voip-token`) by
literal value. A gate that is always red is a gate everybody learns to ignore,
which is the failure this one was one commit away from.

Verified in all four repositories: an untracked planted key now fails the gate,
and all four are clean without it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:12:16 -04:00
Paul HittandClaude Opus 4.8 4441400cd2 Actually let you attach a live stream to a story
Same gap as iOS: §6.13 shipped the wire format, the model, and the viewer's
consent gate, with no way to enter a URL. Adds the field to the composer, with
the disclosure said when attaching rather than only when watching.

⚠️ Post is disabled for a URL that could not be disclosed — a destination with
no host cannot be named, and a viewer cannot consent to one they cannot be
shown.

⚠️ The poster's own copy carries the stream too, so the story does not look
different to the person who sent it than to everyone who receives it.

engine 177, client 96, app 46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:10:25 -04:00
Paul HittandClaude Opus 4.8 1bbf8b460b Embedded live streams in stories, behind a disclosure (§6.13)
Every other media path here is a sealed blob — the relay stores ciphertext and
nobody else is involved. A live stream is not that: it is continuous and hosted
elsewhere, so there is nothing to seal, and watching one means this device
connects to that host, which learns the viewer's IP address and that they
watched.

🔴 **§6.12's answer does not transfer.** A link preview works because the sender
fetches it once and puts the result inside the ciphertext. A stream cannot be
fetched once. So this is disclosure rather than prevention.

The URL rides inside the ciphertext, so the relay learns nothing about what is
watched or by whom. Playback is an explicit tap with the host named and what it
learns spelled out — a card that started playing on its own would make that
disclosure after the fact, which is not a disclosure.

⚠️ The attachment is the POSTER, not the stream. Keeping a real blob means the
strip, the unseen rings and the expiry sweep all work unchanged, and the viewer
sees a still before deciding whether to connect.

⚠️ The host is derived from the URL, never the caption — a story captioned
"youtube.com" pointing elsewhere is the phishing shape link previews already
guard against. Non-http(s) URLs and ones with no host are refused: a `file:` URL
arriving from another person is not something to hand a media player, and a
destination nobody can name is one nobody can consent to.

The wording lives in the engine rather than the UI so it can be asserted.

engine 177, client 96, app 46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 13:14:08 -04:00
Paul HittandClaude Opus 4.8 b8ffe2ea71 Answer a call without opening the app first
A high-priority notification is a banner, not a ringing phone. On a locked
device the old one showed a line of text, and the only way to act was to unlock,
find the app and open it — by which time the caller has given up. A call is a
live event with seconds on the clock.

Now a full-screen intent with `CallStyle`, so the lock screen shows Answer and
Decline. ⚠️ `CATEGORY_CALL` and `CallStyle` are not decoration: Android 14
grants USE_FULL_SCREEN_INTENT at install only to apps that ring like this, and
without them the permission is refused and the call silently degrades to the
banner it used to be.

⚠️ Decline is a broadcast, not an activity. The point of answering from a lock
screen is that the phone stays locked for the choice the user did NOT make.

🔴 **The notification is `ongoing`**, so it cannot be swiped away by accident
mid-ring — which also means it never dismisses itself, and every path that ends
a call has to cancel it. Four of them: declined, answered, the caller hung up,
and nobody picked up. The last has no event behind it, so the service checks for
a parked offer past the dial timeout; without it the lock screen keeps offering
to answer a call that is long dead.

🔴 `isStale()` asks without consuming. `take()` is the handover and can only
happen once, so a staleness check built on it would swallow a live offer and
drop a call that was still ringing — the exact opposite of the bug it exists to
prevent. There is a test for that specifically.

Answering hands off through the SAME parked offer the app already uses on
launch. This session consumed the envelope, so the SDP in hand is the only copy
— a second answer path would be a second way to lose a call.

engine 168, client 96, app 46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 13:05:51 -04:00
Paul HittandClaude Opus 4.8 dc1be35816 Link previews the recipient never fetches (§6.12)
🔴 **The obvious implementation is the one that must not be built.** Having each
recipient fetch the URL to draw a card tells the site the reader's IP address,
that they received a link, and roughly when they opened it — once per member on
a group message. A messenger that hides the message and then has every reader
announce its contents to a third party has given away what it was protecting.

So the SENDER fetches, and the preview travels inside the ciphertext. The
receiver renders from those bytes and contacts nobody.

⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is about
to share that URL, from this address. The setting exists for that, and the copy
says who learns what rather than implying it is free.

🔴 **The host is shown separately and derived from the URL**, because title and
description come from a page the sender did not write — a card claiming one
destination while the link points at another is a phishing primitive. Both
fields are bounded on the way IN as well as line-limited at render, so the cap
does not depend on a future renderer remembering one.

⚠️ `Content.text` still returns the raw body byte-for-byte when there is no
preview, so an ordinary message is unchanged on the wire and older clients see
exactly what they saw before.

⚠️ The fetch is short-timeout and best-effort, and runs on the IO context. A
preview is a decoration and must never be able to delay a send — every failure
path ends as a message with a plain URL in it.

engine 168, client 96, app 40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:45:09 -04:00
Paul HittandClaude Opus 4.8 7f4fa1bf2b Scheduled send, held on this phone rather than at the relay
The relay could hold a scheduled message and deliver it on time even with the
phone off. It would then know that a message exists, who it is for, and exactly
when it was meant to land — a richer piece of metadata than anything else this
design gives it, in exchange for a convenience. So it waits here, and the banner
states the cost rather than hiding it: "when the app next runs".

🔴 **"Not due yet" means KEEP, never fail.** The outbox loop is also the retry
queue, so a scheduled entry falling through the failure path would be dropped
outright and the message would simply never exist. That predicate is the whole
of the risk, so it has tests of its own — including the boundary, where an
off-by-one delays delivery by a cycle.

⚠️ `not_before` is absent on entries queued before scheduling existed, and they
send immediately rather than being held for a date they have no value for.

⚠️ Scheduling clears the composer, like an ordinary send. Text left sitting
there afterwards is how somebody sends the same message twice.

Also adds the real `org.json` to the app module's unit tests. The Android
framework's version is STUBBED there — every method returns null — so a test
that builds a JSONObject fails with a NullPointerException that says nothing
about why. The engine and client modules already pull it in for exactly this.

engine 163, client 96, app 40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:32:16 -04:00
Paul HittandClaude Opus 4.8 3f0dfd669c The triage pair: snooze, and "remind me if no reply"
Two halves of the same problem — a conversation you are not ready for, and one
you are waiting on — and both are local to this device on purpose. "I am
ignoring this person until Tuesday" and "remind me if they have not answered"
are statements about a relationship, and the relay has no business holding
either. The honest cost is that neither follows you to a new phone; the
alternative is handing a server the list of people you are avoiding.

🔴 **A reply arriving cancels the reminder, and it happens in `Session.record`**
— the one place every inbound message passes through. Hanging it off a screen or
a poll handler means the one path that forgot to call it produces a reminder
that fires after they answered, and that is the single thing that would teach
people to ignore reminders.

The service fires due reminders from its existing loop, deliberately AFTER
`receive()`: a reply that has just landed cancels its own reminder before it can
go off. Checking first would nudge about a conversation answered a moment ago.
Cleared as it fires, too — a reminder is one-shot, and leaving the row would
re-notify every half second for ever.

⚠️ Snoozes are recomputed, never cached as a flag. A snooze expires by the clock
passing and nothing runs at that moment, so a stored boolean leaves a
conversation muted past its own deadline.

⚠️ Same privacy rule as message notifications: a count, never a name. Who you
are waiting on is as sensitive as what they said.

engine 163, client 96, app 35.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:18:21 -04:00
Paul HittandClaude Opus 4.8 020eba2228 Android was sending the original photo file
The picker read the file and handed the ORIGINAL bytes to `sendImage` — no
downscale, no re-encode, nothing. A modern phone camera produces 12-50 MP and
often 5-15 MB of JPEG, so a photo either exceeded the relay's blob cap and
simply failed to send, or fit and made the recipient download tens of megabytes
nobody asked for. iOS has downscaled since it shipped, so the two platforms
disagreed about what sending a photo even means.

Adds `ImagePrep`, matching the iOS one: 2048px at quality 80 by default, 4096 at
95 with the new "Send full quality" switch, stepping down through size and
quality until the bytes are under budget. The budget sits under the server's cap
because the blob is encrypted before upload and the AEAD tag rides on top —
sizing to the exact limit produces a file that passes locally and is refused
remotely.

⚠️ Bounds are read with `inJustDecodeBounds` and the decode uses `inSampleSize`,
so the full-size bitmap never exists. Decoding a 50MP JPEG outright is ~200 MB
of bitmap, which is how a photo picker gets OOM-killed.

⚠️ "Full quality" is not "uncompressed", and the copy says so. A setting that
silently failed on a large photo would be a worse answer than the compression it
replaced.

engine 155, client 96, app 35.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:10:59 -04:00
Paul HittandClaude Opus 4.8 2481af6cc8 The device list was rendering its own source code
`"Registered ${'$'}{fmt.format(instant)}"` — `${'$'}` emits a LITERAL dollar
sign, so the template was never evaluated and every device row read

    Registered ${fmt.format(instant)}

Kotlin cannot warn about this: escaping a dollar is a legitimate thing to want,
just never here. And nothing could test it, because the string was assembled
inline inside a `Text(...)`.

Lifted into `registeredLine` so it can be asserted, with a test that fails if
template source ever reaches the screen again. That is the real fix — the
one-character correction would have left the next one just as invisible.

engine 155, client 96, app 35.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:06:45 -04:00
Paul HittandClaude Opus 4.8 db7d05bd98 Say "not available in your country" instead of "relay returned 451"
The edge geo-gate serves US, PE and BO for the relay and accounts hosts, and
answers 451 for anywhere else — before the request reaches the relay. Nothing in
either client knew what 451 meant, so it fell through to the generic branch.

⚠️ **The download site is deliberately not gated**, which makes this worse than
a bad error string. Someone outside those countries can read the page, install
the app, choose a password, tap sign up, and be told "relay returned 451".
Nothing in that mentions their country, and it is not a failure they can retry
past, so the only sensible reading is that the app is broken.

451 is the right status — this is a legal/policy refusal, not a 403 — so the
only thing missing was somebody translating it.

client 96.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:39:37 -04:00
Paul HittandClaude Opus 4.8 f9f063a211 Disappearing messages, symmetric and from send time (§6.11)
The relay has had per-conversation lifetimes since the port and **no client
could set one** — its own comment says so. For E2E conversations the server
timer would not have helped anyway: it expires rows in the legacy plaintext
table, not the envelopes these apps actually exchange. So this is client-side,
which is also the only place it can be honest.

**Symmetric.** Either party sets the timer; the other is told inside the ratchet
and adopts it. A timer only the setter honoured would be worse than none — they
would believe their words were gone from a phone still holding them.

**Inside the ratchet**, so the relay never learns that a conversation is timed,
let alone for how long. It rides as ordinary control traffic carrying the `ctl`
flag from §6.10, which is what makes it safe to add at all: without that, this
envelope would surface in every older install as an "[unsupported attachment]"
bubble.

**Expiry runs on read**, in `Vault.history`. A sweep on a timer leaves expired
messages on disk between runs, and a disappearing message still sitting in the
vault is a claim rather than a feature. Reading is the one path every caller
shares, so nothing can see — or keep — an expired message.

⚠️ **Ages come from this device's own timestamp**, not one the peer supplies.
"From send time" in practice means when this device sent or received it, which
differs by seconds and cannot be manipulated: a peer who chose the clock could
backdate a message so it vanished from your phone immediately, or forward-date
one so it never expired.

⚠️ **A message with no timestamp is kept.** Those predate the field, and an
unknown age is not evidence of an old one — deleting somebody's history on a
guess cannot be undone.

The panel sits on the thread rather than in a menu, because "fewer taps to turn
it on" and "clearer per-conversation state" are the same problem: a timer nobody
can see is one nobody trusts. Presets only — arbitrary durations are the ones
nobody can reason about later.

engine 155, client 94, app 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:23:04 -04:00
Paul HittandClaude Opus 4.8 c9e386ec0d Consume control kinds this build has never heard of (§6.10)
`isRenderable` asked `kind !in Kind.CONTROL` — an allowlist of the four control
kinds that existed when it was written. A control kind from a newer peer is in
no set here, so it fell through and rendered as an "[unsupported attachment]"
bubble: a message the user can neither read nor act on, for something addressed
to a layer rather than a person.

Every control kind added from here would have shipped that to every older
install. `ctl: 1` is the flag that stops it; `isControl` trusts the flag as well
as the list, since the four that predate it do not carry it and never will.

⚠️ `needsNewerClient` is deliberately NOT part of `isRenderable`. Unknown
*content* already parses to a placeholder and is shown, which is right — the
first cut of this change suppressed it and an existing test caught that
immediately. Dropping a real message silently is the one failure a messenger
cannot make, so the flag decides rather than the unknown-ness.

engine 144, client 90, app 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:07:32 -04:00
Paul HittandClaude Opus 4.8 cc8f4ca1d0 Show the presence the relay stored, not the one we asked for
`setPresence` did `it.copy(presence = status)` on success — the value it had
requested. The relay answered 200 for statuses it did not recognise and stored
nothing, and `busy` and `offline` were both in that set, so tapping either lit
the chip and changed nothing. Someone who chose "offline" stayed visible to
every contact with no way to find out.

The relay is fixed too, but a client that reports its own request back to itself
is wrong regardless of how the server behaves — so `setStatus` now returns what
the relay says the status is, and the screen renders that.

⚠️ The reply is not always the word sent: `offline` is stored as `invisible`,
the same choice under two names, so it is mapped back to this screen's
vocabulary or no chip would appear selected right after a successful change.

`PRESENCE_CHOICES` moves to the companion object so the chips and the test read
one list. It is a contract with another repository that no compiler checks, and
the test says so: every entry must be a value the relay's `ChosenStatus`
accepts.

engine 141, client 90, app 32 — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:00:06 -04:00
Paul HittandClaude Opus 4.8 076e85d756 Actually send the key-log head we gossip about (§19.4)
`Trust.headPayload` built the gossip envelope and nothing called it. The
receiving half was fully wired — `consumeControl` handles `kt_head`, compares
against our pin, requests a consistency proof and sets `forkDetected` — so
every part of transparency reported working while the mechanism was inert.

That is the failure mode that looks like success. §19.4 detects a forked relay
only when two clients COMPARE heads, and a client that never offers one is a
client no fork can be detected against. Each side independently confirmed the
relay was consistent *with itself*, which is exactly what a relay serving two
self-consistent branches wants. Only the JS reference sent it, so gossip
worked web→app and in no other direction; iOS had the identical dead function
and is fixed in the same pass.

Sends the head once per contact per session as a second prelude alongside the
delivery key, in memory rather than in the vault to match the JS reference: a
device that pins a new head after a restart has to be able to mention it.
Marked before the send, like the delivery-key prelude and for the reason
documented there — a repeat costs one envelope, a crash between sending and
marking repeats it for ever.

GossipHeadSentTest drives the real send path against MockWebServer and asserts
the head goes, that it rides the ratchet rather than the envelope, and that it
is not attached to every message. Verified it fails when the wiring is removed
— three of its four tests go red — because a test written after the fact
otherwise proves only that it compiles.

⚠️ The "not posted in the clear" assertion initially passed against an EMPTY
list: the request-path filter was wrong, and a loop over nothing satisfies
every claim inside it. It now asserts the count first.

engine 141, client 90, app 28 — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:59:46 -04:00
Paul HittandClaude Opus 4.8 bd2cdf0394 Add CONTRIBUTING, and point the vectors at the repository that owns them
The README said the engine is held to `engine/test/vectors/protocol-v1.json`,
a path from the pre-split subtree layout that does not exist here. The vectors
live in flamenet-e2e and are read from there rather than copied, because a
copied vector file drifts and a drifting vector file stops being a check.
PublishedVectorsTest already searches for it and fails loudly when it cannot
find it; the README now says so, and names FLAMENET_VECTORS.

CONTRIBUTING records what a JVM test cannot tell you: Conscrypt and the JVM's
JCA are different providers, so :engine:test passing proves nothing about a
handset, which is what interop/ is for. Also the exception escaping a WebRTC
callback that SIGABRTs rather than surfacing, the missing permission that
presented as a dead button, and `*/` inside a KDoc reporting an unclosed
comment hundreds of lines from its cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:45:04 -04:00
Paul HittandClaude Opus 4.8 efd5dee189 Avatars merge instead of replacing on refresh
Same rule as iOS: the live refresh replaced the whole map, so a refresh
while the buddy list was briefly empty produced an empty map that blanked
every picture. Merge, and ignore an empty result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:34:10 -04:00
Paul HittandClaude Opus 4.8 251a9f9c4a Multi-device fan-out: merge the directory with fetched bundles, never choose
listDevices returns identity-only stubs — enough for a device we already
have a session with, useless for one we do not, because initiate()
verifies the signed prekey's signature and a stub carries empty bytes.
Choosing between the two lists meant that whenever ANY device already had
a session, every device got a stub, so a device still needing one could
drop out of the fan-out with no partial-delivery warning (the other
device succeeded, so  was non-empty).

The rule is union: full bundle where the key route returned one, stub
otherwise. Found while verifying a two-device pair by hand; the live
suite's 'a message reaches every device a person has' passes either way,
which is why it needed the hand test to surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:29:55 -04:00
Paul HittandClaude Opus 4.8 130edd7019 Audit LOW: empty offer, attachment id, QR binding, skipped-key order
Same empty-offer and attachment-id rules as iOS. The QR scanner now
binds a scanned code by its fnid rather than the handle even on the home
relay — the fnid certifies itself, a handle is a name the relay maps,
so resolving by handle handed the relay the choice of who you scanned.
And a skipped message key is consumed only after its tag verifies: it
was defused today by the caller discarding the session, but any future
in-memory ratchet cache would have turned it into real message loss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:13:25 -04:00
Paul HittandClaude Opus 4.8 cbe2292d3c Audit H3: the safety number covers the post-quantum identity too
Mirror of iOS, byte-identical by construction: both keys folded under
one label when both sides have a PQ identity, classical pairs unchanged,
and the exact digits pinned against an independent implementation so the
two platforms cannot drift into showing different numbers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:02:09 -04:00
Paul HittandClaude Opus 4.8 b0c5a108b2 Audit M1: verify the signed log head against a pinned issuer key
The engine had no head-signature function at all and Trust.head() threw
the signature away, so a fork was an accusation rather than evidence.
KeyLog.verifyHead now checks the same string the relay signs, the issuer
key is pinned in the vault on first sight, and a head that fails under
the pin raises the standing fork alarm. Four tests pin the string and
the refusals — including a head signed by a different key, which is the
substitution the pin exists to stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:57:44 -04:00
Paul HittandClaude Opus 4.8 e435cd710d Audit M5: cap the attachment download at its declared size
Header and read both bounded — contentLength is -1 on a chunked reply,
so trusting it alone would leave the hole open. Same reasoning as iOS:
the size came through the ratchet, so the relay cannot inflate it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:44:25 -04:00
Paul HittandClaude Opus 4.8 d1b0acdda6 Audit H1: a group message is filed only if BOTH ends are members
Filing on the sender's word let a contact conjure a group thread we are
not in, and let anyone sharing a session with us — including a member we
removed — post into a group they had left. The relay's roster decides,
cached briefly so a poll pass carrying many group messages costs one
round trip rather than one each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:35:32 -04:00
Paul HittandClaude Opus 4.8 4b4126707f Audit C1+C2: gate inbound sessions on the pin, and bind device to user
C1 (Android-only, critical): building a session from a prekey envelope
accepted whatever identity key it offered — no pin check, where iOS has
always had one. Anyone could hand us a handshake with their own key
while claiming to be a pinned contact, and every later message from that
session rendered as that contact. The same gate the send path uses now
runs on receive.

C2 (both platforms): a device binds first-use-wins to its user, the
claim is checked against that binding on every decrypt, and control
traffic (delivery key, avatar) is refused from an unproven sender — a
poisoned delivery key forces the next send UNSEALED and deanonymizes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:32:49 -04:00
Paul HittandClaude Opus 4.8 1799ada15b Delete individual messages via long-press; leave-group already shipped
Long-press any bubble for Copy and Delete for me (Unsend on your own),
in 1:1 and group threads. Vault.deleteLine rewrites the thread minus the
line, keyed by timestamp+side+text — messages carry no stable id here.
Leaving a group was already wired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:07:21 -04:00
Paul HittandClaude Opus 4.8 68329bcf0f S4: transparency that acts — schedule the check, name the change
Mirror of iOS: the poll loop runs a throttled, best-effort key-log
consistency sweep so a fork between bundle fetches is caught on a live
conversation; and KeyChangeDescription turns a log entry into a named,
dated sentence for the changed-keys warning. Both pure pieces are
unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:01:33 -04:00
Paul HittandClaude Opus 4.8 6baaf1315a T4+S3: CI on the Mac, and a build a stranger can verify
The unit suites run on the project's own runner before main moves. And
scripts/verify-build.py proves what two clean builds already showed:
every byte of the APK's contents reproduces from this source — only the
signing block differs, because signatures are randomized. Rebuild,
compare contents, check the certificate: that is the whole trust chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 07:36:09 -04:00
Paul HittandClaude Opus 4.8 62356139dc P1: the contact list can live only on your devices
Session.roster() reads the vault once the list is off-server, mirrors
the relay's copy into the vault while one exists, and falls back to the
sealed copy when the network fails. The roster rides the history backup.
Settings gains the one-way door with the costs stated where the button
is: presence goes dark, and a new device restores contacts from the
encrypted backup rather than the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 06:33:22 -04:00
Paul HittandClaude Opus 4.8 2a5970309e Badges, video stories, and parental controls
Launcher badges on the message notification (the service notice
explicitly never badges). Stories accept picked videos — validated
against a 30s/9MB cap rather than transcoded, said honestly when
refused — and play looping in the card via the framework player. The
wire's dur field travels for any attachment that has one.

Parental controls mirror iOS: a parent's PIN guards adding contacts,
stories, and calls; incoming calls answer busy; sign-up gains an
on-device age gate that stores nothing. The server learns nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 21:36:43 -04:00
Paul HittandClaude Opus 4.8 53ba8ea42e One MainActivity, ever: a stacked instance made hang-up a no-op
The default launch mode stacked a second MainActivity — with a second
ViewModel, session and CallManager — whenever the app was re-entered.
The visible screen's Hang up then commanded a manager with no call while
the real call rang on underneath, and the peer never got the END.
singleTask, plus a 30s connect guard so an answered call whose ICE never
finds a path gives up out loud on both screens instead of sitting in
Connecting forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 21:11:51 -04:00
Paul HittandClaude Opus 4.8 6a0f642e0d D4: history the user can carry — encrypted export and import
Same design as iOS: the vault's history re-sealed under a passphrase-
derived key (PBKDF2 600k rounds, AES-256-GCM), saved through SAF, merged
back by content identity so the same backup twice adds nothing. Expired
stories stay expired — a backup is not a way back in. The passphrase is
stored nowhere, deliberately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 20:29:22 -04:00
Paul HittandClaude Opus 4.8 f1122777c1 A proxy's 404 is not the relay's answer: un-latch token minting on 401
Same fix as iOS: RelayAuth latched 'no token endpoint' off one 404, and
an outage's proxy-level 404s wedged the client into tokenless requests.
invalidate() now un-latches — the 401 is proof the conclusion was wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 18:41:56 -04:00
Paul HittandClaude Fable 5 3ab8e6c641 A revoked device wipes itself
Mirror of the iOS change: a 410 from the poll cancels polling, destroys
the vault (Android's sign-out is already a full wipe), and lands on the
sign-in screen with a sentence that says what happened and what signing
in again means.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:52:07 -04:00
Paul HittandClaude Fable 5 b6557d6409 Re-tell the delivery key when a told peer writes unsealed
Mirror of the iOS change: the poll path un-marks a toldPeer whose
message arrived unsealed — their copy of our key is gone, and the next
send re-tells it. Same staleness class, and same cure, as the avatar
marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:44:05 -04:00
Paul HittandClaude Fable 5 4e6aedf5f0 429 copy uses the relay's Retry-After
RelayException.RateLimited has parsed the header all along; the relay
never sent it until today. The message now says "Try again in about
N minutes" when the clock is known, with the old copy as fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:37:32 -04:00
Paul HittandClaude Fable 5 7885f55849 Registered devices card on Account — list, date, and remove
Same contract as iOS: the relay's roster for this account, oldest
first, the current install marked and not removable from itself (that
is Sign out), removal behind an in-place "Really remove" confirmation
per this screen's house style. RelayClient grows revokeDevice
(DELETE /e2e/devices/{id}); Session surfaces ownDevices()/revokeDevice
and parses the directory's new created_at.

The relay caps accounts at three devices, so this card is the only way
off the cap when registration refuses a fourth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:05:12 -04:00
Paul HittandClaude Fable 5 8ca63c4fa1 Open a chat at the newest message
The size-keyed follow effect animated from the very top on first
composition — a tour of the history before landing. A Unit-keyed instant
jump runs first; the animated follow keeps handling arrivals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:23:51 -04:00
Paul HittandClaude Fable 5 3b6bdb43a3 Group authors, avatars everywhere, QR scan/share, and iOS-parity Messages bar
- Session.Line carries `author` (record always persisted it; the read
  side never surfaced it) — group bubbles now wear the WRITER's name and
  picture instead of the group's initial for every message.
- Story cards use the author's real picture (your own from myAvatar).
- avatarShared marker stamps the peer's device set, not just the blob
  id: a bare marker outlived their reinstall and they saw initials for
  ever. Old markers mismatch once and self-heal with one resend.
- QR: AddressCard embeds ?u=<handle> so a same-relay scan resolves
  through the ordinary username lookup, and the Home + Messages bars
  gain a scanner — WebRTC's own Camera2 capture rendered to a
  SurfaceViewRenderer with each frame's Y plane offered to zxing:core
  (the one new dependency; decode only, no second camera framework).
  parseScannedAddress mirrors iOS ContactCard.decode's refusals.
- Messages screen matches iOS: bare title with scan + add-contact
  actions, New group beside the Groups heading (GroupsSection's own
  label, always drawn now, carries it), New chat beside CHATS with a
  contact picker, and the duplicated GROUPS label is gone.
- Story composer gains a per-story audience: Everyone / Only… /
  All except… chips with a member checklist, seeded from the account
  setting, never written back to it. postStory takes the override.
- AddContactForm accepts a prefill so a scan lands in the field —
  looked up, then added, never auto-added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:14:08 -04:00
Paul HittandClaude Opus 4.8 91ab857107 IME Send action on the chat composer — enter sends
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:35:01 -04:00
Paul HittandClaude Opus 4.8 4d77e21cf9 Messages tab: Chats/Groups section headings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:28:55 -04:00
Paul HittandClaude Opus 4.8 31548674de calls badge clears on view; outbox tests track the directory-first send
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:21:03 -04:00
Paul HittandClaude Opus 4.8 7727cfa432 send path uses the device directory, not the prekey route — every send burned bundle quota and popped OPKs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:18:26 -04:00
Paul HittandClaude Opus 4.8 cf3f43fdbf video calls: capture/render/signal + video buttons; story card sizing; Alerts off the nav; avatar live refresh; sealed-key eviction
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:11:52 -04:00
Paul HittandClaude Opus 4.8 e7a3b60b32 checkpoint: calls/first-contact/stories fixes + Home/Messages redesign (unverified)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 14:58:43 -04:00
Paul HittandClaude Opus 5 59fa88c2ad Group sends keep the outbox promise, with a real completion rule
"Undelivered - will send" on a group bubble was a lie: group text and group
stickers recorded PENDING when members were unreachable and nothing queued
them. The 1:1 outbox could not be stretched over it - a group send fails per
member - so the entry grew the thread it restates, and the design questions got
answers instead of a shrug:

WHO is queued: exactly the members nothing reached. Group text reads them out
of the failure map, where the no-device guard files them as "user:<id>"; the
sticker loop sees per-member results directly. A member whose devices took the
message is NOT queued - resending to them would duplicate it.

WHERE it restates: the group thread, carried on the entry. Restating
threadForUser(member) would touch a 1:1 thread that may not even exist.

WHEN the bubble flips: only when the LAST queued member is reached. Flipping on
the first success would mark the message delivered while other members are
still owed it - "will send" would quietly stop being true for exactly the
people it was still true for. Until the set drains, PENDING remains the honest
label, because the queue genuinely will keep sending.

The test walks the whole rule: two members away, one returns (bubble still
PENDING, one entry left), the second returns (bubble DELIVERED, group thread
redrawn, the prose body not replaced by the wire form).

⚠️ iOS sends to groups too (sendGroupText/sendGroupImage) and still has no
group outbox - it stays on the roadmap with this as the reference
implementation.

Engine, client and app suites green; seven outbox tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:21:07 -04:00
Paul HittandClaude Opus 5 09a4fb90fc Emoji picker gains recents and grouping, matching iOS
This was a flat grid while iOS kept a recents row and grouped the set the way
people think about them - a gap found when a duplicate iOS picker nearly
shadowed the better one. Two platforms offering the same emoji through
different furniture is the drift nobody files a bug about and everybody
notices.

Mechanics mirror the iOS file: the last twelve picked, most recent first, in
ordinary SharedPreferences - which emoji somebody reaches for is not a secret
worth a vault entry, and the vault cannot be read before sign-in anyway. The
set and its grouping are byte-identical to iOS's, with a note in both files to
change them in the same sitting. Test tags are scoped by section, as on iOS,
because the same emoji legitimately appears in Recent and in its own group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:57:01 -04:00
Paul HittandClaude Opus 5 a68c9212d9 Queue undelivered stickers - the bubble already promised it
A sticker to somebody signed out was marked "Undelivered - will send" and then
never entered the outbox, so the label was a lie for exactly one content kind.
iOS got this fix earlier via a wire field on its outbox entry; Android's queue
turned out not to need one - conversations.send transmits its argument as the
ratchet plaintext verbatim, and a sticker's wire IS its plaintext, so the
existing replay path delivers both kinds once the entry stores the encoded
envelope instead of prose.

OutboxTest gains the sticker version of the end-to-end promise: queued while
they are away, delivered AS A STICKER once a device exists (a real second
identity, not a fake bundle), and the bubble restated without turning into
text. Six outbox tests green; engine, client and app suites green.

⚠️ The same lie still stands for GROUP sends, text and sticker both: they
record PENDING when nobody was reachable and nothing queues them. That needs a
thread-aware outbox (per-member retry, a completion rule, restating the group
thread) - designed work, not a patch, and it is on the roadmap rather than
half-built here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:55:29 -04:00
Paul HittandClaude Opus 5 6a26668d72 Port the README rewrite the split raced past
The third of three: this README was rewritten upstream after the split was cut,
so the standalone repository still opened with 'Nothing here is an app yet'
above the finished app - the exact staleness the rewrite existed to fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:27:09 -04:00
Paul HittandClaude Opus 5 d6beeafa2a Fetch the published vectors in CI - the split removed the sibling they lived in
The engine job failed on its first real run: PublishedVectorsTest and
AccountKdfTest search sibling checkouts for the canonical vector files, and the
standalone repository has no ../../engine and no flamenet-e2e clone beside it
on a runner. Both tests already honour an env override, so CI fetches both
files from flamenet-e2e at HEAD and points the tests at them.

Deliberately NOT a copied vector file in this repo: a copy drifts, and an
engine tested against its own copy has quietly stopped being tested against
the published ones. The job now fails if flamenet-e2e is unreachable, which is
the correct failure - an engine that cannot see the vectors must not report
green.

Verified by reproducing CI locally: standalone checkout, env vars only,
:engine:test passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:23:53 -04:00
Paul HittandClaude Opus 5 de33ff450e Run CI on the vps runner, not the exhausted shared minutes
Every job failed with ci_quota_exceeded on first run. GitLab.com shared minutes
are spent and a public project does not get them free on this plan. The ovh-
runner already serving the iOS repository was not locked to it, so it now serves
all four projects.

⚠️ It is registered run_untagged=false, so a job WITHOUT the tag is never picked
up - it sits pending forever rather than failing, which is the more confusing of
the two outcomes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:11:21 -04:00
Paul Hitt 0d274b2c1c Merge remote-tracking branch 'origin/main' 2026-08-28 07:58:36 -04:00
Paul HittandClaude Opus 5 3c7d9f940a CI for all four repos, and unblock the gate that was stopping publication
🔴 The find: the secret sweep was failing closed on false positives, so the
mirror job refused to publish. flamenet-e2e has been stale since 2026-08-21 and
flamenet-relay since 08-25, and this is why. A gate that blocks every release on
nothing is a gate somebody switches off, and then it is not a gate.

Two exclusions, neither weakening the shape probes. Interpolated values -
`let token = "\(selector).\(secret)"` builds a string from variables and has no
literal to leak. And `sweep:allow`, an explicit annotation for genuine
exceptions, deliberately visible so an exception is written on the line it
applies to rather than hidden in the sweep script.

⚠️ It must be on the SAME line as the match. I put one on the line above first
and it silently did nothing - documented, because the next person will do the
same.

The test fixture that tripped it sixteen times is now one named constant, which
is better code regardless: one place to change instead of sixteen.

Pipelines for the three mirrored repos, each of which is a mirror and therefore
needs its own - a green pipeline upstream proves the code passes THERE, next to
siblings it does not ship with, and says nothing about whether what landed is
complete. Each has a `complete` job asserting the shape of the tree, because
what a mirror gets wrong is missing files, not failing logic: a test that cannot
import a module is a test that does not run, and a partial copy passes
everything.

And the mirror job whose absence let flamenet-messenger-android rot. It was a
hand-made extract carrying only :engine, from before :client, :app and :interop
existed; its settings.gradle.kts still declared one module while this tree
declared four. Its `complete` job now fails on exactly that drift.

Upstream also runs :client:test, which was never in CI - the module where the
silent message loss lived, whose regression test is worth nothing unless
something runs it. And the server is no longer copied into flamenet-e2e as
well: two public copies of one server means one of them is stale with no way to
tell which.

All four validate against GitLab's linter. Engine offline tests: 87, 32 and 124
passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:42:06 -04:00
Paul HittandClaude Opus 5 e5815d27e2 Android app lock, matching iOS
The last of the four privacy features Android was missing that iOS already had.
The sealed history file protects a phone that is off or locked; it does nothing
about the phone somebody just handed to a friend. iOS asked for Face ID; on
Android anyone holding an unlocked handset read every conversation.

Biometrics OR the device credential, deliberately - a phone with a PIN and no
fingerprint reader should still be able to use this, the same reason iOS asks
for deviceOwnerAuthentication rather than biometrics specifically. Offered only
when the device can actually authenticate: a switch that turns on and then
cannot show a prompt locks somebody out of their own messages with no way back.

Two traps worth naming. MainActivity had to become a FragmentActivity -
BiometricPrompt hosts a fragment, so on a plain ComponentActivity it silently
cannot show and the callback never fires, which looks like a broken lock rather
than a missing base class. And the gate wraps Root rather than drawing over it:
composed underneath, the message list still renders for a frame, and a frame is
all a screenshot or a shoulder needs.

The lock screen deliberately says nothing - no sender, no preview, no count. A
lock screen reporting "3 new messages from Dana" has given away the two facts
that usually matter most.

The setting states what it does NOT do. This stops someone who picks up your
unlocked phone; it adds no encryption, because the vault and ratchet keys are
already sealed by the keystore either way. Claiming more would be selling a
screen as if it were a key.

Android engine, client and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:21:03 -04:00
Paul HittandClaude Opus 5 98e2580d02 Android FLAG_SECURE, iOS notification privacy, and the padding design
⚠️ NOT BUILT. The machine has been at load 550-640 for three hours with 180
iOS-27 simulator processes on it; xcodebuild was killed rather than failing on
its merits. Committed so the work is not lost, and must be compiled before it
is trusted.

FLAG_SECURE on Android. Blocks screenshots and recording, but the half that
matters is the recents thumbnail: without it Android captures the open
conversation and WRITES IT TO DISK, so a thread existed as a plaintext bitmap
outside the ratchet, the sealed history file and the device-only key. iOS has
had the equivalent since app lock landed; Android had nothing.

iOS notification previews now default to OFF, and the sender is hidden too.
Correcting what I told the owner earlier: Android does NOT leak notification
content - it shows "New message" and never the sender or body. I misread the
count string for the message text. iOS was the leaky one, and worse than it
looked: content.title was set to the sender unconditionally, so turning
previews off hid what was said and still announced who said it. On a lock
screen that is often the more sensitive half, and the setting was labelled
"Show previews".

No incognito-keyboard flag, deliberately: Compose has no stable API for it, and
FLAG_SECURE already makes most keyboards including Gboard suppress
personalised learning. A second mechanism that might be a no-op is worse than
the note.

docs/PADDING.md designs envelope padding rather than implementing it. It is a
wire-format change on a live protocol; the group-decode mismatch in this
repository's history is what that looks like when it goes wrong. Includes the
three-stage rollout, why stripping cannot be inferred, and why the relay needs
no change at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 00:38:42 -04:00
Paul HittandClaude Opus 5 b74cc1907f Make a call that goes nowhere say so
Android's Session.sendCallSignal called the send path and threw the result
away. That result is the same Sent every other send returns - how many devices
took it, which failed - so an offer that reached NOBODY was indistinguishable
from one that rang. The caller watched "Calling..." for a signal that had gone
nowhere, and neither device had anything to say so. The same shape as the
message-loss bug fixed earlier today, on the one path where the UI never shows
a delivery banner because a call is placed from inside a WebRTC callback.

Also instruments the drops. An incoming offer can be discarded in five places
in iOS's CallManager - already-ended, older than the dial timeout, busy, wrong
state, unknown id - and every one was a bare `return`. Working out which of the
five happened costs more than the logging does. Same treatment E2EInbox.dropped
already gives ingest, and for the same reason.

Not a fix for "Android calls iOS and nothing happens" - I have not reproduced
that. It rules out and instruments: the wire shapes match, iOS builds its
CallManager eagerly at sign-in so onCallSignal is always hooked, Android sets no
from_addr so the federated fork is not swallowing it, and every clock (mac,
host, relay, db) agrees within a second so the staleness check is not
misfiring. The next attempt will say where it stopped instead of nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:20:16 -04:00
Paul HittandClaude Opus 5 780dc50652 Story audiences (Android): all contacts, only chosen, or all except
Three modes, stored in the vault, applied at post time.

🔴 The audience is enforced by who is SENT the bytes, not by a flag. A story is
sealed to each recipient's devices through the ratchet, so somebody outside the
list is never sent it - there is no copy on the relay for them to be denied.
Every mainstream product holds one server-side copy and decides who is shown
it; this cannot work that way even if somebody wanted it to, and that is the
feature rather than a limitation.

Which also answers "make sure stories are only visible to friends": they
already were, structurally. There is no public audience and there cannot be
one - a story has to be encrypted to somebody in particular.

⚠️ The saved list is intersected with the live contact list in every branch,
including "only chosen". An id left behind after that person was removed as a
contact must not resurrect them as a recipient.

The screen says what will actually happen - "your next story goes to 3
contacts" - resolved the same way, and refuses a post that would reach nobody
rather than silently succeeding at nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:11:20 -04:00
Paul HittandClaude Opus 5 85d6825fdf Stickers and emoji in the composer (Android), and a written queue
Stickers send, arrive, file into the right thread - group messages under the
group, not scattered across one thread per member - and draw bare. No bubble:
the kit draws none, and a glyph inside a grey rounded rectangle reads as a
broken image. The timestamp and avatar stay, because that is the line saying
who and when.

Two controls, not one, because they are two different things. An emoji goes
INTO the draft and is indistinguishable from any other character once sent -
nothing on the receiving end needs to understand it. A sticker is its own
message kind that is never rendered as text. Behind one button they would imply
they behave the same way. Hence also: the emoji panel stays open on tap, since
picking three in a row should put three characters in the box, while a sticker
sends and closes because it IS the message.

The emoji set is a short curated list, said plainly in the code: a full picker
is search, skin tones, categories and recents - a real feature the system
keyboard already ships. This is what a thumb can reach.

docs/ROADMAP.md now holds the queue, including the security and privacy work.
Written down because this project has already lost a finished feature to a
parked branch. Two items there are real and current: Android spends a one-time
prekey on EVERY message via fetchKeys, silently costing initial forward secrecy
once the pool drains - iOS fixed this and Android has the unused listDevices
sitting next to it - and a passphrase IS the account, with no second factor and
no recovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:04:54 -04:00
Paul HittandClaude Opus 5 3b195c730c Teach both engines the sticker wire
A sticker is a content envelope carrying an id, never an image: sending the
picture would cost an upload and a download of bytes both ends already ship
with, on every sticker. This is about sixty bytes.

Recognised before the text path on both sides, because a sticker is renderable
but is NOT text - it draws as a glyph with no bubble, and falling through would
render it as an empty message. An unknown pack or id has to degrade to the
pack's fallback rather than break, which is what the envelope format exists for.

The pack is one JSON manifest read by both clients, so there is a single source
of truth for what exists. docs/STICKERS.md says plainly what the first cut is:
the glyphs are Unicode, because real sticker art is a design job, not a code
one. The protocol, picker, rendering, fallback and tests are real and finished;
swapping in artwork is a change to the manifest plus an image loader, not to
any of that. It is not a claim to have drawn a sticker set.

Emoji are deliberately NOT this. They go into the text of an ordinary message
and are indistinguishable from any other character once sent - no new kind, no
new field, nothing for the receiver to understand. That picker is a keyboard
convenience, not a protocol feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:50:48 -04:00
Paul HittandClaude Opus 5 8ba0020785 Add a Calls tab, and the call log it needed
The kit's bottom bar is four icons and one of them is a phone; its
"08 Calls-Listing" is a real screen. This app had a live call UI, a missed-call
notification from the relay, and no history at all - so "who rang me yesterday"
had no answer anywhere.

So the log had to exist first. It lives in the vault with everything else: who
somebody called and when is precisely the metadata this app exists to keep off
a server, and encrypting the words while leaving the call log in the clear
would be an odd place to stop. Bounded at 500, because the whole log is
re-sealed on every write.

Filed once, when a call ends. WebRTC walks DIALLING -> RINGING -> CONNECTING ->
ACTIVE on the way, and recording each transition would turn one call into five
rows; ENDED is usually followed by IDLE, hence the guard. Whether it ever
connected is captured BEFORE the manager is torn down - asking afterwards
always says no, which would file every answered call as missed.

The row is measured from item/call/White: 48dp avatar, 16pt name, a 12dp
direction icon before a 12pt line. Missed calls carry the kit's pink so the row
somebody is actually looking for is the one that stands out, and the tab badges
missed incoming calls only - an answered call is not news.

No video icon, though the kit's row has one at x=255: there is no video path in
either CallManager.

relativeTime is shared with the chat list rather than reimplemented - two
screens formatting times differently is drift nobody files a bug about and
everybody notices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:38:21 -04:00
Paul HittandClaude Opus 5 7719ae062e Match the kit's chat bar and message box
Measured out of "Light Mode - iOS/07 Messages-Details 1" rather than eyeballed:
the App Bar is 88 tall with back at x=24, title 16pt Medium over a 12pt line
with a dot beside it, and Call at x=279. The Message Box is 84 tall: plus at
x=16, an F3F3F3 pill from x=56, camera at x=287, more at x=335.

The composer is one row now. Voice recording was a chip hanging underneath it -
the last thing on the screen that did not belong to that row - and the mic is
an icon in the row, with the permission plumbing hoisted to where it is used.
Send appears only when there is something to send; at rest the row is the kit's.

iOS tab bar drops its labels, matching the kit's "Bottom Bar/Without Label" and
Android. Titles survive as accessibility names - dropping text from the screen
must not drop it from VoiceOver.

PresenceDot was still an olive, a mustard and a rust: the previous brand's
palette, left behind when the accent changed. Now teal, blue and pink from the
kit.

Two things the kit draws that this deliberately does not: a video-call button
and an emoji key. Neither CallManager has any video path at all, and there is
no emoji picker to open - the system keyboard already carries one. A mockup can
draw a feature; the app cannot draw one it does not have, and this codebase has
already shipped one dead call button once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:30:07 -04:00
Paul HittandClaude Opus 5 94337cbc07 Hold an undelivered message and send it when they come back
Asked: how does the sender know to try again when the other person has no
registered device - especially since they are friends. They should not have to.
Marking it undelivered and stopping there moves the problem onto the person who
did nothing wrong: they would have to remember, notice their friend was back,
and retype it.

So the message goes into an outbox and is retried on every poll tick. Nothing
expires and nothing is discarded - a contact who reinstalls in a month gets it
in a month. Timing out would mean deleting something somebody wrote in order to
tidy up a label.

The queue has to live on the sender's phone, and that is forced by the
encryption rather than chosen: every envelope is sealed to a particular
device's keys, so somebody with no device has no keys to seal to, and the relay
could only queue plaintext. The consequence is that it goes out the next time
THIS app runs and polls, not the instant the recipient signs in - so the label
says "Undelivered - will send" rather than implying it is in flight.

Delivery is now a tri-state. PENDING is quiet and grey because it is the normal
thing that happens when a friend is signed out and there is nothing to do about
it; only FAILED is a warning, and the outbox never produces one.

OutboxTest covers it end to end, including delivery against a real second
identity rather than a hand-written bundle: queued while away, sent when they
register, and the bubble restated - a version that delivered the message but
left it reading "undelivered" would pass a test that only checked the queue.

Engine, client and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:06:19 -04:00
Paul HittandClaude Opus 5 f711dd279b Every message carries whether it was delivered, and says so
Asked: how do we prevent ghost messages. Fixing the one cause does not, so
this fixes the thing that makes any cause invisible.

A message could only be a ghost because a delivered bubble and an undelivered
one were the same picture. The code had one bit - record it or do not - and
both answers were wrong, and the app shipped one of each:

  1:1 text recorded only on success, so a message sent while the other person
  was signed out vanished out of the box with no bubble and no error. That is
  why it took sending twice to get one to appear.

  Group text recorded unconditionally, so it left a bubble that looked exactly
  like a delivered one and had reached nobody.

Now every send records, carrying the outcome, and a bubble nobody received is
marked. Null stays null for received messages and for anything written before
the field existed - absence of a claim, not a claim of failure - so old
threads do not sprout warnings.

Correcting the previous commit message: it said the lost 1:1 message was
written into the sender's own thread. It was not; nothing was written at all.
The ghost bubble was the group path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:57:58 -04:00
Paul HittandClaude Opus 5 80b903e168 A message nobody can receive must not report success
Reported: a message sent from Android while the other end was signed out on
iOS was lost, and a group text vanished the same way.

Envelopes are addressed per device, and signing out deregisters the device.
sendPlaintext looped over an empty bundle list, made no request at all, and
returned Sent(delivered=[], failed={}) - which every caller reads as "it
went". The message was written into the sender's own thread and existed
nowhere else. Group sends call this once per member, so the same path lost
group text.

iOS never had this: transmit throws on an empty device list. The platforms
disagreed about a delivery guarantee for as long as both have existed, and
nothing caught it, because the tests that covered sending had both ends
signed in - and a happy-path test cannot tell a delivery from a no-op, since
both are silent.

Recorded in the failure map rather than thrown, so a group send still reaches
the members who ARE reachable. The banner now says the message was not queued
and has to be sent again, instead of "Delivered to 0 of 1 devices".

SendToNobodyTest pins it; verified failing with the guard removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:54:12 -04:00
Paul HittandClaude Opus 5 0983e760b4 Draw the author's picture beside a received bubble
Reported: profile pictures do not appear in the chat boxes. They did not.

iOS had an avatar slot in MessageBubble that hand-rolled only the initials
fallback - it never had the user id or the inbox, so it could not reach the
picture that AvatarView already downloads and decrypts everywhere else.
Android reserved the kit's 40dp received inset for an avatar and drew nothing
in it at all.

Both now pass the author and the picture through. Sent bubbles get none: you
know who you are, and a picture on both sides halves the width a thread has.

Also gives the iOS list row a real last-message preview and a timestamp,
matching the Android row - it was showing a screen name and the word 'Online',
a second copy of the dot already on the avatar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:40:40 -04:00
Paul HittandClaude Opus 5 5db9f8f7af Give the Android list row a preview, a time and a badge
The kit's row is four things - avatar, name, last message, time - plus an
unread count on the right. Ours was a name and the word 'online', which is
most of why a two-contact screen read as empty.

The preview comes from the local vault, not the relay: the relay sends none
and asking it to would leak which conversations are active, while the history
is already decrypted on this device. Presence moves to the dot on the avatar,
where the kit draws it and where it costs no line.

Section headings now appear only when both contacts and groups are on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:36:44 -04:00
Paul HittandClaude Opus 5 682d691a04 Draw icons instead of typing them
Every icon in the Android app was a text character or an emoji — +, →, ‹, ⋯,
☎️, 🎤, ⊞ — and material-icons-extended had been a dependency the whole time
with zero Icon() calls against it. Reported three times as bare/text-links;
each time I adjusted spacing and type, because the structure genuinely did
match the kit and the code read correctly.

Adds KitIcons.kt as the vocabulary and migrates the tab bar, bar actions, row
overflow, composer, call controls, contact rail and alert glyphs. The tab bar
loses its text labels to match the kit's 'Bottom Bar/Without Label' artboard
and keeps them as accessibility names.

Also fixes the iOS composer sitting under the tab pill: HidesPillTabBar was a
PreferenceKey, and preferences do not travel out of a NavigationStack
destination, so the chat screen's request never reached the bar watching for
it. Now an observable with balanced retain/release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:23:47 -04:00
Paul HittandClaude Opus 5 c9de1f26b8 Profile pictures, end to end, and the chat bar's second line
🔴 **An avatar is an attachment, not a field on the account.** The
obvious design — upload a JPEG and let the relay serve one per user —
hands the server everybody's face, on a product whose whole claim is
that it cannot see what you send. This rides the same encrypted blob
path as a photo message: the relay stores ciphertext, and the key
travels to each contact inside the ratchet exactly as the delivery key
already does.

⚠️ **The cost, stated on the screen rather than discovered.** It is per
contact. Somebody who has never messaged you cannot see your picture,
and changing it means telling every contact again. Sealed sender
already makes this bargain; a different answer for avatars would mean
two answers to the same question.

The pieces: a new `avatar` control kind, which is parsed WITH its
attachment ref rather than falling through the CONTROL early-return —
otherwise a profile picture arrives in the conversation as though
somebody had sent you their face as a photo. Encrypted at rest in the
vault like everything else, because a face on disk in the clear is the
one thing a stolen phone should not hand over about your contacts.
Sharing is keyed on the blob id, not a boolean: a flag would say "told"
for ever and a new picture would never reach anyone who had the old one.

⚠️ Contacts are told on OPENING a conversation, not when the picture is
chosen. Somebody added afterwards would otherwise never be told, would
see initials for ever, and nothing on any screen would explain why.

⚠️ Partial delivery is reported. "Sent, but 2 of 5 could not be told"
is the truth when two contacts have a changed key, and silence would
let somebody believe it had gone out.

Also: the chat bar gained the kit's second line — a presence dot and a
12sp fact, or a member count for a group, which shows no dot because a
group has many presences and one dot cannot speak for them. And the
reassuring identity states dropped to 12sp grey; they were as loud as
the title bar, which is the wrong weight for "nothing has happened".
The CHANGED state is untouched and stays loud.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:58:58 -04:00
Paul HittandClaude Opus 5 7295f43a13 Android gets a contact screen
🔴 **There was none.** Everything you could do to a person was an inline
row that unfolded under their list entry — Block, Clear chat, Remove,
three text buttons in a line — with nowhere to explain what any of them
did and nowhere to put a safety number. iOS has the kit's version; this
is the same screen: a cover, a name plate, and a rail of actions
floating at the seam between them.

⚠️ The rail carries only actions that do something. The kit's has a
"personal page" icon; there is no such page here, and a fourth control
that opens nothing is worse than a rail of three.

⚠️ Presence sits on the name's own line, not the plate's trailing edge.
The rail floats over the right-hand third of the plate, so anything put
there is behind it — a thing only visible once rendered, and it was
caught on iOS the same way an hour earlier.

Removing a contact confirms in place rather than in a dialog, matching
the Account screen: a dialog is dismissed reflexively; a row that
changes to say what it is about to do is read.

🔴 The on-device test asserted `block-$id`, `clear-$id`, `remove-$id`,
which no longer exist — those actions moved off the row and are not
keyed by peer id any more. Updated to follow them, plus an assertion
that there is a way back, or the screen is a trap. That suite needs a
device and cannot run here, so it would have failed silently until
somebody next ran it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:50:14 -04:00
Paul HittandClaude Opus 5 b39c992115 Android gets a real call screen; iOS gets its tab bar out of the way
🔴 **Android had no call screen — it had a banner.** A line of text and
two TextButtons stacked above whatever you happened to be on, for the
single most attention-demanding thing this app does. iOS has had a full
screen throughout and the kit draws one. Same split as iOS: the look
takes plain values so the screenshot harness can photograph it, because
a call screen nobody can look at is a call screen nobody has checked.

⚠️ The privacy prompt stays a banner deliberately. It is a question
asked BEFORE a call exists, and a full screen for it would look like a
call already happening.

🔴 **And a bug I introduced: the floating tab pill was sitting on the
message box.** Reported from the iOS app. The pill overlaps content by
design — that is what makes it float rather than reserve a strip — and
the composer is exactly where it lands, so the field could not be
tapped. `TabView` hid its own bar on a push and the replacement had
nothing to do that with. Both conversation screens now declare
`hidesPillTabBar()`, which is what the kit's own Messages-Details
artboard shows: a composer at the bottom and no tab bar at all.

⚠️ The iOS fix is written but NOT yet rendered — the machine is pinned
copying Xcode out of iCloud Drive and I am not running builds into
that. It compiles; it has not been looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:45:12 -04:00
Paul HittandClaude Opus 5 86550f3f61 Stories as a feed you scroll, which is the kit's Timeline
A screen, not a strip. Stories were a horizontal row of circles on the
Messages screen -- enough to tell you something exists and nothing
more. The kit's Timeline is a column of full-width cards: author over a
description line, the image, the caption. A card feed cannot sit above
a contact list without pushing it off the phone, so it gets a tab; the
strip stays on Messages as the entry point.

⚠️ No like-or-comment bar, and the kit's post card has one -- two emoji
reactions and a share icon. A story here has none of those: no comment
thread, no reaction, nothing to share it to. Drawing the bar and wiring
it to nothing would be a lie told in pixels. What a story DOES have
that the kit's post does not is a deadline, so that is what sits in the
slot the kit gives a description -- the one fact about a story that
changes while you are looking at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:58:30 -04:00
Paul HittandClaude Opus 5 87e96761cc The kit's palette, not just its layout
I kept Flamenet orange and defended it as brand continuity. That was
wrong on the facts: the orange had already been retired and the public
pages carry none of it, so the apps were the last thing wearing a brand
colour the brand had dropped — on the authority of a comment in
Theme.kt that nobody had rechecked. The comment is corrected in place
so nobody restores it for the same stale reason.

The values are measured out of the Sketch file rather than sampled from
a render. The two text colours are the ones that actually appear in its
symbols — #090710 on 165 text layers, #505C69 on 164 — and the accent
#2298FF is what it uses at 12pt as well as on buttons, so it is their
link colour and not only a fill.

The constants were called Ember and held orange. They are Accent now:
a constant named for a colour it no longer holds is how a palette
drifts back.

⚠️ Recorded because it will come up: #2298FF on white is about 3.3:1 —
fine for a bold button label, not for small text. The small text on
these screens is grey, and it needs to stay that way.

Also in this commit, the Android sign-in takes the kit's Welcome
layout: a logo mark, a 40pt greeting and one line saying what this is,
centred with room above them. It was a 24pt product name jammed against
a Server field, which is the shape of a login dialog rather than the
first thing anyone sees of the app. The submit button is 240 wide and
centred, as the kit draws it — a button spanning the phone reads as the
bottom of a form; one that does not reads as a choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:54:34 -04:00
Paul HittandClaude Opus 5 5a592e9858 Design pass with the screens actually in front of me
The JVM harness renders whole screens now, not just parts. The screens
take their state as `phase` and only touch the view model when
something is tapped, so an unstarted model is enough — it opens the
Keystore vault in `init`, which fails inside a runCatching on a
background dispatcher under Robolectric. That is why this works rather
than by design; if it starts hanging, look there first.

With the screens visible at the kit's own 375x812 canvas, four things
were obvious that were not from inside the code:

🔴 The large title said "fnreviewalpha" over a relay and a device id.
The kit's title names the SCREEN — "Messages". Three lines about who
you are, at the top of a list about other people, and it dominated
because a handle is longer than a title. Identity belongs on Account,
where it already was.

Contact rows read "fnreviewbeta / fnreviewbeta · online" — the name
twice and a status — because the handle is usually the same string as
the display name. It is dropped when it matches.

"No groups yet. Start one with your buddies." was a line of prose in
the middle of a list, with a section label above it, for the ordinary
state of a new account: two lines of furniture for zero rows. Both go
when the section is empty AND loaded. The failure case still speaks,
because the reason that message existed is real — an empty list
rendering nothing gave no way to tell "you have none" from "it did not
load".

Alerts offered "Mark all read" on an empty screen, where its only
possible effect was nothing, and it was the most prominent thing there.
"Nothing new." sat against the heading like a subtitle of it. Proper
empty state now, and the button appears only when something is unread.
Alert rows drew initials of the KIND, so "contact request" became "CR"
— two letters that look like a person's initials and name nobody.

Address card: the QR was the largest thing on the Account screen and
pushed everything below the fold, and the address wrapped mid-hostname
as "…@rela / y.flamenet.io". A relay name split across a line break is
the kind of thing somebody retypes wrong, so the host is its own line
now. Two fields whose placeholder repeated the label above them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:32:34 -04:00
Paul HittandClaude Opus 5 d8b8d63687 Render Android screens without an emulator
Verifying a layout meant booting an 8 GB AVD, installing an APK,
tapping to a screen and pulling a screencap — and the emulator was
killed by memory pressure four times in one session, each time
mid-check. Robolectric in native-graphics mode draws the same Compose
pixels on the JVM. `Scripts/screenshots.sh` now renders the screens in
about four seconds with no device.

Two things had to be got right and both fail in a way that reads as
something else:

`isIncludeAndroidResources` is not optional — without it the theme
cannot resolve and every render throws on a missing resource, which
looks like a Compose problem rather than a configuration one.

🔴 And the capture. `captureToImage()` goes through PixelCopy and waits
for a frame-commit callback Robolectric never delivers, so every render
died as "Condition still not satisfied after 2000 ms" — which reads as
a hung composition and is nothing of the sort. Drawing the decor view
into a software canvas produces the same pixels with no window
involved. `createComposeRule()` has the same problem for the same
reason; it needs the Activity rule.

⚠️ These are output, not assertions: they write files and pass unless
something throws. A screenshot test that diffs against a stored
baseline is a different thing, worth adding once the design stops
moving — right now it is moving, and a baseline would be re-blessed on
every change, which proves nothing.

This immediately paid for itself. The address card had only ever been
seen in its empty state, because the emulator's stored session predates
the fnid and a fresh sign-in erases that phone's keys. It renders
populated here, and the QR in it — encoded by our own Kotlin encoder,
drawn by the app's own Compose code — decodes back to the exact address
through CoreImage's detector, which shares no line with any of it.

Rendered at the kit's own 375x812 canvas, so a PNG can be put beside
the Sketch artboard rather than mentally rescaled from a 1080x2400
phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:24:50 -04:00
Paul HittandClaude Opus 5 0a452b9657 Controls, not a page of orange words
Reported as "looks like a bunch of text links", with the honest caveat
of not knowing whether that is normal for Android. It is not, and it is
not the kit either: almost every action in the app was a bare
TextButton -- Back, Call, Safety number, Record a voice note, Mark all
read, Look up, Leave, Create group, and the four presence states -- all
the same undifferentiated orange text with nothing around it. That is
not an Android convention; it is the absence of one.

The kit ships three buttons, all 64 tall at radius 32 with a 14 medium
label: a filled primary, an outlined secondary, a grey tertiary. Plus a
small chip for something sitting in a row of its peers.

Presence was the worst of it -- four orange words that gave no sign of
being a single choice with one answer; the selected one was merely
bold. Chips now. Accepting a changed identity key is the most
consequential tap in the app and sat as matching orange text beside
"Show number", so the two read as interchangeable; it is an outlined
danger button now and the other is a chip.

Bar actions became glyphs in tappable circles with real accessibility
labels, rather than words. No icon set is pulled in for eight buttons.

Also in this pass: section headings shrank to 12sp labels and their
buttons moved into the app bar as icons. Two heading rows -- "Groups /
New" and "Contacts / Add" -- took the top third of a screen whose whole
content was two rows, and the kit's list has no headings at all.

iOS gets the same treatment: the tab bar is the kit's floating pill
rather than a TabView strip (PillTabBar records what the system bar was
doing for us that now has to be done by hand, including the one thing
that does not come back -- scroll-to-top), the list screen lost its
cards, and settings moved off .insetGrouped, which draws every section
as a floating card, onto plain rows separated by the kit's 16pt band.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:15:11 -04:00
Paul HittandClaude Opus 5 7218a9b175 Android: the layout, not just the parts inside it
The last pass changed row internals and left the screen architecture
alone, and the screen architecture is what makes the kit look like the
kit. Reported back as "looks exactly the same and nothing like the
sketch", which was accurate.

Four cards -- a presence picker, a paragraph about what stories are,
contacts, groups -- each on its own white surface with a heading. The
kit's messages screen is one flat list under a title, a search box and
a strip of stories. No amount of restyling the rows inside those cards
was going to get there; the cards were the thing.

🔴 The type scale was the other half, and it was invisible from inside
a single screen. Material 3's defaults are considerably larger than the
kit's, so a section heading rendered at 24sp where the kit says 16 and
every screen inherited it. Set once in the theme, which fixes screens
nobody has touched.

Stories was a heading, a Post button, and -- with nothing posted, which
is the normal state -- three lines of prose explaining what a story is.
Prose where the design has a row of circles is the loudest possible
signal that this is not the same app. It is a strip of faces now, with
the add-circle always first so there is always somewhere to tap.

The tab bar is the kit's floating pill rather than a row of text
buttons; it is the most recognisable single element in the design.
Group rows carry the same avatar and the same 72dp pitch as contact
rows, so the two read as one list rather than two that follow each
other. The presence picker moved to Account, where the kit would put
it, instead of being dropped with the card it lived in.

The buddy list is plain rows in the page's own scroll now. It was a
lazy list bounded at 320dp -- a small scrolling window inside a
scrolling page, which is exactly what a flat list is not. The bound
existed because a lazy list in a scrolling Column is measured with
infinite height and crashes; rendering rows directly sidesteps that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:58:08 -04:00
Paul HittandClaude Opus 5 b48d71a75a Take the design kit's layout, keep Flamenet orange
Measured out of `Messenger App V2.sketch` by reading the file's JSON
rather than eyeballing a render, and written down in DESIGN.md so both
apps cite one source instead of drifting.

The layout is theirs; the colour is ours. The kit's primary is blue and
adopting it would mean the app no longer looks like the product it
belongs to, so everywhere the kit says primary, read Flamenet orange.
Only #F3F3F3 is taken directly, as the received-bubble and pill fill.
The typeface is not adopted either -- bundling DM Sans is a licensing
decision, not a layout one -- so the sizes, weights and line heights
land on the system face.

🔴 The bubble corners are asymmetric and that is their whole character:
radius 16 everywhere except one corner at 2, which points at its author
-- bottom-right on a message you sent, top-left on one you received.
Rounding all four equally is what makes a chat app look like every
other chat app, and it is exactly what gets lost when a spec is taken
from a screenshot. Only reading the per-corner radii out of the JSON
found it; the symbol's fixedRadius reports a single number and is
misleading.

Conversation rows now have the kit's 48pt avatar, 16pt gutter and
16/14 type. Android had no avatar at all -- a coloured dot beside two
lines of text -- and no amount of adjusting the type would have made
that read like the design.

The Android chat screen needed more than bubbles. Back and the name sat
on one line, Call on a line of its own beneath, and the identity panel
under that, so a third of the screen was chrome before a message. One
bar now. The composer was an outlined field with two labelled buttons
beside it, which was the last part of the app that looked like a form;
it is the kit's pill, built on BasicTextField because a Material text
field reserves room for a floating label and will not go below 56dp
whatever shape it is given.

The timestamp moved outside the bubble, where the kit puts it. Inside,
a one-word message became a tall two-line box -- "sup" rendered taller
than it was wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:49:37 -04:00
Paul HittandClaude Opus 5 e46d51430a Android can show an address, and the Account screen looks like the app
Two gaps, one in each direction.

Android could ENCODE an address and had no way to DERIVE one, so it
could not show yours and nobody on another relay could be given it --
the cross-relay work was iOS-only in practice. The engine was missing
`localAccountSalt` and a way to get both sign-in secrets from one
stretch; asking for the account key separately re-runs 650,000 PBKDF2
iterations for a value already in hand. The salt is pinned against the
JavaScript engine, which is normative -- a Kotlin test asserting what
Kotlin computes would prove only that Kotlin is self-consistent, and
the failure to guard against is three engines disagreeing, which shows
up as one person having two addresses depending on which phone they
used.

The address is stored in the vault because it can only be derived at
sign-in: the passphrase exists for that one moment and is never kept,
so a restored session had nothing to recompute from. Caught by the
empty state saying so on the emulator rather than showing a blank.

A QR encoder, written rather than depended on. Android has no encoder
in the platform, and this is one fully specified algorithm for one
fixed job -- turning a short ASCII address into a bitmap. It is
verified by DECODING: thirteen codes, every version from 1 to 10 at
exact capacity, read back by CoreImage's detector, which shares no line
with it. That found the bug that mattered. The symbol was structurally
perfect -- finders, timing, alignment, data placement and mask all
confirmed correct against a reference symbol -- and nothing could read
it, because the fifteen format bits were written least-significant
first. It fails identically at every version with no clue where.

The Account screen was a flat stack of fields, buttons and paragraphs
with dividers between them, so a security setting carried the same
weight as a text box and "Delete account" the same as "Save display
name". Cards now, matching the Contacts screen that already had them,
with the destructive pair on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:36:58 -04:00
Paul HittandClaude Opus 5 f6f2fbd283 Finish the swallowed-failure sweep, and stop stories being wiped
Completing the four sites the previous commit named rather than leaving them.

🔴 **Stories were CLOBBERED by a failed fetch, not merely mis-described.** Both
`refreshStories` and the poll loop did
`runCatching { current.stories() }.getOrDefault(emptyList())` and then wrote
that straight into state — so one hiccup emptied the strip the user was looking
at, and the poll repeated it on every tick. A poll that cannot reach the relay
has learned nothing, and nothing is not "no stories". Both now keep what is on
screen and skip that tick.

⚠️ Groups, both platforms. Android rendered NOTHING for an empty list — no
guidance and no way to tell "you have no groups" from "the list did not load".
It now says which, matching the contacts card. iOS already had copy and it had
the same flaw: `refreshGroups` uses `try?`, so a failure left the list empty and
the section confidently said "No groups yet. Start one with your buddies."
`groupsLoaded` gates that on both sides.

That shape is worth naming, because it produced three separate findings today:
a swallowed failure, written into state, rendered as a confident empty state.
The group DECODE bug hid inside it for months — groups were invisible on iOS
and working on Android, and nothing anywhere said a word.

Android 194 tests green, iOS 46 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:20:49 -04:00
Paul HittandClaude Opus 5 62b4798881 "Nobody here yet" was also what a failed load looked like
🔴 **A claim about the account, made about a request that failed.** Both roster
load sites swallow failures with
`runCatching { … }.getOrDefault(emptyList())`, so a network hiccup, an expired
token or a rate limit all produced an empty list — and the contacts card then
said "Nobody here yet. Add someone by the username they signed up with."

Someone whose contacts had simply failed to fetch was told they had none.

Found by auditing the shape that hid the iOS group bug: a swallowed failure
that renders as a confident empty state. Six such sites exist on Android; the
two on the sign-in and restore paths are the ones a user meets first.

`Phase.SignedIn` now carries `buddiesLoaded`, defaulting to FALSE so a phase
built without saying so cannot assert the fetch succeeded. The empty state only
claims emptiness when the list actually loaded; otherwise it says the load
failed and what to do.

Verified on the emulator with the radios off: the card reads "Couldn't load
your contacts. Check the connection and sign in again." With the network back,
the roster loads and the contact shows as before.

⚠️ Four sites remain that still default to empty on failure — groups, stories,
and two others. They deserve the same treatment; contacts came first because it
is the screen somebody lands on. Android suites green (194 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:00:15 -04:00
Paul HittandClaude Opus 5 6527f76151 Android acquired the microphone on the UI thread too
Porting the iOS recorder fix back the other way, and the same bug was here.

`MediaRecorder.prepare()` and `start()` acquire the microphone, and acquiring
hardware blocks. `startRecording` was a plain function called straight from the
UI, so that happened on the main thread — as did `File.createTempFile` beside
it. `cancelRecording` released the recorder the same way.

⚠️ **The tell was the inconsistency.** `stopAndSendRecording` already went
through `withContext(Dispatchers.IO)`, so someone had thought about this for
stopping and not for starting — and starting is the half that waits for the
audio route to come up. On iOS the identical mistake froze the main thread for
SIXTY SECONDS on a simulator; here it did not misbehave on the emulator, which
is not the same as being correct on a device whose audio route is busy.

All three paths now agree. The UI state on cancel is cleared immediately rather
than after the hardware is handed back, because the user pressed Cancel and
should not watch the bar linger.

Verified on the emulator: recording still starts (timer ticking at 8.4s),
cancel still stops it and the composer returns, app alive throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:32:18 -04:00
Paul HittandClaude Opus 5 ea338ea81a The tab bar floated mid-screen, and the Account tab could not be reached
Found by opening the two tabs nothing had ever opened.

⚠️ **The tab bar was not pinned.** It and the screen content were plain
siblings in a Column with no weight between them, so the bar sat immediately
under whatever had been drawn: near the TOP of an empty Alerts tab, above a
screen of blank, which reads as a broken layout rather than as an empty state.

🔴 **On the Account tab it was pushed off the bottom entirely, and the screen
could not scroll.** Account is taller than the viewport, so the sign-out
explanation was unreadable and there was no way to switch tabs from it at all.

The content area now takes the weight and the bar sits below it. ⚠️ Only the
screens WITHOUT a lazy list scroll — Contacts owns a LazyColumn, and a lazy
list inside a vertically scrolling Column is measured with infinite height and
crashes outright, which is what the comment there has always warned about.

⚠️ Not a bug, for whoever sees it next: "Flamenet Messenger isn't responding"
on the emulator during this work was the SYSTEM starving, not the app. The ANR
reason names `Taskbar (server) is not responding, waited 33979ms` with the host
at load 35. The app's own restore does its network work inside
`withContext(Dispatchers.IO)`; it was not blocking the main thread.

⚠️ Also seen and left alone: the group row shows "You, user 32" for a few
seconds after a cold start, because the buddy list has not loaded yet and the
fallback prints the raw id. It corrects itself once buddies arrive. Naming it
properly would mean carrying member handles on the group record, which the
relay does not send today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:00:55 -04:00
Paul HittandClaude Opus 5 f90c6477bb Calling killed the app, twice over
🔴 **Three separate defects, and calling could not work past any of them.**
Found by tapping the Call button, which is not covered by any test.

**1. The button did nothing.** `requestCall` sets `callPrivacyPrompt`, and the
only thing that renders it is `CallBanner` — which was composed inside
`SignedIn`, the HOME screen. The chat screen REPLACES that. So the only Call
button in the app set a flag nothing was on screen to show, and since the
prompt gates placing the call, calling could not be started at all. The button
looked simply dead.

That also means an incoming call arriving while somebody was reading a thread
displayed nothing — directly against the comment above it, which says an
incoming call must be visible whatever screen somebody is on. CallBanner is
now hoisted above the screen switch.

**2. Placing a call SIGABRTed the process.** The manifest never declared
ACCESS_NETWORK_STATE. WebRTC's NetworkMonitor calls
ConnectivityManager.getNetworkState() on its own network thread, that throws
without the permission, and the JNI layer immediately asserts
RTC_CHECK(!env->ExceptionCheck()) — turning a Java exception into abort().
No AndroidRuntime trace, nothing catchable, the process just vanishes:

    Check failed: !env->ExceptionCheck()
    Fatal signal 6 (SIGABRT) in tid network_thread

**3. Hanging up SIGABRTed it again, re-entrantly.** `onIceConnectionChange`
treated CLOSED as a reason to hang up — but CLOSED is exactly what our own
`close()` produces. So Hang up called hangUp, which closed the connection,
which fired CLOSED on the signaling thread, which re-entered hangUp, which did
a BLOCKING NETWORK SEND and disposed the connection it was running inside.

hangUp is now re-entrancy-guarded, CLOSED is a no-op (we caused it), FAILED
still hangs up, `dispose()` is guarded like the `close()` beside it already
was, and the courtesy "call ended" signal is best-effort rather than something
that can take the teardown down with it. The observer body cannot throw at all
now, because WebRTC treats any escaping exception as fatal.

Verified on the emulator: dialling brings up Mute/Hang up and the app survives
both dialling and hanging up.

⚠️ Still unproven: whether AUDIO actually flows. That needs two live endpoints
and a TURN server, and none is deployed. What is fixed is that calling is
reachable and no longer kills the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:05:31 -04:00
Paul HittandClaude Opus 5 fb099442a0 Stories verified, and errors stop blaming Contacts
Stories driven end to end on Android against relay.flamenet.io: Post opens the
scoped photo picker, a selected image is encrypted and uploaded, it appears in
the strip as "You", and opening it decrypts and renders the picture. Together
with groups, that leaves iOS voice notes as the only feature still unverified —
and that one needs a physical device, because a simulator has no audio input.

⚠️ **The screen has ONE error slot and it lived inside BuddyList**, so
everything that failed anywhere reported itself underneath the Contacts
heading. A rate-limited story post read as a contacts problem, which is how
somebody ends up re-adding a contact to fix a story. It now renders at screen
level, under the call banner, where it belongs to the screen rather than to one
section of it.

⚠️ Worth knowing for later runs: the relay enforces per-account rate limits,
and a session that has been driving both apps hard will hit them. "Too many
attempts. Wait a few minutes." is the app reporting a real server response, not
a bug — the story posted on the retry after the window cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 08:10:38 -04:00