From ee17d773b7de4e77d3f5d045dade83d0e7767674 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 09:12:28 -0400 Subject: [PATCH 1/9] A session we ANSWER can now be v3, and receive failures say why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- app/build.gradle.kts | 11 +++ app/proguard-rules.pro | 50 +++++++++++++ .../io/flamenet/messenger/app/CallManager.kt | 55 +++++++++++--- .../io/flamenet/messenger/app/MainActivity.kt | 8 ++- .../messenger/app/MessengerViewModel.kt | 44 +++++++++++- .../io/flamenet/messenger/app/Session.kt | 15 +++- .../io/flamenet/client/Conversations.kt | 71 ++++++++++++++++++- .../kotlin/io/flamenet/client/RelayAuth.kt | 29 +++++++- .../main/kotlin/io/flamenet/e2e/E2ESession.kt | 19 ++++- .../io/flamenet/e2e/HybridSessionTest.kt | 14 +++- 10 files changed, 296 insertions(+), 20 deletions(-) create mode 100644 app/proguard-rules.pro diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 721949d..7e22a6e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -105,6 +105,17 @@ android { // Debug keeps all four, because that is what runs on an emulator. ndk { abiFilters += setOf("arm64-v8a", "armeabi-v7a") } isMinifyEnabled = true + // 🔴 **R8 accepts a missing rules file in silence.** This one was + // referenced here and did not exist, so the release build shipped + // with the platform defaults alone — and answering a call aborted + // the process, because WebRTC's native JNI_OnLoad resolves its Java + // classes by name and R8 had renamed them. Nothing failed, nothing + // warned, and no debug test could see it. Fail the build instead. + file("proguard-rules.pro").takeIf { it.isFile } + ?: throw GradleException( + "app/proguard-rules.pro is missing. The release build needs it — " + + "without the org.webrtc keep rules, answering a call aborts the " + + "process inside JNI_OnLoad with no Kotlin stack trace.") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.findByName("release") } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..c8f421a --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,50 @@ +# R8 rules for the release build. +# +# 🔴 **This file did not exist, and `build.gradle.kts` referenced it anyway.** +# R8 accepts a missing `proguardFiles` entry in silence, so the release build +# ran with the platform defaults alone and nobody was told. What that cost: +# answering a call on a release build killed the process instantly — +# +# Fatal signal 6 (SIGABRT), thread DefaultDispatch +# JNI_OnLoad <- libjingle_peerconnection_so.so +# System.loadLibrary +# org.webrtc.PeerConnectionFactory +# +# — because WebRTC's native `JNI_OnLoad` resolves its Java classes **by name**, +# and R8 had renamed them. The lookup fails and the library aborts the process +# rather than throwing, so there is no Kotlin stack trace and nothing in the +# call log: the app is gone before it can write one. Reported as "calling from +# iOS to Android crashes Android when I hit answer" and, from the other side, +# "iOS shows 2 calls, Android shows 0". One cause, two symptoms. +# +# ⚠️ **Debug builds cannot catch this and never will.** `connectedDebugAndroidTest` +# runs the unminified variant, where every one of these classes still has its +# real name. Every call test this project has ever run was blind to it. A +# release-build call test is the only thing that would have. + +# --- WebRTC ---------------------------------------------------------------- +# Everything, members included: the native side looks up classes, methods AND +# fields by their source names. Narrowing this to class names alone is the +# tempting half-fix and it still aborts, in a different frame. +-keep class org.webrtc.** { *; } +-dontwarn org.webrtc.** + +# Any class holding native methods must keep those names, or the JNI binding +# that pairs them by signature finds nothing. +-keepclasseswithmembernames class * { + native ; +} + +# WebRTC marks its JNI entry points with this annotation. Keeping the +# annotation itself is what lets the rule above stay meaningful after R8's +# attribute stripping. +-keepattributes *Annotation*,InnerClasses,Signature,EnclosingMethod + +# --- Conscrypt ------------------------------------------------------------- +# ⚠️ The engine selects Conscrypt explicitly by provider name (see +# android/PARITY.md). Renaming its classes turns that selection into a silent +# fallback to the JVM's own provider, which is a different implementation with +# different behaviour — the exact class of divergence the parity tests exist +# to catch, arriving only in release. +-keep class org.conscrypt.** { *; } +-dontwarn org.conscrypt.** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index 24f0ce0..b00417e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -149,7 +149,21 @@ class CallManager( onGathered = null gatherGuard?.let { main.removeCallbacks(it) } gatherGuard = null - val sdp = connection?.localDescription?.description ?: return + // 🔴 **This `?: return` sent nobody anything, silently.** After the + // 8s gather timeout the local description can still be null — because + // setLocalDescription had not finished, or had FAILED and said nothing + // (see SimpleSdpObserver). The call then dies here: no offer, no + // envelope on the relay, no ring on the other phone, and no error. + // Verified against the database — zero envelopes had ever been created + // between two accounts that had "placed" several calls. + val sdp = connection?.localDescription?.description ?: run { + android.util.Log.w("fnmsg-call", "NOT SENDING the offer/answer: no localDescription after " + + "gathering (state=${connection?.iceGatheringState()}). " + + "The other side will never see this call.") + return + } + android.util.Log.i("fnmsg-call", "sending signal after gathering, sdp=${sdp.length} bytes, " + + "state=${connection?.iceGatheringState()}") send(sdp) } @@ -213,7 +227,15 @@ class CallManager( continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE } val observer = object : PeerConnection.Observer { - override fun onIceCandidate(candidate: IceCandidate?) = Unit + override fun onIceCandidate(candidate: IceCandidate?) { + // 🔴 The candidate TYPE is the whole diagnosis. `host` only + // means we never reached STUN/TURN and cannot cross a NAT; + // `relay` means TURN answered and the call can connect even + // when a direct path is impossible. + val sdp = candidate?.sdp ?: return + val kind = Regex("typ (\\w+)").find(sdp)?.groupValues?.get(1) ?: "?" + android.util.Log.i("fnmsg-call", "ice candidate: typ=$kind") + } override fun onIceCandidatesRemoved(candidates: Array?) = Unit override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit // 🔴 **Nothing may throw out of here, and nothing may call back @@ -222,6 +244,7 @@ class CallManager( // out — so ANY escaping Kotlin exception aborts the process: // SIGABRT, no AndroidRuntime trace, nothing to catch. override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { + android.util.Log.i("fnmsg-call", "ice connection -> $newState") runCatching { when (newState) { PeerConnection.IceConnectionState.CONNECTED, @@ -239,6 +262,7 @@ class CallManager( } override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) { + android.util.Log.i("fnmsg-call", "ice gathering -> $newState") // Hop to the main thread rather than touching the connection // from here — same JNI rule as above: no WebRTC calls, and no // exceptions, from inside a WebRTC callback. @@ -327,7 +351,7 @@ class CallManager( } pc.createOffer(object : SimpleSdpObserver() { override fun onCreateSuccess(sdp: SessionDescription) { - pc.setLocalDescription(SimpleSdpObserver(), sdp) + pc.setLocalDescription(SimpleSdpObserver("offer:setLocal"), sdp) // The complete SDP — with candidates — exists only after // gathering; see onGathered. DIALLING holds until the answer // arrives, so the caller's screen says what is actually true. @@ -428,12 +452,12 @@ class CallManager( } current = Active(offer.callId, fromUser, incoming = true, video = offer.video) pc.setRemoteDescription( - SimpleSdpObserver(), + SimpleSdpObserver("answer:setRemote"), SessionDescription(SessionDescription.Type.OFFER, sdp), ) pc.createAnswer(object : SimpleSdpObserver() { override fun onCreateSuccess(answer: SessionDescription) { - pc.setLocalDescription(SimpleSdpObserver(), answer) + pc.setLocalDescription(SimpleSdpObserver("answer:setLocal"), answer) // Same as the offer: the answer is complete only after // gathering, and one sent early names no address to reach. main.post { @@ -614,9 +638,24 @@ object PendingIncomingCall { } /** Most SDP callbacks have nothing to say; this stops four stubs per call site. */ -private open class SimpleSdpObserver : SdpObserver { +/** + * 🔴 **This discarded every SDP failure, and that is why calls were undebuggable.** + * `createOffer` or `setLocalDescription` could fail for any reason and nothing + * anywhere recorded it — the offer then simply never went out, the callee never + * rang, and the database showed no envelope had ever been created. Four hours of + * "the call failed" with no cause, because the two callbacks that knew returned + * `Unit`. + * + * ⚠️ A `label` so the log says WHICH step failed. "setLocalDescription failed" + * and "createAnswer failed" send you to different places. + */ +private open class SimpleSdpObserver(private val label: String = "sdp") : SdpObserver { override fun onCreateSuccess(sdp: SessionDescription) = Unit override fun onSetSuccess() = Unit - override fun onCreateFailure(error: String?) = Unit - override fun onSetFailure(error: String?) = Unit + override fun onCreateFailure(error: String?) { + android.util.Log.w("fnmsg-call", "$label: createOffer/createAnswer FAILED: $error") + } + override fun onSetFailure(error: String?) { + android.util.Log.w("fnmsg-call", "$label: setLocal/setRemoteDescription FAILED: $error") + } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index ab36742..f6f67f8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -479,7 +479,13 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo } TextButton( - onClick = { registering = !registering }, + onClick = { + registering = !registering + // The last attempt's message was about the OTHER mode; leaving it + // up makes switching look like it failed. + ageError = null + model.clearAuthError() + }, modifier = Modifier.fillMaxWidth().testTag("toggle-register"), ) { Text(if (registering) "I already have an account" else "Create an account") diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 78a5a0a..1bf3b85 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -404,6 +404,22 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { ) } + /** + * Forget whatever the last sign-in attempt said. + * + * 🔴 **Switching between signing in and creating an account has to do + * this.** The message belongs to the mode it was produced in — "that + * handle is already taken" is advice about signing up, and it stayed on + * screen after the person took that advice and switched to signing in, + * where it reads as a refusal of the thing they are now trying to do. + */ + fun clearAuthError() { + val current = _phase.value + if (current is Phase.SignedOut && current.error != null) { + _phase.value = current.copy(error = null) + } + } + fun signIn(handle: String, passphrase: String, register: Boolean, email: String = "") { val config = site ?: run { _phase.value = Phase.SignedOut(error = "That doesn't look like a server address.") @@ -2460,7 +2476,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { is AccountsClient.Failure.HandleTaken -> "That handle is already taken." is AccountsClient.Failure.RateLimited -> "Too many attempts. Wait a few minutes." - is AccountsClient.Failure.Transport -> failure.message.orEmpty() + // ⚠️ Not the underlying message: at this layer it is a Java networking + // string ("Failed to connect to /10.0.2.2:443"), which names an address + // the person never typed. + is AccountsClient.Failure.Transport -> + "Couldn't reach that server. Check the address and your connection." // ⚠️ NOT "that passphrase is wrong". The relay rejects a TOKEN, which // the user never typed and cannot check. Telling them their passphrase // is wrong when their session merely aged out sends them off to change @@ -2477,7 +2497,27 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { if (mins == 1) "." else "s." } ?: "Too many attempts. Wait a few minutes." is Vault.Corrupt -> "This phone's stored keys could not be read." - else -> failure.message ?: "Something went wrong reaching that server." + + // 🔴 **Anything not named above used to reach the screen verbatim.** + // `RelayException.Http` carries "relay returned 409: {body}" as its + // message, so somebody choosing a handle that was already in use was + // shown a status code and a JSON fragment. Nothing in that tells them + // to pick a different name, which is the entire content of the answer. + // + // ⚠️ The body is deliberately dropped rather than appended. It is + // written for whoever is reading the logs, and half of these carry the + // handle back in it. + is RelayException.Http -> when (failure.status) { + 409 -> "That handle is already taken. Try a different one." + 400, 422 -> "That server couldn't accept those details. " + + "Check the handle and try again." + 403 -> "That isn't allowed on this server." + in 500..599 -> "That server is having trouble right now. " + + "Try again in a moment." + else -> "That server couldn't complete this. Try again in a moment." + } + is RelayException.Malformed -> "That server sent back something unexpected." + else -> "Something went wrong reaching that server." } /** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index f471d32..3d510ab 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -630,8 +630,21 @@ class Session( } /** TURN credentials, or null when this relay has none configured. */ + /** + * 🔴 **A null here silently downgrades every call to STUN-only.** + * `CallManager.iceServers(null)` falls back to Google's STUN server, which + * cannot relay — so two peers behind NAT never connect, the call "just + * fails", and coturn records nothing because it was never contacted. The + * failure was swallowed by `getOrNull()`, so nothing anywhere said why. + */ fun turnCredentials(): org.json.JSONObject? = - runCatching { relay.turnCredentials() }.getOrNull() + runCatching { relay.turnCredentials() } + .onFailure { + android.util.Log.w("fnmsg-call", "no TURN credentials " + + "(${it.javaClass.simpleName}: ${it.message}) — this call will use " + + "STUN only and will not connect across NAT", it) + } + .getOrNull() fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent { val sent = conversations.sendToGroup(group, text, userId) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index ae9bf85..72b057a 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -573,7 +573,21 @@ class Conversations( // where the key route returned one. stubs.map { full[it.deviceId] ?: it } } - guardIdentity(toUserId, bundles) + // 🔴 **The send path had no logging at all, and that is why a message + // that never left the phone was indistinguishable from one that did.** + // The relay records refusals, so an absent POST looks identical to a + // successful one from the server side; only this side knows. + System.err.println("fnmsg-send: to=$toUserId stubs=${stubs.size} " + + "bundles=${bundles.size} mine=${identity.deviceId}") + try { + guardIdentity(toUserId, bundles) + } catch (e: Exception) { + System.err.println("fnmsg-send: REFUSED by guardIdentity " + + "(${e.javaClass.simpleName}: ${e.message}) — nothing will be sent. " + + "This fires when the peer's identity key changed, e.g. after they " + + "reinstalled or the account was recreated.") + throw e + } val delivered = mutableListOf() val failed = LinkedHashMap() val outgoing = mutableListOf() @@ -594,6 +608,8 @@ class Conversations( // above call this once per member with no try around it — throwing // would abandon everyone after the first person who is signed out. if (bundles.none { it.deviceId != identity.deviceId }) { + System.err.println("fnmsg-send: NOT SENDING — the recipient has no " + + "device other than ours (bundles=${bundles.map { it.deviceId }})") failed["user:$toUserId"] = "not signed in on any device" return Sent(delivered, failed, sealed = false) } @@ -626,6 +642,15 @@ class Conversations( bundle.deviceId, wrap(pre, bundle, sealed)) } val envelope = session.encrypt(text) + // ⚠️ Protocol shape only — version and whether the post-quantum + // half of the ratchet step is on the wire. A v3 peer refuses a + // step without it, and nothing on this side reported what we + // actually emitted. + System.err.println( + "fnmsg-send: envelope v${envelope.version} ${envelope.type} " + + "n=${envelope.header.n} pqdh=${envelope.header.pqdh != null} " + + "pqct=${envelope.header.pqct != null} " + + "sessionVersion=${session.version}") // Saved BEFORE the network call. If the relay accepts the // envelope and we then crash, the peer has a message our // ratchet has not advanced past, and nothing either side can @@ -640,7 +665,19 @@ class Conversations( } } + if (outgoing.isEmpty()) { + System.err.println("fnmsg-send: NOT SENDING — every device failed to " + + "encrypt: $failed") + } if (outgoing.isNotEmpty()) { + System.err.println("fnmsg-send: POSTing ${outgoing.size} envelope(s) to " + + "/e2e/messages for user $toUserId sealed=$sealed " + + "toDevices=${outgoing.map { it.deviceId }.distinct()}") + // 🔴 The OUTCOME, not just the attempt. "POSTing" told us the code + // reached the call; it did not tell us whether the request left the + // phone. A DNS blip or a dropped connection throws here, and every + // caller above treats a thrown send as a failed message without + // ever saying what failed. try { relay.sendEnvelopes( toUserId, outgoing, identity.deviceId, @@ -651,7 +688,15 @@ class Conversations( deliveryKey = if (peerToken == null) peerKey?.let(Wire::b64) else null, deliveryToken = peerToken, ) - } catch (e: RelayException.Unauthorized) { + System.err.println("fnmsg-send: relay ACCEPTED ${outgoing.size} envelope(s)") + } catch (e: Exception) { + if (e !is RelayException.Unauthorized) { + System.err.println("fnmsg-send: SEND FAILED " + + "(${e.javaClass.simpleName}: ${e.message}) — the message did " + + "not leave this phone") + throw e + } + @Suppress("NAME_SHADOWING") val e = e // 🔴 **A refused capability means the batch was revoked.** // Retrying with the rest of it for ever is how a conversation // quietly stops working, so the batch goes and the SAME @@ -1081,6 +1126,11 @@ class Conversations( openWith(E2ESession.respond(identity, envelope), envelope) } else null + System.err.println( + "fnmsg-recv: envelope ${message.id} opened by " + + (if (fromCurrent != null) "current" else if (fromRetired != null) "retired" + else if (fromFresh != null) "FRESH handshake" else "nothing") + + " (sealed=$sealed, handshake=${envelope.handshake != null})") val plaintext = when { fromCurrent != null -> { vault.saveSession(fromDevice, fromCurrent.session) @@ -1170,7 +1220,22 @@ class Conversations( sealed = sealed, senderProven = senderProven, at = message.createdAt, fromAddress = fromAddress) - } catch (_: Exception) { + } catch (failure: Exception) { + // 🔴 **Skipped, but never again in silence.** Swallowing this + // whole is what made Android's half of every delivery + // investigation unreadable: the relay says delivered, the app + // shows nothing, and the one place that knew why threw the + // reason away. The message is still skipped — that part was + // right — but it now says which envelope, from which device, + // and what actually failed. + // + // ⚠️ The exception type and its message only. Neither carries + // plaintext or key material; `IdentityChangedException` carries + // a user id and the rest are fixed strings. + System.err.println( + "fnmsg-recv: DROPPED envelope ${message.id} from " + + "${message.fromDevice.take(8)} — " + + "${failure.javaClass.simpleName}: ${failure.message}") // Deliberately swallowed and skipped. One undecryptable message // is not a reason to stop reading the inbox. // diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt b/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt index 659c783..da3827d 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt @@ -50,17 +50,40 @@ class RelayAuth( * A valid token, minting one when the cache is empty or close to expiry. * Null when this deployment does not issue them. */ + /** Plain Kotlin module: no android.util.Log here. On device this lands in + * logcat as `W/System.err`, which is what matters — it is READABLE. */ + private fun warn(message: String) = System.err.println("fnmsg-auth: $message") + fun token(now: Long = System.currentTimeMillis() / 1000): String? { if (unavailable) return null cached?.let { if (it.expiresAt - SLACK_SECONDS > now) return it.token } val minted = try { mint(deviceId, Wire.b64(identityKey.publicKey)) - } catch (_: RelayException.NotFound) { + } catch (e: RelayException.NotFound) { + // 🔴 **Say it.** Every failure here used to be swallowed in + // silence, and the consequence was invisible: with no token the + // client falls back to Basic, the relay refuses `/e2e/*` — which it + // does by design — and the user sees "the call failed", "could not + // finish setting up", or an inbox that 410s and wipes itself. Four + // hours of debugging produced symptoms and no cause, because the + // one line that knew what went wrong discarded it. + warn("no token: relay has no /account/token route; " + + "falling back to Basic, which /e2e/* refuses. Calls and device " + + "registration will fail until this is fixed." + " [" + e + "]") unavailable = true return null - } catch (_: Exception) { + } catch (e: Exception) { + warn("no token: minting failed (${e.javaClass.simpleName}: " + + "${e.message}); falling back to Basic, which /e2e/* refuses.") return null - } ?: return null + } ?: run { + // ⚠️ A non-2xx that is not 404 arrives here as null, not as a + // throw, so this branch is NOT unreachable — it was the quietest + // path of the three. + warn("no token: /account/token answered a non-success " + + "status; falling back to Basic, which /e2e/* refuses.") + return null + } cached = Cached(minted.token, now + minted.lifetimeSeconds) return minted.token } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt b/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt index 59fbd07..101ff49 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt @@ -173,7 +173,24 @@ class E2ESession private constructor( associatedData = X3DH.associatedData( theirIdentitySigning, identity.identitySigningPublic, pq?.second), pendingHandshake = null, - version = if (pq == null) 1 else 2, + // 🔴 **Honour what the initiator declared; this could never + // return 3.** `initiate` above sets 3 whenever the PQ ratchet + // is on, but responding capped at 2 — so a session this device + // ANSWERED was labelled v2 while the far side ran it as v3. + // Every message then arrived at a v3 peer without the + // post-quantum material a v3 step requires, and was refused as + // a downgrade (§13.4) — correctly, and for ever. iOS-initiated + // conversations were one-way: iOS to Android worked, Android to + // iOS was silently dropped on arrival. + // + // ⚠️ Reads the initiator's version rather than assuming 3 + // whenever PQ is present: a v2 peer sends a PQ block too, and + // answering it in v3 breaks the pairing in the other direction. + version = when { + pq == null -> 1 + envelope.version >= 3 -> 3 + else -> 2 + }, ) return session } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt index b736ed8..805a237 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt @@ -98,7 +98,19 @@ class HybridSessionTest { val delivered = Envelope.fromPayload(envelope.toPayload()) val bobSide = E2ESession.respond(bob, delivered) - assertEquals(2, bobSide.version) + // 🔴 **3, and asserting 2 here is what hid a real bug for weeks.** + // The initiator is v3 two assertions above; a responder that answers + // the same handshake as v2 disagrees with its own peer about which + // protocol the session runs. Kotlin-to-Kotlin never noticed, because + // both ends drive the ratchet off their own PQ state and ignore the + // label — so this test round-tripped happily while encoding the defect. + // + // A v3 peer that does enforce it does notice: iOS refuses a ratchet + // step with no post-quantum material as a downgrade, so every message + // Android sent into an iOS-initiated session was dropped on arrival and + // the conversation ran one way only. + assertEquals(3, bobSide.version, + "answering a v3 handshake makes a v3 session, not a v2 one") assertEquals("post-quantum hello", bobSide.decrypt(delivered)) assertContentEquals(session.associatedData, bobSide.associatedData, "both sides must bind the same ciphertext digest") From 2402f7a665ca36ee0d378421b68a84d94e682ac5 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 09:21:19 -0400 Subject: [PATCH 2/9] Interop driver: report the session version a responder settled on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../kotlin/io/flamenet/messenger/app/Session.kt | 17 +++++++++++++++++ .../src/main/kotlin/io/flamenet/interop/Main.kt | 16 +++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 3d510ab..9458092 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -762,6 +762,13 @@ class Session( if (gid != null) { val known = groupRoster(gid) if (known == null || message.fromUser !in known) { + // ⚠️ Refusing is correct — but refusing in silence is what + // made a sealed group look like a group nobody could talk + // in. Say which group and which sender, never the message. + System.err.println( + "fnmsg-recv: NOT FILED into group ${gid.take(8)} — " + + "sender ${message.fromUser} is not in the roster " + + "(roster ${known?.size ?: -1} member(s))") continue } // ⚠️ Every group envelope carries the group's name, and this is @@ -1244,6 +1251,16 @@ class Session( @Volatile var groupNamesUpdated: Boolean = false private fun groupRoster(gid: String): Set? { + // 🔴 **A SEALED group's roster is not the relay's to give.** §8.9b + // exists so the relay holds opaque tags and no membership at all, so + // `listGroups()` returns an EMPTY member list for one — and the caller + // reads that as "the sender is not a member" and drops the message. + // Every message in every sealed group was discarded on arrival, in + // silence, by the check meant to protect it. The sealed roster is the + // authoritative list here and the only one that exists. + if (vault.groupSecrets(gid) != null) { + return sealedGroups().firstOrNull { it.id == gid }?.memberIds?.toSet() + } val now = System.currentTimeMillis() val cached = groupRosterCache if (cached != null && now - cached.first < 30_000) return cached.second[gid] diff --git a/interop/src/main/kotlin/io/flamenet/interop/Main.kt b/interop/src/main/kotlin/io/flamenet/interop/Main.kt index 6a246ff..bcace63 100644 --- a/interop/src/main/kotlin/io/flamenet/interop/Main.kt +++ b/interop/src/main/kotlin/io/flamenet/interop/Main.kt @@ -359,6 +359,7 @@ private fun sessionInitiate(req: JSONObject): JSONObject { // layer where the one bug this protocol has actually shipped lives: a // client and a relay disagreeing about a field name, caught by nothing // until a live round trip. + .put("version", session.version) .put("envelope", session.encrypt(req.getString("text")).toJson()) } @@ -371,6 +372,12 @@ private fun sessionRespond(req: JSONObject): JSONObject { return JSONObject() .put("session", id) .put("ad", b64(session.associatedData)) + // 🔴 **What the session ANSWERED with, not what the harness assumes.** + // Without this the interop test could not see the version a responder + // settled on, and a responder that capped itself below the initiator + // shipped: iOS ran the session as v3, this side as v2, and every + // message sent into it was refused by the peer as a downgrade. + .put("version", session.version) .put("text", session.decrypt(envelope)) } @@ -407,9 +414,12 @@ private fun openSealed(req: JSONObject): JSONObject { .put("envelope", opened.envelope.toJson()) } -private fun sessionEncrypt(req: JSONObject): JSONObject = JSONObject() - .put("envelope", liveSessions.getValue(req.getString("session")) - .encrypt(req.getString("text")).toJson()) +private fun sessionEncrypt(req: JSONObject): JSONObject = + liveSessions.getValue(req.getString("session")).let { session -> + JSONObject() + .put("version", session.version) + .put("envelope", session.encrypt(req.getString("text")).toJson()) + } private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject() .put("text", liveSessions.getValue(req.getString("session")) From 32e4af06aa9127e31acd8930634827d221c1e4c2 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 10:46:17 -0400 Subject: [PATCH 3/9] Interop driver: stand up a peer on an older protocol version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../main/kotlin/io/flamenet/interop/Main.kt | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/interop/src/main/kotlin/io/flamenet/interop/Main.kt b/interop/src/main/kotlin/io/flamenet/interop/Main.kt index bcace63..8a7c88d 100644 --- a/interop/src/main/kotlin/io/flamenet/interop/Main.kt +++ b/interop/src/main/kotlin/io/flamenet/interop/Main.kt @@ -107,7 +107,7 @@ private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) { "encrypt" -> encrypt(req) "decrypt" -> decrypt(req) // --- the session layer, which is what an app actually uses ------------ - "session_device" -> sessionDevice() + "session_device" -> sessionDevice(req) "session_initiate" -> sessionInitiate(req) "session_respond" -> sessionRespond(req) "session_encrypt" -> sessionEncrypt(req) @@ -149,6 +149,14 @@ private fun newDevice(req: JSONObject): JSONObject { // v3, the legacy paths would stop being covered on the day v3 landed — // which is the day they matter most. val postQuantum = req.optBoolean("pq", true) + // 🔴 **A peer can be asked to advertise an OLDER version, and it has to be + // possible to ask.** The standing rule is that the current version and the + // one before it interoperate — so when v4 lands, v3 and v4 must talk, and + // the only way to keep that honest is a harness that can stand up a peer + // on the previous version and hold a real conversation with it. Without + // this the suite can only ever test the newest code against itself, which + // is the shape of every compatibility bug that has ever shipped. + val proto = req.optInt("proto", if (postQuantum) 3 else 1) val id = next("kotlin-device-") val device = Device( deviceId = id, @@ -183,9 +191,13 @@ private fun newDevice(req: JSONObject): JSONObject { // Without `caps_sig` the web engine's `bundleFromWire` discards the // whole v2 block and `negotiate` returns v1, which is exactly what this // harness was quietly doing before. + // ⚠️ Signed over the version actually claimed. A signature over a + // different number is exactly what `provenVersion` exists to reject, + // and a harness that got this wrong would "prove" compatibility by + // silently falling back to v1 on every run. .putOpt("caps_sig", if (postQuantum) b64(Ed25519.sign( - device.ikSig, CAPS_DOMAIN + byteArrayOf(3.toByte()))) else null) - .put("proto", if (postQuantum) 3 else 1) + device.ikSig, CAPS_DOMAIN + byteArrayOf(proto.toByte()))) else null) + .put("proto", proto) return JSONObject().put("device", id).put("bundle", bundle) } @@ -338,12 +350,29 @@ private val identities = HashMap() private val liveSessions = HashMap() /** A device and the bundle wire a relay would serve for it, one prekey included. */ -private fun sessionDevice(): JSONObject { +private fun sessionDevice(req: JSONObject): JSONObject { val identity = DeviceIdentity.create() identities[identity.deviceId] = identity val oneTime = identity.generateOneTimePreKeys(1).first() val wire = identity.registrationWire() .put("opk", JSONObject().put("id", oneTime.id).put("pub", b64(oneTime.publicKey))) + // 🔴 **Stand up a peer on an OLDER version, for the compatibility rule.** + // The engine publishes whatever it currently implements, which is right for + // a device and useless for testing the release before it — and the standing + // rule is that the current version and the one before it interoperate. + // + // ⚠️ The capability signature is REMADE over the number actually claimed. + // Leaving the original in place would advertise one version and sign + // another, which `provenVersion` rejects by falling back to v1 — so the + // test would quietly pass while exercising a pairing nobody ships. + // Overriding lives here rather than in `registrationWire` on purpose: the + // engine should publish what it implements, not what a test wants. + if (req.has("proto")) { + val proto = req.getInt("proto") + wire.put("proto", proto) + wire.put("caps_sig", b64(Ed25519.sign( + identity.identitySigning, CAPS_DOMAIN + byteArrayOf(proto.toByte())))) + } return JSONObject().put("device", identity.deviceId).put("bundle", wire) } @@ -436,8 +465,19 @@ private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject() private fun sessionReload(req: JSONObject): JSONObject { val id = req.getString("session") val archived = liveSessions.getValue(id).archive() - liveSessions[id] = E2ESession.fromArchive(JSONObject(archived.toString())) - return JSONObject().put("reloaded", true).put("bytes", archived.toString().length) + // 🔴 **`as_version` exists to reproduce a phone that stored a session + // BEFORE the responder fix, and it is the only way to test the upgrade.** + // The version is persisted, so a session written by the old build restores + // as v2 no matter how new the code reading it is — updating the app does + // NOT repair a conversation already on disk. That population is every + // existing user, and nothing else in this harness can produce them. + val forced = JSONObject(archived.toString()) + if (req.has("as_version")) forced.put("version", req.getInt("as_version")) + liveSessions[id] = E2ESession.fromArchive(forced) + return JSONObject() + .put("reloaded", true) + .put("version", liveSessions.getValue(id).version) + .put("bytes", archived.toString().length) } private fun accountKdf(req: JSONObject): JSONObject { From cb82d59b7758b1a6e68a982c80b54a056aef22d5 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 11:19:06 -0400 Subject: [PATCH 4/9] Answer a call only once the offer has actually been applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/messenger/app/CallManager.kt | 59 ++++++++++++++----- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index b00417e..6978b07 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -440,22 +440,16 @@ class CallManager( var onStaleOffer: ((Long, Long) -> Unit)? = null /** Accept the call that is ringing. */ - fun accept(turn: JSONObject?) { - val (fromUser, offer) = pendingOffer ?: return - pendingOffer = null - val sdp = offer.sdp ?: return - state = State.CONNECTING - armConnectGuard() - val pc = open(turn, fromUser, offer.callId, offer.video) ?: run { - state = State.IDLE - return - } - current = Active(offer.callId, fromUser, incoming = true, video = offer.video) - pc.setRemoteDescription( - SimpleSdpObserver("answer:setRemote"), - SessionDescription(SessionDescription.Type.OFFER, sdp), - ) - pc.createAnswer(object : SimpleSdpObserver() { + /** + * Build and send the answer. Split out so it can only be reached from + * `setRemoteDescription`'s success callback — the ordering IS the fix. + */ + private fun answerNow( + pc: PeerConnection, + fromUser: Long, + offer: Content.CallSignal, + ) { + pc.createAnswer(object : SimpleSdpObserver("answer:create") { override fun onCreateSuccess(answer: SessionDescription) { pc.setLocalDescription(SimpleSdpObserver("answer:setLocal"), answer) // Same as the offer: the answer is complete only after @@ -471,6 +465,39 @@ class CallManager( }, MediaConstraints()) } + fun accept(turn: JSONObject?) { + val (fromUser, offer) = pendingOffer ?: return + pendingOffer = null + val sdp = offer.sdp ?: return + state = State.CONNECTING + armConnectGuard() + val pc = open(turn, fromUser, offer.callId, offer.video) ?: run { + state = State.IDLE + return + } + current = Active(offer.callId, fromUser, incoming = true, video = offer.video) + // 🔴 **`createAnswer` waits for the remote description to be APPLIED.** + // These two were fired back to back: `setRemoteDescription` is + // asynchronous, and WebRTC cannot answer an offer it has not finished + // reading. When it lost that race `createAnswer` failed, + // `setLocalDescription` never ran, and the send path found no local + // description and gave up — "NOT SENDING the offer/answer ... the other + // side will never see this call". Accepting a call did nothing, the + // caller rang on until it timed out, and neither end said why. + // + // ⚠️ It is a RACE, which is what made it look intermittent: on a quiet + // device the description often lands first and the call connects, so + // the same build both works and fails depending on load. + pc.setRemoteDescription( + object : SimpleSdpObserver("answer:setRemote") { + override fun onSetSuccess() { + main.post { answerNow(pc, fromUser, offer) } + } + }, + SessionDescription(SessionDescription.Type.OFFER, sdp), + ) + } + /** Refuse a ringing call, and say so rather than letting it ring out. */ fun decline() { val (fromUser, offer) = pendingOffer ?: return From ccdf82c00a9d730d9ea2d4ed0b68669702ff22b7 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 14:08:42 -0400 Subject: [PATCH 5/9] Signing out no longer costs every contact a prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../io/flamenet/messenger/app/ChatScreens.kt | 24 ++++++++++- .../messenger/app/MessengerViewModel.kt | 42 ++++++++++++++++--- .../io/flamenet/client/Conversations.kt | 25 ++++++++--- .../kotlin/io/flamenet/client/RelayClient.kt | 25 ++++++++++- .../main/kotlin/io/flamenet/client/Trust.kt | 36 +++++++++++++++- .../io/flamenet/client/LiveKeyLogTest.kt | 14 +++---- .../flamenet/client/LiveSealedSenderTest.kt | 2 +- .../io/flamenet/client/LiveShapesTest.kt | 2 +- .../io/flamenet/client/LiveSignUpTest.kt | 2 +- 9 files changed, 146 insertions(+), 26 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index d21e040..77da1d8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -712,6 +712,7 @@ fun ColumnScope.AccountScreen( var displayName by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var confirmingDelete by remember { mutableStateOf(false) } + var confirmingErase by remember { mutableStateOf(false) } Text("Account", style = MaterialTheme.typography.headlineMedium) Text( @@ -876,9 +877,28 @@ fun ColumnScope.AccountScreen( SettingsSection { SettingsRow( label = "Sign out", - description = "Erases this phone's keys and stored messages. They cannot be recovered.", + description = "Ends this session and keeps your messages on this phone, " + + "so signing back in picks up where you left off.", tag = "sign-out", - onClick = model::signOut, + onClick = { model.signOut() }, + ) + // 🔴 **The one that erases is its own row, and is not the default.** + // Both used to be the same button: signing out for an afternoon + // destroyed this phone's identity, so coming back made every contact + // accept a safety-number change first. Erasing is a real thing people + // need — handing a phone on — but it is the rarer thing, and it must be + // chosen rather than stumbled into. Confirms in place, like the pair + // below it, because a dialog is dismissed reflexively. + SettingsRow( + label = if (confirmingErase) "Really erase — this cannot be undone" + else "Sign out and erase this device", + description = "Also removes this phone's keys and stored messages. " + + "Choose this if you are handing the phone on.", + tag = "sign-out-erase", + onClick = { + if (confirmingErase) model.signOut(erasingDevice = true) + else confirmingErase = true + }, ) SettingsRow( label = if (confirmingDelete) "Really delete — this cannot be undone" diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 1bf3b85..7335dbe 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1392,7 +1392,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun deleteAccount() = viewModelScope.launch { val current = session ?: return@launch val done = withContext(Dispatchers.IO) { runCatching { current.api.deleteAccount() } } - done.onSuccess { signOut() } + done.onSuccess { signOut(erasingDevice = true) } .onFailure { failure -> update { it.copy(error = describe(failure)) } } } @@ -2454,13 +2454,45 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { + "new device.") } - fun signOut() = viewModelScope.launch { + /** + * End the session, and optionally erase this phone with it. + * + * 🔴 **Signing out used to cost every one of your contacts a prompt.** It + * 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 to pay, and it is + * why signing out and back in kept looking like the app breaking. + * + * Two doors now. Plain sign-out ends the session and keeps the device, so + * coming back is silent. Erasing is for handing the phone on, and for + * deleting the account, and the settings screen says which is which. + * + * ⚠️ Keeping the keys does not leave the mailbox reachable: a relay token + * is minted against the account credential, which this clears either way. + */ + fun signOut(erasingDevice: Boolean = false) = viewModelScope.launch { polling?.cancel() polling = null + val current = session + val myDevice = identity?.deviceId session = null - withContext(Dispatchers.IO) { KeystoreVault.destroy(getApplication()) } - identity = null - vault = KeystoreVault.open(getApplication()) + if (erasingDevice) { + withContext(Dispatchers.IO) { + // ⚠️ Tell the relay BEFORE destroying the keys that authorise + // saying it. Otherwise the account goes on publishing a device + // nobody will ever read again, and fan-out is per device — so + // contacts keep addressing copies to a dead mailbox while the + // relay reports every one of them delivered. Best effort: a bad + // network must not leave the phone half-erased. + if (myDevice != null) runCatching { current?.revokeDevice(myDevice) } + KeystoreVault.destroy(getApplication()) + } + identity = null + vault = KeystoreVault.open(getApplication()) + } else { + identity = null + } _phase.value = Phase.SignedOut() } diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 72b057a..ea62792 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -501,7 +501,11 @@ class Conversations( * request happens to verify is the mistake this whole section exists to * avoid. */ - private fun guardIdentity(toUserId: Long, bundles: List) { + private fun guardIdentity( + toUserId: Long, + bundles: List, + servedFnid: String? = null, + ) { val theirs = bundles.filter { it.deviceId != identity.deviceId } if (theirs.isEmpty()) return @@ -513,7 +517,11 @@ class Conversations( ) { throw IdentityChangedException(toUserId) } - theirs.forEach { trust.enforceKeyLogIfServed(it, toUserId) } + // The address just served wins over the pinned one: a device that + // registered a moment ago is logged under whatever the account claims + // NOW, and the pin may predate the claim. + val fnid = servedFnid ?: pinned + theirs.forEach { trust.enforceKeyLogIfServed(it, toUserId, fnid) } } /** @@ -567,8 +575,15 @@ class Conversations( val needsEstablish = stubs.any { it.deviceId != identity.deviceId && existingSession(it.deviceId) == null } + // ⚠️ The address comes back with the bundles and is REMEMBERED. The + // directory route serves no fnid, so a later send that needs no bundle + // would otherwise be unable to check the log again. + var servedFnid: String? = null val bundles = if (!needsEstablish) stubs else { - val full = relay.fetchKeys(toUserId).associateBy { it.deviceId } + val keys = relay.fetchKeys(toUserId) + servedFnid = keys.fnid + keys.fnid?.let { trust.rememberFnid(toUserId, it) } + val full = keys.bundles.associateBy { it.deviceId } // Union: every device the directory knows, carrying a full bundle // where the key route returned one. stubs.map { full[it.deviceId] ?: it } @@ -580,7 +595,7 @@ class Conversations( System.err.println("fnmsg-send: to=$toUserId stubs=${stubs.size} " + "bundles=${bundles.size} mine=${identity.deviceId}") try { - guardIdentity(toUserId, bundles) + guardIdentity(toUserId, bundles, servedFnid) } catch (e: Exception) { System.err.println("fnmsg-send: REFUSED by guardIdentity " + "(${e.javaClass.simpleName}: ${e.message}) — nothing will be sent. " + @@ -1253,7 +1268,7 @@ class Conversations( /** How many prekeys we have left with the relay, and a top-up when it is low. */ fun replenishPreKeysIfLow(threshold: Int = 5, batch: Int = 20): Int { - val mine = relay.fetchKeys(myUserId ?: return 0) + val mine = relay.fetchKeys(myUserId ?: return 0).bundles .firstOrNull { it.deviceId == identity.deviceId } ?: return 0 // The relay pops one per fetch, so seeing none here means the pool was // empty before this call, not that this call drained it. diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index 5b6c85f..a171457 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -138,10 +138,31 @@ class RelayClient( * starting a session. Polling it drains the pool and pushes every peer onto * the last-resort key, which costs initial forward secrecy for everyone. */ - fun fetchKeys(userId: Long): List { + /** A peer's bundles, and the address their key-log entries are filed under. */ + data class Keys(val fnid: String?, val bundles: List) + + /** + * 🔴 **The `fnid` is not decoration; without it the key log cannot be + * checked.** The relay files a leaf under `fnid:
` once an account + * has claimed one, and under `local:/` before that. A + * verifier that does not know the address computes the other subject, finds + * no matching leaf, and refuses to send — reporting "has published keys, + * but none match what this relay served" for a conversation in which + * nothing is wrong. + * + * This response has always carried it and this method always dropped it, so + * every newly registered device on an account with an address was + * unverifiable and therefore unsendable-to. It presents as messaging + * breaking the moment somebody reinstalls. + */ + fun fetchKeys(userId: Long): Keys { val body = getJson("/e2e/keys/$userId") val devices = body.optJSONArray("devices") ?: JSONArray() - return (0 until devices.length()).map { PeerBundle.fromWire(devices.getJSONObject(it)) } + return Keys( + fnid = body.optString("fnid").ifEmpty { null }, + bundles = (0 until devices.length()) + .map { PeerBundle.fromWire(devices.getJSONObject(it)) }, + ) } /** `GET /e2e/devices/{user}` — who a user is, costing no prekey. */ diff --git a/client/src/main/kotlin/io/flamenet/client/Trust.kt b/client/src/main/kotlin/io/flamenet/client/Trust.kt index 506bb48..3d48183 100644 --- a/client/src/main/kotlin/io/flamenet/client/Trust.kt +++ b/client/src/main/kotlin/io/flamenet/client/Trust.kt @@ -247,6 +247,21 @@ class Trust( /** The address this contact was pinned under, if any. */ fun pinnedFnid(userId: Long): String? = vault.loadTrust(userId)?.fnid + /** + * Record the address the relay served for this contact. + * + * ⚠️ Not a trust decision — it changes nothing about which identity keys + * are accepted. It only records the SUBJECT their key-log leaves are filed + * under, which a verifier needs and the device directory does not serve. + * Storing it means a later send that establishes no new session can still + * check the log. + */ + fun rememberFnid(userId: Long, fnid: String) { + val existing = vault.loadTrust(userId) ?: return + if (existing.fnid == fnid) return + vault.saveTrust(userId, existing.copy(fnid = fnid)) + } + /** Trust as it stands, for UI that has not triggered a fetch. */ fun state(userId: Long): IdentityTrust { val t = vault.loadTrust(userId) ?: return IdentityTrust.UNKNOWN @@ -289,8 +304,25 @@ class Trust( * the tick in place would show "verified" for something nobody verified. */ fun acceptIdentityChange(userId: Long, handle: String? = null) { - val offered = relay.fetchKeys(userId).map { it.identitySigning } - if (offered.isEmpty()) return + // ⚠️ The address is taken from the SAME response that carries the keys. + // Accepting a change and then filing it under a stale address leaves + // the key log uncheckable for the very keys just accepted. + val served = relay.fetchKeys(userId) + val offered = served.bundles.map { it.identitySigning } + // 🔴 **An empty answer is a FAILURE, not an acceptance of nothing.** + // This returned quietly, so a fetch that was rate-limited, timed out or + // 500'd left the pin exactly as it was — while the UI reported the + // change accepted. The person taps "accept", nothing happens, every + // message stays refused, and the app insists the problem is on the + // other end. Observed after a burst of retries tripped the per-contact + // key-fetch limit, which is precisely when somebody is trying to + // recover a conversation and most needs this to work. + if (offered.isEmpty()) { + throw RelayException.Malformed( + "the server returned no keys for this contact, so there was " + + "nothing to accept — try again in a moment") + } + served.fnid?.let { rememberFnid(userId, it) } // Re-resolve rather than keep the old address: if what changed WAS the // address, keeping it would leave every later check recomputing against // a name this account no longer publishes under, and honest keys would diff --git a/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt index 03dd1b6..9666227 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt @@ -85,7 +85,7 @@ class LiveKeyLogTest { // was served. Bob genuinely published this key a moment ago, so the // relay can prove it — and if this throws, enforcement is refusing // honest keys, which is worse than not enforcing at all. - val bundle = alice.relay.fetchKeys(bob.userId).single { it.deviceId == bob.identity.deviceId } + val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } val trust = alice.trust() trust.enforceKeyLog(bundle, bob.userId) @@ -109,7 +109,7 @@ class LiveKeyLogTest { val alice = Actor(url, "c") val bob = Actor(url, "d") - val real = alice.relay.fetchKeys(bob.userId).single { it.deviceId == bob.identity.deviceId } + val real = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } val substituted = io.flamenet.e2e.PeerBundle( deviceId = real.deviceId, identityDH = real.identityDH, @@ -141,7 +141,7 @@ class LiveKeyLogTest { val bob = Actor(url, "f") val trust = alice.trust() - val first = alice.relay.fetchKeys(bob.userId).single { it.deviceId == bob.identity.deviceId } + val first = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } trust.enforceKeyLog(first, bob.userId) val pinned = alice.vault.loadKeyLogPin(alice.relay.host)!! @@ -149,7 +149,7 @@ class LiveKeyLogTest { // second call must produce and verify a real consistency proof from the // pinned size to the new one. val second = Actor(url, "g") - val third = alice.relay.fetchKeys(second.userId) + val third = alice.relay.fetchKeys(second.userId).bundles .single { it.deviceId == second.identity.deviceId } trust.enforceKeyLog(third, second.userId) @@ -175,7 +175,7 @@ class LiveKeyLogTest { relay = alice.relay.host, pin = Vault.KeyLogPin(head.getInt("size"), Wire.b64(ByteArray(32) { 0x5A })) ) - val bundle = alice.relay.fetchKeys(bob.userId).single { it.deviceId == bob.identity.deviceId } + val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } assertFailsWith { alice.trust().enforceKeyLog(bundle, bob.userId) } @@ -218,7 +218,7 @@ class LiveKeyLogTest { .also { it.useRelayAuth(second) } .registerDevice(second, second.generateOneTimePreKeys(3)) - val bundle = alice.relay.fetchKeys(bob.userId).single { it.deviceId == second.deviceId } + val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == second.deviceId } val trust = alice.trust() // 1. Nothing pinned: the honest key is refused, and says why — there @@ -254,7 +254,7 @@ class LiveKeyLogTest { val bob = Actor(url, "o") val trust = alice.trust() - val bundle = alice.relay.fetchKeys(bob.userId).single { it.deviceId == bob.identity.deviceId } + val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } // Pin Bob under an address, with his real keys. alice.vault.saveTrust( diff --git a/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt index 88eceae..fe4cedb 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt @@ -145,7 +145,7 @@ class LiveSealedSenderTest { // A submission presenting the OLD key must be refused outright. Sent // through the client rather than by hand, so this exercises the same // path a real sealed send takes. - val bundle = alice.relay.fetchKeys(bob.userId).first { it.deviceId == bob.identity.deviceId } + val bundle = alice.relay.fetchKeys(bob.userId).bundles.first { it.deviceId == bob.identity.deviceId } val envelope = io.flamenet.e2e.E2ESession.initiate(alice.identity, bundle).encrypt("stale") val blob = SealedSender.seal( envelope, alice.userId, alice.identity.deviceId, bundle.deviceId, bundle.identityDH) diff --git a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt index 75bde53..7d32a2d 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt @@ -381,7 +381,7 @@ class LiveShapesTest { // Reachable means a stranger can fetch a bundle WITH a one-time prekey. // A device published without them can send and can never be started // with, which looks identical from the account's own side. - val bundles = relay.fetchKeys(account.userId) + val bundles = relay.fetchKeys(account.userId).bundles val mine = bundles.firstOrNull { it.deviceId == identity.deviceId } assertTrue(mine != null, "$base served no bundle for the device just registered") assertTrue(mine!!.oneTimePreKey != null, "$base served no one-time prekey") diff --git a/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt index cfd13a9..f0715a3 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt @@ -59,7 +59,7 @@ class LiveSignUpTest { // The device is only really registered if a stranger can fetch a bundle // for it — that is what makes this phone reachable. - val bundles = client.fetchKeys(account.userId) + val bundles = client.fetchKeys(account.userId).bundles assertTrue(bundles.any { it.deviceId == identity.deviceId }, "the relay did not serve a bundle for the device we just registered") assertTrue(bundles.first { it.deviceId == identity.deviceId }.oneTimePreKey != null, From fcc6692284152da5c118da7f9e6351a0c12111e9 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 14:58:20 -0400 Subject: [PATCH 6/9] Stories stop carrying where they were taken, and can outlast a day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- .../io/flamenet/messenger/app/ChatScreens.kt | 43 +++++++ .../io/flamenet/messenger/app/MediaScrub.kt | 116 ++++++++++++++++++ .../flamenet/messenger/app/MediaSettings.kt | 29 +++++ .../messenger/app/MessengerViewModel.kt | 4 +- .../io/flamenet/messenger/app/Session.kt | 8 ++ .../io/flamenet/messenger/app/StoryMedia.kt | 59 ++++++--- .../io/flamenet/client/RelayClientTest.kt | 6 +- 7 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 77da1d8..359f374 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1103,6 +1103,13 @@ private fun ColumnScope.AppLockSection() { * ⚠️ There is no "public" option and cannot be one. A story only ever goes to * contacts, because it has to be encrypted to somebody in particular. */ +/** ⚠️ Plain words: "168 hours" would make the reader do the division. */ +private fun storyLifetimeLabel(hours: Int): String = when { + hours < 24 -> if (hours == 1) "1 hour" else "$hours hours" + hours == 24 -> "24 hours" + else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" } +} + @Composable private fun ColumnScope.StoryAudienceSection( model: MessengerViewModel, @@ -1159,6 +1166,42 @@ private fun ColumnScope.StoryAudienceSection( color = Palette.SecondaryText, ) + Spacer(Modifier.height(Space.lg)) + Text("How long your stories last", style = MaterialTheme.typography.titleMedium) + + // 🔴 A day used to be the only answer. The expiry rides inside the sealed + // story, so this is the poster's choice travelling with every copy — not a + // rule the relay is being asked to keep. + val storyContext = LocalContext.current + val mediaSettings = remember { MediaSettings(storyContext) } + var storyHours by remember { mutableStateOf(mediaSettings.storyLifetimeHours) } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm), + horizontalArrangement = Arrangement.spacedBy(Space.sm), + ) { + MediaSettings.STORY_LIFETIME_CHOICES.forEach { hours -> + KitChip( + text = storyLifetimeLabel(hours), + selected = storyHours == hours, + onClick = { + storyHours = hours + mediaSettings.storyLifetimeHours = hours + }, + tag = "story-lifetime-$hours", + ) + } + } + Text( + // ⚠️ Says what it does NOT promise. "Disappears after" alone reads as a + // guarantee about other people's phones, and a viewer who screenshots a + // story has it for good. + "Stories you post from now on disappear after this long, on your device " + + "and your contacts'. Ones already posted keep the time they were " + + "sent with.", + fontSize = 14.sp, + color = Palette.SecondaryText, + ) + // The list only earns its place when the mode uses one. if (audience.mode != Session.AudienceMode.EVERYONE) { if (phase.buddies.isEmpty()) { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt new file mode 100644 index 0000000..ab37457 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt @@ -0,0 +1,116 @@ +package io.flamenet.messenger.app + +import android.media.MediaCodec +import android.media.MediaExtractor +import android.media.MediaFormat +import android.media.MediaMuxer +import java.io.File +import java.nio.ByteBuffer + +/** + * Strip everything from a video except the pictures and the sound. + * + * 🔴 **A clip from the camera roll says where it was shot.** Android writes the + * capture location into the MP4 container — the `©xyz` / `location` atom — and + * the story path used to hand the picked file over byte for byte, so posting a + * video told every viewer the coordinates of wherever the person happened to + * be. Nothing on screen suggested it and no setting controlled it. Photos were + * already safe by accident, because [ImagePrep] re-encodes them through a + * bitmap and `Bitmap.compress` writes no EXIF; video had no equivalent step at + * all. + * + * ⚠️ **A remux, not a transcode.** The samples are copied across untouched, so + * there is no quality loss and no codec work — the container is rebuilt and the + * metadata simply is not carried over. That is cheap enough to do on every + * post, which matters: a scrub people wait for is a scrub somebody eventually + * makes optional. + * + * ⚠️ Rotation IS carried over, deliberately. It says which way up the phone + * was, not where it was, and dropping it plays every portrait clip on its side. + */ +object MediaScrub { + + /** + * @param source the picked video's bytes. + * @param rotationDegrees from `METADATA_KEY_VIDEO_ROTATION`, re-applied to + * the rebuilt file. + * @return scrubbed bytes, or null if the file could not be rebuilt — the + * caller must then refuse the post rather than fall back to the original. + */ + fun videoWithoutMetadata( + source: ByteArray, + rotationDegrees: Int, + cacheDir: File, + ): ByteArray? { + val input = File.createTempFile("scrub-in", ".mp4", cacheDir) + val output = File.createTempFile("scrub-out", ".mp4", cacheDir) + var extractor: MediaExtractor? = null + var muxer: MediaMuxer? = null + var started = false + return try { + input.writeBytes(source) + + extractor = MediaExtractor() + extractor.setDataSource(input.absolutePath) + muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + + // Audio and video only. Anything else in the file — timed text, a + // sensor track, whatever a particular camera app decided to add — + // is exactly the kind of passenger this exists to leave behind. + val trackMap = HashMap() + var bufferSize = DEFAULT_BUFFER + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val mime = format.getString(MediaFormat.KEY_MIME) ?: continue + if (!mime.startsWith("video/") && !mime.startsWith("audio/")) continue + if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) { + bufferSize = maxOf(bufferSize, format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)) + } + extractor.selectTrack(i) + trackMap[i] = muxer.addTrack(format) + } + if (trackMap.isEmpty()) return null + + muxer.setOrientationHint(rotationDegrees) + muxer.start() + started = true + + val buffer = ByteBuffer.allocate(bufferSize) + val info = MediaCodec.BufferInfo() + while (true) { + val track = extractor.sampleTrackIndex + if (track < 0) break + val size = extractor.readSampleData(buffer, 0) + if (size < 0) break + val target = trackMap[track] + if (target != null) { + info.offset = 0 + info.size = size + info.presentationTimeUs = extractor.sampleTime + // ⚠️ Mapped rather than assigned across. The extractor's + // flags and the codec's are different vocabularies that + // happen to agree on the value of "sync sample" today. + info.flags = + if (extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0) { + MediaCodec.BUFFER_FLAG_KEY_FRAME + } else { + 0 + } + muxer.writeSampleData(target, buffer, info) + } + extractor.advance() + } + output.readBytes() + } catch (failure: Exception) { + null + } finally { + if (started) runCatching { muxer?.stop() } + runCatching { muxer?.release() } + runCatching { extractor?.release() } + runCatching { input.delete() } + runCatching { output.delete() } + } + } + + private const val DEFAULT_BUFFER = 1 shl 20 +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt index a4e0fe5..e1f066f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt @@ -24,6 +24,31 @@ class MediaSettings(context: Context) { get() = prefs.getBoolean(FULL_QUALITY, false) set(value) = prefs.edit().putBoolean(FULL_QUALITY, value).apply() + /** + * How long a story posted from this phone stays visible, in hours. + * + * 🔴 **A day was the only answer the app had.** The lifetime was a + * constant, so the one thing people actually want to change about a story + * — how long it lasts — was the one thing they could not. + * + * ⚠️ The choice is the SENDER's and rides inside the sealed envelope + * (`exp`, §6.6), so it does not ask the relay to keep a promise: every copy + * carries the expiry its poster 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 — which is what a raw 0 would do. + */ + var storyLifetimeHours: Int + get() { + val stored = prefs.getInt(STORY_LIFETIME_HOURS, STORY_LIFETIME_DEFAULT) + return if (stored in STORY_LIFETIME_CHOICES) stored else STORY_LIFETIME_DEFAULT + } + set(value) = prefs.edit().putInt(STORY_LIFETIME_HOURS, value).apply() + + /** Seconds, as [io.flamenet.client.Conversations.postStory] wants it. */ + val storyLifetimeSeconds: Double get() = storyLifetimeHours * 60.0 * 60.0 + /** * Refuse to send unless the relay can prove the recipient's keys appear in * the log it publishes. @@ -61,6 +86,10 @@ class MediaSettings(context: Context) { private const val PREFS = "media-settings" private const val REQUIRE_KEY_LOG = "requireKeyLog" private const val FULL_QUALITY = "full_quality" + private const val STORY_LIFETIME_HOURS = "stories.lifetimeHours" + const val STORY_LIFETIME_DEFAULT = 24 + /** ⚠️ Same set as iOS, so the two apps offer the same thing. */ + val STORY_LIFETIME_CHOICES = listOf(1, 6, 24, 72, 168) private const val LINK_PREVIEWS = "link_previews" } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 7335dbe..43b5ca2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1963,7 +1963,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val done = withContext(Dispatchers.IO) { runCatching { current.postStory(bytes, mime, width, height, caption, recipients, - duration, streamUrl) + duration, streamUrl, + lifetimeSeconds = MediaSettings(getApplication()) + .storyLifetimeSeconds) } } done.onSuccess { posted -> diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 9458092..4374c31 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -906,8 +906,16 @@ class Session( duration: Double = 0.0, /** A live stream this story embeds (§6.13); [bytes] is then the poster. */ streamUrl: String? = null, + /** + * ⚠️ Passed explicitly rather than left to the client's default. The + * default is 24 hours and the setting exists precisely so that is not + * the only answer; a call site that omits it silently ignores what the + * person chose. + */ + lifetimeSeconds: Double, ): io.flamenet.client.Conversations.StoryPosted { val posted = conversations.postStory(bytes, mime, width, height, caption, toUserIds, + lifetimeSeconds = lifetimeSeconds, duration = duration, streamUrl = streamUrl) if (posted.reached > 0) { val now = System.currentTimeMillis() / 1000.0 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt index a13b8aa..be3fb99 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt @@ -28,14 +28,23 @@ import java.io.File /** * Staging a picked story, image or video, from either entry point. * - * Videos are validated, not transcoded: this platform has no built-in - * exporter the way AVFoundation is one, and pulling in a transcoding stack - * is a decision for later. So the caps are enforced honestly — a clip too - * long or too heavy is refused with the reason, and a clip that fits goes - * out exactly as picked. + * Videos are validated and scrubbed, not transcoded: this platform has no + * built-in exporter the way AVFoundation is one, and pulling in a transcoding + * stack is a decision for later. So the caps are enforced honestly — a clip too + * long or too heavy is refused with the reason — and a clip that fits is + * remuxed to drop the container metadata before it goes out. See [MediaScrub]; + * "exactly as picked" used to include the capture location. */ object StoryMedia { + /** Duration in seconds, display dimensions, and the container rotation. */ + private data class Meta( + val seconds: Double, + val width: Int, + val height: Int, + val rotation: Int, + ) + /** A story is a moment, not a film — same cap as iOS. */ const val MAX_DURATION_S = 30 /** Under the relay's 10 MB attachment cap, with room for the seal. */ @@ -66,27 +75,40 @@ object StoryMedia { ?.toIntOrNull() ?: 0 // A portrait phone clip is stored landscape plus a rotation. if (rotation == 90 || rotation == 270) { val t = w; w = h; h = t } - Triple(durationMs / 1000.0, w, h) + // ⚠️ The rotation is carried out of here now, not just used to + // swap the dimensions: rebuilding the container to strip its + // metadata drops the orientation with everything else, and a + // portrait story would play on its side. + Meta(durationMs / 1000.0, w, h, rotation) }.getOrNull().also { runCatching { retriever.release() } } if (meta == null) { model.reportStoryProblem("That video couldn't be read.") return } - val (seconds, w, h) = meta + val (seconds, w, h, rotation) = meta if (seconds > MAX_DURATION_S + 0.5) { model.reportStoryProblem( "Stories are capped at $MAX_DURATION_S seconds — this one is " + "${seconds.toInt()}. Trim it and try again.") return } - val bytes = runCatching { + val picked = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() - if (bytes == null) { + if (picked == null) { model.reportStoryProblem("That video couldn't be read.") return } + // 🔴 **Where it was shot does not go with it.** See [MediaScrub]. + // A failure here refuses the post rather than falling back to the + // original: "we could not remove the location, so we sent it + // anyway" is the one outcome this must never produce. + val bytes = MediaScrub.videoWithoutMetadata(picked, rotation, context.cacheDir) + if (bytes == null) { + model.reportStoryProblem("That video couldn't be prepared for sending.") + return + } if (bytes.size > MAX_BYTES) { model.reportStoryProblem( "That video is too large to send — the cap is about 9 MB. " @@ -97,16 +119,25 @@ object StoryMedia { return } - val bytes = runCatching { + val picked = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() - if (bytes == null) { + if (picked == null) { model.reportStoryProblem("That file couldn't be read.") return } - val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } - android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) - model.stageStory(bytes, mime, bounds.outWidth, bounds.outHeight) + // 🔴 **Prepared, not posted as picked.** 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; a story, which is shown to + // every contact at once, was not. + val prepared = ImagePrep.prepare(picked, MediaSettings(context).fullQuality) + if (prepared == null) { + model.reportStoryProblem("That picture couldn't be read.") + return + } + model.stageStory(prepared.bytes, prepared.mime, prepared.width, prepared.height) } /** The first frame, for the composer's preview of a staged video. */ diff --git a/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt b/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt index 0ed399c..0d3b5bd 100644 --- a/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt @@ -293,7 +293,11 @@ class RelayClientTest { .put("opk", JSONObject().put("id", oneTime.id).put("pub", Wire.b64(oneTime.publicKey))) server.enqueue(json("""{"devices":[$wire]}""")) - val bundles = client().fetchKeys(42) + // ⚠️ `fetchKeys` answers with the fnid alongside the bundles now — the + // address is what the key log is checked against, and returning only + // the bundles threw it away at the one point it is available. + val fetched = client().fetchKeys(42) + val bundles = fetched.bundles assertEquals(1, bundles.size) assertEquals(peer.deviceId, bundles[0].deviceId) assertEquals(oneTime.id, bundles[0].oneTimePreKeyId) From fc0dff18cab03439068be88f094e8f2d561102b3 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 15:28:17 -0400 Subject: [PATCH 7/9] Sign-in loads the identity it already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 **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 --- .../messenger/app/MessengerViewModel.kt | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 43b5ca2..620f1a1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -506,10 +506,26 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } val vault = vault ?: error("the vault is not open") - val device = identity ?: DeviceIdentity.create().also { - vault.saveIdentity(it) - identity = it - } + // 🔴 **The stored identity FIRST, and this line is why signing out and + // back in used to cost every contact a prompt.** Sign-in only ever + // consulted the in-memory field, and sign-out clears it — so it minted + // a fresh identity and overwrote the vault's, making this phone a brand + // new device on every sign-in. Contacts saw a safety-number change, + // sends were refused until they accepted it, and the relay's public key + // log had not yet caught up with the key being offered, so even after + // accepting, the next send failed on "the keys offered by this contact + // are not in the public log". + // + // ⚠️ It was harmless right up 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 is what + // turned this into the bug it always looked like. + val device = identity + ?: vault.loadIdentity()?.also { identity = it } + ?: DeviceIdentity.create().also { + vault.saveIdentity(it) + identity = it + } val relay = RelayClient(config.baseUrl, username = account.username, appPassword = credential) relay.useRelayAuth(device) From 1cda4050723a069fdcc37f925e2e615a37a48e5d Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 15:59:07 -0400 Subject: [PATCH 8/9] Say which envelopes are worth interrupting somebody for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../io/flamenet/client/Conversations.kt | 23 +++++++++++++++++++ .../kotlin/io/flamenet/client/RelayClient.kt | 14 +++++++++++ 2 files changed, 37 insertions(+) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index ea62792..1b5ee14 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -532,6 +532,28 @@ class Conversations( fun sendPlaintextForGroupInvite(toUserId: Long, text: String): Sent = sendPlaintext(toUserId, text) + /** + * Whether an envelope is worth interrupting somebody for. + * + * 🔴 **Everything this app sends was.** The relay holds ciphertext and + * cannot tell a call answer from a birthday message, so it announced every + * envelope as "New message" — and call answers, call ends, delivery keys, + * key-log gossip, reactions and avatars are all envelopes. One call + * produced a burst of banners on the far end for things nobody sent. + * + * ⚠️ 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. + */ + private fun warrantsAlert(body: String): Boolean { + if (!body.startsWith("""{"fnc":""")) return true + val content = Content.parse(body) + // Only the OFFER announces itself. An answer and a hang-up are the far + // end's half of a conversation already on screen. + content.call?.let { return it.action == Content.CallSignal.OFFER } + return content.isRenderable + } + private fun sendPlaintext(toUserId: Long, text: String): Sent { // 🔴 **The DIRECTORY, not the key route.** `/e2e/keys` pops a one-time // prekey per device and is metered per contact pair — calling it on @@ -696,6 +718,7 @@ class Conversations( try { relay.sendEnvelopes( toUserId, outgoing, identity.deviceId, + silent = !warrantsAlert(text), // ⚠️ The key only when there is no capability. Sending both // would let the relay accept on the key when the capability // had been revoked, so it would never learn it was refused diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index a171457..61e7ca4 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -217,6 +217,19 @@ class RelayClient( * revocation is the whole lever §21 adds. */ deliveryToken: String? = null, + /** + * Deliver without announcing: protocol traffic, not a message. + * + * 🔴 **The relay announced everything, because it cannot tell.** It + * holds ciphertext, so a call answer, a delivery key and a birthday + * message are the same bytes to it — and every one of them raised + * "New message" on the far end. One call produced a burst of banners + * for things nobody sent. The sender is the only party that knows. + * + * ⚠️ Advisory and unauthenticated. A sender that lies suppresses its + * own notifications and nobody else's. + */ + silent: Boolean = false, ): JSONObject = postJson("/e2e/messages", JSONObject() .put("to", toUser) @@ -224,6 +237,7 @@ class RelayClient( if (deliveryToken != null) body.put("delivery_token", deliveryToken) else if (deliveryKey != null) body.put("delivery_key", deliveryKey) else body.put("from_device", fromDevice) + if (silent) body.put("silent", true) } .put("messages", JSONArray().also { array -> envelopes.forEach { From 62a8206e5a8d4346dfa36d46bb10c6df40cd996f Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 17:43:57 -0400 Subject: [PATCH 9/9] =?UTF-8?q?Android=200.8=20=E2=80=94=20published?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7e22a6e..0fbd998 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,8 +33,8 @@ android { // ⚠️ Android also refuses to install an APK whose `versionCode` is // lower than the installed one, so a code that goes backwards makes a // release uninstallable for everybody already on the higher number. - versionCode = 7 - versionName = "0.7" + versionCode = 8 + versionName = "0.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }