Fix outgoing session verification stuck with incomplete recovery setup (#7433)

This commit is contained in:
Cipherd
2026-08-17 13:14:45 +00:00
committed by GitHub
parent 12791406ca
commit f94566e44f
2 changed files with 86 additions and 3 deletions
@@ -15,7 +15,6 @@ import com.freeletics.flowredux.dsl.FlowReduxStateMachine
import io.element.android.features.verifysession.impl.util.andLogStateChange
import io.element.android.features.verifysession.impl.util.logReceivedEvents
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.data.tryOrNull
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.SessionVerificationData
@@ -23,6 +22,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.timeout
@@ -95,10 +95,13 @@ class OutgoingVerificationStateMachine(
// If a key backup exists, wait until it's restored or a timeout happens
val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse()
if (hasBackup) {
tryOrNull {
encryptionService.recoveryStateStateFlow.filter { it == RecoveryState.ENABLED }
try {
encryptionService.recoveryStateStateFlow
.filter { it == RecoveryState.ENABLED || it == RecoveryState.DISABLED || it == RecoveryState.INCOMPLETE }
.timeout(10.seconds)
.first()
} catch (_: TimeoutCancellationException) {
// The backup was not restored in time, proceed to the Completed state anyway
}
}
state.override { State.Completed.andLogStateChange() }
@@ -16,6 +16,7 @@ import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificat
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
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.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
@@ -216,6 +217,85 @@ class OutgoingVerificationPresenterTest {
}
}
@Test
fun `present - When verification is approved and a backup exists but recovery is disabled, the flow completes`() = runTest {
val service = unverifiedSessionService(
requestUserVerificationLambda = { },
startSasVerificationLambda = { },
approveVerificationLambda = { },
)
val encryptionService = FakeEncryptionService().apply {
givenDoesBackupExistOnServerResult(Result.success(true))
recoveryStateStateFlow.value = RecoveryState.DISABLED
}
val presenter = createOutgoingVerificationPresenter(
service = service,
verificationRequest = anOutgoingUserVerificationRequest(),
encryptionService = encryptionService,
)
presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification)
assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java)
service.emitVerificationFlowState(VerificationFlowState.DidFinish)
assertThat(awaitItem().step).isEqualTo(Step.Completed)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - When verification is approved and a backup exists but recovery is incomplete, the flow completes`() = runTest {
val service = unverifiedSessionService(
requestUserVerificationLambda = { },
startSasVerificationLambda = { },
approveVerificationLambda = { },
)
val encryptionService = FakeEncryptionService().apply {
givenDoesBackupExistOnServerResult(Result.success(true))
recoveryStateStateFlow.value = RecoveryState.INCOMPLETE
}
val presenter = createOutgoingVerificationPresenter(
service = service,
verificationRequest = anOutgoingUserVerificationRequest(),
encryptionService = encryptionService,
)
presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification)
assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java)
service.emitVerificationFlowState(VerificationFlowState.DidFinish)
assertThat(awaitItem().step).isEqualTo(Step.Completed)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - When verification is approved but the recovery state is not determined, the flow completes after the timeout`() = runTest {
val service = unverifiedSessionService(
requestUserVerificationLambda = { },
startSasVerificationLambda = { },
approveVerificationLambda = { },
)
val encryptionService = FakeEncryptionService().apply {
givenDoesBackupExistOnServerResult(Result.success(true))
}
val presenter = createOutgoingVerificationPresenter(
service = service,
verificationRequest = anOutgoingUserVerificationRequest(),
encryptionService = encryptionService,
)
presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification)
assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java)
service.emitVerificationFlowState(VerificationFlowState.DidFinish)
// The recovery state never leaves UNKNOWN, so the 10 seconds timeout fires and the flow completes anyway
this@runTest.advanceUntilIdle()
assertThat(awaitItem().step).isEqualTo(Step.Completed)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - When verification is declined, the flow is canceled`() = runTest {
val service = unverifiedSessionService(