Make sure we know the session verification state before showing the option to verify the session. #5521

This commit is contained in:
Benoit Marty
2025-11-04 12:11:11 +01:00
parent c13fafd836
commit a2b6561009
9 changed files with 228 additions and 60 deletions

View File

@@ -15,7 +15,9 @@ import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
@@ -27,8 +29,33 @@ class ChooseSelfVerificationModePresenter(
@Composable
override fun present(): ChooseSelfVerificationModeState {
val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val canEnterRecoveryKey by remember { derivedStateOf { recoveryState == RecoveryState.INCOMPLETE } }
val canEnterRecoveryKey by encryptionService.recoveryStateStateFlow
.mapState { recoveryState ->
when (recoveryState) {
RecoveryState.WAITING_FOR_SYNC,
RecoveryState.UNKNOWN -> AsyncData.Loading()
RecoveryState.INCOMPLETE -> AsyncData.Success(true)
RecoveryState.ENABLED,
RecoveryState.DISABLED -> AsyncData.Success(false)
}
}
.collectAsState()
val buttonsState by remember {
derivedStateOf {
val canUseAnotherDevice = hasDevicesToVerifyAgainst.dataOrNull()
val canEnterRecoveryKey = canEnterRecoveryKey.dataOrNull()
if (canUseAnotherDevice == null || canEnterRecoveryKey == null) {
AsyncData.Loading()
} else {
AsyncData.Success(
ChooseSelfVerificationModeState.ButtonsState(
canUseAnotherDevice = canUseAnotherDevice,
canEnterRecoveryKey = canEnterRecoveryKey,
)
)
}
}
}
val directLogoutState = directLogoutPresenter.present()
@@ -39,8 +66,7 @@ class ChooseSelfVerificationModePresenter(
}
return ChooseSelfVerificationModeState(
canUseAnotherDevice = hasDevicesToVerifyAgainst,
canEnterRecoveryKey = canEnterRecoveryKey,
buttonsState = buttonsState,
directLogoutState = directLogoutState,
eventSink = ::eventHandler,
)

View File

@@ -8,10 +8,15 @@
package io.element.android.features.ftue.impl.sessionverification.choosemode
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.architecture.AsyncData
data class ChooseSelfVerificationModeState(
val canUseAnotherDevice: Boolean,
val canEnterRecoveryKey: Boolean,
val buttonsState: AsyncData<ButtonsState>,
val directLogoutState: DirectLogoutState,
val eventSink: (ChooseSelfVerificationModeEvent) -> Unit,
)
) {
data class ButtonsState(
val canUseAnotherDevice: Boolean,
val canEnterRecoveryKey: Boolean,
)
}

View File

@@ -9,23 +9,49 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.architecture.AsyncData
class ChooseSelfVerificationModeStateProvider :
PreviewParameterProvider<ChooseSelfVerificationModeState> {
override val values = sequenceOf(
aChooseSelfVerificationModeState(canUseAnotherDevice = false, canEnterRecoveryKey = true),
aChooseSelfVerificationModeState(canUseAnotherDevice = false, canEnterRecoveryKey = false),
aChooseSelfVerificationModeState(canUseAnotherDevice = true, canEnterRecoveryKey = true),
aChooseSelfVerificationModeState(canUseAnotherDevice = true, canEnterRecoveryKey = false),
aChooseSelfVerificationModeState(
buttonsState = AsyncData.Success(
aButtonsState(canUseAnotherDevice = false, canEnterRecoveryKey = true),
),
),
aChooseSelfVerificationModeState(
buttonsState = AsyncData.Success(
aButtonsState(canUseAnotherDevice = false, canEnterRecoveryKey = false),
),
),
aChooseSelfVerificationModeState(
buttonsState = AsyncData.Success(
aButtonsState(canUseAnotherDevice = true, canEnterRecoveryKey = true),
),
),
aChooseSelfVerificationModeState(
buttonsState = AsyncData.Success(
aButtonsState(canUseAnotherDevice = true, canEnterRecoveryKey = false),
),
),
aChooseSelfVerificationModeState(
buttonsState = AsyncData.Loading(),
),
)
}
fun aChooseSelfVerificationModeState(
canUseAnotherDevice: Boolean = true,
canEnterRecoveryKey: Boolean = true,
buttonsState: AsyncData<ChooseSelfVerificationModeState.ButtonsState> = AsyncData.Success(aButtonsState()),
) = ChooseSelfVerificationModeState(
canUseAnotherDevice = canUseAnotherDevice,
canEnterRecoveryKey = canEnterRecoveryKey,
buttonsState = buttonsState,
directLogoutState = aDirectLogoutState(),
eventSink = {},
)
fun aButtonsState(
canUseAnotherDevice: Boolean = true,
canEnterRecoveryKey: Boolean = true,
) = ChooseSelfVerificationModeState.ButtonsState(
canUseAnotherDevice = canUseAnotherDevice,
canEnterRecoveryKey = canEnterRecoveryKey,
)

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
@@ -50,7 +51,6 @@ fun ChooseSelfVerificationModeView(
BackHandler {
activity?.finish()
}
HeaderFooterPage(
modifier = modifier,
topBar = {
@@ -73,29 +73,12 @@ fun ChooseSelfVerificationModeView(
)
},
footer = {
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 16.dp)
) {
if (state.canUseAnotherDevice) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_use_another_device),
onClick = onUseAnotherDevice,
)
}
if (state.canEnterRecoveryKey) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
onClick = onUseRecoveryKey,
)
}
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
onClick = onResetKey,
)
}
ChooseSelfVerificationModeButtons(
state = state,
onUseAnotherDevice = onUseAnotherDevice,
onUseRecoveryKey = onUseRecoveryKey,
onResetKey = onResetKey,
)
}
) {
Row(
@@ -113,6 +96,53 @@ fun ChooseSelfVerificationModeView(
}
}
@Composable
private fun ChooseSelfVerificationModeButtons(
state: ChooseSelfVerificationModeState,
onUseAnotherDevice: () -> Unit,
onUseRecoveryKey: () -> Unit,
onResetKey: () -> Unit,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 16.dp)
) {
when (state.buttonsState) {
AsyncData.Uninitialized,
is AsyncData.Failure,
is AsyncData.Loading -> {
Button(
modifier = Modifier.fillMaxWidth(),
enabled = false,
showProgress = true,
text = stringResource(CommonStrings.common_loading),
onClick = {},
)
}
is AsyncData.Success -> {
if (state.buttonsState.data.canUseAnotherDevice) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_use_another_device),
onClick = onUseAnotherDevice,
)
}
if (state.buttonsState.data.canEnterRecoveryKey) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
onClick = onUseRecoveryKey,
)
}
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
onClick = onResetKey,
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun ChooseSelfVerificationModeViewPreview(

View File

@@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
@@ -22,23 +23,92 @@ import org.junit.Test
class ChooseSessionVerificationModePresenterTest {
@Test
fun `initial state - is relayed from EncryptionService`() = runTest {
val encryptionService = FakeEncryptionService().apply {
// Has device to verify against
emitHasDevicesToVerifyAgainst(false)
// Can enter recovery key
emitRecoveryState(RecoveryState.INCOMPLETE)
}
val presenter = createPresenter(encryptionService = encryptionService)
fun `present - initial state`() = runTest {
val presenter = createPresenter()
presenter.test {
awaitItem().run {
assertThat(canUseAnotherDevice).isFalse()
assertThat(canEnterRecoveryKey).isTrue()
assertThat(buttonsState.isLoading()).isTrue()
assertThat(directLogoutState.logoutAction.isUninitialized()).isTrue()
}
}
}
@Test
fun `present - state is relayed from EncryptionService, order 1`() = runTest {
val encryptionService = FakeEncryptionService()
val presenter = createPresenter(encryptionService = encryptionService)
presenter.test {
assertThat(awaitItem().buttonsState.isLoading()).isTrue()
// Has device to verify against
encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(false))
// Can enter recovery key
encryptionService.emitRecoveryState(RecoveryState.DISABLED)
assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo(
ChooseSelfVerificationModeState.ButtonsState(
canUseAnotherDevice = false,
canEnterRecoveryKey = false,
)
)
}
}
@Test
fun `present - state is relayed from EncryptionService, order 2`() = runTest {
val encryptionService = FakeEncryptionService()
val presenter = createPresenter(encryptionService = encryptionService)
presenter.test {
assertThat(awaitItem().buttonsState.isLoading()).isTrue()
// Can enter recovery key
encryptionService.emitRecoveryState(RecoveryState.DISABLED)
// Has device to verify against
encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(false))
assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo(
ChooseSelfVerificationModeState.ButtonsState(
canUseAnotherDevice = false,
canEnterRecoveryKey = false,
)
)
}
}
@Test
fun `present - can use another device`() = runTest {
val encryptionService = FakeEncryptionService()
val presenter = createPresenter(encryptionService = encryptionService)
presenter.test {
assertThat(awaitItem().buttonsState.isLoading()).isTrue()
// Can enter recovery key
encryptionService.emitRecoveryState(RecoveryState.DISABLED)
// Has device to verify against
encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(true))
assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo(
ChooseSelfVerificationModeState.ButtonsState(
canUseAnotherDevice = true,
canEnterRecoveryKey = false,
)
)
}
}
@Test
fun `present - can enter recovery key`() = runTest {
val encryptionService = FakeEncryptionService()
val presenter = createPresenter(encryptionService = encryptionService)
presenter.test {
assertThat(awaitItem().buttonsState.isLoading()).isTrue()
// Can enter recovery key
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
// Has device to verify against
encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(false))
assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo(
ChooseSelfVerificationModeState.ButtonsState(
canUseAnotherDevice = false,
canEnterRecoveryKey = true,
)
)
}
}
@Test
fun `sing out action triggers a direct logout`() = runTest {
val logoutEventRecorder = lambdaRecorder<DirectLogoutEvents, Unit> {}
@@ -49,8 +119,8 @@ class ChooseSessionVerificationModePresenterTest {
presenter.test {
val initial = awaitItem()
initial.eventSink(ChooseSelfVerificationModeEvent.SignOut)
logoutEventRecorder.assertions().isCalledOnce().with(value(DirectLogoutEvents.Logout(ignoreSdkError = false)))
logoutEventRecorder.assertions().isCalledOnce()
.with(value(DirectLogoutEvents.Logout(ignoreSdkError = false)))
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn
@@ -43,7 +44,7 @@ class ChooseSessionVerificationModeViewTest {
fun `clicking on use another device calls the callback`() {
ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(canUseAnotherDevice = true),
aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseAnotherDevice = true))),
onUseAnotherDevice = callback,
)
rule.clickOn(R.string.screen_identity_use_another_device)
@@ -55,7 +56,7 @@ class ChooseSessionVerificationModeViewTest {
fun `clicking on enter recovery key calls the callback`() {
ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(canEnterRecoveryKey = true),
aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canEnterRecoveryKey = true))),
onEnterRecoveryKey = callback,
)
rule.clickOn(R.string.screen_session_verification_enter_recovery_key)

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.api.encryption
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import kotlinx.coroutines.flow.Flow
@@ -17,7 +18,7 @@ interface EncryptionService {
val recoveryStateStateFlow: StateFlow<RecoveryState>
val enableRecoveryProgressStateFlow: StateFlow<EnableRecoveryProgress>
val isLastDevice: StateFlow<Boolean>
val hasDevicesToVerifyAgainst: StateFlow<Boolean>
val hasDevicesToVerifyAgainst: StateFlow<AsyncData<Boolean>>
suspend fun enableBackups(): Result<Unit>

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.encryption
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.mapFailure
@@ -42,6 +43,7 @@ import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.EnableRecoveryProgressListener
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.UserIdentity
import timber.log.Timber
import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState
import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress
import org.matrix.rustcomponents.sdk.RecoveryException as RustRecoveryException
@@ -103,14 +105,20 @@ class RustEncryptionService(
* 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 {
override val hasDevicesToVerifyAgainst: StateFlow<AsyncData<Boolean>> = flow {
while (currentCoroutineContext().isActive) {
val result = hasDevicesToVerifyAgainst().getOrDefault(false)
emit(result)
val result = hasDevicesToVerifyAgainst()
result
.onSuccess {
emit(AsyncData.Success(it))
}
.onFailure {
Timber.e(it, "Failed to get hasDevicesToVerifyAgainst, retrying in 5s...")
}
delay(5_000)
}
}
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, AsyncData.Uninitialized)
override suspend fun enableBackups(): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.test.encryption
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
@@ -34,7 +35,7 @@ class FakeEncryptionService(
override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(RecoveryState.UNKNOWN)
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Starting)
override val isLastDevice: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val hasDevicesToVerifyAgainst: MutableStateFlow<Boolean> = MutableStateFlow(true)
override val hasDevicesToVerifyAgainst: MutableStateFlow<AsyncData<Boolean>> = MutableStateFlow(AsyncData.Uninitialized)
private var waitForBackupUploadSteadyStateFlow: Flow<BackupUploadState> = flowOf()
private var recoverFailure: Exception? = null
@@ -84,7 +85,7 @@ class FakeEncryptionService(
this.isLastDevice.value = isLastDevice
}
fun emitHasDevicesToVerifyAgainst(hasDevicesToVerifyAgainst: Boolean) {
fun emitHasDevicesToVerifyAgainst(hasDevicesToVerifyAgainst: AsyncData<Boolean>) {
this.hasDevicesToVerifyAgainst.value = hasDevicesToVerifyAgainst
}