Session.absorb handled edits (§6.15), pins (§6.19), reactions (§6.9)
and story reactions (§6.22) inside its loop over renderable messages,
and none of them is renderable, so every one that arrived was dropped:
no reaction, edit or pin from anybody else ever showed on Android. The
tests that existed called applyEdit and the like directly, which is why
they passed.
They are now applied from the whole batch before the renderable filter,
as answers and game moves already are. Each lands only on what it names,
in a group only from one of its members, and none is ever drawn as a
bubble. None is news either, as each of those sections requires: the
thread is redrawn, never marked unread, and nothing notifies. A pin
moves the open chat's banner when it is redrawn.
The test goes through absorb for each of the four, and every one of
them failed before this change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stickers in FlameNet's art, cards, events and answers, games in one-to-one
chats, the Today tab, the small-web directory and hello links.
versionCode and versionName move together, as the comment above them
requires.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Today tab, the small-web directory with sends to a chat, hello links,
and Home's scanner filling in the address it read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stickers in FlameNet's art, cards, events and answers, games in one-to-one
chats, one-to-one backups keyed by user id, and link previews on incoming
messages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule (DESIGN 19) has two checks: a relay's tag is kept only when it is
the SHA-256 of the file it came with, and If-None-Match goes out only when
the stored tag is that hash of the copy held. Breaking either one alone left
every test green, because the other still held the behaviour. Now a test
watches each: a foreign tag is not stored, and a foreign tag already on disk
is not sent. Each was seen to fail with its half removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sender's link preview (§6.12) arrived inside the message and was
never handed to the history store, so no incoming message has shown one
since dc1be35 added them. Directory shares (DESIGN item 18) are sent as
exactly such a preview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The address card shows the hello link (SPEC 6.34) with Copy and
Share, says what it does, and its QR code holds it.
flamenetmessenger://hi#<address> opens the app and fills in Home's add
screen; nothing is looked up or sent until the person chooses to. Both
scanners read a hello link, an older card or a bare address, and say
so when a code holds none. Parsing follows DESIGN 21: the known link
forms only, the fragment percent-decoded once, fnid: and 52 letters of
the FNID alphabet, a lowercased host with a dot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Headlines, Markets ("Prices are delayed. Not advice."), Weather for
the cities picked on the phone, Site of the day with the file's own
note, the blog when there is a post, and the way into the directory.
The tab asks the relay when it opens and when the app comes back to
it; offline it shows the cached copy with "From <date>", and a 404
shows only the directory and "Today's page is not offered by this
relay." Links open in the browser on a tap, with the host shown.
The dev key (kid a9a94e74) is compiled into debug builds only, from
src/debug; release has its own empty copy, and TodayKeys.PRODUCTION is
empty until the release step. Eight tabs fit a 360dp phone with the
labels one size smaller.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
directory-v1.json (DESIGN 5) ships as an asset: 2,964 sites in 90
categories, browsed by category and searched on the phone with no
network. A site sent to a chat is an ordinary text through the 1:1
send path, the URL as the words and an lnk built from the entry: the
title, and desc present and empty (DESIGN 18). Nothing is fetched to
make it, and with link previews off the URL goes alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TodayFile checks a Today envelope (SPEC 8.11) before reading a field
of it: size, version, a pinned kid, the Ed25519 signature over the body
bytes, then a body with a real date and a made time. Lists are cut to
their bounds, strings are cut on character boundaries, and a link that
is not http or https drops its entry.
TodayFeed asks the home relay for /today.json through the default
client, so a bridge carries it: no Authorization, cookie, id or query,
and If-None-Match only when it is the hash of the copy held (DESIGN
19). At most once an hour from the relay's last answer, five minutes
after a failed connection. Redirects are not followed. The last good
envelope is kept on disk and re-verified when read; a 404 clears it,
and an older signed file never replaces a newer one.
The signed sample flamenet.io's builder made is a test fixture, and
LiveTodayTest checks a relay on this machine when FN_LIVE_TODAY_RELAY
names one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scanning a code on Home filled the add field with the literal text
${card.fnid}@${card.relay}: the line in 130edd7 was written with ${'$'}
escapes, so the template was never evaluated. Every scan on Home gave
the same unusable string.
homePrefill() turns a scanned code into the address, bound by the fnid
as the audit asked, and Home uses it. The test pins both the address
and the absence of the escaped template in MainActivity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A one-to-one thread went into the shared backup under this app's own
name for it, "user:7", and came back under whatever key the file had.
The iPhone keys these threads "7" and skips any key that is not a
number, so every one-to-one conversation was lost when a backup moved
between the two phones, in either direction: the iPhone dropped
Android's threads, and Android filed the iPhone's under "7", where
nothing reads them.
The prefix now comes off on the way out and goes back on for a numeric
key on the way in. A file written before this, keyed "user:7", still
restores into the same thread. The test goes through the vault's own
export and import, where the payload-only checks passed all along; the
older tests that used a bare id as this app's thread name now use the
real one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Game" sits in the attach row of a one-to-one chat only: pick chess,
checkers, reversi or tic-tac-toe, and who goes first. The card shows the
board from the engine's own board, row 0 at the top for the first player
and turned round for the second, with whose move it is.
Tap a piece and its targets are marked; tap one to send the move. A
checkers jump is entered square by square, the path marked as it goes,
and sent as one move once complete; a chess promotion asks for the
piece; reversi marks its legal squares and offers a lone Pass when that
is the only move; tic-tac-toe takes the tapped cell. Whatever is sent is
exactly a move the engine offered.
Under the board: decline or cancel before play starts, resign after a
confirmation, offer a draw once it is the other person's move, accept or
decline theirs, in one row; the result in words once it is over; "Your
boards disagree" with "Start a new game" (same game, same sides) when
out of step; and the moves, folded away. Plain colours that read in both
themes, and every square labelled with its name and what stands on it.
A move or an ending that did not reach them says so and changes nothing.
The attach row now scrolls sideways instead of squeezing five buttons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A game lives on its start message's history row as the log alone, so it
follows that row through off the record (memory only), delete, the
disappearing timer and the backup. Everything else is replayed from the
log with the capped engine replay.
Receiving follows §6.33 rules 1 to 4: an unknown game is rebuilt from
the envelope; a stale or duplicate log changes nothing; a log that
disagrees, a peer adding a ply for this person's side, an illegal or
malformed move, or a log over the cap marks the game out of step, which
is sticky. Endings follow DESIGN items 16, 22 and 23: an ending is judged
at the log its gend carries, once that log is in step; a draw offer
counts only from the side that has just moved, at the receiver's whole
log, and the next move lapses it; decline and cancel count only until
the invitee's first move in that log; a decline for a game this phone
never saw rebuilds it as the game it started, declined.
Moves and endings go out announced, so the other phone wakes (item 15),
and are never queued: one that reaches nobody, a contact with no device
included, is taken back (item 24). The start is queued like a card.
Their move notifies without a sound, "Your move: chess with Sam"; an
ending says "<Game> with <name>: <result>"; a draw offer "<name> offers
a draw: <Game>"; a refused offer says nothing; a snoozed chat holds them
all. A game from another server, or from this account's own other
device, is never a board.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Content reads and writes `game`, `gmove` and `gend` exactly as §6.33
shows them: `mine` is the side the sender of that envelope plays, `log`
the whole game so far, `r` one of the six endings. A start is drawn as a
board in a one-to-one chat only; a game in a group, or one this build
cannot read, shows the unsupported placeholder. A move or an ending is
control traffic and never a bubble, with or without `ctl`.
A backup carries the game on its start message as
{"id","t","me","log","r","rby"} (DESIGN items 12 and 14). The out-of-step
flag and an open draw offer stay on the phone, each listed with its
reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`:games:test` joins the unit job's task list, the results gate counts
the module with a floor of 13 of its 15 tests, and its build directory
is kept out of Spotlight like the other modules'.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in 597dd804: the chess, checkers, reversi and tic-tac-toe rules
engines the chat games are built on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN items 7 to 13, as both apps now do:
- An answer's n is the unix second it was given, or one past this
device's last answer if that was later. Receivers keep the highest n,
and on an equal n the answer that sorts higher wins (yes over no over
maybe), so one person answering on two devices in one second leaves
every phone with the same answer. Held answers use the same rule.
- A cancel notifies "<title> was cancelled" and the card reads
"Cancelled: <title>".
- An event id that is only spaces is malformed, as on the iPhone.
Comments and test names cite §6.31 (cards), §6.32 (events and answers),
§17.2 and docs/STICKERS.md instead of DESIGN.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plain JVM module set up like :engine: chess, checkers, reversi and
tic-tac-toe as DESIGN.md section 2 describes them, with the same API as
the iOS FlamenetGames package. Moves are strings in the wire notation and
apply accepts exactly the strings legalMoves returns. Replay refuses a log
over the game's cap before playing it and names the index of the first
move it cannot play.
The tests run games-v1.json, byte for byte the file the iOS package runs:
the perft counts from DESIGN.md and 147 cases. Deeper published perft
counts run with FLAMENET_GAMES_DEEP=1.
Nothing in :app uses it yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two tests carried a narrow no-break space, a no-break space and a line
separator as literal characters, which read as ordinary spaces. They and
the emoji in the new tests and the sticker fallback are now \u escapes,
as the existing tests write them. The strings are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN 1b and 3 in the app, in one-to-one chats and groups.
Cards: the attach panel offers a card with one of the nine flamenet.io
fronts (shipped as assets/cards, titles drawn as text), to, message,
from, and an optional day it stays sealed until, at most 400 days ahead.
The receiver sees the front and "Opens on <date>" until then, and the
card opens by itself when the day comes; the sender's copy is open and
says whom it is sealed for. Copy, report and forward never hand over a
card still sealed here, and a forward goes out unsealed.
Events: title, a time or all day, an optional end, place and note. The
card shows when it is in the reader's zone, the organizer's time when
their clock reads differently, who answered what, Going / Maybe / Can't
go in one row with "Everyone in this chat sees your answer.", and Add to
calendar through the system's own insert sheet. Only the organizer is
offered Change event and Cancel event. Revisions go out under a fresh
mid and fold into the card the event first arrived on.
Arrivals: answers fold onto their event and only redraw it, never
marking a chat unread or notifying; one that comes before its event is
held in memory until the event lands. A card or event from this
account's own other device is kept only when it names a group. With
previews on, a card or an event that arrives alone is announced as
DESIGN words it, and never with a card's words.
Nothing new is written for an off-the-record chat: every change goes
through record and mutateHistory. Rows carry card, ev, org and rsvp, and
HistoryRowsCarryEveryFieldTest reads record() and Session.Line from
source and fails until a new field is carried by the backup, listed in
BackupPayload.NOT_CARRIED, and read back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session.deleteMessage went straight to the vault. For an off-the-record
thread the line is held in memory, so the delete found nothing, left the
line on screen, and rewrote the thread's history record, empty, to disk:
a record naming the person for a conversation §6.28 says is never
written down. It now edits the thread where it lives, through
mutateHistory, like edits and reactions. Cards and events go through the
same delete, which is how this turned up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN 1a: every manifest under assets/stickers is a pack, the glyph set
first and then flamenet.retro, and the picker shows them as sections. A
sticker draws as its picture, then its glyph, then its pack's fallback,
and a pack this build lacks draws as the core fallback. A manifest's
image may only name a file in its own folder. Sending passes the pack
that was picked instead of always the core one.
The 24 retro PNGs and their manifest are the ART agent's output, byte
for byte the files in the iOS worktree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The iPhone reads an rsvp only when n is at least 1, so Android now does
too and both drop the same envelopes. The reason given for leaving rxp
out of the backup now says what the iPhone does with private reactions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A card travels as its wire object and an event as its latest accepted ev
with org and rsvp beside it, as agreed with the iOS build. Row keys left
out of the shared shape are now listed in BackupPayload.NOT_CARRIED with
a reason: the provisional organizer mark, and rxp, which was already
being dropped without one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The highest rev from the organizer replaces the whole event and anything
else is ignored. The organizer is whoever sent rev 1; until rev 1 is
seen the first sender stands in, and a rev 1 from somebody else takes
over with its own content. Each member's answer with the highest count
wins. State maps onto a history row as ev, org and rsvp, the shape the
backup carries, plus a provisional mark that stays local.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN 1b and 3 add three kinds: card and event are renderable and carry
mid like a text; rsvp is control traffic and never a bubble, with or
without its ctl. Each is built by hand with its keys in the order both
apps agreed, and strings go through a new WireJson.quote that writes
what Swift's JSONEncoder writes with slashes left alone. The framework
org.json escapes every slash and the JVM copy does not, so the existing
quote could not give one set of bytes.
Decoding follows the iPhone: a card object is read leniently, an event
needs an id, a rev of at least 1, a title and a time, and anything
unusable falls through to the unsupported placeholder. Flags are read as
numbers or booleans, whole numbers may carry a fraction, and render
bounds and the sealed-until rule (capped at 400 days from arrival) live
on the types so they can be tested here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The messenger repositories move from gitlab.com/paulhitt to
gitlab.com/extant2000. Old web and git URLs redirect; the relay's container
image path does not, so docker-compose.yml names the new registry path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Peer's autoreply loop had no catch, so one failed poll or send to the relay
ended the process. The on-device reply case then waited out its 90 seconds
for an echo from a peer that was gone, which fits the one missed echo seen
in five full runs on 2026-09-28 (its log was lost, so not proven).
Each poll and each echo is now caught, logged to stderr with a timestamp
("poll failed, still answering: ..."), and the loop carries on. The
closing JSON adds an `errors` count. instrumented.sh already prints the
echo peer's log when a run fails, so a recurrence will say why.
Checked against a relay address that refuses connections: the old peer
exits 1 on the first poll with no result; the new one runs its full 8 s,
logs 4 failed polls and prints {"echoed":0,"errors":4}. Against relay1: 0
errors. Full on-device suite with the new peer: 10 of 10.
Harness only: interop/ is not shipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chat keeps its newest message in view when it opens and while the
keyboard slides, and both did it with `listState.scrollToItem`, which forces
a layout on the spot. This screen is composed inside the Scaffold's measure
pass, so when the effect ran straight away it asked for a layout in the
middle of one:
IllegalArgumentException: performMeasureAndLayout called during measure layout
at LayoutNode.forceRemeasure
at LazyListState.snapToItemIndexInternal
at ChatScreens.kt:1740 (the keyboard follow)
... LaunchedEffectImpl.onRemembered ... SubcomposeLayout ... Scaffold
It failed ChatFlowTest's send in every full on-device run since 2026-09-18.
The frames that ran the effect inline are the Compose test dispatcher's; the
shipped app posts effects to the next frame, so this has not been seen to
crash a real device. Forcing a synchronous layout from an effect is wrong
either way.
Both now use `requestScrollToItem` (foundation 1.7.6), which the next layout
applies, and the keyboard is what brings that layout. Still instant, still
not animated. The animated follow for new messages is unchanged: it waits
for animation frames before it moves.
TheKeyboardDoesNotHideTheMessagesTest read the source for
`listState.scrollToItem(`; it now requires `requestScrollToItem(` and refuses
`scrollToItem(` in the keyboard effect. Put the old line back and it fails.
Result on relay1: the full on-device suite passes, 10 of 10, and the crash
buffer has no performMeasureAndLayout. App unit tests: 358 debug, 323
release, all green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aGroupIsCreatedAndListed waited for a row matching `group-\d+`. The relay
mints group ids with randomHexToken(), so the tag is `group-` plus 64 hex
characters, and the pattern only matched when the id happened to start
with a digit: ten times in sixteen. The rest of the time the test failed
with "the group never appeared in the list after Create" while the dump
showed the group in the list ("Test Crew, peer96035270, You", tag
group-eea4ceeb...).
Now `group-[0-9a-f]{16,}`, which matches that tag and still not
`group-name` or `group-member-N`. Checked both patterns against the tag
from the failing run: old no match, new match. On device: passes.
The keyboard was ruled out first: with it fully open (top at y=1517) the
member row and Create sat at y=1201-1377, above it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The suites added `peerjvmone`, one standing account on relay1 whose only
device came online when somebody ran the interop peer by hand. Nobody did.
On relay1 it had no one-time prekeys left, classical or PQ, and 114
messages it would never collect, and every run added more strangers to it.
Adding it and delivering to it failed in some runs and not others.
scripts/instrumented.sh now creates two accounts with the interop peer
before the run and passes them as `contactPeer` and `echoPeer`: one for the
add-and-send cases, one running `autoreply` for the reply case alone, since
an autoreply peer answers the first new account that writes to it. The
peer runs from `:interop:installDist` with plain java, not a second Gradle
build, and the exit trap stops it and waits for it.
The suites no longer fall back to peerjvmone; without the arguments they
say to use the script. The reply case now fails when no echo arrives
instead of logging a warning and passing. FeatureUseTest waited for the
confirm button, which is on screen but disabled until the lookup resolves,
so it tapped a disabled button; it now waits for the lookup result like
the other two suites.
On relay1: 8 of 10. The reply round trip passes. Still red: the group case
("the group never appeared in the list after Create", not yet diagnosed)
and ChatFlowTest's send, which crashes in the app with "performMeasureAndLayout
called during measure layout" at ChatScreens.kt:1740, the keyboard-follow
scroll recorded on 2026-09-18.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One change since 0.30, android!154: the unsealed-send and changed-address
notices. versionCode and versionName move together, as the comment above
them requires.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The on-device sign-up helpers typed the relay and then tapped the
toggle-register button. Typing raises the keyboard, which slides in after
performScrollTo has measured the screen, so the tap landed on the keyboard
and the form stayed on sign-in. The failure read as a missing email field,
and was taken for a race with the relay probe.
Logged on the emulator: after the scroll the toggle sat at y=1878 while the
content ended at y=1517, the top of the keyboard. Every failure dump already
said "This server keeps its own accounts.", so the probe had answered.
Now the toggle is tapped first, while nothing is moving, and the email field
is waited for because the probe decides whether this relay asks for one.
Same change in FeatureUse, CrudSurface, ChatFlow, CrossPlatform and
LiveSignUpFleet. On relay1, every sign-up in the suite now gets through;
the remaining red is the shared peerjvmone fixture, not sign-up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>