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:
@@ -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\"",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user