Offer the review when a key change blocks a call

ios#73. Android already named the reason: a call signal refused with
IdentityChangedException hung up and said "Their security code changed".
But it then told the person to go and find the conversation themselves,
and the banner that compares and accepts lives in the conversation
header, which a call started from the Calls tab or answered from a
notification never passes through. So the failure described the cure
without offering it.

The blocked peer is now carried on the phase rather than only named in
a sentence, and the failure draws a "Review their security code" button
that opens that conversation and clears the failure.

Compare and accept stay behind the existing banner. A second accept
button, phrased on a call screen in a hurry, is the thing the issue's
wording requirement exists to prevent.

4 new tests. 1217 tests pass, counted from the XML.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-09 08:06:22 -04:00
co-authored by Claude Opus 5
parent 1e0bf0ba1f
commit 3d9f18f922
3 changed files with 99 additions and 3 deletions
@@ -734,6 +734,18 @@ internal fun ColumnScope.SignedIn(
) )
} }
// A call refused because the peer's keys changed offers the review here,
// rather than telling somebody to go and find the conversation (ios#73).
// Compare and accept both live behind this, in the banner the chat already
// draws, so the wording it is judged by is unchanged.
if (phase.callKeyReview != null) {
KitSecondaryButton(
text = "Review their security code",
onClick = { model.reviewBlockedCallKeys() },
tag = "review-call-keys",
)
}
if (addingContact) { if (addingContact) {
AddContactForm(model, phase, initial = scannedPrefill) { AddContactForm(model, phase, initial = scannedPrefill) {
addingContact = false addingContact = false
@@ -124,6 +124,11 @@ class MessengerViewModel(
val lookupMiss: String? = null, val lookupMiss: String? = null,
/** The call in progress, if any (§8.10). */ /** The call in progress, if any (§8.10). */
val call: CallState? = null, val call: CallState? = null,
/**
* The peer whose key change blocked a call, so the failure can
* offer the review instead of describing it (ios#73).
*/
val callKeyReview: Pair<Long, String>? = null,
/** Shown once, before the first call ever placed on this device. */ /** Shown once, before the first call ever placed on this device. */
val callPrivacyPrompt: Long? = null, val callPrivacyPrompt: Long? = null,
/** /**
@@ -383,6 +388,17 @@ class MessengerViewModel(
} }
} }
/**
* Open the conversation with the peer whose key change blocked a call, and
* clear the failure (ios#73).
*/
fun reviewBlockedCallKeys() {
val current = _phase.value as? Phase.SignedIn ?: return
val (peerId, peerName) = current.callKeyReview ?: return
_phase.value = current.copy(error = null, callKeyReview = null)
openChat(peerId, peerName)
}
fun signIn( fun signIn(
handle: String, handle: String,
passphrase: String, passphrase: String,
@@ -2272,10 +2288,10 @@ class MessengerViewModel(
it.copy( it.copy(
call = null, call = null,
error = error =
"Their security code changed, so the call " + "Their security code changed, so the call was " +
"was not placed. Open the conversation with " + "not placed. Review the change before you " +
"${nameFor(it, peerId)} to review it, then " +
"call again.", "call again.",
callKeyReview = peerId to nameFor(it, peerId),
) )
} }
} }
@@ -0,0 +1,68 @@
package io.flamenet.messenger.app
import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* A call refused because the peer's keys changed offers the review (ios#73).
*/
class CallKeyReviewTest {
private fun source(path: String): String {
val file = File("src/main/kotlin/io/flamenet/messenger/app/$path")
assertTrue("missing $path", file.exists())
return file.readText()
}
/** The blocked peer is carried, not just named in a sentence. */
@Test
fun theBlockedPeerIsCarried() {
val vm = source("MessengerViewModel.kt")
assertTrue(
"the failure must carry the peer",
vm.contains("val callKeyReview: Pair<Long, String>? = null"),
)
assertTrue(
"and be set where the trust gate refuses the signal",
vm.contains("callKeyReview = peerId to nameFor(it, peerId)"),
)
}
/** Tapping it opens that conversation and clears the failure. */
@Test
fun reviewOpensTheConversation() {
val vm = source("MessengerViewModel.kt")
assertTrue(vm.contains("fun reviewBlockedCallKeys()"))
assertTrue("must open the chat", vm.contains("openChat(peerId, peerName)"))
assertTrue(
"and must not leave the failure on screen",
vm.contains("copy(error = null, callKeyReview = null)"),
)
}
/** The button is on the screen the failure appears on. */
@Test
fun theButtonIsRendered() {
val main = source("MainActivity.kt")
assertTrue(main.contains("phase.callKeyReview != null"))
assertTrue(main.contains("model.reviewBlockedCallKeys()"))
assertTrue(main.contains("\"review-call-keys\""))
}
/**
* The reason is still named. A generic "call failed" was the original
* complaint, and the fix must not regress into one.
*/
@Test
fun theReasonIsStillNamed() {
val vm = source("MessengerViewModel.kt")
assertTrue(vm.contains("Their security code changed"))
// The quoted form only: the KDoc on reviewBlockedCallKeys describes
// opening a conversation, and that is not copy anybody reads.
assertFalse(
"must not tell somebody to go and find the conversation themselves",
vm.contains("\"Open the conversation with "),
)
}
}