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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember 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.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
@@ -45,6 +48,7 @@ class IncomingVerificationPresenter(
@Assisted private val navigator: IncomingVerificationNavigator, @Assisted private val navigator: IncomingVerificationNavigator,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
private val stateMachine: IncomingVerificationStateMachine, private val stateMachine: IncomingVerificationStateMachine,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
) : Presenter<IncomingVerificationState> { ) : Presenter<IncomingVerificationState> {
@@ -92,12 +96,15 @@ class IncomingVerificationPresenter(
mode = DateFormatterMode.TimeOrDate, mode = DateFormatterMode.TimeOrDate,
) )
} }
val step by remember { val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val isMissingSecrets = recoveryState == RecoveryState.INCOMPLETE
val step by remember(isMissingSecrets) {
derivedStateOf { derivedStateOf {
stateAndDispatch.state.value.toVerificationStep( val currentStep = stateAndDispatch.state.value.toVerificationStep(
sessionVerificationRequestDetails = verificationRequest.details, sessionVerificationRequestDetails = verificationRequest.details,
formattedSignInTime = formattedSignInTime, formattedSignInTime = formattedSignInTime,
) )
if (isMissingSecrets && currentStep is Step.Initial) Step.Unavailable else currentStep
} }
} }
@@ -131,6 +138,7 @@ class IncomingVerificationPresenter(
} else { } else {
stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge) stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
} }
Step.Unavailable,
Step.Canceled, Step.Canceled,
Step.Completed, Step.Completed,
Step.Failure -> navigator.onFinish() Step.Failure -> navigator.onFinish()
@@ -32,6 +32,8 @@ data class IncomingVerificationState(
val isWaiting: Boolean, val isWaiting: Boolean,
) : Step ) : Step
data object Unavailable : Step
data object Canceled : Step data object Canceled : Step
data object Completed : Step data object Completed : Step
data object Failure : Step data object Failure : Step
@@ -43,6 +43,7 @@ open class IncomingVerificationStatePreviewParam : PreviewParameterProvider<Inco
anIncomingVerificationState(step = Step.Completed, verificationRequest = anIncomingUserVerificationRequest()), anIncomingVerificationState(step = Step.Completed, verificationRequest = anIncomingUserVerificationRequest()),
anIncomingVerificationState(step = Step.Failure), anIncomingVerificationState(step = Step.Failure),
anIncomingVerificationState(step = Step.Canceled), anIncomingVerificationState(step = Step.Canceled),
anIncomingVerificationState(step = Step.Unavailable),
// Add other state here // Add other state here
) )
} }
@@ -95,7 +95,8 @@ fun IncomingVerificationView(
@Composable @Composable
private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.Incoming) { private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.Incoming) {
val iconStyle = when (step) { val iconStyle = when (step) {
Step.Canceled -> BigIcon.Style.AlertSolid Step.Canceled,
Step.Unavailable -> BigIcon.Style.AlertSolid
is Step.Initial -> when (request) { is Step.Initial -> when (request) {
is VerificationRequest.Incoming.OtherSession -> BigIcon.Style.Default(CompoundIcons.Devices()) is VerificationRequest.Incoming.OtherSession -> BigIcon.Style.Default(CompoundIcons.Devices())
is VerificationRequest.Incoming.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid()) is VerificationRequest.Incoming.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid())
@@ -106,6 +107,7 @@ private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.
} }
val titleTextId = when (step) { val titleTextId = when (step) {
Step.Canceled -> CommonStrings.common_verification_failed 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.Initial -> R.string.screen_session_verification_request_title
is Step.Verifying -> when (step.data) { is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title 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) { val subtitleTextId = when (step) {
Step.Canceled -> R.string.screen_session_verification_request_failure_subtitle 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 Step.Initial -> when (request) {
is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_request_subtitle is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_request_subtitle
is VerificationRequest.Incoming.User -> R.string.screen_session_verification_user_responder_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, Step.Canceled,
is Step.Completed, is Step.Completed,
is Step.Failure -> { 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.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.FlowId 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.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails 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.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_DEVICE_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP 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.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.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
@@ -294,6 +297,47 @@ class IncomingVerificationPresenterTest {
navigatorLambda.assertions().isCalledOnce() 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( private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.OtherSession(
@@ -314,11 +358,13 @@ internal fun TestScope.createPresenter(
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest, verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest,
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() }, navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
service: SessionVerificationService = FakeSessionVerificationService(), service: SessionVerificationService = FakeSessionVerificationService(),
encryptionService: EncryptionService = FakeEncryptionService(),
dateFormatter: DateFormatter = FakeDateFormatter(), dateFormatter: DateFormatter = FakeDateFormatter(),
) = IncomingVerificationPresenter( ) = IncomingVerificationPresenter(
verificationRequest = verificationRequest, verificationRequest = verificationRequest,
navigator = navigator, navigator = navigator,
sessionVerificationService = service, sessionVerificationService = service,
encryptionService = encryptionService,
stateMachine = IncomingVerificationStateMachine(service), stateMachine = IncomingVerificationStateMachine(service),
dateFormatter = dateFormatter, dateFormatter = dateFormatter,
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,