Merge branch 'fix/calls-say-when-the-key-changed' into 'main'

Say when a call was refused because the key changed (android#20)

See merge request paulhitt/flamenet-messenger-android!68
This commit was merged in pull request #95.
This commit is contained in:
Paul Hitt
2026-09-05 14:43:07 +00:00
2 changed files with 191 additions and 79 deletions
@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.flamenet.client.ChainRpc
import io.flamenet.client.IdentityChangedException
import io.flamenet.client.DeviceClock
import io.flamenet.client.AccountsClient
import io.flamenet.client.RelayClient
@@ -2214,6 +2215,33 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// WebRTC callback thread it is raised from.
viewModelScope.launch(Dispatchers.IO) {
runCatching { session?.sendCallSignal(peerId, signal) }
// 🔴 **This was `runCatching { }` and nothing else, so
// every failure here vanished.** The one that matters
// is a changed key: the send path REFUSES to encrypt to
// an unreviewed identity — TOFU working exactly as
// intended — and the exception was swallowed, so the
// offer never left and the screen sat in DIALLING until
// it timed out. Reported as "calls wouldn't connect and
// it wasn't obvious why", which is precisely right.
//
// ⚠️ The fix is one tap and it is on a screen the
// person is not looking at: the accept-the-new-key
// banner lives in the CHAT, and somebody calling from
// the Calls tab never sees it. So say where to go.
.onFailure { failure ->
if (failure is IdentityChangedException) {
calls?.hangUp(notifyPeer = false)
update {
it.copy(
call = null,
error = "Their security code changed, so the call "
+ "was not placed. Open the conversation with "
+ "${nameFor(it, peerId)} to review it, then "
+ "call again.",
)
}
}
}
}
},
onStateChanged = { state ->
@@ -3511,7 +3539,59 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* service answers them identically on purpose, and a message here that
* distinguishes them would undo that at the last step.
*/
private fun describe(failure: Throwable): String = when (failure) {
private fun describe(failure: Throwable): String = describeFailure(failure)
/**
* The failure-to-sentence mapping, as a pure function of its argument.
*
* ⚠️ Lifted out of the view model so it can be tested without an
* `Application`. It never touched instance state — it reads only the
* throwable — so this is a move, not a rewrite, and the alternative was
* a Robolectric run to assert a string.
*/
/**
* ⚠️ A recorder outlives the view model unless something stops it, and a
* live MediaRecorder holds the microphone against the whole device.
*/
override fun onCleared() {
calls?.shutdown()
calls = null
recordingTicker?.cancel()
recording?.cancel()
recording = null
super.onCleared()
}
}
/**
* The four levels the local vault offers, and no compartments.
*
* 🔴 **Matches iOS's `Vocabularies.local` exactly, ordinals included.** The
* NUMBER is what travels with a file — the word is only how it is drawn — so a
* client that renumbered these would file somebody's Restricted document as
* Internal on the other phone, with no error anywhere and nothing on either
* screen looking wrong.
*
* ⚠️ No compartments here, deliberately. The lattice supports 64 of them and
* the local vault offers none: there is nobody to agree with about what area
* 7 means, and an area that only one phone understands is a label that stops
* a file opening rather than one that protects it.
*/
object FileLevels {
val LOCAL_LEVELS: List<Pair<Short, String>> = listOf(
0.toShort() to "Unclassified",
1.toShort() to "Internal",
2.toShort() to "Confidential",
3.toShort() to "Restricted",
)
fun levelName(classification: Short): String =
LOCAL_LEVELS.firstOrNull { it.first == classification }?.second
?: "Level $classification"
}
internal fun describeFailure(failure: Throwable): String = when (failure) {
is AccountsClient.Failure.BadCredentials -> "That handle or passphrase isn't right."
is AccountsClient.Failure.SessionExpired ->
"Your session expired. Sign out and back in to keep going."
@@ -3555,6 +3635,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
"Too many attempts. Try again in about $mins minute" +
if (mins == 1) "." else "s."
} ?: "Too many attempts. Wait a few minutes."
// 🔴 **Named, because the generic fallback sent people to the network.**
// "Something went wrong reaching that server" is what this used to
// produce for a changed key, and the server is fine — the refusal is
// this phone declining to encrypt to an identity nobody has reviewed.
// iOS names it ("Their security code changed"); this said nothing
// anywhere, in the call path or the message path.
is IdentityChangedException ->
"Their security code changed. Open the conversation to review it " +
"before sending anything else."
is Vault.Corrupt -> "This phone's stored keys could not be read."
// 🔴 **Anything not named above used to reach the screen verbatim.**
@@ -3590,45 +3679,4 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
is RelayException.Malformed -> "That server sent back something unexpected."
else -> "Something went wrong reaching that server."
}
/**
* ⚠️ A recorder outlives the view model unless something stops it, and a
* live MediaRecorder holds the microphone against the whole device.
*/
override fun onCleared() {
calls?.shutdown()
calls = null
recordingTicker?.cancel()
recording?.cancel()
recording = null
super.onCleared()
}
}
/**
* The four levels the local vault offers, and no compartments.
*
* 🔴 **Matches iOS's `Vocabularies.local` exactly, ordinals included.** The
* NUMBER is what travels with a file — the word is only how it is drawn — so a
* client that renumbered these would file somebody's Restricted document as
* Internal on the other phone, with no error anywhere and nothing on either
* screen looking wrong.
*
* ⚠️ No compartments here, deliberately. The lattice supports 64 of them and
* the local vault offers none: there is nobody to agree with about what area
* 7 means, and an area that only one phone understands is a label that stops
* a file opening rather than one that protects it.
*/
object FileLevels {
val LOCAL_LEVELS: List<Pair<Short, String>> = listOf(
0.toShort() to "Unclassified",
1.toShort() to "Internal",
2.toShort() to "Confidential",
3.toShort() to "Restricted",
)
fun levelName(classification: Short): String =
LOCAL_LEVELS.firstOrNull { it.first == classification }?.second
?: "Level $classification"
}
@@ -0,0 +1,64 @@
package io.flamenet.messenger.app
import io.flamenet.client.IdentityChangedException
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* A refused-because-the-key-changed failure has to say so (android#20).
*
* 🔴 **Reported from a real session: "the calls wouldn't connect because the
* keys were changed. it wasn't obvious why the call failed."** It was not
* obvious because nothing said it. The call's signalling lambda was
* `runCatching { … }` with no `onFailure`, so `IdentityChangedException` — the
* send path refusing to encrypt to an unreviewed identity, which is TOFU
* working — vanished. The offer never left and the screen sat in DIALLING.
*
* ⚠️ **And the fix is on a screen the caller is not looking at.** Accepting a
* changed key is a banner in the CHAT; somebody calling from the Calls tab
* never sees it. So the message has to name the conversation, not just the
* problem.
*
* ⚠️ iOS has said "Their security code changed" on this path since it was
* built. This is a parity gap, not a new idea — which is why the assertion is
* about the substance of the sentence rather than its exact wording.
*/
class ChangedKeyIsExplainedTest {
private val message = describeFailure(IdentityChangedException(7L))
@Test
fun `it names the security code rather than the network`() {
assertTrue(
message.contains("security code", ignoreCase = true),
"a changed key was described as \"$message\" — the person is sent " +
"hunting a network problem that does not exist",
)
}
/**
* 🔴 **The actionable half.** A refusal somebody cannot act on is the same
* as no explanation: they retry the same thing and are refused again. This
* file's neighbours in `describe()` all say what to DO, and that rule is
* written down there — this one has to keep it.
*/
@Test
fun `it says where to go and what to do`() {
assertTrue(
message.contains("conversation", ignoreCase = true) &&
message.contains("review", ignoreCase = true),
"\"$message\" does not point at the conversation, which is the only " +
"place the key can be accepted",
)
}
/** ⚠️ Not the generic fallback, which is what this used to produce. */
@Test
fun `it is not the generic something-went-wrong line`() {
assertTrue(
!message.contains("Something went wrong", ignoreCase = true) &&
!message.contains("reaching that server", ignoreCase = true),
"a changed key still falls through to the generic server line: \"$message\"",
)
}
}