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>
Without this branch an edit falls through parse to the last case, which
returns the caption as text — so an iPhone correcting a typo appears on
Android as a SECOND bubble beside the original, with nothing to say
either replaced the other. That is the bug this fixes today, before any
of the applying half exists.
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 correction silently, which is worse than the duplicate
because nobody would notice.
⚠️ Codec only. Session.Line records no mid, so nothing in an Android
transcript can be named by an edit and the app cannot apply one. That is
the same gap reactions have been sitting behind (PARITY.md "codec done,
no UI") and one transcript change unlocks replies, reactions and edits
together — filed separately.
Refs paulhitt/flamenet-messenger-ios#26
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay can charge a few seconds of arithmetic before an account
exists — cheap for one person, expensive for a farm, and unlike a
CAPTCHA it hands nothing to a third party. This is the Android half,
matching the iPhone app.
Asked for only when the relay refuses without one, so a relay with the
feature off — every relay running today — still sees exactly one
request. Rethrown rather than swallowed when this app cannot answer: a
difficulty above the cap leaves it genuinely unable to sign up here, and
"update the app" is then the true sentence.
Two refusals get their own sentences. challenge_required means the app
is behind its server; challenge_failed is nearly always a challenge that
expired while somebody was typing. Sharing one message would send half
these people to the Play Store for nothing. Decided by the relay's code,
never the status, because a 400 is also a bad email.
The bit counting is pinned to exact vectors. A byte-counting
implementation still SATISFIES a 9-bit difficulty — it overshoots to 16
and does 230x the work — so an assertion about "at least 9 leading
zeros" cannot see the bug.
Refs flamenet-relay#39
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client half of flamenet-relay!13, which gates a relay token on a proved
email address.
🔴 **Without this the refusal is indistinguishable from a broken
password.** An unverified account arrives as a bare 403; `RelayAuth` logs
"minting failed" and falls back to Basic, which the e2e routes refuse. So
the person sees calls that do not work and an inbox that does nothing —
and nothing anywhere says the remedy is a code sitting in their inbox.
⚠️ **The status alone is not enough.** 403 is also "that device belongs
to another account". Telling somebody to check their email for that would
send them looking for a message that was never sent, so the relay's own
`code` decides, not the number. A 403 with no code, an unparseable body,
and the same code on a 500 all stay generic — three tests hold that.
`RelayAccounts.verifyEmail` spends the code. Unauthenticated on purpose,
exactly like reset confirm: the code IS the proof, and requiring a
session would mean somebody kept out *because* they are unverified could
never get in.
⚠️ **Kotlin nests block comments**, so a slash-star written inside a
KDoc — as part of a route glob — opens a comment that never closes and
takes the module down with ninety unrelated errors, none of them near the
real line. Cost twenty minutes here; there is now a note in the file.
Five tests, proven able to fail: removing the code check turns three red.
All three Android suites pass.
🔴 **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.
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>
The relay announced every envelope as "New message" because it holds
ciphertext and cannot tell. It now takes `silent` from the sender.
Marked silent: call answers, call ends, delivery keys, key-log gossip,
reactions, avatars — everything that is a layer talking to a layer. A
call offer still announces itself, and so does anything a person would
see in the thread. `Content.isRenderable` already drew exactly this line
for the receive side; this is the same question asked on the way out.
⚠️ A plain string is a message: text from an older build carries no
envelope at all, and the safe reading of something unrecognised is that
a person meant to say it.
🔴 **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>
**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>
`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>
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>
`session_device` takes a `proto` so the harness can build a peer that
advertises the previous version, which is the only way to keep the
standing compatibility rule honest — the current version and the one
before it must interoperate, and until now the suite could only test the
newest code against itself.
⚠️ The capability signature is remade over the number actually claimed.
A bundle that advertises one version and signs another is rejected by
`provenVersion` and falls back to v1, so leaving the original signature
would let a compatibility test pass while exercising a pairing nobody
ships.
The override lives in the driver rather than in `registrationWire`: the
engine should publish what it implements, not what a test wants.
`session_reload` gained `as_version` for the same reason — reproducing a
session stored by an older build, which is a state no amount of running
the new code can otherwise produce.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`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>
`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>
newDevice's header said this engine has no ML-KEM and therefore always
publishes proto 1. It gained ML-KEM, the command gained a `pq` flag, and
the comment stayed — so it now reads as a statement of a limitation that
no longer exists, on the one file whose job is to be the honest account
of what the Kotlin engine can do.
Also names the distinction a reader of this file needs: these low-level
commands wire X3DH and the ratchet together by hand, and the session_*
commands go through E2ESession, which is the layer the app calls and the
layer that owns the negotiation decision.
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.