Android 0.8 — sign-in identity, v3 responder, media scrubbing #35

Merged
paulhitt merged 9 commits from fix/v3-responder into main 2026-09-01 22:09:24 +00:00
9 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 4.8 62a8206e5a Android 0.8 — published
versionCode 7 -> 8, versionName 0.7 -> 0.8, and the build carrying it is
live at /downloads/flamenet-messenger.apk on relay.flamenet.io.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 17:43:57 -04:00
Paul Hitt 1cda405072 Say which envelopes are worth interrupting somebody for
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.
2026-09-01 15:59:07 -04:00
Paul HittandClaude Opus 4.8 fc0dff18ca Sign-in loads the identity it already has
🔴 **This is why signing out and back in broke everything.** Sign-in only
ever consulted the in-memory `identity` field, and sign-out clears it —
so it minted a fresh `DeviceIdentity` and OVERWROTE the vault's, making
this phone a brand new device on every single sign-in.

Everything the owner hit follows from that one line:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also here:

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 09:12:28 -04:00