Commit Graph
158 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 455cdcd818 Edit, forward and pin from the conversation (§6.15/§6.16/§6.19)
The outbound half of the three controls whose codecs were already back. The
long-press menu gains Edit, Pin/Unpin and Forward; the thread gains a pinned
banner; a bubble says `edited` and `Forwarded`.

🔴 An edit that silently replaces the words is worse than the typo. The reader
has no way to tell a correction from their own misremembering, and no way to
know the message they replied to is not the message that is there now. So the
bubble is marked, on both ends, and the edit sheet says plainly that everyone in
the conversation sees the new wording and a mark on their copy.

🔴 A forward carries the words and NOTHING about where they came from — no
author, no thread, no quote — and the test asserts the ABSENCE of a reference
rather than only the presence of the flag. The obvious "improvement", carrying
a reference so the reader can see the original, would republish a private
message's author to somebody who was never in that conversation. It also gets a
NEW message id: reusing the original's would make the copy a target for edits
and reactions aimed at the original, one of which would silently rewrite the
other.

⚠️ Only your own message can be edited, checked in `Session` and not only in
the menu. The receiving side enforces the same rule independently, so neither
end is trusting the other. Anyone may PIN, by contrast: a pin is a shared
banner rather than a permission, and restricting it to the author would mean the
useful message in a group is exactly the one nobody but its writer can pin.

⚠️ Edits and pins are applied locally first and rolled back if the send fails,
the same shape `react` already uses. Waiting on a round trip to see your own
correction appear makes it feel broken on a slow connection; leaving one that
exists only on this phone is worse, because you would believe you had fixed it.

⚠️ A forward does not carry an attachment yet. Re-sending a blob means
re-uploading it under a new token, and passing on the ORIGINAL token would hand
the new recipient a capability to a file in a conversation they are not part of.
Text only until that upload exists.

⚠️ The pinned banner resolves the id against the lines on screen, so a pinned
message that was since deleted shows nothing rather than a stale copy. Clearing
a thread clears its pin.

Two things found while wiring it: `Content.text` has no `groupId` parameter, so
a group forward needs `groupText` — a compile error rather than a silent drop.
And the edit/forward state is hoisted above the message list, because a row's
own `remember` dies when it scrolls out and an edit box vanishing mid-typing is
the worst way to lose a correction.

