Commit Graph
78 Commits
Author SHA1 Message Date
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
Paul HittandClaude Opus 5 5c8060b690 Groups verified, and stop calling the user a number
Groups now driven end to end on Android against relay.flamenet.io: created
"Testers" with a member, it appears in the list, it SURVIVES a full `pm clear`
and re-sign-in (restored from the relay), it opens, and a message sends into
it. This was one of the original asks and had never been exercised.

Two things it turned up:

🔴 **The relay's 401 also blamed the passphrase.** The AccountsClient fix in
the previous commit was correct and incomplete: `describe()` mapped
`RelayException.Unauthorized` to "That handle or passphrase isn't right" too,
and that is the path that actually fires when a relay token ages out. Same
lie, same consequence — a user changing a password that was never wrong. It
now says the session expired, which is what happened.

⚠️ **You are never in your own buddy list**, so every "find the name among the
buddies" fallback rendered the signed-in user as `user 31`. The group row read
"user 31, fnreviewbeta". `Phase.SignedIn` now carries its own `userId` and both
fallbacks say "You". Verified live: the row reads "You, fnreviewbeta".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:30:19 -04:00
Paul HittandClaude Opus 5 cda64d8c2e A contact who reinstalled could never be messaged again
🔴 **The chat header said "keys unchanged since you met" while every send was
refused with "identity keys changed".** Found by driving two real apps: the
iOS side relaunched with -FNMSGResetState, which is a genuinely new device with
new keys, and Android correctly refused to send to it. Correct — and then
there was no way out.

`Trust.state()` could return UNKNOWN, VERIFIED or PINNED and nothing else.
CHANGED was computed inside `record()` while a send was in flight and thrown
away, because `Vault.Trust` had nowhere to remember it. So:

- the header kept reporting the OLD pinned keys as unchanged,
- the "Accept new keys" button, which only renders for CHANGED, never appeared,
- `acceptIdentityChange()` was unreachable from the UI,
- and the contact stayed unmessageable for ever, with the app insisting
  nothing was wrong.

That is precisely what a tester does on day one: reinstall, and discover their
friend can no longer reach them.

`Vault.Trust` now carries `changed`, `record()` persists the detection without
adopting the offered keys and drops the verified tick (whatever was compared in
person was compared against the old key), `state()` reports it, and accepting
clears it. Keys that match again also clear it, so a transient wrong answer
does not strand somebody behind a banner for ever.

Verified live on the emulator: the banner appears with its full text, Accept
new keys is reachable, and the header returns to "keys unchanged" afterwards.
Four tests in TrustTest; three of them fail against the old `state()`.

---

🔴 **An expired session told the user their passphrase was wrong.** Hit
immediately afterwards: a send failed with "That handle or passphrase isn't
right", ten hours after a successful sign-in, because AccountsClient mapped
EVERY 401 and 403 to BadCredentials. The natural reading is that the password
stopped working or was changed by someone else, and the natural response is to
change a password that was never wrong.

Only `sessions` and `accounts` actually present a passphrase, so only those can
report one as wrong. Everything else now raises SessionExpired, which the app
states as what it is. Two tests cover both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:09:08 -04:00
Paul HittandClaude Opus 5 3c66d3e9bd Take the palette from the site instead of inventing one
flamenetmessenger.com declares its colours as CSS custom properties, so they
were there to be read rather than guessed:

    --bg: #0a0e1a   --panel: #121a2e   --border: #1f2b45
    --ink: #e8edf7  --muted: #9aa7c2
    --flame: #ff5a1f  --blue: #3b6fff  --accent: #2dd4a7

⚠️ The site is dark and the apps are light, so only the accents transfer
literally. --ink and --muted are light-on-dark values that would be invisible
on a light ground; the neutrals here are the same navy family resolved for
light instead. Dark mode DOES use the site's real --bg and --panel.

The apps had been warm — ember on parchment. They are now the site's flame on
a cool navy-tinted ground, with the site's blue and teal available for
informational and success states rather than a second invented accent.

⚠️ Two flames, on purpose. White on #ff5a1f is about 3.1:1 — acceptable for a
bold button label under AA Large and not acceptable for small text. Small text
and links take a darkened #c4400f, which clears 5:1 on white. An accent used
uniformly is how it quietly becomes unreadable.

`Palette.Parchment` is now `Palette.Mist`: it is a cool near-white and the old
name described a colour that no longer exists here.

Also fixes the contacts card reserving a slab of empty white. BuddyList's
LazyColumn carried weight(1f), which always expands to whatever height it is
offered — so bounding the CARD just moved the problem, and one contact still
reserved 340dp. The list now caps itself with heightIn and the card sizes to
its content, scrolling only once the list is genuinely long.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 06:37:32 -04:00
Paul HittandClaude Opus 5 4fd648382f The mic button froze the app, and one design system for both
🔴 **Tapping "record a voice note" hung the iOS main thread.** VoiceRecorder is
@MainActor and ran AVAudioSession.setCategory/setActive and AVAudioRecorder's
init and record() directly on it. setActive(true) blocks until the audio route
is up. Measured under XCUITest: the tap was followed by SIXTY SECONDS of "App
event loop idle notification not received", after which every accessibility
query cost 31 seconds because the main thread was still stuck — which is why
the failure had been reading as a slow test rather than as a frozen app.

The blocking calls now run in a detached task; published state is still
assigned on the main actor afterwards. The stall is gone: zero idle-loop
warnings, and the test fell from 224s to 103s.

Two deadlines, because moving work off the main thread stopped it freezing the
UI without making it finish:

- The permission ask. `AVAudioSession.requestRecordPermission` is deprecated
  since iOS 17; on 17+ we now use AVAudioApplication. Either way it is raced
  against 5 seconds, because a permission API that never calls back would
  otherwise suspend start() for ever.
- Opening the route, against 6 seconds, for the same reason.

Without these, start() returned neither true nor false, so the caller showed
NO recording bar and NO error: a mic button that does nothing at all when
tapped, with no way to tell broken from slow.

⚠️ **iOS voice notes are still not verified.** The freeze is fixed and proven
gone, but recording does not start on a simulator and I could not get the app
to surface a reason there. It needs a physical device before it can be
claimed. Android voice notes ARE verified — recorded and delivered, 29s.

---

**One design system, for real this time.** Theme.kt already claimed to be
"matched to the iOS client's Theme.swift so the two apps look like the same
product" while iOS was indigo and Android was ember. Nothing checks that
claim, so it had been false for a while. Ember wins — the product is called
Flamenet.

Both files now carry the same palette under the same names, plus a shared
scale for radii, spacing, elevation and type, as hex literals that can be
compared by eye across the two. Light parchment ground, white cards, one soft
shadow, one accent.

Applied to the Android home screen: each section on its own card. The sections
themselves are untouched — same composables, same testTags, same order —
because this is a skin over screens that work.

⚠️ The contacts card is height-bounded on purpose. BuddyList's LazyColumn
carries weight(1f), which used to resolve against the whole signed-in column;
inside a card with no height of its own it resolved against infinity, grew to
fill the screen, and pushed Groups and the tab bar off the bottom.

Also: tapRow now scrolls before declaring a settings row missing. A List does
not instantiate cells below the fold, so "Privacy & Security" was correctly
absent and read as a deleted feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 21:36:52 -04:00
Paul HittandClaude Opus 5 7ad6e7be29 The safety number was unreachable until you left the thread
Trust on first use pins the peer's keys during the send itself, so before a
first message the header correctly reads "No keys seen yet". sendMessage
refreshed the trust badge on FAILURE — with a comment explaining that the
header must not go on claiming "pinned" when a send just failed because it is
not — and did not refresh it on SUCCESS, which is the case where the pin
actually happens.

The result: send your first message to someone and the header still says no
keys have been seen, with no Safety number button, until you leave the thread
and come back. On a product whose claim is that you can compare a number in
person, the number was not reachable at the moment someone would look for it.

Group threads are left alone — they have no single peer identity, and
recomputing one into that field would produce something meaningless.

Verified on an emulator against relay.flamenet.io: fresh install, fresh
contact, first message, badge flips to "Keys unchanged since you met" with the
Safety number button, without leaving the thread.

Also, three harness fixes, all confirmed on an idle machine rather than
assumed to be load:

- waitInBursts in UITestSupport, for screens that never stop repainting. A
  single long waitForExistence holds ONE query open against a moving
  hierarchy; the voice recorder ticks its elapsed time, so the query never got
  a settled snapshot and died with "Timed out while evaluating UI query" after
  231 SECONDS against a 15-second wait. That is not a timeout and does not
  name what went wrong.
- SwiftUI puts a confirmationDialog's buttons in the tree more than once, so
  even a unique identifier needs .firstMatch to be tappable.
- The emoji fix from the previous commit is confirmed: that test now passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:49:34 -04:00
Paul HittandClaude Opus 5 053cefbce4 Two apps, three defects that only driving them could find
The relay is asked what it is, and the answer went nowhere. SiteConfig is a
data class whose only constructor property was `baseUrl`; `discovered` was a
`var` in the body, so the generated equals() ignored it. MutableStateFlow
conflates on equality, which made

    _site.value = config.also { it.discovered = found }

a no-op against the value already there. The descriptor was fetched correctly
and discarded. The sign-in screen sat on "Checking what this server uses…"
forever — the one line whose whole job is to say, BEFORE the passphrase is
typed, whether that passphrase leaves the device — and accountProvider stayed
LOCAL for a relay that had just answered SHARED. Now a constructor property,
written with copy(), with the first JVM unit tests the :app module has ever
had. Confirmed live: the screen reads "One Flamenet account, shared with
Flamenet Files."

Two XCUITest queries matched more than one element and so could tap neither:

- "👍" is in the emoji picker's Gestures row, in its Recent row once picked,
  and in ChatView's quick-reaction bar. Picker buttons now carry a
  section-scoped identifier.
- "Sign out" is both the row in ProfileView and the destructive button in the
  confirmation it opens; SwiftUI keeps both in the tree, so the query was
  ambiguous before the dialog was even reached. Both now identified.

Neither was load — both fail the same way on an idle machine.

