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:
+13
-2
@@ -33,8 +33,8 @@ android {
|
|||||||
// ⚠️ Android also refuses to install an APK whose `versionCode` is
|
// ⚠️ Android also refuses to install an APK whose `versionCode` is
|
||||||
// lower than the installed one, so a code that goes backwards makes a
|
// lower than the installed one, so a code that goes backwards makes a
|
||||||
// release uninstallable for everybody already on the higher number.
|
// release uninstallable for everybody already on the higher number.
|
||||||
versionCode = 7
|
versionCode = 8
|
||||||
versionName = "0.7"
|
versionName = "0.8"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +105,17 @@ android {
|
|||||||
// Debug keeps all four, because that is what runs on an emulator.
|
// Debug keeps all four, because that is what runs on an emulator.
|
||||||
ndk { abiFilters += setOf("arm64-v8a", "armeabi-v7a") }
|
ndk { abiFilters += setOf("arm64-v8a", "armeabi-v7a") }
|
||||||
isMinifyEnabled = true
|
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")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
signingConfig = signingConfigs.findByName("release")
|
signingConfig = signingConfigs.findByName("release")
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+50
@@ -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
|
onGathered = null
|
||||||
gatherGuard?.let { main.removeCallbacks(it) }
|
gatherGuard?.let { main.removeCallbacks(it) }
|
||||||
gatherGuard = null
|
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)
|
send(sdp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +227,15 @@ class CallManager(
|
|||||||
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
|
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
|
||||||
}
|
}
|
||||||
val observer = object : PeerConnection.Observer {
|
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 onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
|
||||||
override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
|
override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
|
||||||
// 🔴 **Nothing may throw out of here, and nothing may call back
|
// 🔴 **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:
|
// out — so ANY escaping Kotlin exception aborts the process:
|
||||||
// SIGABRT, no AndroidRuntime trace, nothing to catch.
|
// SIGABRT, no AndroidRuntime trace, nothing to catch.
|
||||||
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
|
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
|
||||||
|
android.util.Log.i("fnmsg-call", "ice connection -> $newState")
|
||||||
runCatching {
|
runCatching {
|
||||||
when (newState) {
|
when (newState) {
|
||||||
PeerConnection.IceConnectionState.CONNECTED,
|
PeerConnection.IceConnectionState.CONNECTED,
|
||||||
@@ -239,6 +262,7 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
||||||
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
|
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
|
// Hop to the main thread rather than touching the connection
|
||||||
// from here — same JNI rule as above: no WebRTC calls, and no
|
// from here — same JNI rule as above: no WebRTC calls, and no
|
||||||
// exceptions, from inside a WebRTC callback.
|
// exceptions, from inside a WebRTC callback.
|
||||||
@@ -327,7 +351,7 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
pc.createOffer(object : SimpleSdpObserver() {
|
pc.createOffer(object : SimpleSdpObserver() {
|
||||||
override fun onCreateSuccess(sdp: SessionDescription) {
|
override fun onCreateSuccess(sdp: SessionDescription) {
|
||||||
pc.setLocalDescription(SimpleSdpObserver(), sdp)
|
pc.setLocalDescription(SimpleSdpObserver("offer:setLocal"), sdp)
|
||||||
// The complete SDP — with candidates — exists only after
|
// The complete SDP — with candidates — exists only after
|
||||||
// gathering; see onGathered. DIALLING holds until the answer
|
// gathering; see onGathered. DIALLING holds until the answer
|
||||||
// arrives, so the caller's screen says what is actually true.
|
// arrives, so the caller's screen says what is actually true.
|
||||||
@@ -416,24 +440,18 @@ class CallManager(
|
|||||||
var onStaleOffer: ((Long, Long) -> Unit)? = null
|
var onStaleOffer: ((Long, Long) -> Unit)? = null
|
||||||
|
|
||||||
/** Accept the call that is ringing. */
|
/** Accept the call that is ringing. */
|
||||||
fun accept(turn: JSONObject?) {
|
/**
|
||||||
val (fromUser, offer) = pendingOffer ?: return
|
* Build and send the answer. Split out so it can only be reached from
|
||||||
pendingOffer = null
|
* `setRemoteDescription`'s success callback — the ordering IS the fix.
|
||||||
val sdp = offer.sdp ?: return
|
*/
|
||||||
state = State.CONNECTING
|
private fun answerNow(
|
||||||
armConnectGuard()
|
pc: PeerConnection,
|
||||||
val pc = open(turn, fromUser, offer.callId, offer.video) ?: run {
|
fromUser: Long,
|
||||||
state = State.IDLE
|
offer: Content.CallSignal,
|
||||||
return
|
) {
|
||||||
}
|
pc.createAnswer(object : SimpleSdpObserver("answer:create") {
|
||||||
current = Active(offer.callId, fromUser, incoming = true, video = offer.video)
|
|
||||||
pc.setRemoteDescription(
|
|
||||||
SimpleSdpObserver(),
|
|
||||||
SessionDescription(SessionDescription.Type.OFFER, sdp),
|
|
||||||
)
|
|
||||||
pc.createAnswer(object : SimpleSdpObserver() {
|
|
||||||
override fun onCreateSuccess(answer: SessionDescription) {
|
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
|
// Same as the offer: the answer is complete only after
|
||||||
// gathering, and one sent early names no address to reach.
|
// gathering, and one sent early names no address to reach.
|
||||||
main.post {
|
main.post {
|
||||||
@@ -447,6 +465,39 @@ class CallManager(
|
|||||||
}, MediaConstraints())
|
}, 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. */
|
/** Refuse a ringing call, and say so rather than letting it ring out. */
|
||||||
fun decline() {
|
fun decline() {
|
||||||
val (fromUser, offer) = pendingOffer ?: return
|
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. */
|
/** 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 onCreateSuccess(sdp: SessionDescription) = Unit
|
||||||
override fun onSetSuccess() = Unit
|
override fun onSetSuccess() = Unit
|
||||||
override fun onCreateFailure(error: String?) = Unit
|
override fun onCreateFailure(error: String?) {
|
||||||
override fun onSetFailure(error: String?) = Unit
|
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 displayName by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
var confirmingDelete by remember { mutableStateOf(false) }
|
var confirmingDelete by remember { mutableStateOf(false) }
|
||||||
|
var confirmingErase by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Text("Account", style = MaterialTheme.typography.headlineMedium)
|
Text("Account", style = MaterialTheme.typography.headlineMedium)
|
||||||
Text(
|
Text(
|
||||||
@@ -876,9 +877,28 @@ fun ColumnScope.AccountScreen(
|
|||||||
SettingsSection {
|
SettingsSection {
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
label = "Sign out",
|
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",
|
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(
|
SettingsRow(
|
||||||
label = if (confirmingDelete) "Really delete — this cannot be undone"
|
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
|
* ⚠️ 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.
|
* 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
|
@Composable
|
||||||
private fun ColumnScope.StoryAudienceSection(
|
private fun ColumnScope.StoryAudienceSection(
|
||||||
model: MessengerViewModel,
|
model: MessengerViewModel,
|
||||||
@@ -1139,6 +1166,42 @@ private fun ColumnScope.StoryAudienceSection(
|
|||||||
color = Palette.SecondaryText,
|
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.
|
// The list only earns its place when the mode uses one.
|
||||||
if (audience.mode != Session.AudienceMode.EVERYONE) {
|
if (audience.mode != Session.AudienceMode.EVERYONE) {
|
||||||
if (phase.buddies.isEmpty()) {
|
if (phase.buddies.isEmpty()) {
|
||||||
|
|||||||
@@ -479,7 +479,13 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
TextButton(
|
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"),
|
modifier = Modifier.fillMaxWidth().testTag("toggle-register"),
|
||||||
) {
|
) {
|
||||||
Text(if (registering) "I already have an account" else "Create an account")
|
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)
|
get() = prefs.getBoolean(FULL_QUALITY, false)
|
||||||
set(value) = prefs.edit().putBoolean(FULL_QUALITY, value).apply()
|
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
|
* Refuse to send unless the relay can prove the recipient's keys appear in
|
||||||
* the log it publishes.
|
* the log it publishes.
|
||||||
@@ -61,6 +86,10 @@ class MediaSettings(context: Context) {
|
|||||||
private const val PREFS = "media-settings"
|
private const val PREFS = "media-settings"
|
||||||
private const val REQUIRE_KEY_LOG = "requireKeyLog"
|
private const val REQUIRE_KEY_LOG = "requireKeyLog"
|
||||||
private const val FULL_QUALITY = "full_quality"
|
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"
|
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 = "") {
|
fun signIn(handle: String, passphrase: String, register: Boolean, email: String = "") {
|
||||||
val config = site ?: run {
|
val config = site ?: run {
|
||||||
_phase.value = Phase.SignedOut(error = "That doesn't look like a server address.")
|
_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 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)
|
vault.saveIdentity(it)
|
||||||
identity = it
|
identity = it
|
||||||
}
|
}
|
||||||
@@ -1376,7 +1408,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
fun deleteAccount() = viewModelScope.launch {
|
fun deleteAccount() = viewModelScope.launch {
|
||||||
val current = session ?: return@launch
|
val current = session ?: return@launch
|
||||||
val done = withContext(Dispatchers.IO) { runCatching { current.api.deleteAccount() } }
|
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)) } }
|
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1947,7 +1979,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val done = withContext(Dispatchers.IO) {
|
val done = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
current.postStory(bytes, mime, width, height, caption, recipients,
|
current.postStory(bytes, mime, width, height, caption, recipients,
|
||||||
duration, streamUrl)
|
duration, streamUrl,
|
||||||
|
lifetimeSeconds = MediaSettings(getApplication())
|
||||||
|
.storyLifetimeSeconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
done.onSuccess { posted ->
|
done.onSuccess { posted ->
|
||||||
@@ -2438,13 +2472,45 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
+ "new device.")
|
+ "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?.cancel()
|
||||||
polling = null
|
polling = null
|
||||||
|
val current = session
|
||||||
|
val myDevice = identity?.deviceId
|
||||||
session = null
|
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
|
identity = null
|
||||||
vault = KeystoreVault.open(getApplication())
|
vault = KeystoreVault.open(getApplication())
|
||||||
|
} else {
|
||||||
|
identity = null
|
||||||
|
}
|
||||||
_phase.value = Phase.SignedOut()
|
_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.HandleTaken -> "That handle is already taken."
|
||||||
is AccountsClient.Failure.RateLimited -> "Too many attempts. Wait a few minutes."
|
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
|
// ⚠️ NOT "that passphrase is wrong". The relay rejects a TOKEN, which
|
||||||
// the user never typed and cannot check. Telling them their passphrase
|
// the user never typed and cannot check. Telling them their passphrase
|
||||||
// is wrong when their session merely aged out sends them off to change
|
// 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."
|
if (mins == 1) "." else "s."
|
||||||
} ?: "Too many attempts. Wait a few minutes."
|
} ?: "Too many attempts. Wait a few minutes."
|
||||||
is Vault.Corrupt -> "This phone's stored keys could not be read."
|
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. */
|
/** 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? =
|
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 {
|
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
|
||||||
val sent = conversations.sendToGroup(group, text, userId)
|
val sent = conversations.sendToGroup(group, text, userId)
|
||||||
@@ -749,6 +762,13 @@ class Session(
|
|||||||
if (gid != null) {
|
if (gid != null) {
|
||||||
val known = groupRoster(gid)
|
val known = groupRoster(gid)
|
||||||
if (known == null || message.fromUser !in known) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
// ⚠️ Every group envelope carries the group's name, and this is
|
// ⚠️ Every group envelope carries the group's name, and this is
|
||||||
@@ -886,8 +906,16 @@ class Session(
|
|||||||
duration: Double = 0.0,
|
duration: Double = 0.0,
|
||||||
/** A live stream this story embeds (§6.13); [bytes] is then the poster. */
|
/** A live stream this story embeds (§6.13); [bytes] is then the poster. */
|
||||||
streamUrl: String? = null,
|
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 {
|
): io.flamenet.client.Conversations.StoryPosted {
|
||||||
val posted = conversations.postStory(bytes, mime, width, height, caption, toUserIds,
|
val posted = conversations.postStory(bytes, mime, width, height, caption, toUserIds,
|
||||||
|
lifetimeSeconds = lifetimeSeconds,
|
||||||
duration = duration, streamUrl = streamUrl)
|
duration = duration, streamUrl = streamUrl)
|
||||||
if (posted.reached > 0) {
|
if (posted.reached > 0) {
|
||||||
val now = System.currentTimeMillis() / 1000.0
|
val now = System.currentTimeMillis() / 1000.0
|
||||||
@@ -1231,6 +1259,16 @@ class Session(
|
|||||||
@Volatile var groupNamesUpdated: Boolean = false
|
@Volatile var groupNamesUpdated: Boolean = false
|
||||||
|
|
||||||
private fun groupRoster(gid: String): Set<Long>? {
|
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 now = System.currentTimeMillis()
|
||||||
val cached = groupRosterCache
|
val cached = groupRosterCache
|
||||||
if (cached != null && now - cached.first < 30_000) return cached.second[gid]
|
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.
|
* Staging a picked story, image or video, from either entry point.
|
||||||
*
|
*
|
||||||
* Videos are validated, not transcoded: this platform has no built-in
|
* Videos are validated and scrubbed, not transcoded: this platform has no
|
||||||
* exporter the way AVFoundation is one, and pulling in a transcoding stack
|
* built-in exporter the way AVFoundation is one, and pulling in a transcoding
|
||||||
* is a decision for later. So the caps are enforced honestly — a clip too
|
* 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
|
* long or too heavy is refused with the reason — and a clip that fits is
|
||||||
* out exactly as picked.
|
* remuxed to drop the container metadata before it goes out. See [MediaScrub];
|
||||||
|
* "exactly as picked" used to include the capture location.
|
||||||
*/
|
*/
|
||||||
object StoryMedia {
|
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. */
|
/** A story is a moment, not a film — same cap as iOS. */
|
||||||
const val MAX_DURATION_S = 30
|
const val MAX_DURATION_S = 30
|
||||||
/** Under the relay's 10 MB attachment cap, with room for the seal. */
|
/** Under the relay's 10 MB attachment cap, with room for the seal. */
|
||||||
@@ -66,27 +75,40 @@ object StoryMedia {
|
|||||||
?.toIntOrNull() ?: 0
|
?.toIntOrNull() ?: 0
|
||||||
// A portrait phone clip is stored landscape plus a rotation.
|
// A portrait phone clip is stored landscape plus a rotation.
|
||||||
if (rotation == 90 || rotation == 270) { val t = w; w = h; h = t }
|
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() } }
|
}.getOrNull().also { runCatching { retriever.release() } }
|
||||||
|
|
||||||
if (meta == null) {
|
if (meta == null) {
|
||||||
model.reportStoryProblem("That video couldn't be read.")
|
model.reportStoryProblem("That video couldn't be read.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val (seconds, w, h) = meta
|
val (seconds, w, h, rotation) = meta
|
||||||
if (seconds > MAX_DURATION_S + 0.5) {
|
if (seconds > MAX_DURATION_S + 0.5) {
|
||||||
model.reportStoryProblem(
|
model.reportStoryProblem(
|
||||||
"Stories are capped at $MAX_DURATION_S seconds — this one is "
|
"Stories are capped at $MAX_DURATION_S seconds — this one is "
|
||||||
+ "${seconds.toInt()}. Trim it and try again.")
|
+ "${seconds.toInt()}. Trim it and try again.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val bytes = runCatching {
|
val picked = runCatching {
|
||||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
if (bytes == null) {
|
if (picked == null) {
|
||||||
model.reportStoryProblem("That video couldn't be read.")
|
model.reportStoryProblem("That video couldn't be read.")
|
||||||
return
|
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) {
|
if (bytes.size > MAX_BYTES) {
|
||||||
model.reportStoryProblem(
|
model.reportStoryProblem(
|
||||||
"That video is too large to send — the cap is about 9 MB. "
|
"That video is too large to send — the cap is about 9 MB. "
|
||||||
@@ -97,16 +119,25 @@ object StoryMedia {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val bytes = runCatching {
|
val picked = runCatching {
|
||||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
if (bytes == null) {
|
if (picked == null) {
|
||||||
model.reportStoryProblem("That file couldn't be read.")
|
model.reportStoryProblem("That file couldn't be read.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
// 🔴 **Prepared, not posted as picked.** A story photo was the one
|
||||||
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
// image path in the app that never went through [ImagePrep], so it was
|
||||||
model.stageStory(bytes, mime, bounds.outWidth, bounds.outHeight)
|
// 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. */
|
/** 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
|
* request happens to verify is the mistake this whole section exists to
|
||||||
* avoid.
|
* 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 }
|
val theirs = bundles.filter { it.deviceId != identity.deviceId }
|
||||||
if (theirs.isEmpty()) return
|
if (theirs.isEmpty()) return
|
||||||
|
|
||||||
@@ -513,7 +517,11 @@ class Conversations(
|
|||||||
) {
|
) {
|
||||||
throw IdentityChangedException(toUserId)
|
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 =
|
fun sendPlaintextForGroupInvite(toUserId: Long, text: String): Sent =
|
||||||
sendPlaintext(toUserId, text)
|
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 {
|
private fun sendPlaintext(toUserId: Long, text: String): Sent {
|
||||||
// 🔴 **The DIRECTORY, not the key route.** `/e2e/keys` pops a one-time
|
// 🔴 **The DIRECTORY, not the key route.** `/e2e/keys` pops a one-time
|
||||||
// prekey per device and is metered per contact pair — calling it on
|
// prekey per device and is metered per contact pair — calling it on
|
||||||
@@ -567,13 +597,34 @@ class Conversations(
|
|||||||
val needsEstablish = stubs.any {
|
val needsEstablish = stubs.any {
|
||||||
it.deviceId != identity.deviceId && existingSession(it.deviceId) == null
|
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 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
|
// Union: every device the directory knows, carrying a full bundle
|
||||||
// where the key route returned one.
|
// where the key route returned one.
|
||||||
stubs.map { full[it.deviceId] ?: it }
|
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 delivered = mutableListOf<String>()
|
||||||
val failed = LinkedHashMap<String, String>()
|
val failed = LinkedHashMap<String, String>()
|
||||||
val outgoing = mutableListOf<RelayClient.ToDevice>()
|
val outgoing = mutableListOf<RelayClient.ToDevice>()
|
||||||
@@ -594,6 +645,8 @@ class Conversations(
|
|||||||
// above call this once per member with no try around it — throwing
|
// above call this once per member with no try around it — throwing
|
||||||
// would abandon everyone after the first person who is signed out.
|
// would abandon everyone after the first person who is signed out.
|
||||||
if (bundles.none { it.deviceId != identity.deviceId }) {
|
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"
|
failed["user:$toUserId"] = "not signed in on any device"
|
||||||
return Sent(delivered, failed, sealed = false)
|
return Sent(delivered, failed, sealed = false)
|
||||||
}
|
}
|
||||||
@@ -626,6 +679,15 @@ class Conversations(
|
|||||||
bundle.deviceId, wrap(pre, bundle, sealed))
|
bundle.deviceId, wrap(pre, bundle, sealed))
|
||||||
}
|
}
|
||||||
val envelope = session.encrypt(text)
|
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
|
// Saved BEFORE the network call. If the relay accepts the
|
||||||
// envelope and we then crash, the peer has a message our
|
// envelope and we then crash, the peer has a message our
|
||||||
// ratchet has not advanced past, and nothing either side can
|
// 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()) {
|
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 {
|
try {
|
||||||
relay.sendEnvelopes(
|
relay.sendEnvelopes(
|
||||||
toUserId, outgoing, identity.deviceId,
|
toUserId, outgoing, identity.deviceId,
|
||||||
|
silent = !warrantsAlert(text),
|
||||||
// ⚠️ The key only when there is no capability. Sending both
|
// ⚠️ The key only when there is no capability. Sending both
|
||||||
// would let the relay accept on the key when the capability
|
// would let the relay accept on the key when the capability
|
||||||
// had been revoked, so it would never learn it was refused
|
// 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,
|
deliveryKey = if (peerToken == null) peerKey?.let(Wire::b64) else null,
|
||||||
deliveryToken = peerToken,
|
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.**
|
// 🔴 **A refused capability means the batch was revoked.**
|
||||||
// Retrying with the rest of it for ever is how a conversation
|
// Retrying with the rest of it for ever is how a conversation
|
||||||
// quietly stops working, so the batch goes and the SAME
|
// quietly stops working, so the batch goes and the SAME
|
||||||
@@ -1081,6 +1164,11 @@ class Conversations(
|
|||||||
openWith(E2ESession.respond(identity, envelope), envelope)
|
openWith(E2ESession.respond(identity, envelope), envelope)
|
||||||
} else null
|
} 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 {
|
val plaintext = when {
|
||||||
fromCurrent != null -> {
|
fromCurrent != null -> {
|
||||||
vault.saveSession(fromDevice, fromCurrent.session)
|
vault.saveSession(fromDevice, fromCurrent.session)
|
||||||
@@ -1170,7 +1258,22 @@ class Conversations(
|
|||||||
sealed = sealed, senderProven = senderProven,
|
sealed = sealed, senderProven = senderProven,
|
||||||
at = message.createdAt,
|
at = message.createdAt,
|
||||||
fromAddress = fromAddress)
|
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
|
// Deliberately swallowed and skipped. One undecryptable message
|
||||||
// is not a reason to stop reading the inbox.
|
// 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. */
|
/** 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 {
|
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
|
.firstOrNull { it.deviceId == identity.deviceId } ?: return 0
|
||||||
// The relay pops one per fetch, so seeing none here means the pool was
|
// The relay pops one per fetch, so seeing none here means the pool was
|
||||||
// empty before this call, not that this call drained it.
|
// 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.
|
* A valid token, minting one when the cache is empty or close to expiry.
|
||||||
* Null when this deployment does not issue them.
|
* 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? {
|
fun token(now: Long = System.currentTimeMillis() / 1000): String? {
|
||||||
if (unavailable) return null
|
if (unavailable) return null
|
||||||
cached?.let { if (it.expiresAt - SLACK_SECONDS > now) return it.token }
|
cached?.let { if (it.expiresAt - SLACK_SECONDS > now) return it.token }
|
||||||
val minted = try {
|
val minted = try {
|
||||||
mint(deviceId, Wire.b64(identityKey.publicKey))
|
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
|
unavailable = true
|
||||||
return null
|
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
|
||||||
} ?: 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)
|
cached = Cached(minted.token, now + minted.lifetimeSeconds)
|
||||||
return minted.token
|
return minted.token
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,10 +138,31 @@ class RelayClient(
|
|||||||
* starting a session. Polling it drains the pool and pushes every peer onto
|
* starting a session. Polling it drains the pool and pushes every peer onto
|
||||||
* the last-resort key, which costs initial forward secrecy for everyone.
|
* 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 body = getJson("/e2e/keys/$userId")
|
||||||
val devices = body.optJSONArray("devices") ?: JSONArray()
|
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. */
|
/** `GET /e2e/devices/{user}` — who a user is, costing no prekey. */
|
||||||
@@ -196,6 +217,19 @@ class RelayClient(
|
|||||||
* revocation is the whole lever §21 adds.
|
* revocation is the whole lever §21 adds.
|
||||||
*/
|
*/
|
||||||
deliveryToken: String? = null,
|
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 =
|
): JSONObject =
|
||||||
postJson("/e2e/messages", JSONObject()
|
postJson("/e2e/messages", JSONObject()
|
||||||
.put("to", toUser)
|
.put("to", toUser)
|
||||||
@@ -203,6 +237,7 @@ class RelayClient(
|
|||||||
if (deliveryToken != null) body.put("delivery_token", deliveryToken)
|
if (deliveryToken != null) body.put("delivery_token", deliveryToken)
|
||||||
else if (deliveryKey != null) body.put("delivery_key", deliveryKey)
|
else if (deliveryKey != null) body.put("delivery_key", deliveryKey)
|
||||||
else body.put("from_device", fromDevice)
|
else body.put("from_device", fromDevice)
|
||||||
|
if (silent) body.put("silent", true)
|
||||||
}
|
}
|
||||||
.put("messages", JSONArray().also { array ->
|
.put("messages", JSONArray().also { array ->
|
||||||
envelopes.forEach {
|
envelopes.forEach {
|
||||||
|
|||||||
@@ -247,6 +247,21 @@ class Trust(
|
|||||||
/** The address this contact was pinned under, if any. */
|
/** The address this contact was pinned under, if any. */
|
||||||
fun pinnedFnid(userId: Long): String? = vault.loadTrust(userId)?.fnid
|
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. */
|
/** Trust as it stands, for UI that has not triggered a fetch. */
|
||||||
fun state(userId: Long): IdentityTrust {
|
fun state(userId: Long): IdentityTrust {
|
||||||
val t = vault.loadTrust(userId) ?: return IdentityTrust.UNKNOWN
|
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.
|
* the tick in place would show "verified" for something nobody verified.
|
||||||
*/
|
*/
|
||||||
fun acceptIdentityChange(userId: Long, handle: String? = null) {
|
fun acceptIdentityChange(userId: Long, handle: String? = null) {
|
||||||
val offered = relay.fetchKeys(userId).map { it.identitySigning }
|
// ⚠️ The address is taken from the SAME response that carries the keys.
|
||||||
if (offered.isEmpty()) return
|
// 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
|
// Re-resolve rather than keep the old address: if what changed WAS the
|
||||||
// address, keeping it would leave every later check recomputing against
|
// address, keeping it would leave every later check recomputing against
|
||||||
// a name this account no longer publishes under, and honest keys would
|
// 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
|
// was served. Bob genuinely published this key a moment ago, so the
|
||||||
// relay can prove it — and if this throws, enforcement is refusing
|
// relay can prove it — and if this throws, enforcement is refusing
|
||||||
// honest keys, which is worse than not enforcing at all.
|
// 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()
|
val trust = alice.trust()
|
||||||
trust.enforceKeyLog(bundle, bob.userId)
|
trust.enforceKeyLog(bundle, bob.userId)
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ class LiveKeyLogTest {
|
|||||||
val alice = Actor(url, "c")
|
val alice = Actor(url, "c")
|
||||||
val bob = Actor(url, "d")
|
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(
|
val substituted = io.flamenet.e2e.PeerBundle(
|
||||||
deviceId = real.deviceId,
|
deviceId = real.deviceId,
|
||||||
identityDH = real.identityDH,
|
identityDH = real.identityDH,
|
||||||
@@ -141,7 +141,7 @@ class LiveKeyLogTest {
|
|||||||
val bob = Actor(url, "f")
|
val bob = Actor(url, "f")
|
||||||
|
|
||||||
val trust = alice.trust()
|
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)
|
trust.enforceKeyLog(first, bob.userId)
|
||||||
val pinned = alice.vault.loadKeyLogPin(alice.relay.host)!!
|
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
|
// second call must produce and verify a real consistency proof from the
|
||||||
// pinned size to the new one.
|
// pinned size to the new one.
|
||||||
val second = Actor(url, "g")
|
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 }
|
.single { it.deviceId == second.identity.deviceId }
|
||||||
trust.enforceKeyLog(third, second.userId)
|
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 }))
|
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> {
|
assertFailsWith<KeyLogForkException> {
|
||||||
alice.trust().enforceKeyLog(bundle, bob.userId)
|
alice.trust().enforceKeyLog(bundle, bob.userId)
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ class LiveKeyLogTest {
|
|||||||
.also { it.useRelayAuth(second) }
|
.also { it.useRelayAuth(second) }
|
||||||
.registerDevice(second, second.generateOneTimePreKeys(3))
|
.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()
|
val trust = alice.trust()
|
||||||
|
|
||||||
// 1. Nothing pinned: the honest key is refused, and says why — there
|
// 1. Nothing pinned: the honest key is refused, and says why — there
|
||||||
@@ -254,7 +254,7 @@ class LiveKeyLogTest {
|
|||||||
val bob = Actor(url, "o")
|
val bob = Actor(url, "o")
|
||||||
|
|
||||||
val trust = alice.trust()
|
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.
|
// Pin Bob under an address, with his real keys.
|
||||||
alice.vault.saveTrust(
|
alice.vault.saveTrust(
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class LiveSealedSenderTest {
|
|||||||
// A submission presenting the OLD key must be refused outright. Sent
|
// A submission presenting the OLD key must be refused outright. Sent
|
||||||
// through the client rather than by hand, so this exercises the same
|
// through the client rather than by hand, so this exercises the same
|
||||||
// path a real sealed send takes.
|
// 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 envelope = io.flamenet.e2e.E2ESession.initiate(alice.identity, bundle).encrypt("stale")
|
||||||
val blob = SealedSender.seal(
|
val blob = SealedSender.seal(
|
||||||
envelope, alice.userId, alice.identity.deviceId, bundle.deviceId, bundle.identityDH)
|
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.
|
// 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
|
// A device published without them can send and can never be started
|
||||||
// with, which looks identical from the account's own side.
|
// 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 }
|
val mine = bundles.firstOrNull { it.deviceId == identity.deviceId }
|
||||||
assertTrue(mine != null, "$base served no bundle for the device just registered")
|
assertTrue(mine != null, "$base served no bundle for the device just registered")
|
||||||
assertTrue(mine!!.oneTimePreKey != null, "$base served no one-time prekey")
|
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
|
// The device is only really registered if a stranger can fetch a bundle
|
||||||
// for it — that is what makes this phone reachable.
|
// 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 },
|
assertTrue(bundles.any { it.deviceId == identity.deviceId },
|
||||||
"the relay did not serve a bundle for the device we just registered")
|
"the relay did not serve a bundle for the device we just registered")
|
||||||
assertTrue(bundles.first { it.deviceId == identity.deviceId }.oneTimePreKey != null,
|
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)))
|
.put("opk", JSONObject().put("id", oneTime.id).put("pub", Wire.b64(oneTime.publicKey)))
|
||||||
server.enqueue(json("""{"devices":[$wire]}"""))
|
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(1, bundles.size)
|
||||||
assertEquals(peer.deviceId, bundles[0].deviceId)
|
assertEquals(peer.deviceId, bundles[0].deviceId)
|
||||||
assertEquals(oneTime.id, bundles[0].oneTimePreKeyId)
|
assertEquals(oneTime.id, bundles[0].oneTimePreKeyId)
|
||||||
|
|||||||
@@ -173,7 +173,24 @@ class E2ESession private constructor(
|
|||||||
associatedData = X3DH.associatedData(
|
associatedData = X3DH.associatedData(
|
||||||
theirIdentitySigning, identity.identitySigningPublic, pq?.second),
|
theirIdentitySigning, identity.identitySigningPublic, pq?.second),
|
||||||
pendingHandshake = null,
|
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
|
return session
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,19 @@ class HybridSessionTest {
|
|||||||
val delivered = Envelope.fromPayload(envelope.toPayload())
|
val delivered = Envelope.fromPayload(envelope.toPayload())
|
||||||
val bobSide = E2ESession.respond(bob, delivered)
|
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))
|
assertEquals("post-quantum hello", bobSide.decrypt(delivered))
|
||||||
assertContentEquals(session.associatedData, bobSide.associatedData,
|
assertContentEquals(session.associatedData, bobSide.associatedData,
|
||||||
"both sides must bind the same ciphertext digest")
|
"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)
|
"encrypt" -> encrypt(req)
|
||||||
"decrypt" -> decrypt(req)
|
"decrypt" -> decrypt(req)
|
||||||
// --- the session layer, which is what an app actually uses ------------
|
// --- the session layer, which is what an app actually uses ------------
|
||||||
"session_device" -> sessionDevice()
|
"session_device" -> sessionDevice(req)
|
||||||
"session_initiate" -> sessionInitiate(req)
|
"session_initiate" -> sessionInitiate(req)
|
||||||
"session_respond" -> sessionRespond(req)
|
"session_respond" -> sessionRespond(req)
|
||||||
"session_encrypt" -> sessionEncrypt(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 —
|
// v3, the legacy paths would stop being covered on the day v3 landed —
|
||||||
// which is the day they matter most.
|
// which is the day they matter most.
|
||||||
val postQuantum = req.optBoolean("pq", true)
|
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 id = next("kotlin-device-")
|
||||||
val device = Device(
|
val device = Device(
|
||||||
deviceId = id,
|
deviceId = id,
|
||||||
@@ -183,9 +191,13 @@ private fun newDevice(req: JSONObject): JSONObject {
|
|||||||
// Without `caps_sig` the web engine's `bundleFromWire` discards the
|
// Without `caps_sig` the web engine's `bundleFromWire` discards the
|
||||||
// whole v2 block and `negotiate` returns v1, which is exactly what this
|
// whole v2 block and `negotiate` returns v1, which is exactly what this
|
||||||
// harness was quietly doing before.
|
// 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(
|
.putOpt("caps_sig", if (postQuantum) b64(Ed25519.sign(
|
||||||
device.ikSig, CAPS_DOMAIN + byteArrayOf(3.toByte()))) else null)
|
device.ikSig, CAPS_DOMAIN + byteArrayOf(proto.toByte()))) else null)
|
||||||
.put("proto", if (postQuantum) 3 else 1)
|
.put("proto", proto)
|
||||||
|
|
||||||
return JSONObject().put("device", id).put("bundle", bundle)
|
return JSONObject().put("device", id).put("bundle", bundle)
|
||||||
}
|
}
|
||||||
@@ -338,12 +350,29 @@ private val identities = HashMap<String, DeviceIdentity>()
|
|||||||
private val liveSessions = HashMap<String, E2ESession>()
|
private val liveSessions = HashMap<String, E2ESession>()
|
||||||
|
|
||||||
/** A device and the bundle wire a relay would serve for it, one prekey included. */
|
/** 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()
|
val identity = DeviceIdentity.create()
|
||||||
identities[identity.deviceId] = identity
|
identities[identity.deviceId] = identity
|
||||||
val oneTime = identity.generateOneTimePreKeys(1).first()
|
val oneTime = identity.generateOneTimePreKeys(1).first()
|
||||||
val wire = identity.registrationWire()
|
val wire = identity.registrationWire()
|
||||||
.put("opk", JSONObject().put("id", oneTime.id).put("pub", b64(oneTime.publicKey)))
|
.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)
|
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
|
// layer where the one bug this protocol has actually shipped lives: a
|
||||||
// client and a relay disagreeing about a field name, caught by nothing
|
// client and a relay disagreeing about a field name, caught by nothing
|
||||||
// until a live round trip.
|
// until a live round trip.
|
||||||
|
.put("version", session.version)
|
||||||
.put("envelope", session.encrypt(req.getString("text")).toJson())
|
.put("envelope", session.encrypt(req.getString("text")).toJson())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +401,12 @@ private fun sessionRespond(req: JSONObject): JSONObject {
|
|||||||
return JSONObject()
|
return JSONObject()
|
||||||
.put("session", id)
|
.put("session", id)
|
||||||
.put("ad", b64(session.associatedData))
|
.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))
|
.put("text", session.decrypt(envelope))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,9 +443,12 @@ private fun openSealed(req: JSONObject): JSONObject {
|
|||||||
.put("envelope", opened.envelope.toJson())
|
.put("envelope", opened.envelope.toJson())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sessionEncrypt(req: JSONObject): JSONObject = JSONObject()
|
private fun sessionEncrypt(req: JSONObject): JSONObject =
|
||||||
.put("envelope", liveSessions.getValue(req.getString("session"))
|
liveSessions.getValue(req.getString("session")).let { session ->
|
||||||
.encrypt(req.getString("text")).toJson())
|
JSONObject()
|
||||||
|
.put("version", session.version)
|
||||||
|
.put("envelope", session.encrypt(req.getString("text")).toJson())
|
||||||
|
}
|
||||||
|
|
||||||
private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject()
|
private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject()
|
||||||
.put("text", liveSessions.getValue(req.getString("session"))
|
.put("text", liveSessions.getValue(req.getString("session"))
|
||||||
@@ -426,8 +465,19 @@ private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject()
|
|||||||
private fun sessionReload(req: JSONObject): JSONObject {
|
private fun sessionReload(req: JSONObject): JSONObject {
|
||||||
val id = req.getString("session")
|
val id = req.getString("session")
|
||||||
val archived = liveSessions.getValue(id).archive()
|
val archived = liveSessions.getValue(id).archive()
|
||||||
liveSessions[id] = E2ESession.fromArchive(JSONObject(archived.toString()))
|
// 🔴 **`as_version` exists to reproduce a phone that stored a session
|
||||||
return JSONObject().put("reloaded", true).put("bytes", archived.toString().length)
|
// 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 {
|
private fun accountKdf(req: JSONObject): JSONObject {
|
||||||
|
|||||||
Reference in New Issue
Block a user