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>
🔴 **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_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>
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.
Everything underneath was built and tested and no screen called any of it, so
every group this app made still handed the relay one readable `gid -> user_id`
row per member. Production is four groups, four of them plaintext.
Making a group now seals it. The relay gets a blob it has no key for and one
opaque tag each.
Old groups get their own answer, because the migration is additive on purpose:
the relay keeps a plaintext roster until a client replaces it, so a group made
before sealing stays readable for ever unless something does it. The owner gets
a button. It keeps the gid — a new one would strand the thread, the history and
everyone else's copy — and reads the members out of the roster we already hold
rather than asking for them.
🔴 The group list now prefers the sealed roster, and that is the security-
relevant half. A sealed group's members come out of a blob the owner signed;
`GET /e2e/groups` carries no signature at all. Letting the relay's answer for
the same gid win — or merely shadow it — puts the server back in charge of
deciding who is in a group. Reverting just that merge fails the new test with
the relay's roster [1, 2, 99] replacing the blob's [1, 2].
Membership edits and leaving route on whether the group is sealed. Sending a
sealed group down the plaintext route asks the relay to edit a table it no
longer has: the call returns fine and the member is not there. One entry point,
so no screen has to branch to be correct.
The button says what it does and does not buy. 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 who receives a
group's traffic still gives it away.
604 tests, 0 failures. assembleDebug succeeds.
Closes#3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This had been sitting uncommitted and unbuilt. It builds.
`Session.updateSealedGroupMembers` is owner-only and refuses locally rather than
sending a request the relay would reject: we hold the owner's signing key only
if we created the group. The version must increase, because a roster signature
stays valid for ever — a stale one could otherwise be replayed to re-add
somebody who was just removed, with a signature that genuinely verifies.
Existing members keep their tags; only newcomers get fresh ones, since reusing
a tag would hand somebody the token another member had held.
The vault carries the group secrets through export and import. Without them a
restored device loses its sealed groups for good: the relay cannot say what you
are a member of — that is the point — so the tag is the only way to fetch a
group and the key the only way to read it. A backup with the messages but not
these restores a device that can see old conversations and can never rejoin the
groups they came from. Import fills gaps only, the same rule the names use.
Verified: :engine:test passes, :app:assembleDebug succeeds.
Closes#1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the iOS side. Create mints a gid, group key, owner signing key and
one random tag per member, seals the roster and hands the relay a blob —
no member id leaves the device. Fetch asks by TAG, never "what groups am I
in", because a sealed relay cannot answer that.
Reachability moved here from the relay, which needed to know who the
members were in order to check it.
⚠️ An invitation is NOT subject to the roster check the group name goes
through, and cannot be: that check asks the relay who is in the group, and
for a group we have not joined yet we cannot fetch it to ask. What guards
it instead is that an invitation is accepted only for a group we hold no
secrets for — a stranger cannot overwrite a group we are really in, which
would make every later roster fail to open and silently empty the
conversation.
The base64 stays inside the engine: `Bytes` is internal there on purpose,
so `SealedRoster` grew `newGroupId`, `newOwnerKey`, `ownerPublic` and
`signRoster` rather than the app reaching past that boundary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two gaps left by the group-name work, both in the half that decides
whether a claim is believed.
The roster check that turns a group-name CLAIM into a stored name lives in
Session, and was covered only through Conversations — which asserts the
claim is NOT adopted there and says nothing about what happens next. iOS
had its equivalent tested directly; Android's was a layer removed. It now
has four tests: a member is adopted, a non-member is refused, a gid we are
not in is refused, and a refused claim is consumed rather than left to
retry against a roster that might later differ. Removing the membership
test fails two of them.
Reaching it needed a seam: `consumeControl` is internal to this module, on
purpose, and driving it through a real ratchet would mean making a real
sender claim a membership it does not have — which a real sender cannot be
made to do. `consumeControlForTesting` says that in its own comment.
The second gap: the announcement addresses our own account so a phone we
own is not told by nobody, and nothing checked that it did. Removing our
account from the list now fails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
versionCode 6, versionName 0.6. Published APK sha256
136bbe7169363ac610c9472f489ceaa400c2f2e79df80590fefdef4e0c147d96.
Certificate unchanged and checked against the published v5 before
publishing — CN=Flamenet Messenger, 791fc826…. A key change makes a
release uninstallable for everyone already on it, so that comparison is
the one step of this that is not optional.
The APK went up before the manifest. A manifest naming a build the server
is not yet serving sends everyone to download the old one and wonder why
nothing changed.
v5 kept as a rollback copy outside the served directory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The announcement has always been refused from an unproven sender (audit
C2). An ordinary group envelope carrying `gname` was not — so the weaker
of the two routes decided what a group is called.
On an unproven device `fromUser` is a CLAIM: the binding says that device
belongs to somebody else. The roster check then asks "is the person they
are pretending to be in this group", which answers yes for any member the
attacker cares to name — and the group heading in someone's chat list is a
good place to put a lie, because the reader has no reason to doubt it.
`Received` now carries `senderProven` so a caller acting on the sender can
ask, and `Session.receive` gates the adoption on it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Names used to be fixed at creation and served by the relay for ever, so
no client ever needed this. The relay has now dropped them — and that
makes every group created before the change read as the placeholder on
every phone, with nobody left to ask what it used to be called. The relay
deleted its copy and no client had ever kept one.
Without a rename, those groups are called "Group" permanently. This is
the repair, and it is the first time a group's name has been changeable
at all.
Stored first, announced second: the name is ours the moment we set it, so
a member who cannot be reached now does not stop us seeing the group
under the name we chose. They pick it up from the next message, which
carries it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay stopped storing them, so `createGroup` no longer sends one and
the `name` it returns is a placeholder. The real name is held in the
sealed vault and travels between members: on every group envelope, and as
an announcement riding the `delivery_key` control kind at creation and on
adding a member.
`delivery_key` rather than a new kind because a new one would arrive on
every phone running v5 as a bubble full of raw JSON.
An announced name is recorded as a CLAIM, not adopted. Conversations does
not know the rosters, and a name taken on the sender's word would let
anyone who can reach you write whatever they liked into your chat list
under a group heading you have no reason to doubt. Session checks it
against the relay's roster and only then stores it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
§21.4 gave a lever to cut off one contact and nothing pulled it. That is
the failure this project has been fixing all day in other places — a
capability built and left unreachable.
Blocking is where it belongs, and the reason is stronger than
convenience. The relay CANNOT enforce a block against a sealed sender:
the request carries no sender to compare against a block list, and the
send route's own comment says so. So blocking stopped the identified
path and nothing else, and a blocked person's sealed messages were
dropped by the app after decryption rather than refused at the door.
Revoking their capabilities is what closes that.
The copy changes with it, because "buddies you block can't send you
messages" was not true and is the kind of overclaim a safety screen must
not make. It now says what blocking does, and names the gap left: the
shared key everybody holds is not that person's alone, so it takes
replacing to close completely — with the cost of that stated in the same
breath rather than in a different screen.
The sealed-sender section changes for the same reason. It said replacing
the key is "the only way" to cut somebody off, which was true when it
was written and is not now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Your address already survives a relay disappearing — it comes from your
passphrase, so signing in elsewhere produces the same one. Your mailbox
does not. Mail sitting at a relay that goes away is gone with it, and a
contact holding only that relay's name has nowhere to write.
The relay has fanned out to a signed home record for a while and no
client could publish one, so the feature existed on exactly one side of
the wire. This is the other side, and both halves of it, because
publishing alone would not have worked: a copy sitting in a mailbox
nothing reads is storage, not redundancy. So the poll now reads every
home, with a cursor per relay — envelope ids come from each relay's own
database, and one shared cursor would let the first home's progress
silently skip the second home's mail.
Copies are collapsed by the id the sender stamps once per message.
Without that the second copy reaches a ratchet that has already deleted
its message key, so the symptom would not be a duplicate — it would be a
dropped envelope and a message that looks lost.
Adding a mailbox is several requests because homing somewhere means
having an account there: sign in, register this device so there is
somewhere to deliver, claim the same address so a sender can resolve it,
then publish the record at EVERY home rather than only the new one. A
home that does not know it is one still accepts mail, but a sender
asking it for the record gets a stale list and skips the others — so the
redundancy would quietly depend on which relay the sender happened to
ask. The new relay accepting is what decides the whole operation:
a record every old home took but the new one refused describes a mailbox
that does not exist.
Removal publishes the shorter list first and forgets the credential only
if that worked. The reverse order leaves mail arriving at a mailbox this
device no longer reads, which from the sender's side is indistinguishable
from delivery. The relay being dropped is not asked to accept the new
record — it is the one party with a reason to refuse.
No one-time prekeys are published at a second home. They are only usable
if this device kept the private halves, and publishing keys it would not
offer back makes every session opened with one undecryptable, which is
worse than offering none.
The passphrase is asked for on the screen that needs it and cleared the
moment the attempt ends. The account key seed lives in memory for the
run and is never written down: it signs the mailbox record, which is the
one thing a relay must never be able to do on the account's behalf.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A session is post-quantum only when both ends are, so with every Android
device registering `proto: 1` the iPhone implementation protected
approximately no real conversation. The property was true of the code
and false of the product. This is the other half.
ML-KEM-768 over Bouncy Castle's low-level `pqc.crypto.mlkem`, the same
way Ed25519 already uses its `rfc8032` class: not the JCA provider,
which is awkward to register on Android and which R8 cannot shrink. The
platform offers nothing here — Conscrypt has no ML-KEM at any API level
— and hand-rolling a lattice KEM to avoid a dependency would be a far
worse trade than the one Ed25519 already made. No new dependency: the
jar was already in the build for Ed25519.
Keys are stored as the 64-byte FIPS 203 seed, never the expanded
decapsulation key — 64 bytes in the vault instead of 2400, and the same
representation iOS persists, which is what lets a history export cross
platforms. That is also the thing most likely to be silently wrong, so
it is checked against the published vector rather than against another
run of our own code: the seed in `pqx3dh-v2.json` derives the public key
the normative JavaScript engine derived, and the full responder side
reproduces the published session key and associated data byte for byte.
Everything else here would pass with an implementation that is
internally consistent and agrees with nobody.
Negotiation refuses in three places, and each is a substitution
somebody would otherwise get for free. A KEM prekey whose signature does
not verify is treated as ABSENT, so a relay handing out a key of its own
drops the session to v1 rather than to its key — the same substitution
`spk_sig` stops classically, and worth more here because it buys the
whole post-quantum half. A `proto` claim with no `caps_sig` reads as v1,
so a relay cannot advertise a v2 device as v1-only and strip the
post-quantum half off every new session. And the one-time id is signed
big-endian and inside the signature, so a renumbered key falls back to
the last-resort one instead of being spent.
Both pools are refilled together, and that is not tidiness. They drain
at the same rate — one key per new session — so a refill that topped up
only the classical pool would let the post-quantum one run dry and push
every later session onto the last-resort key, every one of them sharing
a single KEM key, with nothing anywhere reporting a downgrade.
Mixed fleets work in both directions and a spent prekey is not an error:
a duplicate delivery of a v2 prekey envelope names a one-time key this
device has already consumed, and throwing there would turn a
retransmission into a dead conversation.
Two test fixtures had to say what they are rather than answer by order.
Sending now asks for a transparency-log head on its own, so a queue
matched purely by position handed that request the device listing and
failed on a parse error nowhere near the cause.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things this app could describe and not do.
**Key transparency was a switch almost nobody found.** `requireKeyLog`
was off by default, so the log was published, verified in tests, and
checked for approximately no one. The reason it was opt-in — a relay
predating §19 keeps no log, and refusing every peer there is worse than
the attack — argues for skipping relays that CANNOT answer, never for
skipping the ones that can. `enforceKeyLogIfServed` draws that line: ask
the head route once, enforce fully if it answers, skip if it is absent.
The switch survives as the stricter question underneath — refuse a relay
that keeps no log at all — which is a real choice for somebody who knows
their server publishes one.
Two things make that safe rather than merely automatic. The head is
asked for SEPARATELY, because `enforceKeyLog` reads three routes and a
relay wanting to skip the check would otherwise only have to 404 the
middle one. And a relay that has answered before is remembered, so it
cannot publish a log until clients are talking and then stop — logs do
not disappear, and that is a fork rather than an absence. The flag is
its own vault entry rather than the pin, because the pin can arrive
from gossip and would make a logless relay look dishonest.
Both subject forms are now accepted for a leaf. A leaf names `fnid:`
once an account has claimed an address and `local:` before, and the hash
committed to whichever was true when it was written — so a contact who
registered a device and then claimed an address has older leaves under
one form and newer ones under the other. A verifier computing a single
subject would refuse half the network on the day claiming turned on.
Either candidate still requires the relay to have appended a leaf
binding the exact key it served.
**Federation was iOS-only.** The relay has spoken it for a while and so
has iPhone; Android could neither send to an address nor read one that
arrived. Now: the three remote routes, an always-sealed send that
carries our address as the return path, and a receive side that files a
federated message under that address rather than under `from_user`,
which their relay never set. Filing by user id would drop every message
from every other relay into thread 0 — or worse, into a local contact's,
if their row numbers happened to collide.
The address keyspace is kept apart from the local one throughout, and
that is the load-bearing decision. A remote sender is pinned, bound and
threaded by `fnid@relay`; they cannot write a local delivery key or
avatar, cannot post into a group, and cannot ring a phone, because all
four are addressed by a user id that means nothing across the boundary.
Wired end to end rather than left as a capability: the add-contact field
recognises an address and offers to message it — the QR scanner already
prefilled that field and had its address looked up as a handle — and
conversations on other relays get their own section, without which a
reply is unreachable short of retyping 52 characters of base32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sending path was careful — read the bounds first, then decode sampled —
but the two places that draw an image RECEIVED from a peer called
`decodeByteArray` with no options at all, decoding at full size. Inbound
attachments are capped at 12 MiB, and 12 MiB of a suitably chosen JPEG is a few
hundred megabytes of ARGB_8888 once expanded: OutOfMemoryError.
The effect is not one crash. The decode happens every time the thread or the
story is opened, so a single message wedges that conversation permanently from
the outside, and the victim cannot get past it to delete anything.
Both render paths now go through `ImagePrep.decodeBounded`, which reads the
bounds before allocating — the same thing the send path has always done. The
story composer's own draft goes through it too: a 50MP photo is just as much
bitmap whoever picked it.
⚠️ A byte-count cap does not bound this. The 12 MiB limit was already in place
and is exactly what made the attack convenient — the constraint that matters is
the decoded dimensions, not the compressed size.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
It was a plain in-memory field on the trust object. The switch set it, nothing
wrote it down, and the phase it was read back from defaulted to false — so
somebody who turned it on was protected until they next closed the app, and
nothing told them it had lapsed. The switch simply read off again.
A security control that quietly disarms itself is worse than one that was never
offered, because the person is relying on it.
iOS has persisted this in UserDefaults all along. Android now stores it beside
the other device preferences, restores it into the trust object at sign-in, and
seeds the phase from the same value so the switch shows what is actually being
enforced rather than disagreeing with it.
Still off by default, which is deliberate and unchanged: a relay older than the
transparency log has no log to prove anything against, and enforcing it there
would refuse every peer for a reason the user cannot fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found by finally running the banner I shipped in 0.3 instead of trusting that
it compiled.
`blocked()` read `user_id` from rows the relay emits as `{id, name, slug}` —
the buddy-list shape, not the `user_id` shape the §8 routes use. It threw
`No value for user_id`. Tapping Block worked on the server and then showed the
user an error, which on a safety action is the worst outcome available: it
succeeded and the screen said otherwise.
The reason it survived is the reason it was dangerous. The block list is empty
until somebody blocks their first person, so the map never ran and every test
passed. The first block anybody performed broke it.
🔴 And it did not stop at a message. `adoptStrangers` read the block list with
`.getOrDefault(emptyList())`, so the throw became "nobody is blocked" — and the
next message from someone you had just blocked auto-added them back to your
contacts, defeating the exact property the code comment above it claims to
hold. That now fails closed: if the block list cannot be established, nothing
is adopted and the next poll tries again. A stranger appearing a few seconds
late is cosmetic; re-adding somebody who blocked their way out of a
conversation is not.
The new test uses a NON-EMPTY list, because that is the case that was missing —
an empty-list assertion looks like coverage and exercises no parsing. Confirmed
by reintroducing the bug: the populated case fails, the empty case still
passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app adopts anyone who messages you, because a first message from a
non-contact used to be invisible — no row, no name, just an unread badge over
"Nobody here yet". That fix stays: the message still arrives and is still
readable.
What it also did was decide something for the user. Somebody ends up in your
contacts because they wrote to you, and nothing on screen distinguished that
from a person you went and looked up.
So adoption now sends `auto: true` and leaves the acceptance to the person, and
the thread carries a banner naming what happened with the two useful actions in
it. Nothing is gated: delivery is unchanged, the difference is just visible.
`accepted` defaults to true wherever it is not answered — an older relay says
nothing, and reading silence as "stranger" would label every contact on every
older relay.
The copy states what happened rather than warning about it. Most first messages
are from someone you gave your address to, and opening with an alarm would be
wrong more often than right.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Nothing updates this app on its own. It is downloaded as an APK from a web
page, so there is no store to notice a release and no background updater to
install one — a person can run a build from months ago indefinitely, through
every fix that came after it, and never find out. That is the part of
sideloading that is genuinely worse than a store, and this is the only thing
that closes it.
The background check asks the RELAY the install is signed in to, not a fixed
address of ours. Pointing every install at flamenetmessenger.com would mean an
app whose whole claim is that it does not phone home checking in with us on a
timer, from every device, including the self-hosted ones that deliberately
never contact us at all. A relay that publishes no manifest simply produces no
prompt.
"Check for updates" in Account is the other half, and the reason the fallback
to our site exists: pressing it is a question the user asked, so answering
"your relay does not publish updates" when we plainly could is unhelpful. It
tries the relay first and names whichever host answered, so it is never a
silent phone-home. It also distinguishes "no newer build" from "could not
check" — reporting silence as good news is how somebody sits on a build with a
fix in it believing otherwise.
The download URL is built from the host that answered and never read from the
manifest. A `url` field would let any relay send its users to an APK on a host
of its choosing; Android's signature check is a backstop, not a reason to build
the hole in front of it. A test asserts a hostile manifest cannot move it, and
fails if the guard is removed.
versionCode is bumped and `scripts/release-manifest.sh` reads it back out of
the built APK, so the manifest and the artifact cannot drift.
⚠️ Nobody already installed sees this: every build before this one has no
check in it, so the first prompt anybody gets is for the build after this one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
**Three panels became a menu.** Disappearing messages, snooze, remind-me and
send-later were four blocks of chips stacked above the messages — in a
conversation you had opened to read, they took half the screen. The controls are
behind ⋯ now.
🔴 **What stays on the thread is the STATE, in one line.** A timer both people
are deleting on, a snooze, a pending reminder, a queued message — those are facts
about the conversation, and a fact you must open a menu to discover is one you
will not rely on. Absent entirely when nothing is set, so an ordinary chat
carries no chrome at all.
⚠️ "Send later" only appears when the composer has something in it. Offered
against an empty box it is a control whose only possible effect is nothing.
**Status was at the bottom of Account, under the address card, as four
unlabelled chips.** It is directly under the profile picture now, with a heading
and a line saying what each state does to other people. The person who asked for
the feature could not find it, which is the same as it not being there.
🔴 **And it showed a guess.** The state defaulted to "online" and nothing ever
read the real value, so the screen showed the default rather than what contacts
could see. It loads from the relay now — an empty body on that route is a read,
so no new endpoint was needed.
engine 177, client 96, app 46.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same gap as iOS: §6.13 shipped the wire format, the model, and the viewer's
consent gate, with no way to enter a URL. Adds the field to the composer, with
the disclosure said when attaching rather than only when watching.
⚠️ Post is disabled for a URL that could not be disclosed — a destination with
no host cannot be named, and a viewer cannot consent to one they cannot be
shown.
⚠️ The poster's own copy carries the stream too, so the story does not look
different to the person who sent it than to everyone who receives it.
engine 177, client 96, app 46.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every other media path here is a sealed blob — the relay stores ciphertext and
nobody else is involved. A live stream is not that: it is continuous and hosted
elsewhere, so there is nothing to seal, and watching one means this device
connects to that host, which learns the viewer's IP address and that they
watched.
🔴 **§6.12's answer does not transfer.** A link preview works because the sender
fetches it once and puts the result inside the ciphertext. A stream cannot be
fetched once. So this is disclosure rather than prevention.
The URL rides inside the ciphertext, so the relay learns nothing about what is
watched or by whom. Playback is an explicit tap with the host named and what it
learns spelled out — a card that started playing on its own would make that
disclosure after the fact, which is not a disclosure.
⚠️ The attachment is the POSTER, not the stream. Keeping a real blob means the
strip, the unseen rings and the expiry sweep all work unchanged, and the viewer
sees a still before deciding whether to connect.
⚠️ The host is derived from the URL, never the caption — a story captioned
"youtube.com" pointing elsewhere is the phishing shape link previews already
guard against. Non-http(s) URLs and ones with no host are refused: a `file:` URL
arriving from another person is not something to hand a media player, and a
destination nobody can name is one nobody can consent to.
The wording lives in the engine rather than the UI so it can be asserted.
engine 177, client 96, app 46.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A high-priority notification is a banner, not a ringing phone. On a locked
device the old one showed a line of text, and the only way to act was to unlock,
find the app and open it — by which time the caller has given up. A call is a
live event with seconds on the clock.
Now a full-screen intent with `CallStyle`, so the lock screen shows Answer and
Decline. ⚠️ `CATEGORY_CALL` and `CallStyle` are not decoration: Android 14
grants USE_FULL_SCREEN_INTENT at install only to apps that ring like this, and
without them the permission is refused and the call silently degrades to the
banner it used to be.
⚠️ Decline is a broadcast, not an activity. The point of answering from a lock
screen is that the phone stays locked for the choice the user did NOT make.
🔴 **The notification is `ongoing`**, so it cannot be swiped away by accident
mid-ring — which also means it never dismisses itself, and every path that ends
a call has to cancel it. Four of them: declined, answered, the caller hung up,
and nobody picked up. The last has no event behind it, so the service checks for
a parked offer past the dial timeout; without it the lock screen keeps offering
to answer a call that is long dead.
🔴 `isStale()` asks without consuming. `take()` is the handover and can only
happen once, so a staleness check built on it would swallow a live offer and
drop a call that was still ringing — the exact opposite of the bug it exists to
prevent. There is a test for that specifically.
Answering hands off through the SAME parked offer the app already uses on
launch. This session consumed the envelope, so the SDP in hand is the only copy
— a second answer path would be a second way to lose a call.
engine 168, client 96, app 46.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 **The obvious implementation is the one that must not be built.** Having each
recipient fetch the URL to draw a card tells the site the reader's IP address,
that they received a link, and roughly when they opened it — once per member on
a group message. A messenger that hides the message and then has every reader
announce its contents to a third party has given away what it was protecting.
So the SENDER fetches, and the preview travels inside the ciphertext. The
receiver renders from those bytes and contacts nobody.
⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is about
to share that URL, from this address. The setting exists for that, and the copy
says who learns what rather than implying it is free.
🔴 **The host is shown separately and derived from the URL**, because title and
description come from a page the sender did not write — a card claiming one
destination while the link points at another is a phishing primitive. Both
fields are bounded on the way IN as well as line-limited at render, so the cap
does not depend on a future renderer remembering one.
⚠️ `Content.text` still returns the raw body byte-for-byte when there is no
preview, so an ordinary message is unchanged on the wire and older clients see
exactly what they saw before.
⚠️ The fetch is short-timeout and best-effort, and runs on the IO context. A
preview is a decoration and must never be able to delay a send — every failure
path ends as a message with a plain URL in it.
engine 168, client 96, app 40.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay could hold a scheduled message and deliver it on time even with the
phone off. It would then know that a message exists, who it is for, and exactly
when it was meant to land — a richer piece of metadata than anything else this
design gives it, in exchange for a convenience. So it waits here, and the banner
states the cost rather than hiding it: "when the app next runs".
🔴 **"Not due yet" means KEEP, never fail.** The outbox loop is also the retry
queue, so a scheduled entry falling through the failure path would be dropped
outright and the message would simply never exist. That predicate is the whole
of the risk, so it has tests of its own — including the boundary, where an
off-by-one delays delivery by a cycle.
⚠️ `not_before` is absent on entries queued before scheduling existed, and they
send immediately rather than being held for a date they have no value for.
⚠️ Scheduling clears the composer, like an ordinary send. Text left sitting
there afterwards is how somebody sends the same message twice.
Also adds the real `org.json` to the app module's unit tests. The Android
framework's version is STUBBED there — every method returns null — so a test
that builds a JSONObject fails with a NullPointerException that says nothing
about why. The engine and client modules already pull it in for exactly this.
engine 163, client 96, app 40.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two halves of the same problem — a conversation you are not ready for, and one
you are waiting on — and both are local to this device on purpose. "I am
ignoring this person until Tuesday" and "remind me if they have not answered"
are statements about a relationship, and the relay has no business holding
either. The honest cost is that neither follows you to a new phone; the
alternative is handing a server the list of people you are avoiding.
🔴 **A reply arriving cancels the reminder, and it happens in `Session.record`**
— the one place every inbound message passes through. Hanging it off a screen or
a poll handler means the one path that forgot to call it produces a reminder
that fires after they answered, and that is the single thing that would teach
people to ignore reminders.
The service fires due reminders from its existing loop, deliberately AFTER
`receive()`: a reply that has just landed cancels its own reminder before it can
go off. Checking first would nudge about a conversation answered a moment ago.
Cleared as it fires, too — a reminder is one-shot, and leaving the row would
re-notify every half second for ever.
⚠️ Snoozes are recomputed, never cached as a flag. A snooze expires by the clock
passing and nothing runs at that moment, so a stored boolean leaves a
conversation muted past its own deadline.
⚠️ Same privacy rule as message notifications: a count, never a name. Who you
are waiting on is as sensitive as what they said.
engine 163, client 96, app 35.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The picker read the file and handed the ORIGINAL bytes to `sendImage` — no
downscale, no re-encode, nothing. A modern phone camera produces 12-50 MP and
often 5-15 MB of JPEG, so a photo either exceeded the relay's blob cap and
simply failed to send, or fit and made the recipient download tens of megabytes
nobody asked for. iOS has downscaled since it shipped, so the two platforms
disagreed about what sending a photo even means.
Adds `ImagePrep`, matching the iOS one: 2048px at quality 80 by default, 4096 at
95 with the new "Send full quality" switch, stepping down through size and
quality until the bytes are under budget. The budget sits under the server's cap
because the blob is encrypted before upload and the AEAD tag rides on top —
sizing to the exact limit produces a file that passes locally and is refused
remotely.
⚠️ Bounds are read with `inJustDecodeBounds` and the decode uses `inSampleSize`,
so the full-size bitmap never exists. Decoding a 50MP JPEG outright is ~200 MB
of bitmap, which is how a photo picker gets OOM-killed.
⚠️ "Full quality" is not "uncompressed", and the copy says so. A setting that
silently failed on a large photo would be a worse answer than the compression it
replaced.
engine 155, client 96, app 35.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`"Registered ${'$'}{fmt.format(instant)}"` — `${'$'}` emits a LITERAL dollar
sign, so the template was never evaluated and every device row read
Registered ${fmt.format(instant)}
Kotlin cannot warn about this: escaping a dollar is a legitimate thing to want,
just never here. And nothing could test it, because the string was assembled
inline inside a `Text(...)`.
Lifted into `registeredLine` so it can be asserted, with a test that fails if
template source ever reaches the screen again. That is the real fix — the
one-character correction would have left the next one just as invisible.
engine 155, client 96, app 35.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay has had per-conversation lifetimes since the port and **no client
could set one** — its own comment says so. For E2E conversations the server
timer would not have helped anyway: it expires rows in the legacy plaintext
table, not the envelopes these apps actually exchange. So this is client-side,
which is also the only place it can be honest.
**Symmetric.** Either party sets the timer; the other is told inside the ratchet
and adopts it. A timer only the setter honoured would be worse than none — they
would believe their words were gone from a phone still holding them.
**Inside the ratchet**, so the relay never learns that a conversation is timed,
let alone for how long. It rides as ordinary control traffic carrying the `ctl`
flag from §6.10, which is what makes it safe to add at all: without that, this
envelope would surface in every older install as an "[unsupported attachment]"
bubble.
**Expiry runs on read**, in `Vault.history`. A sweep on a timer leaves expired
messages on disk between runs, and a disappearing message still sitting in the
vault is a claim rather than a feature. Reading is the one path every caller
shares, so nothing can see — or keep — an expired message.
⚠️ **Ages come from this device's own timestamp**, not one the peer supplies.
"From send time" in practice means when this device sent or received it, which
differs by seconds and cannot be manipulated: a peer who chose the clock could
backdate a message so it vanished from your phone immediately, or forward-date
one so it never expired.
⚠️ **A message with no timestamp is kept.** Those predate the field, and an
unknown age is not evidence of an old one — deleting somebody's history on a
guess cannot be undone.
The panel sits on the thread rather than in a menu, because "fewer taps to turn
it on" and "clearer per-conversation state" are the same problem: a timer nobody
can see is one nobody trusts. Presets only — arbitrary durations are the ones
nobody can reason about later.
engine 155, client 94, app 32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`setPresence` did `it.copy(presence = status)` on success — the value it had
requested. The relay answered 200 for statuses it did not recognise and stored
nothing, and `busy` and `offline` were both in that set, so tapping either lit
the chip and changed nothing. Someone who chose "offline" stayed visible to
every contact with no way to find out.
The relay is fixed too, but a client that reports its own request back to itself
is wrong regardless of how the server behaves — so `setStatus` now returns what
the relay says the status is, and the screen renders that.
⚠️ The reply is not always the word sent: `offline` is stored as `invisible`,
the same choice under two names, so it is mapped back to this screen's
vocabulary or no chip would appear selected right after a successful change.
`PRESENCE_CHOICES` moves to the companion object so the chips and the test read
one list. It is a contract with another repository that no compiler checks, and
the test says so: every entry must be a value the relay's `ChosenStatus`
accepts.
engine 141, client 90, app 32 — all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same rule as iOS: the live refresh replaced the whole map, so a refresh
while the buddy list was briefly empty produced an empty map that blanked
every picture. Merge, and ignore an empty result.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same empty-offer and attachment-id rules as iOS. The QR scanner now
binds a scanned code by its fnid rather than the handle even on the home
relay — the fnid certifies itself, a handle is a name the relay maps,
so resolving by handle handed the relay the choice of who you scanned.
And a skipped message key is consumed only after its tag verifies: it
was defused today by the caller discarding the session, but any future
in-memory ratchet cache would have turned it into real message loss.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Filing on the sender's word let a contact conjure a group thread we are
not in, and let anyone sharing a session with us — including a member we
removed — post into a group they had left. The relay's roster decides,
cached briefly so a poll pass carrying many group messages costs one
round trip rather than one each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Long-press any bubble for Copy and Delete for me (Unsend on your own),
in 1:1 and group threads. Vault.deleteLine rewrites the thread minus the
line, keyed by timestamp+side+text — messages carry no stable id here.
Leaving a group was already wired.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session.roster() reads the vault once the list is off-server, mirrors
the relay's copy into the vault while one exists, and falls back to the
sealed copy when the network fails. The roster rides the history backup.
Settings gains the one-way door with the costs stated where the button
is: presence goes dark, and a new device restores contacts from the
encrypted backup rather than the server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Launcher badges on the message notification (the service notice
explicitly never badges). Stories accept picked videos — validated
against a 30s/9MB cap rather than transcoded, said honestly when
refused — and play looping in the card via the framework player. The
wire's dur field travels for any attachment that has one.
Parental controls mirror iOS: a parent's PIN guards adding contacts,
stories, and calls; incoming calls answer busy; sign-up gains an
on-device age gate that stores nothing. The server learns nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The default launch mode stacked a second MainActivity — with a second
ViewModel, session and CallManager — whenever the app was re-entered.
The visible screen's Hang up then commanded a manager with no call while
the real call rang on underneath, and the peer never got the END.
singleTask, plus a 30s connect guard so an answered call whose ICE never
finds a path gives up out loud on both screens instead of sitting in
Connecting forever.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same design as iOS: the vault's history re-sealed under a passphrase-
derived key (PBKDF2 600k rounds, AES-256-GCM), saved through SAF, merged
back by content identity so the same backup twice adds nothing. Expired
stories stay expired — a backup is not a way back in. The passphrase is
stored nowhere, deliberately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror of the iOS change: a 410 from the poll cancels polling, destroys
the vault (Android's sign-out is already a full wipe), and lands on the
sign-in screen with a sentence that says what happened and what signing
in again means.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RelayException.RateLimited has parsed the header all along; the relay
never sent it until today. The message now says "Try again in about
N minutes" when the clock is known, with the old copy as fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same contract as iOS: the relay's roster for this account, oldest
first, the current install marked and not removable from itself (that
is Sign out), removal behind an in-place "Really remove" confirmation
per this screen's house style. RelayClient grows revokeDevice
(DELETE /e2e/devices/{id}); Session surfaces ownDevices()/revokeDevice
and parses the directory's new created_at.
The relay caps accounts at three devices, so this card is the only way
off the cap when registration refuses a fourth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The size-keyed follow effect animated from the very top on first
composition — a tour of the history before landing. A Unit-keyed instant
jump runs first; the animated follow keeps handling arrivals.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>