Only offer to verify if a cross-signed device is available (#5433)

* Only offer to verify if a cross-signed device is available

* Fix tests

* use the right exception mapper

* adjust flag name and logic in ChooseSelfVerificationState

* add comment

* switch order of states to match previous logic
This commit is contained in:
Hubert Chathi 2025-10-06 06:40:52 -04:00 committed by GitHub
parent a9912e4a1e
commit 7c61c70b62
10 changed files with 47 additions and 14 deletions

View file

@ -26,7 +26,7 @@ class ChooseSelfVerificationModePresenter(
) : Presenter<ChooseSelfVerificationModeState> { ) : Presenter<ChooseSelfVerificationModeState> {
@Composable @Composable
override fun present(): ChooseSelfVerificationModeState { override fun present(): ChooseSelfVerificationModeState {
val isLastDevice by encryptionService.isLastDevice.collectAsState() val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val canEnterRecoveryKey by remember { derivedStateOf { recoveryState == RecoveryState.INCOMPLETE } } val canEnterRecoveryKey by remember { derivedStateOf { recoveryState == RecoveryState.INCOMPLETE } }
@ -39,7 +39,7 @@ class ChooseSelfVerificationModePresenter(
} }
return ChooseSelfVerificationModeState( return ChooseSelfVerificationModeState(
isLastDevice = isLastDevice, canUseAnotherDevice = hasDevicesToVerifyAgainst,
canEnterRecoveryKey = canEnterRecoveryKey, canEnterRecoveryKey = canEnterRecoveryKey,
directLogoutState = directLogoutState, directLogoutState = directLogoutState,
eventSink = ::eventHandler, eventSink = ::eventHandler,

View file

@ -10,7 +10,7 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
data class ChooseSelfVerificationModeState( data class ChooseSelfVerificationModeState(
val isLastDevice: Boolean, val canUseAnotherDevice: Boolean,
val canEnterRecoveryKey: Boolean, val canEnterRecoveryKey: Boolean,
val directLogoutState: DirectLogoutState, val directLogoutState: DirectLogoutState,
val eventSink: (ChooseSelfVerificationModeEvent) -> Unit, val eventSink: (ChooseSelfVerificationModeEvent) -> Unit,

View file

@ -13,18 +13,18 @@ import io.element.android.features.logout.api.direct.aDirectLogoutState
class ChooseSelfVerificationModeStateProvider : class ChooseSelfVerificationModeStateProvider :
PreviewParameterProvider<ChooseSelfVerificationModeState> { PreviewParameterProvider<ChooseSelfVerificationModeState> {
override val values = sequenceOf( override val values = sequenceOf(
aChooseSelfVerificationModeState(isLastDevice = true, canEnterRecoveryKey = true), aChooseSelfVerificationModeState(canUseAnotherDevice = false, canEnterRecoveryKey = true),
aChooseSelfVerificationModeState(isLastDevice = true, canEnterRecoveryKey = false), aChooseSelfVerificationModeState(canUseAnotherDevice = false, canEnterRecoveryKey = false),
aChooseSelfVerificationModeState(isLastDevice = false, canEnterRecoveryKey = true), aChooseSelfVerificationModeState(canUseAnotherDevice = true, canEnterRecoveryKey = true),
aChooseSelfVerificationModeState(isLastDevice = false, canEnterRecoveryKey = false), aChooseSelfVerificationModeState(canUseAnotherDevice = true, canEnterRecoveryKey = false),
) )
} }
fun aChooseSelfVerificationModeState( fun aChooseSelfVerificationModeState(
isLastDevice: Boolean = false, canUseAnotherDevice: Boolean = true,
canEnterRecoveryKey: Boolean = true, canEnterRecoveryKey: Boolean = true,
) = ChooseSelfVerificationModeState( ) = ChooseSelfVerificationModeState(
isLastDevice = isLastDevice, canUseAnotherDevice = canUseAnotherDevice,
canEnterRecoveryKey = canEnterRecoveryKey, canEnterRecoveryKey = canEnterRecoveryKey,
directLogoutState = aDirectLogoutState(), directLogoutState = aDirectLogoutState(),
eventSink = {}, eventSink = {},

View file

@ -76,7 +76,7 @@ fun ChooseSelfVerificationModeView(
ButtonColumnMolecule( ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) { ) {
if (state.isLastDevice.not()) { if (state.canUseAnotherDevice) {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_use_another_device), text = stringResource(R.string.screen_identity_use_another_device),

View file

@ -24,15 +24,15 @@ class ChooseSessionVerificationModePresenterTest {
@Test @Test
fun `initial state - is relayed from EncryptionService`() = runTest { fun `initial state - is relayed from EncryptionService`() = runTest {
val encryptionService = FakeEncryptionService().apply { val encryptionService = FakeEncryptionService().apply {
// Is last device // Has device to verify against
emitIsLastDevice(true) emitHasDevicesToVerifyAgainst(false)
// Can enter recovery key // Can enter recovery key
emitRecoveryState(RecoveryState.INCOMPLETE) emitRecoveryState(RecoveryState.INCOMPLETE)
} }
val presenter = createPresenter(encryptionService = encryptionService) val presenter = createPresenter(encryptionService = encryptionService)
presenter.test { presenter.test {
awaitItem().run { awaitItem().run {
assertThat(isLastDevice).isTrue() assertThat(canUseAnotherDevice).isFalse()
assertThat(canEnterRecoveryKey).isTrue() assertThat(canEnterRecoveryKey).isTrue()
assertThat(directLogoutState.logoutAction.isUninitialized()).isTrue() assertThat(directLogoutState.logoutAction.isUninitialized()).isTrue()
} }

View file

@ -43,7 +43,7 @@ class ChooseSessionVerificationModeViewTest {
fun `clicking on use another device calls the callback`() { fun `clicking on use another device calls the callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView( rule.setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(isLastDevice = false), aChooseSelfVerificationModeState(canUseAnotherDevice = true),
onUseAnotherDevice = callback, onUseAnotherDevice = callback,
) )
rule.clickOn(R.string.screen_identity_use_another_device) rule.clickOn(R.string.screen_identity_use_another_device)

View file

@ -17,6 +17,7 @@ interface EncryptionService {
val recoveryStateStateFlow: StateFlow<RecoveryState> val recoveryStateStateFlow: StateFlow<RecoveryState>
val enableRecoveryProgressStateFlow: StateFlow<EnableRecoveryProgress> val enableRecoveryProgressStateFlow: StateFlow<EnableRecoveryProgress>
val isLastDevice: StateFlow<Boolean> val isLastDevice: StateFlow<Boolean>
val hasDevicesToVerifyAgainst: StateFlow<Boolean>
suspend fun enableBackups(): Result<Unit> suspend fun enableBackups(): Result<Unit>

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.sync.RustSyncService import io.element.android.libraries.matrix.impl.sync.RustSyncService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@ -96,6 +97,20 @@ internal class RustEncryptionService(
} }
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
/**
* Check if the user has any devices available to verify against every 5 seconds.
* TODO This is a temporary workaround, when we will have a way to observe
* the sessions, this code will have to be updated.
*/
override val hasDevicesToVerifyAgainst: StateFlow<Boolean> = flow {
while (currentCoroutineContext().isActive) {
val result = hasDevicesToVerifyAgainst().getOrDefault(false)
emit(result)
delay(5_000)
}
}
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
override suspend fun enableBackups(): Result<Unit> = withContext(dispatchers.io) { override suspend fun enableBackups(): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions { runCatchingExceptions {
service.enableBackups() service.enableBackups()
@ -171,6 +186,14 @@ internal class RustEncryptionService(
} }
} }
private suspend fun hasDevicesToVerifyAgainst(): Result<Boolean> = withContext(dispatchers.io) {
runCatchingExceptions {
service.hasDevicesToVerifyAgainst()
}.mapFailure {
it.mapClientException()
}
}
override suspend fun resetRecoveryKey(): Result<String> = withContext(dispatchers.io) { override suspend fun resetRecoveryKey(): Result<String> = withContext(dispatchers.io) {
runCatchingExceptions { runCatchingExceptions {
service.resetRecoveryKey() service.resetRecoveryKey()

View file

@ -32,6 +32,10 @@ class FakeFfiEncryption : Encryption(NoPointer) {
return false return false
} }
override suspend fun hasDevicesToVerifyAgainst(): Boolean {
return true
}
override fun backupState(): BackupState { override fun backupState(): BackupState {
return BackupState.ENABLED return BackupState.ENABLED
} }

View file

@ -34,6 +34,7 @@ class FakeEncryptionService(
override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(RecoveryState.UNKNOWN) override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(RecoveryState.UNKNOWN)
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Starting) override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Starting)
override val isLastDevice: MutableStateFlow<Boolean> = MutableStateFlow(false) override val isLastDevice: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val hasDevicesToVerifyAgainst: MutableStateFlow<Boolean> = MutableStateFlow(true)
private var waitForBackupUploadSteadyStateFlow: Flow<BackupUploadState> = flowOf() private var waitForBackupUploadSteadyStateFlow: Flow<BackupUploadState> = flowOf()
private var recoverFailure: Exception? = null private var recoverFailure: Exception? = null
@ -83,6 +84,10 @@ class FakeEncryptionService(
this.isLastDevice.value = isLastDevice this.isLastDevice.value = isLastDevice
} }
fun emitHasDevicesToVerifyAgainst(hasDevicesToVerifyAgainst: Boolean) {
this.hasDevicesToVerifyAgainst.value = hasDevicesToVerifyAgainst
}
override suspend fun resetRecoveryKey(): Result<String> = simulateLongTask { override suspend fun resetRecoveryKey(): Result<String> = simulateLongTask {
return Result.success(FAKE_RECOVERY_KEY) return Result.success(FAKE_RECOVERY_KEY)
} }