Also adds completeSignIn to UITestSupport, the counterpart to completeSignUp.
Sign-ups are rate limited at three an hour per address, so a test that only
needs AN identity should reuse one rather than mint another and fail with a
429 that reads like a broken sign-up screen. CrossPlatformUITests takes
FN_TEST_PASSWORD and signs in when given one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:42:21 -04:00
Paul HittandClaude Opus 5 b051814a6c Show the relay's sentence, not its JSON
Creating a group with a member who has never signed in put this on screen:

    relay returned 400: {"code":"bad_request","message":"No reachable
    device for user: 30.","data":{"status":400}}

The relay had written a perfectly readable sentence and the app showed the
envelope around it. Found by creating a group on an emulator, not by reading
code.

⚠️ The REFUSAL is correct — requireReachable declines a group whose member has
no device, because there is nothing to encrypt to. Only the presentation was
wrong.

Cause: MessengerApi.send and RelayAccounts already unwrap `message` from the
error body; RelayClient.checked did not. So which client a feature happened to
be built on decided whether its errors were legible, which is not a decision
anyone made. Now all three unwrap.

182 Android unit tests still green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:00:45 -04:00
Paul HittandClaude Opus 5 2855711bc5 Android could never find anybody: the relay answers with an array
Adding a contact was impossible on Android. Every lookup reported "No account
called X here" while the relay was returning the account.

RoutesAccount.lookup answers with an ARRAY — empty for a miss, one element for a
hit — deliberately, so a miss and a hit differ only in length and neither timing
nor shape is an oracle. MessengerApi.lookup sent that through send(), which does
JSONObject(text) and throws on an array, and then swallowed the throw with
runCatching { }.getOrNull(). A parse failure and a genuine miss became the same
thing.

Measured against relay.flamenet.io, before and after, in the running app:

    relay:  [{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]
    before: "No account called appreview2demo here."
    after:  the contact card, with Add contact enabled

⚠️ Three layers kept this quiet: the relay was correct, the failure was
swallowed, and a miss and a crash render identically. CrudSurfaceTest asserts
the lookup CONTROL exists — and it does. **A control being present says nothing
about the feature working**, which is the whole reason this was found by driving
the app rather than by reading the suite.

This is the ONLY array-returning route (grepped), so rawText() is deliberately
narrow: everything else keeps send()'s typed error mapping.

Guarded by MessengerApiLookupTest — hit, miss, and a 500 that must not be
mistaken for a miss. **Verified load-bearing**: restoring the old implementation
fails it. 182 Android unit tests, 0 failures.

Also adds FeatureUseTest, which USES voice notes, groups, stories and calls
rather than asserting their buttons exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:00:45 -04:00
Paul HittandClaude Opus 5 103a2dda23 Sign the messenger with its own key, not Files'
Every release build before this went out as CN=Flamenet Files. Not a decision —
app/build.gradle.kts read ~/.flamenet-android-signing.properties, a path with no
app in its name, so any Flamenet Android app built on that Mac inherited the
Files identity.

It matters because an Android signing key is permanent. Once a device installs a
build signed with one key it REFUSES every later build signed with another, so
the only remedies after the fact are a new package name — a different app,
orphaning every install — or Play App Signing, which has to be set up before
first publication. Sharing one key across products also means one compromised
keystore takes out both.

Now prefers ~/.flamenet-messenger-signing.properties and falls back to the
shared file, so a machine with only the old setup still produces a signed build
rather than failing in a confusing way.

Verified in the artifact:

    Signer #1 certificate DN: CN=Flamenet Messenger, O=Extant 2000 LLC, C=US
    RSA 4096, SHA-256 791fc826d572a70abfaf7db7fa7784dad0f86f204bb313f0d75ee676df8d3a1d

⚠️ The keystore and its password live outside the repo and there is NO recovery
for either. Losing them means the app can never be updated again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:15:36 -04:00
Paul HittandClaude Opus 5 e8d566b3f2 Record what was actually checked for TestFlight
Verified against the built product rather than the source tree, which is what
caught the one blocker: no PrivacyInfo.xcprivacy. Apple requires it for any app
touching a required-reason API and this one uses UserDefaults in five files, so
the upload would have come back ITMS-91053 before reaching TestFlight. The first
archive silently omitted the file because it was added after the project was
generated — hence the note to check the bundle, not the repo.

Also green: archive for generic/platform=iOS, 38 unit tests, launch screen,
usage strings, 1024 icon, ATS with no leftover dev exception, WebRTC's own
manifest, and no tracking SDKs (grepped, not assumed).

Two items left for a human: the collected-data declaration is a legal statement
that must match the ASC privacy label, and versionCode 1 needs bumping after the
first upload.

And a warning that cost an archive: plutil -extract writes back in place without
-o -, so using it to inspect a plist destroys it. Use plutil -p.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:16:09 -04:00
Paul HittandClaude Opus 5 02e5c45cd4 A test that could actually have caught it
`a retired session is kept and still opens its chain` passed while the fix was
broken, because it retires a PRISTINE session — which is not what the receive
path holds. By the time that path decides to retire anything it has just proved
the peer is on another chain by handing this session an envelope it could not
open, and E2ESession is a class, so that attempt consumed skipped keys and
stepped the chain.

The new test reproduces that state and asserts both halves: the damaged instance
must NOT still open its chain (or the test proves nothing), and the copy re-read
from the vault must. It fails against the first version of the fix and passes
against this one.

13 VaultTest cases, 112 engine tests, 0 failures. Both platforms compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:13:59 -04:00
Paul HittandClaude Opus 5 4f15beb160 Retire the pristine session, not the one a failed decrypt mutated
The retained-session fix was necessary and did not work, and a live two-peer
reproduction is what showed it: both second-exchange messages still vanished
with delivered:1, failed:0.

Cause. `DoubleRatchet` is a final class on iOS and `E2ESession` is a class on
Android, so the failed decrypt that PROVES the peer is on another chain mutates
the instance it was handed — skipped keys consumed, chain stepped. Both fixes
then archived that same instance. Storing a part-advanced chain is
indistinguishable from storing nothing: it cannot open what the peer sends. The
vault copy was never handed to a decrypt, so re-read it before retiring it.

Second cause, Android only. Which slot the winner belongs in is not the same
question as which chain opened it. When the retired chain opens an envelope it
has to be written back to the RETIRED slot; promoting it swaps the two and
discards the chain we are sending on. Each branch now writes back deliberately
instead of sharing one `if (session !== existing)`.

Verified against relay1, two peer accounts, no simulators:

    before   p1/p2 poll -> {"messages":[]} / {"messages":[]}
    after    p1/p2 poll -> second-from-Q2 / second-from-Q1

The repro is in PARITY.md and takes about two minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:12:23 -04:00
Paul HittandClaude Opus 5 e62317b6f3 Reproduce the loss without simulators, and show the fix is not sufficient
Two :interop:peer accounts against relay1, both running the retired-session fix.
Neither polls before sending, which is the simultaneous-initiation case:

    p1 send / p2 send   -> delivered:1, failed:0   (each on its own chain)
    p1 poll / p2 poll   -> both first messages arrive
    p1 send / p2 send   -> delivered:1, failed:0
    p1 poll / p2 poll   -> {"messages":[]} / {"messages":[]}

The second exchange is lost in both directions, silently, with the relay
reporting success. Same failure as the two-simulator run with every iOS variable
removed, and it reproduces in about two minutes instead of forty.

So retaining the retired session is necessary and not sufficient. The receive
side can now open the old chain; the loop still does not close. The remaining
gap looks to be on the send side — after adopting the peer's chain each client
encrypts on the adopted one while the peer still reads the one it kept, so
neither is listening where the other is talking. Resolving this properly means
both sides converging on the SAME chain by a deterministic tie-break, not each
adopting the other's.

Recorded so the earlier commit is not read as having closed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:08:34 -04:00
Paul HittandClaude Opus 5 999ab2bd26 Keep the chain a re-establishment replaces
When both sides open the same chat before either has processed the other's
prekey — two people tapping a conversation at the same moment — each runs X3DH
and each ends up adopting the other's chain to read what arrived. From then on
the peer sends on the chain being replaced. Overwriting it without a copy does
not lose one message; it makes that direction undecryptable for the rest of the
conversation, silently, because an envelope that will not open is skipped rather
than reported.

**iOS.** `E2EInbox` had the fallback already — `loadArchivedRatchet` is tried
before building from a prekey, and its comment describes exactly this situation.
It was unreachable: `saveArchivedRatchet` appeared once in the file, and that
call only re-saves a ratchet that was already archived. Nothing ever put a live
session there. It does now, immediately before the session it replaces is
overwritten.

**Android was worse, and had no fallback at all.** `Conversations.poll` read

    val session = existingSession(fromDevice) ?: E2ESession.respond(identity, envelope)

which looks like a fallback and is not one: with a stored session present that
was the ONLY thing tried, `decrypt` threw for a peer on another chain, and the
catch swallowed it. `respond` was never reached. Now three chains are tried in
order — the current one, the retired one, then a fresh one built from the prekey
block — and the replaced chain is kept.

⚠️ The watermark still advances on a total failure, deliberately. An envelope
none of the three chains could open will not open next poll either, and holding
the cursor there would wedge the inbox behind one bad message. What made this
lossy was never the watermark; it was that only one chain was ever tried.

`Vault` grows a retired-session slot under its own prefix — deliberately not
`SESSION_PREFIX`, or `knownPeers()` would list every retired chain as a peer —
and `forgetAllSessions` clears it too, since a sign-out that left chain keys
behind would keep exactly what it claims to erase.

Tests, and the one that matters is the storage-level one:

- `VaultTest.a retired session is kept and still opens its chain` — asserts the
  ADOPTED session cannot open the retired chain before asserting the retired one
  can, so it cannot pass by accident. **Verified load-bearing**: neutering
  `saveRetiredSession` fails it, restoring it passes.
- `VaultTest.forgetting sessions forgets the retired ones as well`.
- `E2ESessionTest.simultaneous initiation does not strand the conversation` —
  covers the engine primitives and asserts the SECOND exchange, the one that
  used to die. ⚠️ It holds both sessions in local variables, so it passes with
  or without the storage fix; it is not evidence for it.

111 engine tests, 0 failures. Both platforms compile.

⚠️ This is a real correctness defect and is fixed on its merits. Its
relationship to the one-way loss seen between two simulators is NOT established
— that symptom is the first message, and both first messages open fine either
way. Do not close that investigation on this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:53:19 -04:00
Paul HittandClaude Opus 5 b6519faada Say what a two-simulator run actually costs
Roughly one second of test time per minute of wall clock, measured with the host
healthy — 38 runnable processes, load 27. The cost is XCUITest snapshotting the
whole accessibility tree per query against a home screen carrying a stories
strip, a buddy list, a groups section and a tab bar.

A DM run is 20-40 minutes per side and the group phase has never completed in
one sitting. Worth knowing before someone schedules this as a quick check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:42:34 -04:00
Paul HittandClaude Opus 5 fbe09d0b61 Disprove my own suspect, and narrow to true simultaneous initiation
The `existing != nil` theory was wrong, twice over. `ratchet` is nil at
E2EInbox.swift:995 only when the envelope is not type `prekey`, so an inbound
prekey never reaches that guard and it cannot drop a first message. And
measurement settles it: testARoundTripWithANonIOSClient passed in 54.2s, with
iOS sending FIRST — creating its own outgoing session — and then receiving and
rendering the peer's reply.

    peer add qrt90210  -> {"added":"qrt90210"}
    peer send          -> {"delivered":1,"failed":0,"to":404}
    iOS round trip     -> passed (54.234s)

Both directions now proven in isolation. What is left is the case neither
experiment covers: both sides sending before either has processed the other's
prekey, which is what two simulators racing through sign-up produces and what a
shell-driven peer never does.

The leading hypothesis moves to line 1006, which overwrites the session built
from an inbound prekey without archiving the one it replaces —
saveArchivedRatchet appears once in the file and only re-saves something already
archived. Recorded with what would confirm it, and with an explicit warning not
to patch on reasoning alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:38:27 -04:00
Paul HittandClaude Opus 5 18499ae746 Narrow the delivery failure to the receive side
The headless peer tool settles the direction. iOS sent to a real shell-driven
account and it arrived:

    testAMessageReachesANonIOSClient   passed (39.2s)
    peer poll -> "hello from iOS to the peer tool", from_user 403

So the send path and the relay are fine, and neither explains two iOS clients
failing one way. What differs there is that both sides initiate a session
simultaneously; the peer tool never initiates, which is why it works.

Suspect is E2EInbox.swift:995. `ingest` returning true means "advance the
cursor" — consume the envelope — and false means hold it for the next poll. The
retry branch is taken only when `existing == nil`. A recipient that already
created its own outgoing session has `existing != nil`, so an envelope it
cannot yet decrypt is skipped forever. That is exactly the simultaneous-
initiation case.

Recorded as a hypothesis with its evidence, not as a fix. It has not been
instrumented, and this file's history includes a previous advance-uncondition-
ally bug that lost messages silently, so the area deserves care rather than a
quick patch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:35:55 -04:00
Paul HittandClaude Opus 5 95659f4685 Record the two reproducible iOS pair symptoms in PARITY
Both reproduce on an idle machine, so neither is load. One-way delivery between
two fresh accounts, and a group create that throws while the sheet sits open.
The evidence that rules out the first wrong theory is written down with them, so
nobody re-derives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:30:00 -04:00
Paul HittandClaude Opus 5 a9ec8e98bb One sign-up helper, because five copies had the same bug
Seven of 52 UI tests failed in one run with "sign-up did not complete". The
app was right and the tests were wrong, and the failure dump said so:

    signup.password  value: •                    ← one character
    signup.confirm   value: •••••••••••••••••    ← all seventeen
    signup.submit    Disabled

iOS puts "Use Strong Password?" over the first `.newPassword` field, and from
the first keystroke on it swallows what follows. Every sign-up helper in this
target called `dismissPasswordSuggestionIfPresent()` — at the end, after both
passwords had been typed, by which point there is nothing left to save. The app
then correctly refused a form whose two passwords disagreed, and the report
blamed sign-up for it.

It is non-deterministic, which is why it reads as flake: a fifth suite running
identical keystrokes passed in the same run. It is not flake and it is not
machine load.

There were five hand-rolled copies of that helper — four here, plus one I wrote
for the new two-simulator suite, which reproduced the bug immediately and is
what makes the point. Now there is one, `XCUIApplication.completeSignUp`, and it
dismisses the panel when the field takes focus, reads both fields back, repairs a
short one, and fails naming the cause instead of the app.

Two new suites:

  TwoSimulatorUITests — two simulators, two accounts, one relay. Both sides run
  the same test with the handles swapped, so neither has to start first; the
  peer lookup retries, which is the rendezvous. A direct message crosses in both
  directions and a group created on one device arrives on the other. This is
  weaker evidence than CrossPlatformUITests and says so in its own header: both
  halves are the same binary and will agree when both are wrong. What it proves
  that nothing else does is that a message crosses two devices, two Keychains
  and two ratchets and lands in the app's own UI.

  AppSurfaceUITests — the screens nothing was opening. Reactions, unsend,
  in-chat search, mute, the voice-note recorder, the stories strip, the groups
  section, the safety number and five of the seven settings screens had no test
  at all. Deliberately shallow and it says so: this catches a screen that
  crashes or fails to push, not one that is wrong.

Also here, each found while running the above:

- DEVELOPMENT_TEAM was "2W7DA3A8W2", the free personal team on the build Mac,
  while the App Store provisioning profile that already exists for
  net.flamenet.messenger belongs to M5923Y84P5 — the team that ships hittfitness
  and shepherdgiving, valid to 2027-06-04. A personal team cannot produce a
  distribution build, so every archive for TestFlight would have failed naming
  the certificate rather than the team ID that put it out of reach.

- Scripts/generate.sh, because .gitignore excludes the whole .xcodeproj and
  SwiftPM keeps Package.resolved inside it. Since the WebRTC dependency landed a
  fresh checkout had no pins at all: resolve against the network and get a
  different build tomorrow, or disable resolution and fail outright. Both
  unacceptable for something going to testers. The pins live in Scripts/ now.

- rounds.sh ran an iOS *build* and called it a round. The 45 XCUITest cases
  never ran. It runs them now — and refuses to score a run that executed zero
  tests as a pass, which xcodebuild will happily report after a runner crashes
  and relaunches:

      Test Suite 'TwoSimulatorUITests' passed
           Executed 0 tests, with 0 failures

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:55:19 -04:00
Paul HittandClaude Opus 5 b4aadda162 Android: voice calls, peer to peer by default
The one third-party library in this app, and unavoidable: the alternative to
`io.github.webrtc-sdk:android` is hand-rolling ICE, DTLS-SRTP and an audio
pipeline. Worth naming rather than absorbing quietly, because everything else
here is deliberately auditable and this is a large native blob that is not.

🔴 **Peer to peer by default.** ICE tries a direct path first — same network,
then each side's address as seen through NAT — and only falls back to relaying
through TURN when both ends sit behind NAT that refuses one. `iceTransportsType`
is ALL rather than RELAY: forcing every call through the server is the opposite
of the default chosen here.

**Signalling rides the ratchet, not the relay.** The offer and answer are
ordinary content envelopes, so the SDP — codecs, and every candidate address —
is end-to-end encrypted like any other message. A relay that could read
signalling would learn both parties' addresses even on a call it never carries.

⚠️ **A direct call shows the other person your IP address.** That is what makes
it direct, and it is not something to find out afterwards. Explained before the
FIRST call, and it waits for an answer — once, not every time, because a prompt
people see repeatedly is one they stop reading. The acknowledgement lives in
ordinary preferences rather than the vault: it is not a secret, and storing it
encrypted would re-show the warning on every sign-out, which is how a one-time
prompt becomes one people click past.

⚠️ **No TURN server is deployed** — `FN_TURN_SECRET` and `FN_TURN_HOST` are
unset, so `/e2e/turn` answers 503. Handled rather than hidden: STUN alone still
connects through most NATs, and the banner says plainly that this server has no
relay so the call connects only if a direct path exists. "Calling did not
connect", with no reason, is the kind of failure people blame on the app for
ever.

Trickle ICE is off. Candidates are gathered before the offer is sent, so
signalling is one encrypted envelope each way rather than a stream of them —
a second or two slower to connect, and far simpler over a channel that is
polled rather than pushed.

⚠️ The microphone is held by the audio source, not the peer connection.
Closing one without disposing the other leaves the mic live with no call
attached, which is the worst failure this particular feature has available.

⚠️ **WebRTC's native libraries dominate the APK**: ~41 MB across four ABIs, of
which x86 and x86_64 (~24 MB) exist only for emulators. Release is restricted
to arm64-v8a and armeabi-v7a — 102 MB debug becomes a 22 MB release — while
debug keeps all four so it still runs on an emulator. Restricted rather than
split per-ABI on purpose: this app is handed to testers as one file, and
per-ABI outputs turn "send them the APK" into "send them the right APK".

A second offer while busy is declined rather than dropped, so the caller hears
something. Group calls are deliberately absent: different key handling, and an
enabled-looking button that cannot work is worse than none.

Tested: 108 engine, 67 client, none failing; debug and release APKs both build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:13:54 -04:00
Paul HittandClaude Opus 5 c1284aab5f Android: member lookup, and the group gaps
**Member lookup.** `MessengerApi.lookup` already existed and nothing called it.
Adding a contact now takes two steps: resolve the handle, see who it is, then
add — and the add button stays disabled until the lookup succeeds.

⚠️ One step is worse than it looks. Adding straight from a typed string means a
typo silently becomes a contact who is a stranger, and the first thing sent to
them is a message meant for somebody else. Seeing a display name first is what
catches that.

⚠️ A miss says "no account called that here" and nothing more. The relay
answers identically for an account that does not exist and one that has not
enrolled — deliberately, so it cannot be used to enumerate accounts — and this
client must not invent a distinction the relay refused to make. The result also
says plainly that it only proves the handle exists, and that identity is
checked by comparing safety numbers once there is a conversation.

This gate broke five existing instrumented call sites, which is the point of
having them: each now looks up before confirming, the way a person does.

**Group images.** Sealed and uploaded ONCE, then one envelope per member —
same reasoning as a story. Uploading per member would multiply the bytes by the
group size and hand the relay a set of blobs it could correlate.

🔴 The composer's photo button called `sendImage(chat.peerId!!, …)` regardless
of thread. In a group `peerId` is null, so sending a photo to a group did not
send to the wrong place — it crashed. Now routed on whether the thread is a
group.

**Group membership** is editable after creation; `updateGroupMembers` existed
with no way to reach it. The copy states what membership actually does: adding
someone lets them read what is sent from now on, removing them stops future
messages, and neither is retroactive. A group cannot be un-read, and a UI
implying otherwise would be the dangerous kind of wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:02:00 -04:00
Paul HittandClaude Opus 5 acee4597d5 Android: stories
An image sealed and uploaded ONCE, then one ordinary story envelope down each
contact's existing ratchet. Uploading per recipient would multiply the bytes by
the size of the contact list for nothing, and would hand the relay a set of
distinct blobs it could correlate by size and timing.

⚠️ **There is no story feed on the relay, and there should not be.** A
server-side story needs the relay to know who may read it — which means giving
it your contact list for a feature that works without one. The cost is that
posting is a fan-out and partial delivery is normal: a contact with no
registered device cannot receive anything, story or not, so the count is
reported rather than the post failing.

⚠️ **A story bypasses the thread entirely.** It belongs to the strip, and
filing it as a message would drop every story somebody posted into a
conversation they did not have. One that arrives already expired — posted while
this device was offline — is dropped rather than shown late.

⚠️ **Expired stories are deleted on WRITE, not filtered on read.** Filtering on
read leaves a store that grows for ever and still holds every image anybody
posted, which is the opposite of what an expiring story promises. The bytes
actually leave the device.

🔴 **Expiry is a promise, not a guarantee, and the empty-state copy says so.**
This client deletes the bytes when the clock runs out because it chooses to.
Nothing stops a recipient's client keeping them. A UI implying otherwise would
tell people a story is safe to post when it is not, which is worse than not
having the feature.

Unseen is marked rather than styled away — a strip where read and unread look
alike is a strip nobody returns to. Stories are read back on every poll rather
than only when a thread was touched, because a story files itself outside the
thread map and would otherwise not appear until something else arrived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:39 -04:00
Paul HittandClaude Opus 5 4728928d98 Android: voice notes
The engine and the client already carried audio — `Attachment.AUDIO`, a
`duration` field, and a `sendAttachment` that takes both. What was missing was
entirely the phone: something to record with, something to play with, and two
controls. So this is app-layer only, and adds no dependency: `MediaRecorder`
and `MediaPlayer` are platform APIs, and a voice note is an ordinary encrypted
attachment the moment it is bytes.

AAC in an MP4 container, mono, 32 kbit/s. Speech rather than music: clearly
intelligible, and roughly a tenth the size of the stereo default nobody asked
for — which matters when every byte is uploaded encrypted and stored by
somebody paying for it.

⚠️ **Tap to start, tap to send — not hold-to-talk.** Hold-to-talk demonstrates
well and behaves badly on a real phone: the gesture competes with scrolling,
and letting go early silently discards what was said. Two deliberate taps
cannot be lost by accident.

⚠️ **`MediaRecorder.stop()` throws when it captured no frames** — a tap instead
of a hold, or a permission granted and then denied by policy. The exception is
a bare `RuntimeException` and it reads as "recording is broken" when the truth
is "there was nothing to record". Treated as an empty recording, and anything
under half a second is dropped rather than delivered: a note that plays silence
looks like a delivery bug, and the person who fumbled the button already knows
they did.

⚠️ **The microphone permission is requested when somebody asks to record**, not
at startup. A microphone prompt on first launch, before anything has been said,
is one people deny on reflex — and a denied permission is far harder to recover
from than one that was never asked for.

⚠️ Received notes are fetched and decrypted **on tap, never on render**. A
thread of twenty would otherwise download and decrypt all twenty on scroll:
bytes nobody asked for, and twenty decrypted files in the cache for a list
nobody has listened to. Playback stops on leaving the thread, because a
MediaPlayer that outlives its composable holds audio focus and a file handle.

Every temporary file — the recording, and the decrypted copy playback needs
because `MediaPlayer` cannot take a byte array — is deleted on both the success
and the failure path. Plaintext audio left in a cache directory is the exact
thing this product is not supposed to do.

`imageBytes` became `attachmentBytes`: photos and voice notes take the same
path, and a method called `imageBytes` that audio also calls is a name that has
quietly stopped being true.

Also retires buddy commands in the parity table — no longer a feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:37:31 -04:00
Paul HittandClaude Opus 5 a213ad8d5d Android: sealed sender, so the relay stops learning who talks to whom
Key transparency stopped the relay substituting keys. It did nothing about the
relay watching who writes to whom, which is the metadata most people actually
leak. §14 moves the sender's identity inside the ciphertext: the relay learns
who a message is for and nothing about who it is from.

Verified against relay1 rather than asserted. A sealed submission is accepted
unauthenticated, the stored row comes back with `from_user = 0` — the relay
saying it does not know — and the recipient still reads the message with the
sender recovered from inside the seal.

Cross-engine verified in BOTH directions against the normative web engine, and
that mattered: the seal is asymmetric in practice, since one side derives from
an ephemeral it generated and the other from one it was handed, so a byte-order
or AAD mistake can easily break only one direction and a single-direction test
picks the half that works. The interop harness gained `seal` and `open_sealed`
ops for it; 43 assertions, none failing.

🔴 **The seal is confidentiality, never authentication.** The recipient's
`IK_dh` is published to the world, so anyone can produce a well-formed seal
claiming to be from anybody. The claimed sender picks a session and nothing
else; the ratchet opening it is what makes it a fact, and the identity pin is
what refuses an impostor. There is a test that forges a sender deliberately, so
that if somebody later renames these fields into something confident the reason
they are claims is still written down and still executing.

⚠️ **The first message to a new contact cannot be sealed.** Sealing needs the
recipient's delivery key and that key travels inside an E2E message, so a prior
exchange is required. `Sent.sealed` reports which happened rather than leaving
the UI free to imply anonymity that was not achieved — a silent fall back to an
unsealed send is exactly what §14.5 warns about.

⚠️ **Rotation is blunt, and the copy says so.** There is no sender to block, so
the only lever is replacing the delivery key, which cuts off every contact
until the next time they are written to. Reachable from Account, asserted by
`CrudSurfaceTest`, and live-tested: a submission presenting a retired key is
refused by the relay.

The relay stores only `SHA-256(key)`. The key itself never passes through the
relay from its owner, which is the only reason an unauthenticated sealed
submission can be authorised without naming who is submitting.

Tested: 108 engine, 67 client, 43 interop, none failing, live suites enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:31:07 -04:00
Paul HittandClaude Opus 5 cfeefa0616 Contacts carry the peer's address, so an honest key verifies
The relay files a key-log leaf under an account's fnid once it has one, and
under `local:<audience>/<user_id>` before that. The subject is inside the
hashed bytes, so a verifier that does not know the address cannot recompute the
leaf — and enforcement refused honest keys from any account that had claimed an
identity. A false positive on a real conversation is how a security setting
gets switched off and left off, so this is the half that decides whether the
feature is usable at all.

No relay change was needed. `GET /e2e/account/identity/{handle}` already
resolves a handle to an address, unauthenticated, and the buddy list already
carries the handle. The contact record now learns the address once and pins it
beside the identity keys, and `enforceKeyLog` recomputes against the pinned one
by default.

⚠️ **Pinned, not looked up per check**, and that is the entire point. Reading it
fresh each time would let a relay that wants to substitute a key simply also
change the name it files that key under, and every verification would agree
with the new answer. Recomputing against the address this contact has always
had is what makes the check about a person rather than about whatever the relay
says today.

First contact is still trust-on-first-use. Taking the relay's word once is not
a check; what it buys is that every later answer has to match it, and the
safety number is what covers the first sighting.

🔴 A CHANGED address is an identity change, for a stronger reason than a
changed key: an fnid is derived from the account key, so a new address is not a
new device — it is a different account key, and therefore a different person.
Accepting a change re-resolves rather than keeping the old address, because if
the address is what changed, keeping it would leave every later check
recomputing against a name the account no longer publishes under, and honest
keys would stay refused for ever.

⚠️ An unreachable relay pins NOTHING rather than pinning "no address".
`rememberIdentity` does nothing once an address is pinned, so a wrong answer
would be permanent — and the next successful lookup would then read as the
address changing, which is the alarm, for a network blip.

Proven against relay1 in three states, because only all three make it legible:
refused with nothing pinned, accepted once the contact carries the right
address, and refused again with a wrong one — so what makes it pass is the
address being right, not the check having been loosened. A changed address is
asserted to come back CHANGED against the real relay too.

Tested: 100 engine, 65 client, none failing, live suites enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:19:01 -04:00
Paul HittandClaude Opus 5 6a6bcc9d08 §19 against relay1, and the false positive it found
The enforcement landed last change was unit-tested against a MockWebServer
carrying responses this repository wrote — which proves the verifier agrees
with our idea of the relay, not with the relay. That distinction has already
cost this protocol three silent wire-shape bugs, every one invisible to a fake.

`LiveKeyLogTest` runs it against relay1's real log, opt-in via `FN_LIVE_FLEET`.
Six cases, all passing: a key the relay genuinely published verifies; a
substituted key is refused; a second check reconciles a pinned head with a
grown tree using proofs the relay generated; a root we never saw at our pinned
size is a fork; and the send path refuses on a bad pin while NOT refusing an
honestly published key with enforcement on.

**And it found a false positive on honest keys.**

An account that has claimed an fnid is logged under it, and under
`local:<audience>/<user_id>` before that. The subject is inside the hashed
bytes, so a verifier that does not know the fnid cannot recompute the leaf. The
key was published perfectly well; the check cannot see it. The test pins the
behaviour both ways — refused without the fnid, verifying with it — so this is
a fact in the suite rather than a comment somebody may or may not read.

This is the worst shape a check like this can fail in. Refusing real
conversations is how a security setting gets switched off and left off.

Nothing in the relay's contact or device routes returns a peer's fnid, so the
client cannot learn one today and this is not fixable here. What is fixed:

  - `KeyNotLoggedException.hadEntries` separates "this device has publications,
    none of which match" from "nothing was ever logged for this device". Both
    still refuse — refusing is the safe direction — but they no longer produce
    the same sentence, and only the second is the shape a substitution takes.
  - `enforceKeyLog`'s `fnid` parameter now documents that it is REQUIRED for any
    account that has claimed one, rather than reading like an optional extra.
  - `RelayClient.claimAccountIdentity` exists, because the test needed to claim
    an identity to prove any of this and Android had no way to.

The real fix is for contact records to carry the peer's fnid, which is stronger
than the current check anyway: it verifies the key was logged under *that
person*, rather than under whatever subject the relay chose to file it against.

Tested: 100 engine, 58 client, none failing, live suites enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:11:11 -04:00
Paul HittandClaude Opus 5 0330a7b84a Android: refuse a key we have reason to distrust
Android had the transparency log's verifier and nothing that called it. The
parity table listed trust state, verification, identity-change acceptance, and
key-log enforcement as absent, and they were — so the relay could hand out a
key it minted, the conversation would encrypt to it, and every byte on the wire
would be correctly encrypted to the wrong person. A log nothing checks records
the substitution faithfully and is never read.

Two independent checks, because neither is sufficient alone.

**Pinning, always on.** Every identity key a peer offers is compared against
what was pinned on first contact; a send to a changed identity is refused until
a person accepts it. The comparison is "every offered key is already pinned",
not "any" — a relay adding one key of its own ALONGSIDE the real ones is the
attack, and the fact that all the genuine devices still work is exactly what
makes it invisible. There is a test for that case specifically.

**The log, opt-in.** `require-key-log` on the Account screen makes a send
demand that the relay prove the recipient's key appears in the log it
publishes. Off by default for the same reason the web engine's is: a relay
predating the log has none, and refusing every peer on those deployments is a
worse failure than the one being prevented.

`KeyLog.verifyConsistency` did not exist in the Kotlin engine. Inclusion alone
is worth very little — a relay that rewrites history reissues perfectly valid
inclusion proofs against its new tree, honest proofs about a dishonest log.
Consistency against a head this device already pinned is the one thing it
cannot produce. Ported from `engine/src/keylog.js`, which is normative, rather
than re-derived: a re-derivation that accepts too much passes every honest test
and fails only against an attacker.

Head gossip rides along with messages and compares a peer's view against ours,
which is what turns a per-device check into a detectable fork — alone, each
client only knows the relay was consistent with itself.

Tested, 100 engine / 52 client, none failing. Consistency is checked over EVERY
pair of prefix sizes in the published vectors, because the traversal has a
special case when the old size is a power of two: an implementation that gets
that wrong still passes 3 -> 5 and fails 2 -> 5. The proof builder the test
uses is trustworthy because the tree it builds is the one whose roots already
matched the published vectors at every size.

The refusals are what carry the weight, so each has its own test: a head going
backwards, two roots at one size, a tampered proof node, a served key absent
from the log, an inclusion proof for somebody else's leaf, and a rewritten log
that cannot be reconciled with a pinned root. A trust check that accepts
everything passes every happy-path test ever written.

Reachable from the UI, per the standing rule. The thread shows identity state
and the safety number; the changed state offers accept and nothing else,
because an "ignore" affordance would be a way to silence the one signal in this
product that means interception. `CrudSurfaceTest` asserts the controls exist.

⚠️ Not yet exercised against a live relay — the tests drive a real HTTP server
with hand-built responses. Implemented and unit-tested, not proven in the
field. ⚠️ Sealed sender is still absent, so the relay still learns who talks to
whom; these protect different things.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:48:44 -04:00
Paul HittandClaude Opus 5 76d485fb55 Bring the parity table back in line with what has been run
Three rows had drifted from the code, in the direction that matters — claiming
less than exists, which sends people to build things that are already there.

`SiteConfig` on Android is not missing: `SiteConfig.kt` holds the three
deployment shapes, `MainActivity` has the relay field, and the screen shows an
`identity-mode` label naming which kind of server it is *before* a passphrase
is typed. Marked ⚪ regardless.

Sign-up on the managed and shared shapes now has a test rather than nothing —
`LiveSignUpFleetTest` — which compiles but has not been run, so the row stays
🟡. The distinction is worth keeping: written and unrun is not built and
unexercised, and neither is done.

Send/receive text was verified app-to-app this time, not app-to-harness, and
the evidence is recorded with the log lines it came from.

Also added the two environment traps that cost the most time today, because
neither is visible in a test log: XCUITest degrades into "Timed out while
synthesizing event" and 1700-second runs when macOS background daemons are
saturating the machine, and Spotlight indexes DerivedData unless the path ends
`.noindex`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:55:33 -04:00
Paul HittandClaude Opus 5 388e3001af The three fleet suites, and Android sign-up on the two shapes it never tried
Four files that were proving things nobody could re-run. Three of them existed
only as loose scripts in a scratchpad — they are the only tests of federation,
of the fleet's invariants, and of multi-homing that exist anywhere, and none of
them was in the repository. All three pass against the live fleet as committed:
federation 11, invariants 10, multi-homing 12.

What they cover, which is why they are worth keeping rather than rewriting:

  federation.mjs      relay1 → relay2 across the boundary, including that the
                      envelope carries no sender attribution once it crosses,
                      that a relay off the allowlist is refused, and that an
                      unknown address is reported back rather than dropped.
  fleet-invariants    the things one relay cannot show: a claim signed for
                      relay1 refused at relay2, a device token that does not
                      travel, and a handle resolving only within its own relay.
  multi-homing        one send, fanned out to both of Bob's homes though only
                      one was named, readable from either, sharing the id that
                      marks the copies as one message.

`LiveSignUpFleetTest` is the Android half of the two shapes the parity table
listed as built but never exercised: the managed relay, and a self-hosted relay
keeping its accounts in the shared service. iOS has had `LiveSignUpUITests` for
both; Android had only the local-accounts shape.

The assertion that carries the weight there is the ABSENCE of the email field.
A relay with its own account table asks for an address to send a reset code to;
the shared service never does. An email field still on screen means discovery
did not land and the passphrase is about to be stretched with the wrong salt —
which does not error. It derives a different account from the same passphrase,
and the person finds out on their next device.

Opt-in through an instrumentation argument, not an environment variable: env
vars do not reach the instrumentation process, so an env-gated skip stays
skipped however it is invoked and the suite reports success having run nothing.
The file also records the sign-up budget these share with the iOS suite — three
per hour from one address — because exceeding it looks like a broken sign-up.

Verified: `:app:compileDebugAndroidTestKotlin` clean. The instrumented test
itself is unrun; it needs a device and the hourly budget was spent proving the
iOS side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 07:21:40 -04:00
Paul Hitt 824e822acf Merge main: relay hotfixes and the docs pages
Conflicts were comment wording only, from the WordPress purge. Kept this
branch's prose: main's side carries the rename's artifacts ('the the
previous platform'), and the code either side registers is identical.
Verified after resolving — one api index, no wp-json index, the
federation refusal path, HomeRelays, and FN_ACCOUNTS_URL all present.
2026-08-24 17:06:37 -04:00
Paul Hitt ff975bf36d Trust the relay you typed, pin the relays that are ours
Correcting a rule I got wrong earlier today. I had the client accept a named
account service only if it was on a pinned list containing exactly
accounts.flamenet.io — which is safe and breaks the case the server already
supports: a self-hoster running Messenger and Files together, with their own
account service holding the one account across both. The file server has taken
ENCLAVE_ACCOUNTS_URL for exactly that since it shipped, and main's relay makes
FN_ACCOUNTS_URL configurable for the same reason. A client refusing the answer
would make "run both" mean "run them with two separate logins".

The rule is asymmetric, and the asymmetry is the point:

  - For flamenet.io and its aliases the account service is PINNED in the client
    and whatever those relays advertise is ignored. Compromising one of ours
    must not let it redirect its users' passphrase-derived secrets.
  - Every other relay is believed. The user typed that hostname themselves; a
    server they chose naming its own account service is a choice they already
    made, and it cannot redirect anybody else's users.

Two tests, one for each half: a pinned relay advertising an attacker's service
still authenticates against ours, and a self-hosted relay may name its own.
2026-08-24 15:39:29 -04:00
Paul Hitt ec03f469c7 Three clean rounds, and the rate limit that shapes them
Item 4. Three consecutive rounds of the Android on-device suite (18 tests each)
and three of the iOS sign-in suite (7 each), all green, against a real relay.
`Scripts/rounds.sh N` runs every layer that can run on a given machine and
prints one line per layer per round -- because "three clean rounds" should be a
command, not a ritual somebody performs from memory and reports on.

⚠️ **A skipped layer prints SKIP, never a pass.** A round that silently did
nothing looks exactly like a round that worked; that is how a suite stops
meaning anything.

The shared-account rounds needed rethinking rather than retrying. The account
service allows **three registrations per address per hour**, deliberately --
forty sign-in attempts is a person with a password manager and a bad afternoon,
forty accounts is a flood. Three sign-UPS per shape consumes an hour's budget
for one shape and fails the next, so a test written that way measures the rate
limiter rather than the product.

So sign-up is proven once per shape and the repeatable part is what a person
actually repeats: sign in, register a device, be reachable -- three rounds,
asserting every round lands on the SAME relay account. The relay keys on the
product-scoped pseudonym rather than the handle, and if it keyed on the handle
each round would quietly make a new account and nobody would notice until
messages stopped arriving.

Fresh-registration rounds stay on the local shape, which has no such limit
because a self-hosted relay is not a shared service.

⚠️ The backoff no longer pretends it can outwait an hour. It fails with the
actual constraint spelled out, because a correct defence dressed up as a flaky
test is worse than either.
2026-08-24 06:20:32 -04:00
Paul Hitt ca626b3368 Public pages deployed, and one credential across two products
Item 7: the comparison, landing and donation pages were written on a branch and
never deployed, so the live site described a product that stopped being accurate
several changes ago. Cherry-picked and deployed; the comparison page now says
relays federate, that an address is derived rather than issued, and why
federating is a trade rather than a win.

Donation flow verified end to end against production keys: out-of-range amounts
are refused before Stripe is contacted at all, and a valid amount creates a real
Checkout Session and redirects to it. No payment was made -- an unpaid session
expires on its own. ⚠️ I could NOT verify with test keys: there is no Stripe
test key on the box, and inventing one proves nothing.

Item 9, which is a narrower claim than it looks and worth testing exactly:
**hosting only Messenger leaves nothing missing.** Proven three ways -- a local
relay names no account service at all, runs a full sign-up and sign-in cycle
with nothing else involved, and never probes for Files.

And when the shared service IS used, one handle and passphrase signs in to both
Messenger and Files. The two sessions carry DIFFERENT pseudonyms, which is the
point rather than a detail: same-pseudonym would let the two services correlate
their users, which is exactly what a shared account service must not enable. A
Files-scoped token is also refused by the Messenger relay -- the product scope
is a boundary, not a label.

⚠️ The shared-account tests now back off and retry on the account service's rate
limit, because a dozen registrations in a burst is something any sane service
should refuse. They still fail if it never clears: a rate limit that does not
lift is a real problem, and skipping on it would hide one.
2026-08-24 05:40:01 -04:00
Paul HittandClaude Opus 5 9ced7dc3f0 All three deployment shapes, and the one that was unreachable
"Self-hosted with shared identity" could not work. Both clients decided where
accounts lived from a hardcoded list of relay hostnames, and a stranger's relay
is never on that list -- so somebody self-hosting could have the shared FlameNet
account service configured on their relay and the app would still authenticate
against the relay itself. Mode 2 existed on the server and nowhere else.

The relay already published the answer at `/.well-known/flamenet/relay`:
`accounts: shared|local` and the service URL. Nothing read it.

Now the client asks, and then checks the answer against a pinned list of account
SERVICES rather than of relays. That keeps the property the hostname list was
protecting -- an operator cannot point their users' passphrase-derived secrets
at a host they control, because a service the client does not already trust is
treated as local -- while letting any relay adopt the shared service. Unknown or
unreachable descriptor also means local, which is the safe direction: the
passphrase goes only to the server the user typed in.

relay2 is now configured as that third shape, so all three exist to test:
managed (relay.flamenet.io), self-hosted shared (relay2), self-hosted local
(relay1, relay3).

`LiveShapesTest` covers sign-up three rounds per shape, a conversation per
shape, that each relay reports what it actually is, and that a relay naming an
untrusted account service is refused. Fresh handles every round, so rounds are
repeatable by construction rather than by cleanup.

⚠️ Two things worth keeping:

Skips are announced. `return` from an unmet precondition makes a test that did
nothing report PASSED -- I had four of those reporting green before switching to
`assumeTrue`, which is the same silent-skip trap I had already criticised in the
Swift interop harness. A suite of silent skips is indistinguishable from a suite
that works.

The handle travels beside the shared-session token, never inside it. The account
service will not put a handle in a token, so a relay cannot correlate its users
with another product's; the relay keys on the product-scoped pseudonym and uses
the handle only the first time it sees one. Tested by signing in twice and
asserting the same relay account comes back.

Also deploys the discovery-document fix to relay.flamenet.io, with a database
backup and a rollback image. The transparency-log root is byte-identical after
the deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 05:28:15 -04:00
Paul HittandClaude Opus 5 e07660559c No more WordPress, and the bug that made iOS unusable
The iOS app could not sign up against a Swift relay at all. Sign-up died on a
bare "Server returned HTTP 404." with nothing pointing at the cause.

The cause: the relay served its route index under BOTH `api` and a legacy
prefix, while serving the actual routes only under `api`. The app settles its
REST prefix by probing for that index and keeping the first prefix that answers
-- so it adopted the legacy prefix, and every call 404'd.

⚠️ This was supposed to have been fixed yesterday, and was not. `f5614ab`
(Aug 21, "Serve the four routes the app was calling into a 404") is what
INTRODUCED the two-prefix index loop; it was the cause, not the cure. The rule
got written down and the code change never landed. My own notes asserted a fix
that no branch contained. Believe grep.

The rule, which is more general than this bug: **serve a discovery document only
where the things it describes exist.** A document listing routes a prefix does
not serve is worse than no document, because it is believed.

Fixed in three places, because one was not enough:
  - the relay serves the index only under `api`;
  - the client probes only `api` -- there is one prefix, and probing for others
    only creates ways to be wrong;
  - the index no longer advertises routes that are not mounted.

And the purge the owner asked for: no `WordPress`, `wp-json`, `wp/v2`,
`wp-admin`, `wp-login`, `wp_` or `wp-cron` anywhere in the relay, the iOS app,
the Android app or the tests. `/api/wp/v2/users/me` is now
`/api/flamenet/v1/messenger/me`; the user lookup moves alongside it; the dead
`ajaxURL` and the two error cases only a web login form could produce are gone.

⚠️ Recorded because it cost time: the deployed relay source on the box is NOT in
any branch. It carries multi-homing `envelope_id` work that exists nowhere in
git, so copying local files over it broke the build. Restored from the prod
tree, then patched in place. That divergence needs closing.

Adds CrossPlatformUITests (iOS) and CrossPlatformTest (Android) -- each driven
by arguments so the two platforms can be interleaved from a shell, because two
iOS accounts in one simulator prove only that the app agrees with itself.

With this, item 5 is done: a message typed in the iOS app arrived and rendered
in the Android app, through relay1, end-to-end encrypted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:46:54 -04:00
Paul HittandClaude Opus 5 f2d74cff0c Android: groups, and the wire shape iOS got wrong
Groups were 100% broken on iOS -- not partly, entirely -- because the client
required a `name` on each member that the relay has never sent. Every group
decode failed, so the feature looked implemented and did nothing.

So this one was built by reading the relay's wire types first and asserting
them against live bytes second. `Member` is `{user_id}` and only that: the relay
has no user table and deliberately will not grow one, because a relay caching
display names is a relay that leaks them. Names are resolved client-side from
the contact list, and a member you have never added shows as an id, which is
honest.

Create, list, open, add and remove members, leave, and send -- all verified
against relay1, including that a group message carries its routing so it files
under the group rather than under whoever sent it.

Two things worth stating plainly:

The relay does no fan-out. A group is a membership list it keeps so clients
agree who is in it; the encryption is still pairwise, one ratchet per recipient
device. An n-person group is n sends. That is the honest cost of the relay not
being able to read anything, and the client is what knows it.

Thread history is now keyed by an opaque thread id -- `user:<id>` or
`group:<gid>` -- rather than by user. Filing a group message under its sender
scatters one conversation across as many threads as it has participants.

Leaving a group also clears its local thread. Keeping it would leave a
conversation nobody can add to and no way to remove it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:32:02 -04:00
Paul HittandClaude Opus 5 e65d1d987b Android: photos, and a door for every capability
Owner feedback, and it was fair: capabilities kept landing in `:client` with a
live test proving them and no way for a person to use them. Remove a contact.
Block someone. Clear a thread. Change a password. Read a notification. All
built, none reachable.

So this is the CRUD pass, plus attachments.

New in the UI: contact actions (block, unblock, clear chat, remove), a presence
picker, a notifications screen with per-item and mark-all-read, an account
screen with display name, password change and account deletion, password reset
on the sign-in screen, bottom navigation, and photos -- sent through the system
picker, rendered inline.

`CrudSurfaceTest` walks the app and asserts the controls exist. It checks
reachability rather than behaviour, because behaviour is what the live tests
cover and the door is what was missing. It found two real gaps immediately:
sign-out was reachable only from the contact list rather than from Account,
where people look for it, and several controls sat below the fold.

Underneath: the §6.6-§6.9 content envelope, which is one codec for attachments,
replies, reactions, group routing and call signalling. 17 unit tests.

Two things it has to get right, and both are one-line mistakes:

`fnc` must serialise FIRST, because the marker is a prefix test on the bytes. A
JSON library that sorts keys puts `att` first, every envelope it produces reads
as plain text on the far side, and somebody's chat fills with raw JSON. The top
level is assembled by hand.

Control messages travel down the same channel as chat -- the web client sends
`delivery_key`, `kt_head` and `sent` copies this way. A client that renders
every envelope it does not recognise shows those to the user. They are now
parsed, marked unrenderable, and handed to the caller with their payload.

Attachments verified end to end against a real relay, including that the relay
holds only ciphertext. That path was 401 on iOS for months because the
attachment routes sent the account password, which the relay refuses on
`/e2e/…`; this one uses the device-scoped token like everything else, and the
test is what says so.

Also: the instrumented suite passed one test at a time and failed as a set. The
Compose rule gives each test a fresh Activity but the process and vault survive,
so the app spends its first moments reading the vault behind a spinner -- and a
reset that checks for a signed-in control immediately concludes "already signed
out" while the session restores underneath it.

114 JVM tests, 37 cross-engine, 15 on-device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:47:45 -04:00
Paul HittandClaude Opus 5 d30a77dd1e Android: contacts, conversations, and a message that made the round trip
The app now holds a conversation. A message typed into it reaches a different
program through the real relay, that program answers, and the answer appears in
the thread -- verified three times, end to end, encrypted the whole way.

96 JVM tests, 37 cross-engine, 11 on-device, plus live round trips against the
throwaway fleet.

What landed: a fan-out send that reaches every device a person has, inbox
polling, message history encrypted at rest, contacts, presence, and a session
that survives a restart. Plus `:interop:peer` -- a Flamenet account you can
drive from a shell, so the correspondent on the other end is not this app
talking to itself.

Three wire shapes were wrong, and only a real relay could say so:

  - `prekeys`, not `opks`. The relay answered 200 and stored nothing, so the
    device was published and could not be started with.
  - The send body names the recipient once at the top, not per envelope.
  - The buddy list is `id`/`presence`/`screen_name`, not `user_id`/`status`.

The second and third at least failed loudly. The first did not, which is why
the live tests exist.

Decisions worth stating:

A send returns partial success rather than throwing. One of someone's three
devices being unreachable must not stop the message reaching the other two, and
the UI says "delivered to 2 of 3" rather than "sent".

The inbox watermark advances per message, after it is decrypted AND stored --
never once for the batch, which would lose everything after a crash. An
undecryptable message advances it too: it is never going to decrypt, and
leaving it would re-fail on every poll for ever.

A sent line is recorded only once a device took it. Writing it first produces a
thread showing messages the recipient never got, which is worse than an error --
the sender believes they said something.

Opening a thread marks it read locally and tells the relay nothing. A read
receipt is a disclosure, and this product does not make one by default.

History is keyed by person, not device. Someone's phone and tablet are one
conversation, and filing by device would split it into threads each holding
half the story.

Also improves how instrumented failures report: the assertion now prints what
the screen was showing. That turned a bare 90-second timeout into "No value for
user_id" and then "Failed to connect to relay1" -- one a bug, one the emulator's
network, and previously indistinguishable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:55:49 -04:00
Paul HittandClaude Opus 5 5f983c1455 Android: the app, and the two things only a phone could tell us
There is now a Flamenet Messenger app that installs, launches, creates an
account on a self-hosted relay, registers a device, publishes prekeys, and
stores all of it in an Android Keystore-backed vault. Verified on an emulator
through the actual screens, not by unit test.

92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays.

Two findings, both of which the JVM suite was structurally incapable of
producing.

**Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and
`KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed
on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every
Ed25519 key in this engine is one we must hold: a device identity in our own
vault, an account key derived from a passphrase. So the JCA route could never
have worked. It compiled, passed 81 tests on a laptop, and threw "Not
initialized" on the first launch on a phone. Worse, it was LATENT: a wrong
password failed before any key was minted, so the error path worked perfectly
while the happy path was broken.

Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's
one real dependency, present because the platform leaves a hole, and a strictly
better answer than the non-constant-time keygen it replaces. ProviderProbeTest
records the measurement so nobody guesses again.

**The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the
registration, returned 200, and stored nothing -- so the device was published
and could not be started with, a failure with no error anywhere. Caught by a
live test against the throwaway fleet, which exists because the same path
through the emulator takes four minutes to say "timed out" and nothing about
where.

Two testing traps, recorded where they bite:

An indeterminate progress spinner animates for ever, so Compose's test clock
never goes idle and `waitUntil` never evaluates its condition -- it fails on the
full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is
now disabled around the wait only; disabling it for the whole test is the next
mistake, because the app opens on a spinner while it reads the vault.

Wiping the Keystore in `@Before` deletes the key the already-launched activity
is holding, and every later write fails against a key that no longer exists. The
test signs out through the UI instead, which is both safe and what a person does.

Failure messages now include what the screen was showing. A bare timeout sends
you to the network, the relay and the crypto in turn while the app has been
displaying the reason the whole time.

Also corrects a note that said this Mac had no Android SDK. It does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:02:16 -04:00
Paul HittandClaude Opus 5 b1ba9cdc58 Kotlin: the relay transport
The engine can hold a conversation but had no way to reach a relay. This is
that: the §8 routes, both authentication modes, and the transparency log.

13 tests against a real HTTP server rather than a hand-written fake. That
distinction is the point -- a fake written alongside the client agrees with it
about everything including the field name it got wrong, which is exactly how
this protocol's one shipped bug survived until a live round trip.

Two more tests run against the deployed relay, opt-in via FN_LIVE_RELAY and
silent when unset. A test that reaches the network cannot sit in a hermetic
suite; it fails on an aeroplane and a suite that is red for unrelated reasons
stops being read. What they check is the thing MockWebServer structurally
cannot: that the URL this client builds is one relay.flamenet.io actually
serves. It does, on both mounts, and the Kotlin engine verified an inclusion
proof from the production log against a head fetched separately -- not against
the root the proof response carried, because a server that supplies both halves
of a comparison has proven nothing.

Details that are load-bearing:

The per-request signature covers the LOGICAL section-8 path, not the URL's. The
relay mounts these routes twice and signs against the former, so signing the
REST-prefixed path yields a valid signature over the wrong string and a refusal
with no hint as to why. Tested by asserting the other one does NOT verify.

The nonce is not decoration. Two inbox polls in the same second with the same
empty body would otherwise produce identical signatures, and the relay's replay
cache cannot tell an honest repeat from a captured one -- so it refuses the
honest client and the app quietly stops receiving mail.

Token minting is lazy, and a deployment with no token route is asked exactly
once. Laziness cost an hour here: the tests hung, because takeRequest() blocks
forever and I was waiting for a mint that had not happened yet. They now use a
bounded read, so that mistake fails instead of looking like a hung build.

Application passwords are stripped of spaces. The account service prints them
in readable groups and users paste what they were shown; sending the spaces is
a 401 nobody can diagnose, because the password on screen looks right.

Kotlin nests block comments, so `/e2e/` followed by a star inside a doc comment
opens one that never closes and the compiler blames the last line of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:37:59 -04:00
Paul HittandClaude Opus 5 8a3770f1d1 Kotlin: the session layer, verified across the wire
An app does not use X3DH and a ratchet, it uses "send this string to that
person". This is that layer: device identities with prekeys and rotation,
envelopes as the relay actually carries them, and sessions that survive being
put away and taken out again.

11 unit tests, and the interop harness grows from 23 assertions to 37 -- the
new ones at the envelope level, which is where this protocol's one shipped bug
lived. A client and a relay disagreed about a field name and nothing caught it
until a live round trip; hand-assembled arguments to X3DH would not have.
Kotlin now builds a whole envelope, the web engine's own session layer parses
it, and both directions agree on the associated data.

Three decisions worth stating.

Retired signed prekeys are kept for longer than the relay keeps undelivered
mail. A sender fetches a bundle, encrypts against that prekey id, and the
envelope can then sit in a queue for the relay's full retention window.
Discarding the private half at rotation turns every one of those into an AEAD
failure -- which the receiving client cannot distinguish from an attack. A
prekey retired past retention is refused outright rather than guessed at, for
the same reason: guessing derives a garbage secret and reports it as tampering.

The handshake block rides every message until one is answered, not until one is
sent. Any message may be the first to arrive.

Sessions archive with their skipped message keys, and the harness proves it
against the peer rather than against our own bytes. Those keys are the messages
already in flight; an archive that drops them loses exactly the mail that was
in the air when the app was killed. The failure this whole path exists to
prevent is a client that loses session state, re-registers a device, and shows
every contact "security keys changed" -- the warning that is supposed to mean
an attack.

`org.json` is compileOnly: it is in the Android framework, so it costs nothing
on a phone and bundling a copy would shadow the platform's. Safe only because
the two encodings that must match byte-for-byte -- the ratchet header and the
canonical log leaf -- are built by hand; Android's implementation preserves key
order and the reference one does not.

Also adds PARITY.md: what the iOS app does, what Android does, what is left.
It is the definition of done, and a row moves only when something tests it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:12:14 -04:00
Paul HittandClaude Opus 5 fc93df24e6 Kotlin: Ed25519, and cross-engine interop against the web engine
The seventeen Kotlin tests that landed with X3DH and the ratchet prove the
engine agrees with itself. That is not the property anyone needs. Two engines
written by the same person agree about the same misreadings of the spec, and
every one of those shows up months later as "the message will not decrypt" on
someone else's phone. So this wires the Kotlin engine up against the web
engine, which is normative, in both directions.

23 assertions, all passing: v1 X3DH both ways, identical associated data,
alternating ratchet turns past the handshake, out-of-order and permanently
lost messages, a drained one-time prekey pool, tamper rejection, safety
numbers, and the canonical transparency-log leaf.

Three things worth recording.

Ed25519 was missing, and the engine could derive an account address but never
sign a claim for one. The JCA offers no route from a private EdDSA key to its
public half, so the RFC 8032 keygen is done here -- and verified against the
RFC 8032 test vectors, not against a round-trip through our own code. A
round-trip proves only that the file agrees with itself, and every wrong clamp,
endianness and sign convention agrees with itself perfectly. The scalar
multiplication uses BigInteger and is therefore not constant-time; it runs once
at sign-in, in the user's own process, and signing deliberately stays with the
platform provider.

The harness reports a build failure as a failure, never a skip. The Swift
harness beside it once printed "skipped" -- which reads as green -- while
silently building a different checkout. Only a missing JDK skips here, and the
resolved path is printed either way.

Each section is wrapped so a divergence is a named failure rather than a crash.
That was not theoretical: breaking the Kotlin message-key derivation on purpose
crashed the process with a stack trace and skipped every later section. It now
reports four failing sections and finishes the run. The negative control is how
this was checked at all -- a suite that passes on its first run and has never
been seen to fail is not evidence of anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:04:35 -04:00
Paul HittandClaude Opus 5 e0589eff14 Kotlin: the Double Ratchet
The second half of a protocol Android did not have. With X3DH it can now agree
a secret, and with this it can hold a conversation.

Two ratchets: the symmetric one advances per message so a compromised key
reveals nothing earlier, the DH one advances on each direction change so a
conversation recovers from compromise rather than staying broken.

The tests run conversations rather than checking steps, because that is what
catches the bugs this code actually has. X3DH is a pure function — it agrees or
it does not. A ratchet can agree for six messages and diverge on the seventh
because one side counted a skipped message differently, and the symptom is only
that something will not decrypt. So: out-of-order delivery, a message lost
forever, a gap ACROSS a direction change (which is the whole reason "pn" is on
the wire), and four DH turns in sequence.

Three things that would each produce plausible-but-wrong bytes, so each is
tested rather than reasoned about:

- the sending chain advances BEFORE the key is used, so a caller that retries
  cannot encrypt twice under one key — two GCM encryptions under one key and
  nonce leak the XOR of both plaintexts
- the header's canonical JSON is hand-built, key order "dh, pn, n", no spaces,
  because it goes into the AAD and three implementations must reproduce it
  byte-for-byte
- the derived nonce is stripped from the wire rather than sent, so a receiver
  cannot be handed a nonce that disagrees with the one its key implies

MAX_SKIP caps derivation at 1000: a header claiming a huge counter costs the
sender nothing and the receiver everything.

13 tests. Still missing before an app: a session layer, a wire client, and the
app itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:51:30 -04:00
Paul HittandClaude Opus 5 c893d412d2 Kotlin: X3DH, and the account KDF beneath it
The Android engine had primitives and no protocol: no handshake, no ratchet, no
session. It could verify a safety number and could not start a conversation.
X3DH is the first half of fixing that.

The four agreements are mixed in the order the spec fixes, and DH4 is omitted
rather than zero-filled when the one-time prekey pool is drained. Both are
choices that produce 32 plausible bytes when wrong — the failure lands at the
far end as a message that will not decrypt, pointing nowhere near here, so the
tests check that the two sides AGREE rather than that either looks right.

Keys are JCA objects rather than raw bytes throughout, because Conscrypt will
not build an XDH key from raw bytes and wants a DER spec. Passing raw bytes
would work on this JVM and throw on the first tap on a phone, which this engine
has already been bitten by once.

Also here: AccountKdf, so Android can derive an account key and an address.
Checked against the vectors pinned from the enclave client — including the
non-ASCII and empty-passphrase cases, where a CharArray API and a UTF-8 wire
format quietly disagree.

⚠️ Still missing before an Android app can send a message: the Double Ratchet,
a session layer, a wire client, and the app itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:47:12 -04:00
Paul Hitt b7021e9a73 Merge remote-tracking branch 'origin/main' into feat/android-account-kdf
# Conflicts:
#	Sources/Views/SignInView.swift
2026-08-23 12:23:21 -04:00
Paul HittandClaude Opus 5 fdd1ab7ee7 Multi-homing: keep a mailbox at more than one relay
Federation let somebody at another relay write to you. It did not stop your
relay being the only place your mail lives — so relay1 going away took the
undelivered messages with it, and relay1 refusing to serve you locked you out
of your own inbox. That is the gap people actually mean by decentralised, and
it is what this closes.

An account registers the same devices, with the same identity keys, at several
relays and publishes a signed record naming them. A sender fans out to every
one. Any single relay can then be unreachable, hostile or gone and the message
still arrives, because it was never only there.

The record is signed by the ACCOUNT key, not asserted by a relay. A relay that
could edit the list would drop the others from it and become the sole delivery
target, which is precisely the position multi-homing exists to escape. A sending
relay verifies against the key the recipient's address decodes to — the address
IS the key — so it never has to trust what the relay it asked handed back.

The serial closes the half a signature does not. An OLD list is genuinely
signed: a relay could serve one from before a second home was added and it
would verify perfectly. Records may only move forward, and the test proves the
real attack rather than a forgery — publish serial 2, then replay the still
validly signed serial 1, and get a 409.

Envelopes carry a client-chosen id, identical in every copy, so a recipient
polling two relays sees one message rather than two. Nullable and not unique:
an envelope from an older client has none and must still be delivered, and two
rows legitimately share an id when one message goes to two devices.

`maxHomes` is 4 because a sender fans out to every entry, and a signed record
naming a thousand hosts would turn any sender's relay into an amplifier aimed
wherever the signer likes — with a signature making it look legitimate.

Verified on relay1/2/3.flamenet.io: 12 checks. Alice names only relay2, her
relay discovers relay3 from the signed record, and Bob reads the message from
either. Local suite 255.

⚠️ Delivery to an unreachable home is reported and NOT retried. A home that is
down when you send simply misses that message; closing that needs a queue and
this does not have one.

⚠️ Neither app calls any of this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:15:01 -04:00
Paul HittandClaude Opus 5 6f83248e1c Give an account an identity no server issued
A relay's `user_id` is a row number it assigned itself, and the account
service's pseudonym is HMAC'd per product precisely so two services cannot
correlate their users. Neither survives a trip to a second relay, so nothing
addressed by either can cross a relay boundary — which is the first thing
federation needs, and the reason a deleted relay currently takes its users'
names with it.

So identity becomes a key the client derives and holds: Ed25519, stretched from
the passphrase under `flamenet/account/identity/v1`, alongside the per-product
keys that already work this way. The public half, Crockford base32, is the
address. Derived rather than generated, so the same passphrase reproduces the
same fnid on a device that never held it — re-register the same key at a new
relay and contacts who pinned it are still talking to the same person.

Relay: `users.account_key` / `users.fnid` (unique in the database, not just in
the handler), `POST /e2e/account/identity` to claim by proving possession, and
`GET /e2e/account/identity/:handle` to resolve. The claim signature binds the
audience, the local user id and the key itself; dropping any one of the three
lets a claim be replayed at another relay, lifted onto another account here, or
transplanted onto another key. Verification uses the very key being registered,
so the relay confirms possession and never acquires an opinion about whose key
it is. Rotation is refused rather than silently overwritten — a new address
strands every contact that pinned the old one, and that needs a migration
record they can follow.

Log leaves go v1 -> v2, binding a subject instead of a row number: `fnid:<b32>`
where an account has enrolled, `local:<audience>/<user_id>` where it has not.
The fallback is qualified rather than bare because a device can be registered
before its key is claimed, and the alternatives were worse in both directions —
skipping those devices leaves a hole in the one record that must not have
holes, and logging them unqualified produces leaves that mean different things
depending on origin with nothing in the leaf to say so.

This is not a server-side change. All three engines hash the leaf and two of
them verify it against the relay, and a published vector pins the exact bytes,
so the format moves in one commit or not at all. Existing v1 leaves keep their
hashes and are backfilled to a self-describing `legacy-v1/` subject: the log is
append-only, and a format change is not a licence to rewrite history. The
version prefix sits inside the hashed bytes so a v1 leaf recomputed under v2
rules fails rather than verifying against a different subject.

Green: engine conformance 80, relay §8 suite 235 (10 new), JS<->Swift interop
74, Kotlin published vectors.

Two things found on the way, neither addressed here:

- iOS never calls `enforceKeyLog`. It and `requireKeyLog` are both declared in
  E2EInbox and nothing reads either, so §19 is dead code on that client while
  the JS one wires it. The subject is threaded through ready for a caller.
- `engine/test/interop/build.sh` prefers ~/Code/flamenetmessenger over the
  checkout it is run from, so in a worktree it silently builds the wrong
  sources and reports a *skip* rather than a failure. Set FNMSG_APP_DIR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:10:28 -04:00
Paul HittandClaude Opus 5 85f6a29953 Kotlin: X25519, with the small-order check that is not optional here
Raw 32-byte keys on the wire, X.509 SubjectPublicKeyInfo in the JCA -- so
every crossing needs the 12-byte DER prefix added or stripped. Written out
as a constant rather than parsed: a DER parser here would be more code than
the thing it parses, and one more place to get a length wrong.

Refuses an all-zero shared secret. X25519 produces all zeros for a
small-order peer key, and a peer that sends one forces both sides to a
secret it already knows. RFC 7748 says implementations MAY check; when the
peer's key arrives over a relay nobody trusts, MAY is not the right reading.
All four small-order points from §6.1 are tested.

⚠️ XDH is API 33+ on Android. Older devices throw NoSuchAlgorithmException
at the first call, and the app must either bundle a provider or refuse to
run -- a decision nobody has made, so it is documented rather than papered
over.

⚠️ These run on the JVM against SunEC. Android routes the same calls through
Conscrypt. Passing here says the encoding is right and nothing about a
device.

Kotlin engine: 27 tests -> 33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:42:01 -04:00
Paul HittandClaude Opus 5 bff7a4bbdd Kotlin: HKDF-SHA256 and AES-256-GCM, against external vectors
Checked against RFC 5869 and NIST CAVP rather than against our JavaScript
engine. An implementation validated only against our other implementation
inherits our misunderstandings, and finding where the first two quietly
agreed on something the spec does not say is the entire reason for writing
a third.

Three things pinned that a naive version gets wrong:

- An empty HKDF salt means HashLen zeros, not "no HMAC key". SecretKeySpec
  throws on a zero-LENGTH key, so the naive version crashes on exactly the
  case the app uses -- §12 derives its per-context secrets with an empty
  salt.
- 255 blocks is a hard ceiling. The counter is one byte; a naive loop wraps
  at 256 and silently returns key material it already returned.
- The AEAD box is nonce || ciphertext || tag. CryptoKit's `combined` emits
  that layout and the JS engine assembles it by hand to match, so a box
  built any other way decrypts to garbage on the other engines rather than
  failing loudly.

HKDF is hand-written on Mac rather than taken from a provider: Android's
providers disagree about whether HKDF exists at all.

Kotlin engine: 9 tests -> 27.

⚠️ Still JVM-only. These prove nothing about Android, where crypto goes
through Conscrypt rather than the JVM's JCA, and nothing here has ever been
compiled against the Android SDK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:22:31 -04:00
Paul HittandClaude Opus 5 e7862c0d95 Point the site and the harnesses at the four repositories
The relay and the Kotlin engine are published on their own now, so the
self-hosting guide told people to clone a directory that no longer exists
and the source page said everything was in one repository.

Both pages list all four now, with what each is for. The self-hosting guide
still only needs the first one -- the rest are there so that self-hostable
is something a reader can check rather than something they are told.

The Android vector loader hardcoded ../../engine/test/vectors, which is only
right inside this repository. It resolves from a candidate list now, honours
FLAMENET_VECTORS, and still FAILS rather than skips when it finds nothing:
the entire value of a third engine is that it is checked against something
other than a reading of our own code. Nine tests pass against the JDK in
Homebrew, which is not on the default java_home path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:23:49 -04:00
Paul HittandClaude Opus 5 06b97ca69b Start Android with the engine, held to the published vectors
Android does not exist, and the first thing it needs is not a screen. It
needs the protocol implemented a third time — which is only worth
anything if the third implementation is checked against the spec and the
published vectors rather than against a reading of our own Swift.

So `android/engine` is a plain Kotlin/JVM library with no Android in it:
no SDK, no emulator, no device, and therefore gateable in CI on Linux
like everything else. It reads
engine/test/vectors/protocol-v1.json from its one location rather than
carrying a copy, because a vector transcribed into the test that checks
it is a vector that agrees with itself.

Covered so far: safety numbers (§7) and key transparency verification
(§19) — RFC 6962 leaf and node hashing, the unbalanced split, and
bottom-up audit paths. Nine tests, all green against the same file the
JavaScript and Swift engines answer to.

The §19 tests are the ones worth having. A top-down reading of a
bottom-up audit path yields the correct root for every left-spine index,
so it passes any suite that only checks index 0 — the vectors pin a
right-descending index, and the tests assert both that the published
path verifies and that the same path reversed does not.

android/README.md lists what is missing — X25519, X3DH, the ratchet,
sealed sender, both post-quantum halves, the relay client, the app — as
a table rather than as an omission. Android stays honestly marked "not
started" on the site until that list is shorter.

Bytecode targets 17 without demanding a JDK 17 be installed: a
jvmToolchain(17) fails on any newer JDK, which is a poor trade for a
library with no JDK 17-specific behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:00:13 -04:00