9 more unit tests. App builds, every JVM suite green. The three PARITY rows are
now ✅.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:01:19 -04:00
Paul HittandClaude Opus 5 e0420a00c6 Apply inbound edits and pins instead of filing them as messages (§6.15/#6.16/#6.19)
🔴 This was an interop bug, not a missing feature. The engine has parsed
`editOf`, `pins` and `fwd` for as long as they have existed, and it exposes
`isRenderable` — "does this belong in a thread" — as the single answer.
`Session`'s inbound loop never consulted it.

So three things arrived from the iPhone app and were filed as ordinary bubbles.
An edit carries its new wording in `text`, so it drew as a DUPLICATE beside the
typo it was meant to replace — a correction that made the thread worse than the
mistake did. A pin carries only an id, so it drew as a blank bubble. And a
forward was drawn as the sender's own words.

Worst of the three is the one nobody had hit yet: a control envelope from a
NEWER peer carries `ctl` and a kind this build has never heard of, and would
have drawn a bubble full of JSON in somebody's conversation. That is precisely
the failure `isControl` was written to prevent, defeated by nobody calling it —
and it would have shipped to every older Android install the first time a
control kind was added on iOS. The guard is `isRenderable` rather than a list of
kinds this build knows, because an allowlist is only correct until the other
platform adds one.

What is applied now:

- an edit rewrites the message it names and stamps `edited`, so a bubble can
  say so — silently replacing the text is worse than the typo, because the
  reader cannot tell a correction from their own misremembering;
- a pin is stored per thread, sealed in the vault like the transcript, as an ID
  and never a copy: a copy goes stale against an edit and outlives a deletion;
- `fwd` reaches the transcript so second-hand words are not drawn as the
  sender's own — THAT it was forwarded, never from whom.

🔴 An inbound edit is applied only when it comes from the person whose message
it is. Without that check any contact sharing a session could rewrite anybody's
words in the reader's transcript — a better attack than sending a message of
their own, because the words would carry somebody else's name.

⚠️ Clearing a thread clears its pin. A banner pointing at a message that was
just cleared reads as the clear not having worked.

⚠️ `fwd` is read as both a boolean and a number, for the same reason `isControl`
accepts both: `org.json`'s `optBoolean` does not coerce a number, and that is
how this class of bug shipped once already.

9 unit tests. App builds, every JVM suite green. The three PARITY rows stay 🟡:
the inbound half is done, originating each still needs the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:53:13 -04:00
Paul HittandClaude Opus 5 f362db0ee7 Handle a wrong clock (ios#45, Android half)
🔴 A wrong device clock breaks a promise made to somebody else. A story
disappearing after an hour is a promise the SENDER made, and it is enforced on
the READER's device — so a reader whose clock runs behind keeps a message the
sender was told would be gone, and nobody involved can tell.

That asymmetry decides the design, and it is the whole of this change. Expiry
takes the LATER of the device's clock and the relay's, never either alone. A
device set back cannot extend a message's life, and a hostile relay claiming an
earlier time cannot either, because the other source overrules it. The
remaining failure — expiring early on a clock set forward — costs the reader
something they were about to lose anyway.

The skew is learned free, from the `Date` header every HTTP response already
carries. Observed in `RelayClient.checked`, which is the one funnel every relay
call passes through, and BEFORE the success check: a phone whose clock is wrong
enough to be refused is exactly the phone that needs to be told why.

Story and disappearing-message expiry on the app side now reads
`DeviceClock.expirySeconds()` rather than `System.currentTimeMillis()`, at all
seven sites.

⚠️ Android needs synchronisation iOS does not. The Swift version is
`nonisolated(unsafe) static var` because its writers sit on one actor; here the
observation is written from whichever OkHttp thread finished a call and read
from the UI thread. The two fields are `@Volatile` and updated together under a
lock — a torn read of "skew from now, timestamp from an hour ago" would age out
an observation just made, or trust one that had not been.

⚠️ `SimpleDateFormat` is built per call rather than shared, because it is not
thread-safe, with a fixed `Locale.US` and GMT: a formatter taking its locale
from the device parses nothing on a phone set to most of the world, and the
failure is silent — the header is simply never understood.

15 unit tests. Proven: replacing the rule with "trust the server" turns the
hostile-relay test and the never-earlier test red. App builds, every JVM suite
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:38:55 -04:00
Paul HittandClaude Opus 5 fffab8e685 Back off on repeated app-lock attempts (ios#41, Android half)
Android had the lock and none of the back-off. iOS has counted failed unlocks
and imposed a growing wait since ios#41; here every attempt was free, so
somebody holding an unlocked phone could work through PINs as fast as the
prompt would reappear. Two platforms disagreeing about a security guarantee is
how one of them ends up being the one that leaks — the same argument that put
the lock on Android in the first place.

Same schedule as iOS: three free attempts, then 30 seconds doubling to a
five-minute ceiling. Both ends matter. A mistyped PIN or a fingerprint that did
not read is ordinary, and making the first slip cost a wait would punish the
owner far more often than anybody else; and an unbounded back-off locks
somebody out of their own messages permanently, which is worse than what it
defends against — no reset, no support desk, and the data is on their phone.

🔴 One real divergence from iOS, and it is a bug the Swift version cannot have.
iOS computes `30 * pow(2, steps)` in a Double, which saturates to infinity and
still caps correctly. Kotlin shifting a Long does not: `30L shl 60` wraps
NEGATIVE, and `minOf(negative, cap)` returns the negative — a "wait" in the
past, which reads as no wait at all and silently removes the whole feature at
exactly the point somebody is grinding attempts. The steps are clamped before
the shift. Proven: removing the clamp turns that test red.

🔴 The refusal happens BEFORE the prompt is shown. Letting the system sheet
appear and then discarding the answer would teach somebody that attempts are
free, which is the opposite of the point.

⚠️ Only a genuine wrong answer counts. `onAuthenticationFailed` is a
fingerprint that did not match; `onAuthenticationError` is a cancel, a platform
lockout or a hardware problem. Only the first moves the counter, so dismissing
the prompt cannot back the owner into a wait — and the platform's own lockout is
not counted on top of ours, because two back-offs stacking produce a wait nobody
can predict from either rule.

⚠️ Switching the lock off clears any outstanding wait. Leaving one behind
strands somebody behind a countdown for a screen that no longer appears.

7 unit tests. App builds, every JVM suite green. PARITY.md gains a Hardening
section listing the three that remain (ios#19, #39, #45).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:35:56 -04:00
Paul Hitt eff8cdb493 A 403 is not an expired session
Signing in on a fresh install said "Your session expired. Sign out and back
in to keep going." — advice that cannot work, because signing in again hits
the same rule and says the same thing. There is no way out of that loop from
inside the app.

RelayAccounts mapped 401 and 403 to the same Unauthorized. They are not the
same: 401 means the token was refused and signing in again IS the fix; 403
means a rule refused this — too many devices, a retired device, an
unverified address — and the relay writes a sentence naming which one and
what to do about it. RelayClient already made this distinction; RelayAccounts
did not, so which client a path happened to use decided whether its refusals
were legible.

The 403 sentence now reaches the screen instead of "That isn't allowed on
this server", which replaced an answer somebody could act on with one they
could not. Only when it is really a sentence: a JSON fragment or a bare code
keeps the generic line rather than putting a payload on screen.

Predates tonight's merges — checked against fa92581 — and is why a sign-in
failure could not be diagnosed from the device.
2026-09-03 19:22:27 -04:00
Paul Hitt 3f0e36fffc Count the sealed-to-unsealed fallback, and offer to refuse it (ios#16)
🔴 **Off by default, and that is the honest default rather than the lazy
one.** An unsealed send is still end-to-end encrypted; what leaks is
that this account sent something to that one. Refusing turns a metadata
leak into an undelivered message, which for most people is the worse
outcome — so the default degrades and says so, and the refusal is there
for the people who would rather a message did not go than go
attributable.

The counters answer a question nobody could ask before. `Sent.sealed`
says whether one send was sealed; the record says how often this happens
to you, which is what somebody actually needs to decide whether the
switch is worth throwing.

⚠️ Refusing costs nothing: `sealed` is false exactly when neither a
capability nor a key was found, so there is no token to hand back.

⚠️ Both the policy and the counters are wired in ONE helper called from
both sign-in paths. Two call sites building a `Session` and only one
applying the policy is how a setting silently stops working on one of
them.

Taken by hand rather than cherry-picked — the original also rewrote a
ChatScreens that has since changed underneath it. The settings switch
for it is UI work still to come.
2026-09-03 19:22:26 -04:00
Paul Hitt a0bc8e68d2 Report a story to whoever runs the server (§6.25)
The reporting path, taken by hand — the original commit also rewrote 318
lines of ChatScreens against a story feed that no longer looks like that.
The UI control comes next; this is the part underneath it.

🔴 **A report is the one thing in this app that leaves readable.** The
relay cannot open a sealed story, so a report about one is only
actionable if the reporter hands over what they saw. That is the point
and the cost, and it means the consent has to name the consequence —
`reportStory` does not ask, and must never be reached from a control
that does not.

⚠️ It sends only what it is passed, and never reaches into the thread
for surrounding context. §6.25 forbids including anything the reporter
did not choose to include.

⚠️ The reasons are enumerated to match the relay. A reason the relay
rejects is a report somebody believed they had filed.

The message afterwards says what will and will not happen: whoever runs
the server sees it, and you are not told what they do. Promising an
outcome we cannot observe would be worse than saying nothing.
2026-09-03 19:20:37 -04:00
Paul Hitt 32e2f6b5df Post a story to a group (§6.24)
Taken by hand, like the two before it — the original was cut from a tree
whose text stories and story replies differ from this branch's.

🔴 The group's roster is the audience, and the story audience does NOT
apply. Those are two different lists making two different statements —
who may see this account's stories, and who is in a conversation — and
they will disagree. The roster wins, because a story addressed to a
group that silently dropped members would be a group message part of the
group cannot see.

`groupStoryReach` returns that overlap rather than a warning. A sender
who believes their story audience still applies is being misled by the
interface, and a disclosure that cannot name the consequence is not one.
The two categories deliberately do not double-count: somebody who is not
a contact was never on that list to be excluded from.

🔴 On receipt, a claimed group id is checked against our own roster. A
story is caught before the group-message branch, so without that check a
sender could attach the id of a group they are not in and have their
story shown as having come from it. The claim is dropped to null rather
than the story refused — the story is legitimately theirs; only the
claim about where it was posted is unverified.

⚠️ `gid` names the audience, never a destination. Rendering a group
story into the group's messages would make a story into a message, and
one that never expires out of it.
2026-09-03 19:19:37 -04:00
Paul Hitt bee63e0ef7 Keep your own stories in a private archive
Reinstates ios#54, taken by hand rather than cherry-picked. The original
commit was cut from a tree where story mute, text stories and story
replies all looked different from the versions rebuilt on this branch
today, so applying it whole would have left two implementations of each
under different names — no compile error, and exactly the kind of
divergence that rots quietly.

What came across is the archive and nothing else: the `archive()` rule,
the store, the sweep, and the setting.

🔴 The rule refuses anything not the author's own, and it does so in the
engine as well as at the caller. An archive holding somebody else's
expired story breaks the promise the app made to them, and "it is
private to you" does not repair that.

⚠️ The sweep runs at the one moment it can. Expired stories leave on
write, so if they are not caught before `saveStories` they are gone —
which is correct for everybody else's and wrong for your own.

`archiveEnabled` is injected rather than read from a context, because
this layer has no Android dependency and an unset value has to read as
OFF. This is a feature that keeps something the interface said would
disappear, so it stays off until somebody asks for it.

Vault story serialisation is now shared between the live set and the
archive rather than written twice.
2026-09-03 19:13:56 -04:00
Paul HittandClaude Opus 4.8 9ca2c352e7 Save a story you posted yourself, before it expires
Android half of ios#34. A story disappears from its author's own device
too, so there was no way to keep something you made.

🔴 Only ever your own, and the rule is `Vault.isSaveable` rather than a
condition on the menu item. Saving somebody else's story keeps a copy of
something they were told would disappear — the opposite of what the
feature promises them — and a guard living in a composable is one
refactor away from being lost. Tested from both directions.

⚠️ Written through MediaStore, so no storage permission is asked for. An
app inserting its own entry needs no runtime grant on any version this
app supports; asking for WRITE_EXTERNAL_STORAGE in order to save one
picture would request read access to everything the person has, to
perform a write.

⚠️ The message says the copy has left the disappearing model and names
where it went. "Saved" alone would be true and useless — somebody not
told that may reasonably assume the expiry still covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:11:21 -04:00
Paul HittandClaude Opus 4.8 968a88789a Search inside messages, across every conversation
🔴 There is no index, and that is a decision rather than an omission. An
index derived from decrypted history is plaintext by another name: it
would have to be sealed as carefully as the vault, taken by
forgetEverything, and kept consistent with the threads — a second copy
of everything anybody ever wrote, with its own ways to go wrong. This
reads what already exists and throws the plaintext away.

⚠️ It costs more here than on iOS, and the code says so. The iPhone app
keeps the whole decrypted history in memory; Android decrypts each
thread on demand. Hence a two-character minimum, a bound, a 250ms
debounce, and Dispatchers.IO.

🔴 Folding is length-preserving on purpose: the snippet slices the
ORIGINAL string at an index found in the folded one, so a fold that
dropped characters would cut in the wrong place — and for an accented
character, mid-character. There is a test with accents before the match.

Accent-insensitive because somebody typing "cafe" means "café", and a
search that knows the difference quietly fails in every language with
accents — Spanish is next on the localisation list.

Refs paulhitt/flamenet-messenger-ios#25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:09:49 -04:00
Paul HittandClaude Opus 4.8 fc8d40ecf6 A timer on one message, not just the conversation (§6.17)
The rule that keeps this from making the product worse: the shorter
timer always wins. Without it a sender could defeat the recipient's
conversation timer by putting a long secs on every message — turning a
setting the recipient chose, on their own device, into something the
other party overrules.

🔴 pruneExpired returned early when the CONVERSATION had no timer, so a
timed message in an untimed thread would have been kept for ever — the
feature working on the sending phone and doing nothing on the receiving
one. Pruning is per message now.

⚠️ Content.text returns raw bytes with no envelope when it has nothing
to carry, so the timer had to join that decision or it would be dropped
and the message would live for ever.

The sender's own copy expires too: a timer that only cleared the
recipient's phone would leave the message with the person who set it.

Refs paulhitt/flamenet-messenger-ios#29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:09:10 -04:00
Paul HittandClaude Opus 4.8 edd53bc771 Tell somebody when their disappearing message is screenshotted
Android half of ios#36, spec §6.27. `shot` joins the control kinds, so a
build that does not know it stays quiet rather than rendering JSON.

🔴 The care is in refusing to let the absence of a notice mean anything.
The device that took the screenshot reports it, so a modified build, a
second phone, or a screen recorder produce nothing. A notice means one
was probably taken; no notice means NOTHING, and nothing counts them.

⚠️ Android's half of the asymmetry is stated rather than hidden. There is
no screenshot callback at all before API 34, and the one added there does
not cover screen recording — so the disclaimer says that, in those words.
Claiming a symmetry with iOS that does not exist would be worse than the
gap, because somebody would rely on it.

⚠️ Only where a timer is set, and the timer check comes before the
rate-limit check so a suppression window cannot make an untimed
conversation start reporting.

⚠️ Last-notice times are in memory. Persisting them would store who
screenshotted whom on disk to enforce a five-second window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:08:44 -04:00
Paul HittandClaude Opus 4.8 3dd44ebc9a The transcript records a message id and a reply reference
🔴 The wire always carried both and Session.Line never stored them.
Content.groupText has taken a messageId since §6.9 shipped and parse
reads it back — the transcript was where it stopped. So nothing here
could name an earlier message: no replies to resolve, no reactions to
fold, no edits to apply. All three were "codec done" and none worked.

A reply from the iPhone app arrived as a bare message with the quote
silently removed, which is worse than the missing id: the reader had no
way to know anything had been dropped.

🔴 Also fixes a real bug in sendGroup. It built the envelope twice —
once to send, once for the outbox — so an id minted inside either would
differ between them, and a member who was offline would eventually
receive a DIFFERENT message from everybody else, which nothing could
reconcile with a reply or a reaction. One id, minted by the caller, used
by both.

Both fields written only when present: a null on every message would
grow the transcript for nothing and make "no id" indistinguishable from
"an id of nothing".

Refs paulhitt/flamenet-messenger-ios#58, #10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:08:44 -04:00
Paul HittandClaude Opus 4.8 9a2ff570e9 Keep a half-written message across a relaunch
Sealed in the vault, not in SharedPreferences — and that is the whole
point. A draft is plaintext the person has not decided to send, very
often the most sensitive text in the app because it is the thing they
are still weighing. SharedPreferences writes readable XML that survives
into backups; the vault seals it under the conversation's own key with
the record name bound into the AAD, and forgetEverything takes it on
sign-out. There is a test that asserts the bytes on disk do not contain
the text, because the wrong implementation would pass every other test
here.

Keyed by the same threadFor* ids the transcript uses, so a group called
"7", the contact who is user 7, and the address "7" are three
different drafts.

Every write to the composer state goes through one setDraft: send clears
it by assigning directly in three places, and any one of those that
forgot to clear the stored copy would bring the message back next time
as a draft of something already sent.

Closes #47 on iOS side; Android half of the same feature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:05:17 -04:00
Paul Hitt 96b612597d A skipped live sign-up must report skipped, not passed
The two live sign-up tests guarded themselves with a bare `return`, so
without `-e live true` they finished immediately and reported PASSED. A
test that did nothing and says it passed is worse than one that did not run:
a suite of those is indistinguishable from a suite that works, which is the
only reason to run it.

`assumeTrue` instead, with the message saying how to opt in. Assumed-out
shows as skipped, so the count reflects what was actually exercised.

The iOS side already had this reasoning written down; Android had the
comment's opposite.

Compiles: :app:compileDebugAndroidTestKotlin.
2026-09-03 19:02:54 -04:00
Paul Hitt 8a1a51073e Read the relay's refusal, not just the HTTP status
🔴 **A 409 is not always a taken handle, and this said it was.** The relay
answers 409 for a taken handle, an already-registered email, AND — since
flamenet-relay!11 — a handle too close to an existing one. Three problems
with three different fixes, all reported as "that handle is already
taken".

Somebody whose EMAIL was already registered was told to pick a different
handle. Doing so cannot work, and nothing anywhere would have told them
why. That is a pre-existing bug, not something the new refusals
introduced; adding a third meaning to 409 is what made it visible.

400 was worse. Every format refusal — a short passphrase, an unusable
email address, a disallowed handle — became "the account service returned
400", which is not a sentence anybody can act on.

The status is the transport's opinion. The `code` in the body is the
relay's, and it has always been sent; nothing here ever read it.

⚠️ **Unknown codes still refuse.** A relay newer than the app is the
normal case, so an unrecognised code falls back to the status rather than
to success — an account the server never created would be a much worse
failure than a vague message.

⚠️ **An unparseable body cannot throw.** A proxy returning HTML on a 409
must still be a refusal, not a crash.

Five tests, proven able to fail: collapsing 409 back to a single meaning
turns four of them red. All three Android suites pass.

Pairs with flamenet-relay!11 and flamenet-messenger-ios!45.
2026-09-03 19:01:39 -04:00
Paul HittandClaude Opus 4.8 26b4f20a5f Give every message an id, and reactions something to land on
Reactions, edits and pins have all sat in the codec with no way to reach
them, and the reason was the same in each case: they say "this one", and
an Android message had no id to point at. 1:1 text went out as the raw
body — cheaper on the wire, and it cost all three features.

Every message now carries one. The sent copy, the copy written into our
own transcript, and the copy queued for retry all share it: three
constructions that disagreed about the id would be three different
messages, and anything said about one would land on nothing. Inbound
messages keep the SENDER's id rather than one we invent, because that is
what they will use when they refer back to it.

Old lines keep no id and must not be given one on read. Two devices
would invent different ids for the same message, and a reaction would
land on nothing — or on the wrong thing. Those messages simply cannot be
reacted to, and the emoji row is absent on them rather than inert.

Reacting is one long-press and one tap: the five emoji are the menu's
first row, not behind a "React" item that opens a second menu. One emoji
per person, so tapping another replaces it and tapping the same one
takes it back. Applied locally before the send, because waiting on a
round trip to see your own tap register makes it feel broken — and a
failed send restores what was there before, not "nothing", which would
be wrong for somebody replacing or removing one.

Reactions render under the bubble, not inside it. They are what other
people said about the message, not part of what was written.

648 unit tests green across app, engine and client; 8 new ones pin the
id and the one-emoji-per-person rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:19:49 -04:00
Paul HittandClaude Opus 4.8 2179615263 Stories you can answer, and an @ that finds people
The story features existed in the codec and nowhere a person could reach
them. + went straight to the file chooser, so a story of words was
unreachable even though the engine could carry one; there was no way to
react, reply, mute, or see what anybody had said back.

+ is a menu now. Reactions are a fixed row of five — a story is glanced
at and scrolled past, and anything that takes two taps to say "nice"
does not get used. Your own story shows the tally instead, because you
cannot react to yourself and a row of dead buttons is worse than none.

Mentions get an `@` picker in the group composer, matching iOS down to
the rule that matters: the id is recorded when somebody is PICKED, never
re-derived from the text at send time. Deriving it would mean typing a
name reached that person whether or not they were chosen, and renaming a
contact would silently unmention them. Only the ones still named in the
body are sent, so picking somebody and then deleting their name doesn't
ring them about a message that no longer mentions them.

A group member who isn't in your contacts has no name to show, so they
appear under their id. Un-mentionable would be worse than ugly — it
would mean a group where some people cannot be reached.

Mentions on Android are carried and rendered but override nothing: there
is no per-conversation notification mute here to override yet.

640 unit tests green across app, engine and client. PARITY.md updated —
mentions and the story features move to ✅, and Files and Wallet get rows
saying plainly that Android has none of either.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:11:21 -04:00
Paul HittandClaude Opus 4.8 fdaf06415e Draw a story that is only words — and fix a bubble that never compiled
🔴 The file bubble committed earlier today DID NOT COMPILE. It reached for
Icons.Filled.InsertDriveFile in a file that imports no Material icons at
all, and I reported it as building.

The reason it went unnoticed matters more than the mistake: ANDROID_HOME
was unset, so `:app:compileDebugKotlin` failed on "SDK location not found"
before Kotlin ran — and the check for errors was a grep for lines starting
`e:`, which a build that never reached the compiler does not produce. A
green-looking check that never compiled anything. Every Android app-side
claim I made today rested on it.

It now uses the kit's own icon set, like the rest of the file, and the app
compiles with the SDK on the path.

Alongside: Vault.Story.ref is nullable, so a story can be words. Every
path here assumed a picture, which is the same shape that made a text
story impossible on iOS rather than merely unbuilt.

⚠️ `loadStories` used `getJSONObject("ref")`, which THROWS. A single text
story would have taken down the load of every story — a feed that is
simply empty, with nothing to say why. It reads the optional accessor now,
and tells "no ref at all" (a text story) apart from "a ref that would not
parse" (a corrupt row), because those deserve different treatment.

Expiry moved onto the story, falling back to the attachment for everything
already stored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:18:48 -04:00
Paul HittandClaude Opus 4.8 688a519e0c Understand a file somebody sent, rather than showing a line of text
iOS can now send a document from Files. This build treated `file` as a kind
from a newer peer: legible, because a file envelope always carries a
caption naming the document, but only as text.

The engine now knows the kind, parses the additive `fn`/`cls`/`cmp` keys,
and can assemble them — so sending works in both directions, not one.

🔴 The interop test parses an envelope produced by the iOS build rather
than by this engine. Two implementations of one format that each only test
against themselves agree right up to the day somebody sends a real file,
and the symptom is "[unsupported attachment]" with no clue why. It also
pins the two rules that keep older peers working: a file envelope is never
captionless, and a photo envelope still carries none of the new keys.

⚠️ The bubble shows the classification as a MARKING. It says how the
sender had it filed; it does not mean this device was cleared for it, and
whoever can read the message already has the key.

⚠️ No "Save to Files" on this side yet — this build has no Files of its
own, so the button would be one that cannot work. Showing what arrived is
the honest half of parity while the other half is built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:21:41 -04:00
Paul Hitt df00bea388 Grant notification permission before the activity launches
Every instrumented test died with "No compose hierarchies found in the
app". MainActivity.onCreate asks for POST_NOTIFICATIONS before it calls
setContent, and the permission dialog is a separate activity that takes
focus — so the Compose rule attached to a window with no hierarchy yet.

GrantPermissionRule at order = 0, outside the Compose rule at order = 1, so
the grant happens first and no dialog ever appears.

With this, the three fixes in the previous commit, and a relay on the host,
aPersonCanCreateAnAccountOnASelfHostedRelay passes: a real account created
on a real relay, driven by test tags. That is the first end-to-end pass this
harness has produced.

  ./gradlew :app:connectedDebugAndroidTest \
      -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810
2026-09-02 21:05:03 -04:00
Paul Hitt 16e0101daf Make the instrumented tests capable of failing
Three separate reasons these suites could never catch a regression, all
fixed here. None of them are about the app; all of them are about the tests
being unable to report the truth.

1. A skipped test reported as a pass. Thirteen guards were bare returns —
   `if (!relayReachable()) return` — so with no relay the method returned
   immediately and the runner recorded PASSED. Now `assumeTrue`, which
   reports SKIPPED, and the run says "(2 skipped)" where it used to say
   nothing at all. Only the guards that abandon a whole test were changed;
   helper loop exits like `if (found) return` are control flow and were
   left alone.

2. The relay was hardcoded to relay1.flamenet.io — production. Every run
   that did work created real accounts there. Now `-e relay <host>`, still
   defaulting to production so an unqualified run is unchanged.

3. Android blocks cleartext HTTP, so a debug build could not reach a relay
   on the developer's machine even when one was running. A debug-only
   network security config permits cleartext to 10.0.2.2, 127.0.0.1 and
   localhost, and nothing else. src/debug, so no release APK is affected.

Together these took the sign-up suite from "2 skipped, reported green" to
"2 run, 2 failed" — which is worth more, because it is true. What it
fails on now (no compose hierarchy at launch) is the next thing to fix.

  ./gradlew :app:connectedDebugAndroidTest \
      -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810
2026-09-02 20:58:26 -04:00
Paul Hitt aebf41ed95 Put the app back to the last state that worked
Tonight's merges left sign-in, messages, calls and stories broken. Restores
app, engine and client to fa92581 — the tree that shipped as v8 and works.

Why they broke: those features are not independent. A message, a call
signal, a story and a story reply are the same content envelope with a
different kind, so they run through the same few functions in Content.kt.
Twenty-three MRs, each written against an older main and green on its own
branch, had never existed together until they were merged in one evening.

The conflicts that produced were resolved mechanically — union a parameter
list, merge a condition — and that produces code which compiles and passes
while being wrong: a field on the wrong struct, an argument in the wrong
order, a term in the wrong condition. Three were caught by the compiler.
The ones the compiler cannot see are why calls stopped connecting.

And the gate could not see any of it. 489 unit tests stayed green through
every break, because they test units: "assemble produces this JSON" is a
unit test, "Android shows what iOS sent" is not.

versionCode 10 so it installs over the broken v9.

Nothing is lost — every MR is still a merged commit with its branch on the
remote, and re-lands in small batches once there is a test that actually
sends a message between two devices.
2026-09-02 19:03:30 -04:00
Paul Hitt 2f46263aff A 403 is not an expired session
Signing in on a fresh install said "Your session expired. Sign out and back
in to keep going." — advice that cannot work, because signing in again hits
the same rule and says the same thing. There is no way out of that loop from
inside the app.

RelayAccounts mapped 401 and 403 to the same Unauthorized. They are not the
same: 401 means the token was refused and signing in again IS the fix; 403
means a rule refused this — too many devices, a retired device, an
unverified address — and the relay writes a sentence naming which one and
what to do about it. RelayClient already made this distinction; RelayAccounts
did not, so which client a path happened to use decided whether its refusals
were legible.

The 403 sentence now reaches the screen instead of "That isn't allowed on
this server", which replaced an answer somebody could act on with one they
could not. Only when it is really a sentence: a JSON fragment or a bare code
keeps the generic line rather than putting a payload on screen.

Predates tonight's merges — checked against fa92581 — and is why a sign-in
failure could not be diagnosed from the device.
2026-09-02 18:36:59 -04:00
Paul HittandClaude Opus 4.8 4c3f30e180 Count the sealed-to-unsealed fallback, and offer to refuse it
Android half of ios#16. `Sent.sealed` already reported each send's
outcome, but nothing accumulated, so nobody could answer "how often does
this happen to me".

🔴 The copy distinguishes sender from content, with tests asserting it.
An unsealed send is still end-to-end encrypted; what the relay learns is
who sent it. Somebody who reads "sealed" as "encrypted" makes the wrong
decision in both directions.

⚠️ Aggregate only. Per-contact counts would be more useful and would also
build a record of who this account talks to and how often — the precise
thing sealing exists to keep off a server.

Refusing is offered and off by default: it turns a metadata leak into an
undelivered message, which for most people is the worse outcome. It has
its own exception type so the refusal can explain itself.

⚠️ Refusing spends nothing. `sealed` is false exactly when neither a
capability nor a key was found, so `takePeerToken` returned null and
there is no token to hand back — my first cut had a line handing one
back, which was unreachable.

⚠️ The policy and the counter are injected as lambdas, since `client` and
the engine have no Android dependency, and both are wired in one helper.
Two sign-in paths build a session, and only one of them applying the
policy is how a setting silently stops working on half the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:57:00 -04:00
Paul HittandClaude Opus 4.8 6fe463d578 Report or block from a story
Android half of ios#56, spec §6.25. Needs the relay endpoint from
flamenet-relay!24.

🔴 A report is the one thing this app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report
about one is only actionable if the reporter hands over what they saw.
The dialog states that before anything is sent: what leaves the device,
who can read it, and that it cannot be undone. Declining to include a
copy is offered, and says plainly there may then be nothing to act on.

⚠️ `reportStory` sends only what it is passed. It does not reach into the
thread for surrounding context — §6.25 forbids including anything the
reporter did not choose to include.

⚠️ Blocking and reporting sit side by side and are never merged. Blocking
is this person's own decision and takes effect now; a report is a request
to somebody else. The copy says which is which, because a combined
control would make one of the two invisible.

The reasons are enumerated to match the relay, and a test asserts the two
lists are equal — a reason the relay rejects is a report somebody
believed they had filed. CSAM is its own reason rather than folded into
abuse: it carries a different obligation, and an operator cannot triage
what nobody can name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:55:29 -04:00
Paul HittandClaude Opus 4.8 b43be1b780 Post a story to a group
Android half of ios#55, spec §6.24. An ordinary story envelope carrying
`gid`, fanned out to the members and recorded in the story set.

🔴 `Vault.groupStoryReach` returns the overlap rather than a warning: who
is excluded by this account's story audience and who is not a contact at
all, counted separately. The roster decides either way — those are two
different lists making two different statements and they will disagree —
so a disclosure that cannot name the consequence is not one.

🔴 The group label is adopted only from an actual member. The roster
check that guards group messages runs after the story branch, so without
this a sender could attach the id of a group they are not in and have
their story shown as having come from it. Dropped to null rather than
refusing the story: the story is legitimately theirs, and only the claim
about where it was posted is unverified.

The story branch already sat before group routing, which is what keeps a
group story out of the conversation — filed there it would be a story
that is a message, and one that never expires out of the thread. That
ordering is now written down rather than incidental.

⚠️ `gid` is persisted, or a reload forgets which story went where.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:53:48 -04:00
Paul HittandClaude Opus 4.8 8b719032c2 React to a story with one tap
Android half of ios#52, spec §6.22.

🔴 Decoded before §6.9's reaction path, which requires `about`. A story
reaction carries `sre` instead — a story is not in the conversation and
has no `mid` there, so one carrying `re` would fold onto whatever message
happened to share that id, or onto nothing. Falling through would leave
it unhandled and drawn as an empty line in the thread. Tested both ways,
including that an ordinary thread reaction still decodes as before.

⚠️ Its own disclosure and its own stored key. A reaction reveals that you
watched exactly as a reply does; what differs is the deliberation. A
reply is typed. A reaction is one tap on a story already filling the
screen, so an account told about replies has been warned about the
considered case and not the careless one.

⚠️ The row does not share a gesture with anything that navigates: an
acknowledgement delivered by a mis-tap is a disclosure the person did not
make.

Branched from feat/story-replies, which carries the `sre` plumbing this
reuses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:52:32 -04:00
Paul HittandClaude Opus 4.8 52a793ce5c Reply to a story, as a sealed direct message
Android half of ios#30, spec §6.20. A reply is an ordinary direct
message to the author carrying `sre.sid`, landing in the existing
conversation. Stories gain no comment thread and no viewer list.

The reference carries the id and nothing else. A story's author had the
story by definition, so the only way they no longer hold it is that their
own timer removed it — sending a copy back would resurrect exactly that,
at a moment they did not choose.

🔴 `Content.text` returns the raw body when it has nothing to wrap, so
`storyReplyId` had to join that condition. Left out, a reply with no
message id and no quote would have gone out as plain text and arrived
with the reference silently dropped — on exactly the path a story reply
normally takes. There is a test for it.

The disclosure is shown before the first reply this account sends, names
what is revealed, and says where the reply goes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:50:17 -04:00
Paul HittandClaude Opus 4.8 242391119b Keep your own stories in a private archive
Android half of ios#54. Expired stories were gone from the author's
device too, so there was no way to look back at what you posted.

🔴 This keeps something the interface told everybody would disappear, so
the shape of the feature is the argument for it being acceptable: off
unless switched on, labelled as what it is, and only ever holding stories
this account posted. `Vault.archive` refuses anything else — the caller
filters too, and this is the guard that survives the caller being
rewritten.

🔴 The archive is deliberately NOT written through `saveStories`.
Everything in it is expired by definition, and that path drops expired
stories on write — so reusing it would empty the archive on the next
pass. Separate record, and a test named for exactly that.

Archiving happens in `stories()`, before `saveStories` drops the expired
ones: the only moment they can be caught, since expired bytes leave on
write. That runs on every load, so the fold is idempotent.

⚠️ The setting is injected into `Session` as a lambda. The engine layer
has no Android dependency and must not read preferences, and an absent
value has to read as OFF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:47:44 -04:00
Paul HittandClaude Opus 4.8 819fab3161 Reply to a story, as a sealed direct message
Android half of ios#30, spec §6.20. A reply is an ordinary direct
message to the author carrying `sre.sid`, landing in the existing
conversation. Stories gain no comment thread and no viewer list.

The reference carries the id and nothing else. A story's author had the
story by definition, so the only way they no longer hold it is that their
own timer removed it — sending a copy back would resurrect exactly that,
at a moment they did not choose.

🔴 `Content.text` returns the raw body when it has nothing to wrap, so
`storyReplyId` had to join that condition. Left out, a reply with no
message id and no quote would have gone out as plain text and arrived
with the reference silently dropped — on exactly the path a story reply
normally takes. There is a test for it.

The disclosure is shown before the first reply this account sends, names
what is revealed, and says where the reply goes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:46:24 -04:00
Paul HittandClaude Opus 4.8 83d7803f9e Save a story you posted yourself, before it expires
Android half of ios#34. A story disappears from its author's own device
too, so there was no way to keep something you made.

🔴 Only ever your own, and the rule is `Vault.isSaveable` rather than a
condition on the menu item. Saving somebody else's story keeps a copy of
something they were told would disappear — the opposite of what the
feature promises them — and a guard living in a composable is one
refactor away from being lost. Tested from both directions.

⚠️ Written through MediaStore, so no storage permission is asked for. An
app inserting its own entry needs no runtime grant on any version this
app supports; asking for WRITE_EXTERNAL_STORAGE in order to save one
picture would request read access to everything the person has, to
perform a write.

⚠️ The message says the copy has left the disappearing model and names
where it went. "Saved" alone would be true and useless — somebody not
told that may reasonably assume the expiry still covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:44:21 -04:00
Paul HittandClaude Opus 4.8 7c51bc2521 Post a story that is only words, or a link
Android half of ios#32, spec §6.21. `att` becomes optional and the
caption becomes the story.

🔴 The decode sits before the attachment branch, and that placement is
the feature. The branch requires `att` for a story; a text story failing
it falls through to the ordinary-message path and shows as a chat bubble
in the conversation that never expires — both of the things §6.6 says a
story must not be. There is a test named for it.

`Vault.Story.ref` is nullable rather than carrying a sentinel, so nothing
can read a blob id off a story that has none. That forced a `key` for
seen-marks and dedup, and an `expiresAt` that folds the two cases so no
caller has to know which kind it is holding.

⚠️ The stored record omits `ref` entirely rather than writing an empty
object — a reload would otherwise resurrect a blob that fails to fetch,
and `loadStories` had to stop treating an absent ref as malformed.

A "Write" entry on the Stories tab posts one, and says who enforces the
expiry: there is no photo to delete, so it relies on the receiving app
honouring `exp`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:43:43 -04:00
Paul HittandClaude Opus 4.8 42b7d1732f Search inside messages, across every conversation
🔴 There is no index, and that is a decision rather than an omission. An
index derived from decrypted history is plaintext by another name: it
would have to be sealed as carefully as the vault, taken by
forgetEverything, and kept consistent with the threads — a second copy
of everything anybody ever wrote, with its own ways to go wrong. This
reads what already exists and throws the plaintext away.

⚠️ It costs more here than on iOS, and the code says so. The iPhone app
keeps the whole decrypted history in memory; Android decrypts each
thread on demand. Hence a two-character minimum, a bound, a 250ms
debounce, and Dispatchers.IO.

🔴 Folding is length-preserving on purpose: the snippet slices the
ORIGINAL string at an index found in the folded one, so a fold that
dropped characters would cut in the wrong place — and for an accented
character, mid-character. There is a test with accents before the match.

Accent-insensitive because somebody typing "cafe" means "café", and a
search that knows the difference quietly fails in every language with
accents — Spanish is next on the localisation list.

Refs paulhitt/flamenet-messenger-ios#25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:03:09 -04:00
Paul HittandClaude Opus 4.8 ddb82b87b6 A timer on one message, not just the conversation (§6.17)
The rule that keeps this from making the product worse: the shorter
timer always wins. Without it a sender could defeat the recipient's
conversation timer by putting a long secs on every message — turning a
setting the recipient chose, on their own device, into something the
other party overrules.

🔴 pruneExpired returned early when the CONVERSATION had no timer, so a
timed message in an untimed thread would have been kept for ever — the
feature working on the sending phone and doing nothing on the receiving
one. Pruning is per message now.

⚠️ Content.text returns raw bytes with no envelope when it has nothing
to carry, so the timer had to join that decision or it would be dropped
and the message would live for ever.

The sender's own copy expires too: a timer that only cleared the
recipient's phone would leave the message with the person who set it.

Refs paulhitt/flamenet-messenger-ios#29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:59:37 -04:00
Paul HittandClaude Opus 4.8 a43f8a6feb Tell somebody when their disappearing message is screenshotted
Android half of ios#36, spec §6.27. `shot` joins the control kinds, so a
build that does not know it stays quiet rather than rendering JSON.

🔴 The care is in refusing to let the absence of a notice mean anything.
The device that took the screenshot reports it, so a modified build, a
second phone, or a screen recorder produce nothing. A notice means one
was probably taken; no notice means NOTHING, and nothing counts them.

⚠️ Android's half of the asymmetry is stated rather than hidden. There is
no screenshot callback at all before API 34, and the one added there does
not cover screen recording — so the disclaimer says that, in those words.
Claiming a symmetry with iOS that does not exist would be worse than the
gap, because somebody would rely on it.

⚠️ Only where a timer is set, and the timer check comes before the
rate-limit check so a suppression window cannot make an untimed
conversation start reporting.

⚠️ Last-notice times are in memory. Persisting them would store who
screenshotted whom on disk to enforce a five-second window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:52 -04:00
Paul HittandClaude Opus 4.8 3b7e69c0eb The transcript records a message id and a reply reference
🔴 The wire always carried both and Session.Line never stored them.
Content.groupText has taken a messageId since §6.9 shipped and parse
reads it back — the transcript was where it stopped. So nothing here
could name an earlier message: no replies to resolve, no reactions to
fold, no edits to apply. All three were "codec done" and none worked.

A reply from the iPhone app arrived as a bare message with the quote
silently removed, which is worse than the missing id: the reader had no
way to know anything had been dropped.

🔴 Also fixes a real bug in sendGroup. It built the envelope twice —
once to send, once for the outbox — so an id minted inside either would
differ between them, and a member who was offline would eventually
receive a DIFFERENT message from everybody else, which nothing could
reconcile with a reply or a reaction. One id, minted by the caller, used
by both.

Both fields written only when present: a null on every message would
grow the transcript for nothing and make "no id" indistinguishable from
"an id of nothing".

Refs paulhitt/flamenet-messenger-ios#58, #10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:34 -04:00
Paul HittandClaude Opus 4.8 f95cb1e6aa Keep a half-written message across a relaunch
Sealed in the vault, not in SharedPreferences — and that is the whole
point. A draft is plaintext the person has not decided to send, very
often the most sensitive text in the app because it is the thing they
are still weighing. SharedPreferences writes readable XML that survives
into backups; the vault seals it under the conversation's own key with
the record name bound into the AAD, and forgetEverything takes it on
sign-out. There is a test that asserts the bytes on disk do not contain
the text, because the wrong implementation would pass every other test
here.

Keyed by the same threadFor* ids the transcript uses, so a group called
"7", the contact who is user 7, and the address "7" are three
different drafts.

Every write to the composer state goes through one setDraft: send clears
it by assigning directly in three places, and any one of those that
forgot to clear the stored copy would bring the message back next time
as a draft of something already sent.

Closes #47 on iOS side; Android half of the same feature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:01 -04:00
Paul Hitt 8c8f2c1b98 A skipped live sign-up must report skipped, not passed
The two live sign-up tests guarded themselves with a bare `return`, so
without `-e live true` they finished immediately and reported PASSED. A
test that did nothing and says it passed is worse than one that did not run:
a suite of those is indistinguishable from a suite that works, which is the
only reason to run it.

`assumeTrue` instead, with the message saying how to opt in. Assumed-out
shows as skipped, so the count reflects what was actually exercised.

The iOS side already had this reasoning written down; Android had the
comment's opposite.

Compiles: :app:compileDebugAndroidTestKotlin.
2026-09-02 15:48:12 -04:00
Paul HittandClaude Opus 4.8 0c1af3e45f Mute one contact's stories, without blocking them
Android half of ios#33. Same shape as iOS: the muted set lives in the
vault beside the audience list, because who somebody has muted is a
statement about their social graph and shared preferences are readable
by anything that reaches the file.

The filter runs on the way out of `stories()`, after `saveStories` has
already written the live set back. A muted contact's stories are still
received and still stored — dropping them on receipt would be cheaper,
but unmuting would then show an empty strip until their next post, and
muting would quietly mean something other than what it says.

`Vault.visibleStories` never drops the signed-in user, even if their own
id is in the set. `setStoryMuted` refuses to put it there, but a restored
backup from a build without that guard could carry one, and hiding
somebody's own posts from them reads as the posts having failed to send.

Long press on a story bubble to hide or restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:47:55 -04:00
Paul Hitt e5f4397b28 Read the relay's refusal, not just the HTTP status
🔴 **A 409 is not always a taken handle, and this said it was.** The relay
answers 409 for a taken handle, an already-registered email, AND — since
flamenet-relay!11 — a handle too close to an existing one. Three problems
with three different fixes, all reported as "that handle is already
taken".

Somebody whose EMAIL was already registered was told to pick a different
handle. Doing so cannot work, and nothing anywhere would have told them
why. That is a pre-existing bug, not something the new refusals
introduced; adding a third meaning to 409 is what made it visible.

400 was worse. Every format refusal — a short passphrase, an unusable
email address, a disallowed handle — became "the account service returned
400", which is not a sentence anybody can act on.

The status is the transport's opinion. The `code` in the body is the
relay's, and it has always been sent; nothing here ever read it.

⚠️ **Unknown codes still refuse.** A relay newer than the app is the
normal case, so an unrecognised code falls back to the status rather than
to success — an account the server never created would be a much worse
failure than a vague message.

⚠️ **An unparseable body cannot throw.** A proxy returning HTML on a 409
must still be a refusal, not a crash.

Five tests, proven able to fail: collapsing 409 back to a single meaning
turns four of them red. All three Android suites pass.

Pairs with flamenet-relay!11 and flamenet-messenger-ios!45.
2026-09-01 20:48:11 -04:00
Paul HittandClaude Opus 4.8 62a8206e5a Android 0.8 — published
versionCode 7 -> 8, versionName 0.7 -> 0.8, and the build carrying it is
live at /downloads/flamenet-messenger.apk on relay.flamenet.io.

🔴 **The bump is the point, not bookkeeping.** The published 0.7 and
today's build were different bytes under the same versionCode, and
Android refuses to install an APK whose versionCode is not higher — so
publishing it as 0.7 would have offered nothing to everyone who already
had 0.7 and reached only new installs. Every fix in this release would
have sat there invisible.

What is in it: sign-in loading the stored identity instead of minting a
new one (the cause of contacts being asked to accept keys, and of
messages and calls failing afterwards); protocol traffic marked silent so
the relay stops announcing it as mail; EXIF and container metadata
stripped from story photos and videos; a story lifetime setting; and the
two sign-out doors.

⚠️ Downloads had been PAUSED since 2026-09-01 00:04 — the owner pulled
them because of the bugs above. This release is what un-pauses them.

⚠️ Signing cert unchanged: 791fc826…, CN=Flamenet Messenger. A different
key would make this uninstallable over an existing copy rather than an
update.

⚠️ The 0.7 build was copied into apk-rollback/ before publishing. It had
never been kept as a rollback point — only 0.6 and v5 were there.

Verified as SERVED, not as copied: downloaded from the public URL and
hashed, and it matches the sha256 the manifest promises
(000a9618…). The manifest reads its version out of the APK rather than
being told, so the two cannot disagree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 17:43:57 -04:00
Paul HittandClaude Opus 4.8 fc0dff18ca Sign-in loads the identity it already has
🔴 **This is why signing out and back in broke everything.** Sign-in only
ever consulted the in-memory `identity` field, and sign-out clears it —
so it minted a fresh `DeviceIdentity` and OVERWROTE the vault's, making
this phone a brand new device on every single sign-in.

Everything the owner hit follows from that one line:

  - contacts saw a safety-number change and had to accept new keys;
  - the account accumulated a registered device per sign-in, and fan-out
    is per device, so half of everything sent to this account went to a
    mailbox nobody reads — which is why the failures looked random and
    different each time;
  - the relay's public key log had not caught up with a key registered
    seconds earlier, so even after accepting, the next send failed on
    "the keys offered by this contact are not in the public log".

⚠️ It was harmless until it wasn't. Sign-out used to destroy the vault,
so there was never a stored identity to find and minting one was
correct. Keeping the device across a plain sign-out — which is what
makes signing out cheap — is what turned this into the bug it always
looked like. The two changes had to land together and only one did.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 15:28:17 -04:00
Paul HittandClaude Opus 4.8 fcc6692284 Stories stop carrying where they were taken, and can outlast a day
**Location.** Two story paths handed the picked file over byte for byte:

  - A story PHOTO was the one image path in the app that never went
    through `ImagePrep`, so it was uploaded with its EXIF block intact —
    including, for anything shot on the phone, the GPS coordinates. Chat
    photos have always been re-encoded and were safe by consequence
    (`Bitmap.compress` writes no EXIF); a story, which goes to every
    contact at once, was not.
  - A story VIDEO carried the capture location in the MP4 container.
    This file's own doc said "a clip that fits goes out exactly as
    picked", and that included where it was shot.

`MediaScrub` rebuilds the container with `MediaExtractor` +
`MediaMuxer`. A REMUX, not a transcode: the samples are copied across
untouched, so there is no quality loss and no codec work, and it is
cheap enough to run on every post — a scrub people wait for is a scrub
somebody eventually makes optional. Audio and video tracks only;
anything else a camera app decided to add is the kind of passenger this
exists to leave behind.

⚠️ Rotation IS carried over, deliberately. It says which way up the
phone was, not where it was, and dropping it plays every portrait story
on its side.

🔴 A scrub that fails REFUSES the post. "We could not remove the
location, so we sent it anyway" is the one outcome this must never
produce.

**Stories.** The lifetime was a constant. 1 hour / 6 hours / 24 hours /
3 days / 7 days, same set as iOS, defaulting to what it has always done.
`Conversations.postStory` already took `lifetimeSeconds` with a default,
so only the setting and the plumbing were missing — and
`Session.postStory` now REQUIRES it rather than defaulting, so a future
call site cannot silently ignore what the person chose.

⚠️ Clamped to the offered set on read: a value from a future build, or a
corrupted preference, must not post a story that expires before anybody
sees it.

Also fixes `RelayClientTest`, which had not compiled since `fetchKeys`
started returning the fnid alongside the bundles. Every other call site
in the tests was updated at the time; this one was missed, and I had not
run this suite since.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 14:58:20 -04:00
Paul HittandClaude Opus 4.8 ccdf82c00a Signing out no longer costs every contact a prompt
`signOut` destroyed the vault unconditionally, so signing back in was a
genuinely NEW device: every contact had the old key pinned, saw a
safety-number change, and could not message you until they accepted it.
For someone stepping away for an afternoon that is a strange price, and
it is why signing out and back in kept looking like the app breaking.

Two doors now, matching iOS. Plain sign-out ends the session and keeps
the device, so coming back is silent. "Sign out and erase this device"
is its own row, confirms in place like the delete control beside it, and
retires the device on the relay BEFORE destroying the keys that
authorise saying so — otherwise the account goes on publishing a mailbox
nobody reads, while the relay reports every copy delivered.

Also: changing your password erased the phone. `updatePassword` signed
out to drop the stale credential, which is right, and took the
destructive door to do it, which was not — a password change made you a
new device to everyone you had ever spoken to. It takes the plain one
now. Account deletion asks for the erasing door explicitly.

⚠️ Keeping the keys does not leave the mailbox reachable: a relay token
is minted against the account credential, which both doors clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 14:08:42 -04:00
Paul HittandClaude Opus 4.8 cb82d59b77 Answer a call only once the offer has actually been applied
Accepting a call failed intermittently, and when it did the caller heard
nothing and neither end said why:

    NOT SENDING the offer/answer: no localDescription after gathering
    (state=null). The other side will never see this call.

`accept` fired `setRemoteDescription` and `createAnswer` back to back.
`setRemoteDescription` is asynchronous and WebRTC cannot answer an offer
it has not finished reading, so when it lost that race `createAnswer`
failed, `setLocalDescription` never ran, and the send path found no local
description and gave up. The phone showed a call being accepted; the
caller rang on until it timed out.

⚠️ A race, which is what made it look intermittent — on a quiet device
the description usually lands first and the call connects, so the same
build both works and fails depending on load. That is also why it
survived every previous round of call testing.

`createAnswer` now runs from `setRemoteDescription`'s success callback.
The offer path needed no change: `createOffer` does not depend on a
remote description, and the caller applying the answer is terminal.

Found in a live log alongside the first end-to-end proof that calls
connect at all — the same run recorded `ice connection -> CONNECTED` and
then `COMPLETED` between this device and an iPhone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 11:19:06 -04:00
Paul HittandClaude Opus 4.8 2402f7a665 Interop driver: report the session version a responder settled on
`session_respond` returned no version at all, so the cross-engine test
could not assert the one thing that mattered — that a session this
device ANSWERS agrees with the initiator about which protocol it runs.
`session_initiate` and `session_encrypt` report it too, so a version
that changes across a store-and-reopen is visible rather than inferred.

Sealed groups: file the message instead of dropping it in silence.
`groupRoster` asked the relay for the membership of a group the relay
cannot know — §8.9b exists so it holds opaque tags and no member list —
so the sender was never "in the roster" and EVERY message in EVERY
sealed group was discarded on arrival by the check meant to protect it.
The sealed roster is the authoritative list and the only one that
exists. A refusal now says which group and which sender.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 09:21:19 -04:00
Paul HittandClaude Opus 4.8 ee17d773b7 A session we ANSWER can now be v3, and receive failures say why
`E2ESession.respond` hard-coded `version = if (pq == null) 1 else 2`, so
a session this device answered could never be v3 — while `initiate`
twelve lines above correctly returns 3 whenever the post-quantum ratchet
is on. A conversation iOS started therefore ran as v3 on one side and v2
on the other, and every message this device sent into it was refused by
the peer as a downgrade (§13.4) and dropped on arrival. iOS to Android
worked; Android to iOS did not, for as long as the pairing existed.

Kotlin talking to Kotlin never noticed, because both ends drive the
ratchet from their own PQ state and ignore the label. Only a peer that
enforces the version sees it. `HybridSessionTest` asserted the wrong
value — `assertEquals(2, bobSide.version)`, two lines below requiring
the initiator to be 3 — so the suite passed while encoding the defect.
Corrected, with the reason recorded next to it.

The version now follows the initiator rather than assuming 3 whenever PQ
is present: a v2 peer sends a PQ block too, and answering that in v3
breaks the pairing in the other direction.

Receive-side failures are no longer swallowed. `catch (_: Exception) {}`
threw away the reason for every undecryptable envelope, which left this
half of every delivery investigation unreadable — the relay says
delivered, the app shows nothing, and the one place that knew why
discarded it. The envelope is still skipped; it now names the id, the
device and the exception. Sends report the protocol shape they emit, and
receives report which of the three chains opened them.

Also here:

  - proguard-rules.pro, which did not exist while build.gradle.kts
    referenced it. R8 accepts a missing entry in silence, so release
    builds ran on the platform defaults and answering a call aborted the
    process inside WebRTC's JNI_OnLoad — it resolves Java classes by
    name, and R8 had renamed them. The build now fails if the file is
    missing rather than shipping without it.
  - Server refusals read as sentences. `RelayException.Http` had no case
    in `describe()`, so choosing a taken handle showed "relay returned
    409:" and a JSON fragment, which says nothing about picking another
    name. Transport failures showed a Java networking string naming an
    address the person never typed.
  - Switching between signing in and creating an account clears the last
    error. It belonged to the other mode and stayed on screen, so taking
    the advice it gave looked like it had failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 09:12:28 -04:00
Paul Hitt f80132a5a5 Release 0.7 — group member lists leave the server
versionCode 7. Never reused, and never backwards: it is the only thing that
tells an install a newer build exists, and Android refuses an APK numbered below
what is already on the phone.

What a person gets:

Making a group now seals its member list. The relay is handed a blob it has no
key for and one opaque token per member, instead of a readable `gid -> user_id`
row each. A group made before this can be sealed too, by whoever owns it, from
the group's info screen — everyone stays in and nobody else has to do anything.

⚠️ What that does NOT buy, and the app says so where the button is: it protects
the list if the database is stolen or handed over. It does not hide the group
from whoever runs the server, because messages are addressed to each device and
watching who receives a group's traffic still gives the group away.

Also in this build, and invisible: post-quantum key agreement now runs through
every ratchet step rather than only the handshake (§13), and this engine
advertises v3. A conversation only gets it when both ends do, so today that is
Android to Android on 0.7 — iPhone has it built and is still with Apple.

Verified before publishing: cert 791fc826… unchanged, so this installs over 0.6;
version read back out of the artifact as 7 / 0.7; 609 tests, 28 announced skips,
0 failures; cross-engine 56 passed.
2026-08-31 16:58:29 -04:00