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>
🔴 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>
Engine parity for the five features iOS regained: text stories, mentions,
story reactions, story replies. This build could already receive them —
legibly, because each degrades to its caption — but understood none of
them as what they are.
🔴 Every envelope in the test is the shape **iOS** assembles, not this
engine's own output. Two implementations that each only test against
themselves agree right up to the day somebody sends a real message, and
the symptom is a mention that never rings or a story that arrives as
"[unsupported attachment]", with nothing in either log to say why.
Two things the tests caught that reasoning would not have:
⚠️ Kotlin prints a Double in scientific notation, so this engine writes
`"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and
both sides read either — but a story's expiry decides whether it shows at
all, and "arrived already expired" is indistinguishable from "never
arrived". Now pinned on both sides. Not a new risk: picture stories have
carried `exp` this way all along, which is the reason it works.
⚠️ An earlier version of one test built a JSONObject from an assembled
envelope and re-serialised it — which reordered the keys and moved `fnc`
off the front. It failed, correctly, and is a neat demonstration of why
both engines hand-assemble these strings instead of encoding a map.
UI comes next; this is the layer that decides whether the two apps agree
about what a message means.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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
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
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.
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.
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>
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>
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>
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>
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>
Android half of ios#53, spec §6.23. Each item is an ordinary story
envelope carrying a `set` naming the sequence, its position and its size.
🔴 One envelope per item rather than an array of attachments. The array
fails the single-`att` guard on any build predating §6.23 and renders the
whole set as a message in the conversation; separate envelopes degrade to
several stories in order, which every existing build already draws.
🔴 `setExpiry` takes the earliest stamp. Items are required to carry the
same one, but a sender can write different ones, and if the latest won
the difference would keep part of a sequence alive after the rest had
gone. A zero is skipped rather than read as "expires at the epoch",
which would kill the whole set.
⚠️ `groupedStories` renders what arrived: `n` is a claim, any item can
fail to arrive, and a missing one is indistinguishable from one never
sent. Stories with no set are keyed so two of them cannot collide.
⚠️ The set is persisted, or a relaunch scatters every sequence back into
loose stories.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
Codec only — the app does not pin yet — but without this branch an
inbound pin falls through parse to the attachment path and comes back
renderable, so every pin the iPhone app sets would draw an empty bubble
in the conversation.
isRenderable excludes it for the same reason it excludes a reaction, and
it is NOT in Kind.CONTROL: a build that swallowed it as generic control
would drop every pin while looking like it worked.
🔴 The envelope carries an id and never a copy of the message. A pin
carrying the text would keep a message readable after its own timer had
removed it, so pinning would quietly defeat disappearing messages.
Refs paulhitt/flamenet-messenger-ios#50
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 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>
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>
Codec only — the app does not compose or act on them yet — but it must
already understand an envelope the iPhone app sends, and more
importantly must not be made to do work by one.
🔴 Bounded and deduplicated on receipt. The list comes off the wire, and
a hostile sender could name ten thousand ids in one envelope; the cost
of that lands on every recipient's device rather than theirs. Capped at
256, far above any real roster.
A malformed list does not break the message: it is an ordinary message
with an odd field, not a reason to drop what somebody wrote.
Refs paulhitt/flamenet-messenger-ios#49
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
🔴 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>
An inbound forward is shown as one rather than as the sender's own
words. Both flag shapes accepted, like ctl: org.json's optBoolean does
not coerce a number, so an engine reading one shape would silently miss
every forward the other two send.
⚠️ The subtle one: Content.text returns raw bytes with no envelope at
all when there is no id, reply or link to carry. A forward that was not
counted in that condition would lose its flag and arrive looking like
the sender's own composition — so is part of the test for
whether an envelope is needed, not just a field inside it.
Codec only. Originating a forward needs the transcript work in #10.
Refs paulhitt/flamenet-messenger-ios#28
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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.
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>