Merge branch 'fix/v3-responder' into 'main'

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

See merge request paulhitt/flamenet-messenger-android!8
This commit was merged in pull request #35.
This commit is contained in:
Paul Hitt
2026-09-01 22:09:24 +00:00
22 changed files with 867 additions and 91 deletions
+13 -2
View File
@@ -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"
}
@@ -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")
}
+50
View File
@@ -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 <methods>;
}
# 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.**
@@ -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<out IceCandidate>?) = 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.
@@ -416,24 +440,18 @@ 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(),
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)
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 {
@@ -447,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
@@ -614,9 +665,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")
}
}
@@ -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"
@@ -1083,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,
@@ -1139,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()) {
@@ -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")
@@ -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<Int, Int>()
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
}
@@ -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"
}
}
@@ -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.")
@@ -490,7 +506,23 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
val vault = vault ?: error("the vault is not open")
val device = identity ?: DeviceIdentity.create().also {
// 🔴 **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
}
@@ -1376,7 +1408,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)) } }
}
@@ -1947,7 +1979,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 ->
@@ -2438,13 +2472,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()) }
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()
}
@@ -2460,7 +2526,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 +2547,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."
}
/**
@@ -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)
@@ -749,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
@@ -886,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
@@ -1231,6 +1259,16 @@ class Session(
@Volatile var groupNamesUpdated: Boolean = false
private fun groupRoster(gid: String): Set<Long>? {
// 🔴 **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]
@@ -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. */
@@ -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<PeerBundle>) {
private fun guardIdentity(
toUserId: Long,
bundles: List<PeerBundle>,
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) }
}
/**
@@ -524,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
@@ -567,13 +597,34 @@ 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 }
}
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, servedFnid)
} 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<String>()
val failed = LinkedHashMap<String, String>()
val outgoing = mutableListOf<RelayClient.ToDevice>()
@@ -594,6 +645,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 +679,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,10 +702,23 @@ 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,
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
@@ -651,7 +726,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 +1164,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 +1258,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.
//
@@ -1188,7 +1291,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.
@@ -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
}
@@ -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<PeerBundle> {
/** A peer's bundles, and the address their key-log entries are filed under. */
data class Keys(val fnid: String?, val bundles: List<PeerBundle>)
/**
* 🔴 **The `fnid` is not decoration; without it the key log cannot be
* checked.** The relay files a leaf under `fnid:<address>` once an account
* has claimed one, and under `local:<audience>/<id>` 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. */
@@ -196,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)
@@ -203,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 {
@@ -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
@@ -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<KeyLogForkException> {
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(
@@ -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)
@@ -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")
@@ -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,
@@ -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)
@@ -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
}
@@ -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")
@@ -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<String, DeviceIdentity>()
private val liveSessions = HashMap<String, E2ESession>()
/** 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)
}
@@ -359,6 +388,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 +401,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 +443,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"))
@@ -426,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 {