Say so when this session has no secrets to verify another one with

A session whose recovery state is INCOMPLETE has not got the secrets it
would need to sign another device, so accepting an incoming verification
request from it can only fail. The screen offered "Start verification"
anyway, and the user learnt otherwise at the end of the flow.

Show the design's alert step instead — "Verification with this device is
currently unavailable" — with only the Ignore action, and leave the
request unacknowledged in every other respect.

Part of #5344.
This commit is contained in:
hayaksi1
2026-08-25 16:44:08 +03:00
parent 12203aaf6d
commit 1cd27761ad
6 changed files with 83 additions and 3 deletions
@@ -13,6 +13,7 @@ package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -26,6 +27,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
@@ -45,6 +48,7 @@ class IncomingVerificationPresenter(
@Assisted private val navigator: IncomingVerificationNavigator,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
private val stateMachine: IncomingVerificationStateMachine,
private val dateFormatter: DateFormatter,
) : Presenter<IncomingVerificationState> {
@@ -92,12 +96,15 @@ class IncomingVerificationPresenter(
mode = DateFormatterMode.TimeOrDate,
)
}
val step by remember {
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val isMissingSecrets = recoveryState == RecoveryState.INCOMPLETE
val step by remember(isMissingSecrets) {
derivedStateOf {
stateAndDispatch.state.value.toVerificationStep(
val currentStep = stateAndDispatch.state.value.toVerificationStep(
sessionVerificationRequestDetails = verificationRequest.details,
formattedSignInTime = formattedSignInTime,
)
if (isMissingSecrets && currentStep is Step.Initial) Step.Unavailable else currentStep
}
}
@@ -131,6 +138,7 @@ class IncomingVerificationPresenter(
} else {
stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
}
Step.Unavailable,
Step.Canceled,
Step.Completed,
Step.Failure -> navigator.onFinish()
@@ -32,6 +32,8 @@ data class IncomingVerificationState(
val isWaiting: Boolean,
) : Step
data object Unavailable : Step
data object Canceled : Step
data object Completed : Step
data object Failure : Step
@@ -43,6 +43,7 @@ open class IncomingVerificationStatePreviewParam : PreviewParameterProvider<Inco
anIncomingVerificationState(step = Step.Completed, verificationRequest = anIncomingUserVerificationRequest()),
anIncomingVerificationState(step = Step.Failure),
anIncomingVerificationState(step = Step.Canceled),
anIncomingVerificationState(step = Step.Unavailable),
// Add other state here
)
}
@@ -95,7 +95,8 @@ fun IncomingVerificationView(
@Composable
private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.Incoming) {
val iconStyle = when (step) {
Step.Canceled -> BigIcon.Style.AlertSolid
Step.Canceled,
Step.Unavailable -> BigIcon.Style.AlertSolid
is Step.Initial -> when (request) {
is VerificationRequest.Incoming.OtherSession -> BigIcon.Style.Default(CompoundIcons.Devices())
is VerificationRequest.Incoming.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid())
@@ -106,6 +107,7 @@ private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.
}
val titleTextId = when (step) {
Step.Canceled -> CommonStrings.common_verification_failed
Step.Unavailable -> R.string.screen_session_verification_unavailable_title
is Step.Initial -> R.string.screen_session_verification_request_title
is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
@@ -116,6 +118,7 @@ private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.
}
val subtitleTextId = when (step) {
Step.Canceled -> R.string.screen_session_verification_request_failure_subtitle
Step.Unavailable -> R.string.screen_session_verification_unavailable_subtitle
is Step.Initial -> when (request) {
is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_request_subtitle
is VerificationRequest.Incoming.User -> R.string.screen_session_verification_user_responder_subtitle
@@ -246,6 +249,15 @@ private fun IncomingVerificationBottomMenu(
)
}
}
Step.Unavailable -> {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_ignore),
onClick = { eventSink(IncomingVerificationViewEvent.IgnoreVerification) },
)
}
}
Step.Canceled,
is Step.Completed,
is Step.Failure -> {
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string name="screen_session_verification_unavailable_title">Verification with this device is currently unavailable</string>
<string name="screen_session_verification_unavailable_subtitle">Please use another device or recovery key.</string>
</resources>
@@ -13,6 +13,8 @@ import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificat
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -21,6 +23,7 @@ import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError
@@ -294,6 +297,47 @@ class IncomingVerificationPresenterTest {
navigatorLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - a session missing its secrets cannot be used to verify and offers no verification action`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { lambdaError() }
val encryptionService = FakeEncryptionService().apply {
recoveryStateStateFlow.value = RecoveryState.INCOMPLETE
}
createPresenter(
service = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
resetLambda = resetLambda,
),
encryptionService = encryptionService,
).test {
advanceUntilIdle()
assertThat(awaitItem().step).isEqualTo(IncomingVerificationState.Step.Unavailable)
acceptVerificationRequestLambda.assertions().isNeverCalled()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - a session that holds its secrets is offered the verification action`() = runTest {
val encryptionService = FakeEncryptionService().apply {
recoveryStateStateFlow.value = RecoveryState.ENABLED
}
createPresenter(
service = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = { },
resetLambda = { },
),
encryptionService = encryptionService,
).test {
advanceUntilIdle()
assertThat(awaitItem().step).isInstanceOf(IncomingVerificationState.Step.Initial::class.java)
cancelAndIgnoreRemainingEvents()
}
}
}
private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.OtherSession(
@@ -314,11 +358,13 @@ internal fun TestScope.createPresenter(
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest,
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
service: SessionVerificationService = FakeSessionVerificationService(),
encryptionService: EncryptionService = FakeEncryptionService(),
dateFormatter: DateFormatter = FakeDateFormatter(),
) = IncomingVerificationPresenter(
verificationRequest = verificationRequest,
navigator = navigator,
sessionVerificationService = service,
encryptionService = encryptionService,
stateMachine = IncomingVerificationStateMachine(service),
dateFormatter = dateFormatter,
sessionCoroutineScope = backgroundScope,