diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 3985128b21..db75acff64 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -216,7 +216,9 @@ class LoggedInFlowNode @AssistedInject constructor( data object VerifySession : NavTarget @Parcelize - data object SecureBackup : NavTarget + data class SecureBackup( + val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root + ) : NavTarget @Parcelize data object InviteList : NavTarget @@ -253,6 +255,10 @@ class LoggedInFlowNode @AssistedInject constructor( backstack.push(NavTarget.VerifySession) } + override fun onSessionConfirmRecoveryKeyClicked() { + backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey)) + } + override fun onInvitesClicked() { backstack.push(NavTarget.InviteList) } @@ -298,7 +304,7 @@ class LoggedInFlowNode @AssistedInject constructor( } override fun onSecureBackupClicked() { - backstack.push(NavTarget.SecureBackup) + backstack.push(NavTarget.SecureBackup()) } override fun onOpenRoomNotificationSettings(roomId: RoomId) { @@ -324,10 +330,24 @@ class LoggedInFlowNode @AssistedInject constructor( .build() } NavTarget.VerifySession -> { - verifySessionEntryPoint.createNode(this, buildContext) + val callback = object : VerifySessionEntryPoint.Callback { + override fun onEnterRecoveryKey() { + backstack.replace( + NavTarget.SecureBackup( + initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey + ) + ) + } + } + verifySessionEntryPoint + .nodeBuilder(this, buildContext) + .callback(callback) + .build() } - NavTarget.SecureBackup -> { - secureBackupEntryPoint.createNode(this, buildContext) + is NavTarget.SecureBackup -> { + secureBackupEntryPoint.nodeBuilder(this, buildContext) + .params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement)) + .build() } NavTarget.InviteList -> { val callback = object : InviteListEntryPoint.Callback { diff --git a/changelog.d/2421.bugfix b/changelog.d/2421.bugfix new file mode 100644 index 0000000000..e5f46b0061 --- /dev/null +++ b/changelog.d/2421.bugfix @@ -0,0 +1 @@ +Add ability to enter a recovery key to verify the session. Also fixes some refresh issues with the verification session state. diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt index a66197e733..05160dca56 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt @@ -78,7 +78,6 @@ class ShowLocationViewTest { ), onBackPressed = EnsureNeverCalled(), ) - val shareContentDescription = rule.activity.getString(CommonStrings.action_share) rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true)) } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt index ed735f6afe..34f3ca84a6 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt @@ -24,28 +24,22 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.bool.orTrue -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.EncryptionService import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import javax.inject.Inject class LogoutPresenter @Inject constructor( private val matrixClient: MatrixClient, private val encryptionService: EncryptionService, - private val featureFlagService: FeatureFlagService, ) : Presenter { @Composable override fun present(): LogoutState { @@ -54,23 +48,12 @@ class LogoutPresenter @Inject constructor( mutableStateOf(AsyncAction.Uninitialized) } - val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) - .collectAsState(initial = null) - - val backupUploadState: BackupUploadState by remember(secureStorageFlag) { - when (secureStorageFlag) { - true -> encryptionService.waitForBackupUploadSteadyState() - false -> flowOf(BackupUploadState.Done) - else -> emptyFlow() - } + val backupUploadState: BackupUploadState by remember { + encryptionService.waitForBackupUploadSteadyState() } .collectAsState(initial = BackupUploadState.Unknown) - var isLastSession by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - isLastSession = encryptionService.isLastDevice().getOrNull() ?: false - } - + val isLastDevice by encryptionService.isLastDevice.collectAsState() val backupState by encryptionService.backupStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() @@ -100,7 +83,7 @@ class LogoutPresenter @Inject constructor( } return LogoutState( - isLastSession = isLastSession, + isLastDevice = isLastDevice, backupState = backupState, doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(), recoveryState = recoveryState, diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt index 6da9df8e72..4b0121d052 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt @@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.RecoveryState data class LogoutState( - val isLastSession: Boolean, + val isLastDevice: Boolean, val backupState: BackupState, val doesBackupExistOnServer: Boolean, val recoveryState: RecoveryState, diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt index f1b11f61e9..dc563daa33 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt @@ -27,22 +27,22 @@ open class LogoutStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aLogoutState(), - aLogoutState(isLastSession = true), - aLogoutState(isLastSession = false, backupUploadState = BackupUploadState.Uploading(66, 200)), - aLogoutState(isLastSession = true, backupUploadState = BackupUploadState.Done), + aLogoutState(isLastDevice = true), + aLogoutState(isLastDevice = false, backupUploadState = BackupUploadState.Uploading(66, 200)), + aLogoutState(isLastDevice = true, backupUploadState = BackupUploadState.Done), aLogoutState(logoutAction = AsyncAction.Confirming), aLogoutState(logoutAction = AsyncAction.Loading), aLogoutState(logoutAction = AsyncAction.Failure(Exception("Failed to logout"))), aLogoutState(backupUploadState = BackupUploadState.SteadyException(SteadyStateException.Connection("No network"))), // Last session no recovery - aLogoutState(isLastSession = true, recoveryState = RecoveryState.DISABLED), + aLogoutState(isLastDevice = true, recoveryState = RecoveryState.DISABLED), // Last session no backup - aLogoutState(isLastSession = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false), + aLogoutState(isLastDevice = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false), ) } fun aLogoutState( - isLastSession: Boolean = false, + isLastDevice: Boolean = false, backupState: BackupState = BackupState.ENABLED, doesBackupExistOnServer: Boolean = true, recoveryState: RecoveryState = RecoveryState.ENABLED, @@ -50,7 +50,7 @@ fun aLogoutState( logoutAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (LogoutEvents) -> Unit = {}, ) = LogoutState( - isLastSession = isLastSession, + isLastDevice = isLastDevice, backupState = backupState, doesBackupExistOnServer = doesBackupExistOnServer, recoveryState = recoveryState, diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt index 90168c09dd..19160236ac 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt @@ -97,7 +97,7 @@ fun LogoutView( private fun title(state: LogoutState): String { return when { state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title) - state.isLastSession -> { + state.isLastDevice -> { if (state.recoveryState != RecoveryState.ENABLED) { stringResource(id = R.string.screen_signout_recovery_disabled_title) } else if (state.backupState == BackupState.UNKNOWN && state.doesBackupExistOnServer.not()) { @@ -116,7 +116,7 @@ private fun subtitle(state: LogoutState): String? { (state.backupUploadState as? BackupUploadState.SteadyException)?.exception is SteadyStateException.Connection -> stringResource(id = R.string.screen_signout_key_backup_offline_subtitle) state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_subtitle) - state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_subtitle) + state.isLastDevice -> stringResource(id = R.string.screen_signout_key_backup_disabled_subtitle) else -> null } } @@ -128,7 +128,7 @@ private fun ColumnScope.Buttons( onChangeRecoveryKeyClicked: () -> Unit, ) { val logoutAction = state.logoutAction - if (state.isLastSession) { + if (state.isLastDevice) { OutlinedButton( text = stringResource(id = CommonStrings.common_settings), modifier = Modifier.fillMaxWidth(), diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt index 8ee46b63ac..b0924e61e0 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt @@ -17,14 +17,12 @@ package io.element.android.features.logout.impl.direct import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutPresenter @@ -33,14 +31,10 @@ import io.element.android.features.logout.impl.tools.isBackingUp import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.EncryptionService import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import javax.inject.Inject @@ -48,7 +42,6 @@ import javax.inject.Inject class DefaultDirectLogoutPresenter @Inject constructor( private val matrixClient: MatrixClient, private val encryptionService: EncryptionService, - private val featureFlagService: FeatureFlagService, ) : DirectLogoutPresenter { @Composable override fun present(): DirectLogoutState { @@ -58,22 +51,12 @@ class DefaultDirectLogoutPresenter @Inject constructor( mutableStateOf(AsyncAction.Uninitialized) } - val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) - .collectAsState(initial = null) - - val backupUploadState: BackupUploadState by remember(secureStorageFlag) { - when (secureStorageFlag) { - true -> encryptionService.waitForBackupUploadSteadyState() - false -> flowOf(BackupUploadState.Done) - else -> emptyFlow() - } + val backupUploadState: BackupUploadState by remember { + encryptionService.waitForBackupUploadSteadyState() } .collectAsState(initial = BackupUploadState.Unknown) - var isLastSession by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - isLastSession = encryptionService.isLastDevice().getOrNull() ?: false - } + val isLastDevice by encryptionService.isLastDevice.collectAsState() fun handleEvents(event: DirectLogoutEvents) { when (event) { @@ -91,7 +74,7 @@ class DefaultDirectLogoutPresenter @Inject constructor( } return DirectLogoutState( - canDoDirectSignOut = !isLastSession && + canDoDirectSignOut = !isLastDevice && !backupUploadState.isBackingUp(), logoutAction = logoutAction.value, eventSink = ::handleEvents diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt index 83b7799c3a..9531ea8886 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt @@ -22,8 +22,6 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState @@ -50,7 +48,7 @@ class LogoutPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - assertThat(initialState.isLastSession).isFalse() + assertThat(initialState.isLastDevice).isFalse() assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) assertThat(initialState.doesBackupExistOnServer).isTrue() assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN) @@ -63,15 +61,15 @@ class LogoutPresenterTest { fun `present - initial state - last session`() = runTest { val presenter = createLogoutPresenter( encryptionService = FakeEncryptionService().apply { - givenIsLastDevice(true) + emitIsLastDevice(true) } ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(3) + skipItems(2) val initialState = awaitItem() - assertThat(initialState.isLastSession).isTrue() + assertThat(initialState.isLastDevice).isTrue() assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } @@ -96,10 +94,9 @@ class LogoutPresenterTest { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.isLastSession).isFalse() + assertThat(initialState.isLastDevice).isFalse() assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) - skipItems(1) val waitingState = awaitItem() assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting) skipItems(1) @@ -209,6 +206,5 @@ class LogoutPresenterTest { ): LogoutPresenter = LogoutPresenter( matrixClient = matrixClient, encryptionService = encryptionService, - featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), ) } diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt index 697da3cad5..78cc65b117 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt @@ -153,7 +153,7 @@ class LogoutViewTest { rule.setContent { LogoutView( aLogoutState( - isLastSession = true, + isLastDevice = true, eventSink = eventsRecorder ), onChangeRecoveryKeyClicked = callback, diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt index 50a1e381e8..bf3df93731 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt @@ -23,8 +23,6 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -57,14 +55,13 @@ class DefaultDirectLogoutPresenterTest { fun `present - initial state - last session`() = runTest { val presenter = createDefaultDirectLogoutPresenter( encryptionService = FakeEncryptionService().apply { - givenIsLastDevice(true) + emitIsLastDevice(true) } ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.canDoDirectSignOut).isFalse() assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } @@ -84,8 +81,8 @@ class DefaultDirectLogoutPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(2) - val initialState = awaitItem() + skipItems(1) + val initialState = awaitFirstItem() assertThat(initialState.canDoDirectSignOut).isFalse() assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } @@ -180,7 +177,6 @@ class DefaultDirectLogoutPresenterTest { } private suspend fun ReceiveTurbine.awaitFirstItem(): T { - skipItems(1) return awaitItem() } @@ -190,6 +186,5 @@ class DefaultDirectLogoutPresenterTest { ): DefaultDirectLogoutPresenter = DefaultDirectLogoutPresenter( matrixClient = matrixClient, encryptionService = encryptionService, - featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 067bd2a26f..434985e068 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -79,9 +79,6 @@ class PreferencesRootPresenter @Inject constructor( val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() - val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) - .collectAsState(initial = null) - val accountManagementUrl: MutableState = remember { mutableStateOf(null) } @@ -101,7 +98,7 @@ class PreferencesRootPresenter @Inject constructor( version = versionFormatter.get(), deviceId = matrixClient.deviceId, showCompleteVerification = showCompleteVerification, - showSecureBackup = !showCompleteVerification && secureStorageFlag == true, + showSecureBackup = !showCompleteVerification, showSecureBackupBadge = showSecureBackupIndicator, accountManagementUrl = accountManagementUrl.value, devicesManagementUrl = devicesManagementUrl.value, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 2dea13eac3..f9789639f3 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -65,7 +65,6 @@ class PreferencesRootPresenterTest { indicatorService = DefaultIndicatorService( sessionVerificationService = sessionVerificationService, encryptionService = FakeEncryptionService(), - featureFlagService = FakeFeatureFlagService(), ), directLogoutPresenter = object : DirectLogoutPresenter { @Composable diff --git a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt index 1b357c794c..0404ce18fc 100644 --- a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt +++ b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt @@ -34,6 +34,7 @@ interface RoomListEntryPoint : FeatureEntryPoint { fun onCreateRoomClicked() fun onSettingsClicked() fun onSessionVerificationClicked() + fun onSessionConfirmRecoveryKeyClicked() fun onInvitesClicked() fun onRoomSettingsClicked(roomId: RoomId) fun onReportBugClicked() diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt index f505020151..5dec53e158 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt @@ -68,6 +68,10 @@ class RoomListNode @AssistedInject constructor( plugins().forEach { it.onSessionVerificationClicked() } } + private fun onSessionConfirmRecoveryKeyClicked() { + plugins().forEach { it.onSessionConfirmRecoveryKeyClicked() } + } + private fun onInvitesClicked() { plugins().forEach { it.onInvitesClicked() } } @@ -97,6 +101,7 @@ class RoomListNode @AssistedInject constructor( onSettingsClicked = this::onOpenSettings, onCreateRoomClicked = this::onCreateRoomClicked, onVerifyClicked = this::onSessionVerificationClicked, + onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked, onInvitesClicked = this::onInvitesClicked, onRoomSettingsClicked = this::onRoomSettingsClicked, onMenuActionClicked = { onMenuActionClicked(activity, it) }, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 3349eacc55..69a1b4cf9a 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -51,6 +51,8 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId 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.sync.SyncService +import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.getCurrentUser @@ -73,7 +75,6 @@ private const val EXTENDED_RANGE_SIZE = 40 class RoomListPresenter @Inject constructor( private val client: MatrixClient, - private val sessionVerificationService: SessionVerificationService, private val networkMonitor: NetworkMonitor, private val snackbarDispatcher: SnackbarDispatcher, private val inviteStateDataSource: InviteStateDataSource, @@ -87,6 +88,8 @@ class RoomListPresenter @Inject constructor( private val analyticsService: AnalyticsService, ) : Presenter { private val encryptionService: EncryptionService = client.encryptionService() + private val sessionVerificationService: SessionVerificationService = client.sessionVerificationService() + private val syncService: SyncService = client.syncService() @Composable override fun present(): RoomListState { @@ -108,22 +111,24 @@ class RoomListPresenter @Inject constructor( val isMigrating = migrationScreenPresenter.present().isMigrating - // Session verification status (unknown, not verified, verified) + var securityBannerDismissed by rememberSaveable { mutableStateOf(false) } val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false) - var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) } - // We combine both values to only display the prompt if the session is not verified and it wasn't dismissed - val displayVerificationPrompt by remember { - derivedStateOf { canVerifySession && !verificationPromptDismissed } - } + val isLastDevice by encryptionService.isLastDevice.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() - val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) - .collectAsState(initial = null) - var recoveryKeyPromptDismissed by rememberSaveable { mutableStateOf(false) } - val displayRecoveryKeyPrompt by remember { + val syncState by syncService.syncState.collectAsState() + val securityBannerState by remember { derivedStateOf { - secureStorageFlag == true && + when { + securityBannerDismissed -> SecurityBannerState.None + canVerifySession -> if (isLastDevice) { + SecurityBannerState.RecoveryKeyConfirmation + } else { + SecurityBannerState.SessionVerification + } recoveryState == RecoveryState.INCOMPLETE && - !recoveryKeyPromptDismissed + syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation + else -> SecurityBannerState.None + } } } @@ -135,8 +140,8 @@ class RoomListPresenter @Inject constructor( fun handleEvents(event: RoomListEvents) { when (event) { is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) - RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true - RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true + RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true + RoomListEvents.DismissRecoveryKeyPrompt -> securityBannerDismissed = true RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility) is RoomListEvents.ShowContextMenu -> { coroutineScope.showContextMenu(event, contextMenu) @@ -157,8 +162,7 @@ class RoomListPresenter @Inject constructor( matrixUser = matrixUser.value, showAvatarIndicator = showAvatarIndicator, roomList = roomList, - displayVerificationPrompt = displayVerificationPrompt, - displayRecoveryKeyPrompt = displayRecoveryKeyPrompt, + securityBannerState = securityBannerState, snackbarMessage = snackbarMessage, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, invitesState = inviteStateDataSource.inviteState(), diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index b89cb2a25c..076f6bb10e 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -31,8 +31,7 @@ data class RoomListState( val matrixUser: MatrixUser?, val showAvatarIndicator: Boolean, val roomList: AsyncData>, - val displayVerificationPrompt: Boolean, - val displayRecoveryKeyPrompt: Boolean, + val securityBannerState: SecurityBannerState, val hasNetworkConnection: Boolean, val snackbarMessage: SnackbarMessage?, val invitesState: InvitesState, @@ -62,3 +61,9 @@ enum class InvitesState { SeenInvites, NewInvites, } + +enum class SecurityBannerState { + None, + SessionVerification, + RecoveryKeyConfirmation, +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index ed1a948400..89597fea06 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -17,10 +17,12 @@ package io.element.android.features.roomlist.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.aLeaveRoomState import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary +import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.features.roomlist.impl.search.aRoomListSearchState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData @@ -37,35 +39,47 @@ open class RoomListStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aRoomListState(), - aRoomListState().copy(displayVerificationPrompt = true), - aRoomListState().copy(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)), - aRoomListState().copy(hasNetworkConnection = false), - aRoomListState().copy(invitesState = InvitesState.SeenInvites), - aRoomListState().copy(invitesState = InvitesState.NewInvites), - aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")), - aRoomListState().copy(contextMenu = aContextMenuShown(isFavorite = true)), - aRoomListState().copy(displayRecoveryKeyPrompt = true), - aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())), - aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())), - aRoomListState().copy(matrixUser = null, displayMigrationStatus = true), - aRoomListState().copy(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), + aRoomListState(securityBannerState = SecurityBannerState.SessionVerification), + aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)), + aRoomListState(hasNetworkConnection = false), + aRoomListState(invitesState = InvitesState.SeenInvites), + aRoomListState(invitesState = InvitesState.NewInvites), + aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")), + aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)), + aRoomListState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), + aRoomListState(roomList = AsyncData.Success(persistentListOf())), + aRoomListState(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())), + aRoomListState(matrixUser = null, displayMigrationStatus = true), + aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), ) } -internal fun aRoomListState() = RoomListState( - matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), - showAvatarIndicator = false, - roomList = AsyncData.Success(aRoomListRoomSummaryList()), - hasNetworkConnection = true, - snackbarMessage = null, - displayVerificationPrompt = false, - displayRecoveryKeyPrompt = false, - invitesState = InvitesState.NoInvites, - contextMenu = RoomListState.ContextMenu.Hidden, - leaveRoomState = aLeaveRoomState(), - searchState = aRoomListSearchState(), - displayMigrationStatus = false, - eventSink = {} +internal fun aRoomListState( + matrixUser: MatrixUser? = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), + showAvatarIndicator: Boolean = false, + roomList: AsyncData> = AsyncData.Success(aRoomListRoomSummaryList()), + hasNetworkConnection: Boolean = true, + snackbarMessage: SnackbarMessage? = null, + securityBannerState: SecurityBannerState = SecurityBannerState.None, + invitesState: InvitesState = InvitesState.NoInvites, + contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden, + leaveRoomState: LeaveRoomState = aLeaveRoomState(), + searchState: RoomListSearchState = aRoomListSearchState(), + displayMigrationStatus: Boolean = false, + eventSink: (RoomListEvents) -> Unit = {} +) = RoomListState( + matrixUser = matrixUser, + showAvatarIndicator = showAvatarIndicator, + roomList = roomList, + hasNetworkConnection = hasNetworkConnection, + snackbarMessage = snackbarMessage, + securityBannerState = securityBannerState, + invitesState = invitesState, + contextMenu = contextMenu, + leaveRoomState = leaveRoomState, + searchState = searchState, + displayMigrationStatus = displayMigrationStatus, + eventSink = eventSink, ) internal fun aRoomListRoomSummaryList(): ImmutableList { diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index ff956b0ec3..290ea0f6a5 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -78,6 +78,7 @@ fun RoomListView( onRoomClicked: (RoomId) -> Unit, onSettingsClicked: () -> Unit, onVerifyClicked: () -> Unit, + onConfirmRecoveryKeyClicked: () -> Unit, onCreateRoomClicked: () -> Unit, onInvitesClicked: () -> Unit, onRoomSettingsClicked: (roomId: RoomId) -> Unit, @@ -109,6 +110,7 @@ fun RoomListView( modifier = Modifier.padding(top = topPadding), state = state, onVerifyClicked = onVerifyClicked, + onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onRoomClicked = onRoomClicked, onRoomLongClicked = { onRoomLongClicked(it) }, onOpenSettings = onSettingsClicked, @@ -166,6 +168,7 @@ private fun EmptyRoomListView( private fun RoomListContent( state: RoomListState, onVerifyClicked: () -> Unit, + onConfirmRecoveryKeyClicked: () -> Unit, onRoomClicked: (RoomId) -> Unit, onRoomLongClicked: (RoomListRoomSummary) -> Unit, onOpenSettings: () -> Unit, @@ -227,7 +230,7 @@ private fun RoomListContent( ) { when { state.displayEmptyState -> Unit - state.displayVerificationPrompt -> { + state.securityBannerState == SecurityBannerState.SessionVerification -> { item { RequestVerificationHeader( onVerifyClicked = onVerifyClicked, @@ -235,10 +238,10 @@ private fun RoomListContent( ) } } - state.displayRecoveryKeyPrompt -> { + state.securityBannerState == SecurityBannerState.RecoveryKeyConfirmation -> { item { ConfirmRecoveryKeyBanner( - onContinueClicked = onOpenSettings, + onContinueClicked = onConfirmRecoveryKeyClicked, onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) } ) } @@ -302,6 +305,7 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class) onRoomClicked = {}, onSettingsClicked = {}, onVerifyClicked = {}, + onConfirmRecoveryKeyClicked = {}, onCreateRoomClicked = {}, onInvitesClicked = {}, onRoomSettingsClicked = {}, diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml index d9980835f4..edfa414381 100644 --- a/features/roomlist/impl/src/main/res/values/localazy.xml +++ b/features/roomlist/impl/src/main/res/values/localazy.xml @@ -1,7 +1,7 @@ - "Your chat backup is currently out of sync. You need to confirm your recovery key to maintain access to your chat backup." - "Confirm your recovery key" + "Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup." + "Enter your recovery key" "This is a one time process, thanks for waiting." "Setting up your account." "Create a new conversation or room" diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 57afadcc64..7fe08975e1 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -45,7 +45,6 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore import io.element.android.libraries.indicator.impl.DefaultIndicatorService @@ -54,9 +53,8 @@ import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.timeline.ReceiptType -import io.element.android.libraries.matrix.api.verification.SessionVerificationService -import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -70,6 +68,7 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService @@ -105,7 +104,7 @@ class RoomListPresenterTests { assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID) assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME) assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL) - assertThat(withUserState.showAvatarIndicator).isFalse() + assertThat(withUserState.showAvatarIndicator).isTrue() scope.cancel() } } @@ -114,13 +113,13 @@ class RoomListPresenterTests { fun `present - show avatar indicator`() = runTest { val scope = CoroutineScope(coroutineContext + SupervisorJob()) val encryptionService = FakeEncryptionService() + val sessionVerificationService = FakeSessionVerificationService() val matrixClient = FakeMatrixClient( encryptionService = encryptionService, + sessionVerificationService = sessionVerificationService, ) - val sessionVerificationService = FakeSessionVerificationService() val presenter = createRoomListPresenter( client = matrixClient, - sessionVerificationService = sessionVerificationService, coroutineScope = scope ) moleculeFlow(RecompositionMode.Immediate) { @@ -128,12 +127,12 @@ class RoomListPresenterTests { }.test { skipItems(1) val initialState = awaitItem() - assertThat(initialState.showAvatarIndicator).isFalse() + assertThat(initialState.showAvatarIndicator).isTrue() sessionVerificationService.givenCanVerifySession(false) - assertThat(awaitItem().showAvatarIndicator).isFalse() - encryptionService.emitBackupState(BackupState.UNKNOWN) + assertThat(awaitItem().showAvatarIndicator).isTrue() + encryptionService.emitBackupState(BackupState.ENABLED) val finalState = awaitItem() - assertThat(finalState.showAvatarIndicator).isTrue() + assertThat(finalState.showAvatarIndicator).isFalse() scope.cancel() } } @@ -238,28 +237,42 @@ class RoomListPresenterTests { } @Test - fun `present - handle DismissRequestVerificationPrompt`() = runTest { - val roomListService = FakeRoomListService() - val matrixClient = FakeMatrixClient( - roomListService = roomListService, - ) + fun `present - handle RecoveryKeyConfirmation last session`() = runTest { + val scope = CoroutineScope(context = coroutineContext + SupervisorJob()) + val presenter = createRoomListPresenter( + coroutineScope = scope, + client = FakeMatrixClient( + encryptionService = FakeEncryptionService().apply { + emitIsLastDevice(true) + } + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val eventSink = awaitItem().eventSink + // For the last session, the state is not SessionVerification, but RecoveryKeyConfirmation + assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation) + eventSink(RoomListEvents.DismissRequestVerificationPrompt) + assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.None) + scope.cancel() + } + } + + @Test + fun `present - handle DismissRequestVerificationPrompt`() = runTest { val scope = CoroutineScope(context = coroutineContext + SupervisorJob()) val presenter = createRoomListPresenter( - client = matrixClient, - sessionVerificationService = FakeSessionVerificationService().apply { - givenIsReady(true) - givenVerifiedStatus(SessionVerifiedStatus.NotVerified) - }, coroutineScope = scope, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val eventSink = awaitItem().eventSink - assertThat(awaitItem().displayVerificationPrompt).isTrue() - + assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification) eventSink(RoomListEvents.DismissRequestVerificationPrompt) - assertThat(awaitItem().displayVerificationPrompt).isFalse() + assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.None) scope.cancel() } } @@ -269,6 +282,10 @@ class RoomListPresenterTests { val encryptionService = FakeEncryptionService() val matrixClient = FakeMatrixClient( encryptionService = encryptionService, + sessionVerificationService = FakeSessionVerificationService().apply { + givenCanVerifySession(false) + }, + syncService = FakeSyncService(initialState = SyncState.Running) ) val scope = CoroutineScope(context = coroutineContext + SupervisorJob()) val presenter = createRoomListPresenter( @@ -280,13 +297,13 @@ class RoomListPresenterTests { }.test { skipItems(1) val initialState = awaitItem() - assertThat(initialState.displayRecoveryKeyPrompt).isFalse() + assertThat(initialState.securityBannerState).isEqualTo(SecurityBannerState.None) encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) val nextState = awaitItem() - assertThat(nextState.displayRecoveryKeyPrompt).isTrue() + assertThat(nextState.securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation) nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) val finalState = awaitItem() - assertThat(finalState.displayRecoveryKeyPrompt).isFalse() + assertThat(finalState.securityBannerState).isEqualTo(SecurityBannerState.None) scope.cancel() } } @@ -579,7 +596,6 @@ class RoomListPresenterTests { private fun TestScope.createRoomListPresenter( client: MatrixClient = FakeMatrixClient(), - sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(), networkMonitor: NetworkMonitor = FakeNetworkMonitor(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), inviteStateDataSource: InviteStateDataSource = FakeInviteDataSource(), @@ -589,7 +605,7 @@ class RoomListPresenterTests { }, roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(), sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), - featureFlagService: FeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), coroutineScope: CoroutineScope, migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter( matrixClient = client, @@ -599,7 +615,6 @@ class RoomListPresenterTests { searchPresenter: Presenter = Presenter { aRoomListSearchState() }, ) = RoomListPresenter( client = client, - sessionVerificationService = sessionVerificationService, networkMonitor = networkMonitor, snackbarDispatcher = snackbarDispatcher, inviteStateDataSource = inviteStateDataSource, @@ -616,9 +631,8 @@ class RoomListPresenterTests { ), featureFlagService = featureFlagService, indicatorService = DefaultIndicatorService( - sessionVerificationService = sessionVerificationService, + sessionVerificationService = client.sessionVerificationService(), encryptionService = client.encryptionService(), - featureFlagService = featureFlagService, ), migrationScreenPresenter = migrationScreenPresenter, searchPresenter = searchPresenter, diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt new file mode 100644 index 0000000000..347ff65687 --- /dev/null +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomlist.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.roomlist.impl.components.RoomListMenuAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomListViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on close verification banner emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomListView( + state = aRoomListState( + securityBannerState = SecurityBannerState.SessionVerification, + eventSink = eventsRecorder, + ) + ) + val close = rule.activity.getString(CommonStrings.action_close) + rule.onNodeWithContentDescription(close).performClick() + eventsRecorder.assertSingle(RoomListEvents.DismissRequestVerificationPrompt) + } + + @Test + fun `clicking on continue verification banner invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setRoomListView( + state = aRoomListState( + securityBannerState = SecurityBannerState.SessionVerification, + eventSink = eventsRecorder, + ), + onVerifyClicked = callback, + ) + rule.clickOn(CommonStrings.action_continue) + } + } + + @Test + fun `clicking on close recovery key banner emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomListView( + state = aRoomListState( + securityBannerState = SecurityBannerState.RecoveryKeyConfirmation, + eventSink = eventsRecorder, + ) + ) + val close = rule.activity.getString(CommonStrings.action_close) + rule.onNodeWithContentDescription(close).performClick() + eventsRecorder.assertSingle(RoomListEvents.DismissRecoveryKeyPrompt) + } + + @Test + fun `clicking on continue recovery key banner invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setRoomListView( + state = aRoomListState( + securityBannerState = SecurityBannerState.RecoveryKeyConfirmation, + eventSink = eventsRecorder, + ), + onConfirmRecoveryKeyClicked = callback, + ) + rule.clickOn(CommonStrings.action_continue) + } + } + + @Test + fun `clicking on start chat when the session has no room invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setRoomListView( + state = aRoomListState( + eventSink = eventsRecorder, + roomList = AsyncData.Success(persistentListOf()), + ), + onCreateRoomClicked = callback, + ) + rule.clickOn(CommonStrings.action_start_chat) + } + } + + @Test + fun `clicking on a room invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aRoomListState( + eventSink = eventsRecorder, + ) + val room0 = state.roomList.dataOrNull()!!.first() + ensureCalledOnceWithParam(room0.roomId) { callback -> + rule.setRoomListView( + state = state, + onRoomClicked = callback, + ) + rule.onNodeWithText(room0.lastMessage!!.toString()).performClick() + } + } + + @Test + fun `long clicking on a room emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aRoomListState( + eventSink = eventsRecorder, + ) + val room0 = state.roomList.dataOrNull()!!.first() + rule.setRoomListView( + state = state, + ) + rule.onNodeWithText(room0.lastMessage!!.toString()).performTouchInput { longClick() } + eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0)) + } + + @Test + fun `clicking on a room setting invokes the expected callback and emits expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aRoomListState( + contextMenu = aContextMenuShown(), + eventSink = eventsRecorder, + ) + val room0 = (state.contextMenu as RoomListState.ContextMenu.Shown).roomId + ensureCalledOnceWithParam(room0) { callback -> + rule.setRoomListView( + state = state, + onRoomSettingsClicked = callback, + ) + rule.clickOn(CommonStrings.common_settings) + } + eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) + } + + @Test + fun `clicking on invites invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + val state = aRoomListState( + invitesState = InvitesState.NewInvites, + eventSink = eventsRecorder, + ) + ensureCalledOnce { callback -> + rule.setRoomListView( + state = state, + onInvitesClicked = callback, + ) + rule.clickOn(CommonStrings.action_invites_list) + } + } +} + +private fun AndroidComposeTestRule.setRoomListView( + state: RoomListState, + onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onSettingsClicked: () -> Unit = EnsureNeverCalled(), + onVerifyClicked: () -> Unit = EnsureNeverCalled(), + onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(), + onCreateRoomClicked: () -> Unit = EnsureNeverCalled(), + onInvitesClicked: () -> Unit = EnsureNeverCalled(), + onRoomSettingsClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onMenuActionClicked: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + RoomListView( + state = state, + onRoomClicked = onRoomClicked, + onSettingsClicked = onSettingsClicked, + onVerifyClicked = onVerifyClicked, + onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, + onCreateRoomClicked = onCreateRoomClicked, + onInvitesClicked = onInvitesClicked, + onRoomSettingsClicked = onRoomSettingsClicked, + onMenuActionClicked = onMenuActionClicked, + ) + } +} diff --git a/features/securebackup/api/build.gradle.kts b/features/securebackup/api/build.gradle.kts index c9117d1d39..015cc600f2 100644 --- a/features/securebackup/api/build.gradle.kts +++ b/features/securebackup/api/build.gradle.kts @@ -16,6 +16,7 @@ plugins { id("io.element.android-library") + id("kotlin-parcelize") } android { diff --git a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt index 8824fdf84b..1fee6418a9 100644 --- a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt +++ b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt @@ -16,6 +16,28 @@ package io.element.android.features.securebackup.api -import io.element.android.libraries.architecture.SimpleFeatureEntryPoint +import android.os.Parcelable +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import kotlinx.parcelize.Parcelize -interface SecureBackupEntryPoint : SimpleFeatureEntryPoint +interface SecureBackupEntryPoint : FeatureEntryPoint { + sealed interface InitialTarget : Parcelable { + @Parcelize + data object Root : InitialTarget + + @Parcelize + data object EnterRecoveryKey : InitialTarget + } + + data class Params(val initialElement: InitialTarget) : NodeInputs + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun build(): Node + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt index a870255a9a..e3d5fde961 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt @@ -18,6 +18,7 @@ package io.element.android.features.securebackup.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.libraries.architecture.createNode @@ -26,7 +27,18 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultSecureBackupEntryPoint @Inject constructor() : SecureBackupEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext): Node { - return parentNode.createNode(buildContext) + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SecureBackupEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : SecureBackupEntryPoint.NodeBuilder { + override fun params(params: SecureBackupEntryPoint.Params): SecureBackupEntryPoint.NodeBuilder { + plugins += params + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt index 62eecd09c2..172c6672e5 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt @@ -27,6 +27,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode @@ -44,7 +45,10 @@ class SecureBackupFlowNode @AssistedInject constructor( @Assisted plugins: List, ) : BaseFlowNode( backstack = BackStack( - initialElement = NavTarget.Root, + initialElement = when (plugins.filterIsInstance(SecureBackupEntryPoint.Params::class.java).first().initialElement) { + SecureBackupEntryPoint.InitialTarget.Root -> NavTarget.Root + SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey -> NavTarget.EnterRecoveryKey + }, savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, diff --git a/features/securebackup/impl/src/main/res/values/localazy.xml b/features/securebackup/impl/src/main/res/values/localazy.xml index 613ff72719..f05725a075 100644 --- a/features/securebackup/impl/src/main/res/values/localazy.xml +++ b/features/securebackup/impl/src/main/res/values/localazy.xml @@ -5,7 +5,7 @@ "Backup ensures that you don\'t lose your message history. %1$s." "Backup" "Change recovery key" - "Confirm recovery key" + "Enter recovery key" "Your chat backup is currently out of sync." "Set up recovery" "Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere." @@ -27,7 +27,7 @@ "Enter the 48 character code." "Enter…" "Recovery key confirmed" - "Confirm your recovery key" + "Enter your recovery key" "Copied recovery key" "Generating…" "Save recovery key" diff --git a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt index 933ca1994f..5eb1bc8daa 100644 --- a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt +++ b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt @@ -16,6 +16,20 @@ package io.element.android.features.verifysession.api -import io.element.android.libraries.architecture.SimpleFeatureEntryPoint +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint -interface VerifySessionEntryPoint : SimpleFeatureEntryPoint +interface VerifySessionEntryPoint : FeatureEntryPoint { + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onEnterRecoveryKey() + } +} diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts index a85d714d1e..3f62c6898e 100644 --- a/features/verifysession/impl/build.gradle.kts +++ b/features/verifysession/impl/build.gradle.kts @@ -22,6 +22,11 @@ plugins { android { namespace = "io.element.android.features.verifysession.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } anvil { @@ -44,10 +49,13 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) ksp(libs.showkase.processor) } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt index da8c22e756..b514742a87 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt @@ -18,6 +18,7 @@ package io.element.android.features.verifysession.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.verifysession.api.VerifySessionEntryPoint import io.element.android.libraries.architecture.createNode @@ -26,7 +27,18 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultVerifySessionEntryPoint @Inject constructor() : VerifySessionEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext): Node { - return parentNode.createNode(buildContext) + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): VerifySessionEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : VerifySessionEntryPoint.NodeBuilder { + override fun callback(callback: VerifySessionEntryPoint.Callback): VerifySessionEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } } } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt index 7612f9292c..3aa75f0c26 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt @@ -21,9 +21,11 @@ import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.verifysession.api.VerifySessionEntryPoint import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @@ -32,12 +34,19 @@ class VerifySelfSessionNode @AssistedInject constructor( @Assisted plugins: List, private val presenter: VerifySelfSessionPresenter, ) : Node(buildContext, plugins = plugins) { + private fun onEnterRecoveryKey() { + plugins().forEach { + it.onEnterRecoveryKey() + } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() VerifySelfSessionView( state = state, modifier = modifier, + onEnterRecoveryKey = { onEnterRecoveryKey() }, goBack = { navigateUp() } ) } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt index 4d6889f716..2a7740d8d0 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt @@ -20,12 +20,15 @@ package io.element.android.features.verifysession.impl import androidx.compose.runtime.Composable 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 import com.freeletics.flowredux.compose.rememberStateAndDispatch import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +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.SessionVerificationService import io.element.android.libraries.matrix.api.verification.VerificationFlowState import kotlinx.coroutines.CoroutineScope @@ -38,6 +41,7 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionStateMach class VerifySelfSessionPresenter @Inject constructor( private val sessionVerificationService: SessionVerificationService, + private val encryptionService: EncryptionService, private val stateMachine: VerifySelfSessionStateMachine, ) : Presenter { @Composable @@ -46,9 +50,14 @@ class VerifySelfSessionPresenter @Inject constructor( // Force reset, just in case the service was left in a broken state sessionVerificationService.reset() } + val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val stateAndDispatch = stateMachine.rememberStateAndDispatch() val verificationFlowStep by remember { - derivedStateOf { stateAndDispatch.state.value.toVerificationStep() } + derivedStateOf { + stateAndDispatch.state.value.toVerificationStep( + canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE + ) + } } // Start this after observing state machine LaunchedEffect(Unit) { @@ -71,10 +80,12 @@ class VerifySelfSessionPresenter @Inject constructor( ) } - private fun StateMachineState?.toVerificationStep(): VerifySelfSessionState.VerificationStep = + private fun StateMachineState?.toVerificationStep( + canEnterRecoveryKey: Boolean + ): VerifySelfSessionState.VerificationStep = when (val machineState = this) { StateMachineState.Initial, null -> { - VerifySelfSessionState.VerificationStep.Initial + VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = canEnterRecoveryKey) } StateMachineState.RequestingVerification, StateMachineState.StartingSasVerification, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt index 1273367a71..fa3cb68adf 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt @@ -28,7 +28,7 @@ data class VerifySelfSessionState( ) { @Stable sealed interface VerificationStep { - data object Initial : VerificationStep + data class Initial(val canEnterRecoveryKey: Boolean) : VerificationStep data object Canceled : VerificationStep data object AwaitingOtherDeviceResponse : VerificationStep data object Ready : VerificationStep diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt index 81f25866bd..59d42f11cd 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt @@ -25,29 +25,32 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider get() = sequenceOf( aVerifySelfSessionState(), - aVerifySelfSessionState().copy( + aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse ), - aVerifySelfSessionState().copy( + aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized) ), - aVerifySelfSessionState().copy( + aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading()) ), - aVerifySelfSessionState().copy( + aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled ), - aVerifySelfSessionState().copy( + aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready ), - aVerifySelfSessionState().copy( + aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized) ), + aVerifySelfSessionState( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true) + ), // Add other state here ) } -private fun aEmojisSessionVerificationData( +internal fun aEmojisSessionVerificationData( emojiList: List = aVerificationEmojiList(), ): SessionVerificationData { return SessionVerificationData.Emojis(emojiList) @@ -59,9 +62,12 @@ private fun aDecimalsSessionVerificationData( return SessionVerificationData.Decimals(decimals) } -private fun aVerifySelfSessionState() = VerifySelfSessionState( - verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial, - eventSink = {}, +internal fun aVerifySelfSessionState( + verificationFlowStep: VerifySelfSessionState.VerificationStep = VerifySelfSessionState.VerificationStep.Initial(false), + eventSink: (VerifySelfSessionViewEvents) -> Unit = {}, +) = VerifySelfSessionState( + verificationFlowStep = verificationFlowStep, + eventSink = eventSink, ) private fun aVerificationEmojiList() = listOf( diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt index e71df4c6d1..69fdfc5dfc 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt @@ -61,8 +61,9 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver @Composable fun VerifySelfSessionView( state: VerifySelfSessionState, - modifier: Modifier = Modifier, + onEnterRecoveryKey: () -> Unit, goBack: () -> Unit, + modifier: Modifier = Modifier, ) { fun goBackAndCancelIfNeeded() { state.eventSink(VerifySelfSessionViewEvents.CancelAndClose) @@ -85,7 +86,11 @@ fun VerifySelfSessionView( }, footer = { if (buttonsVisible) { - BottomMenu(screenState = state, goBack = ::goBackAndCancelIfNeeded) + BottomMenu( + screenState = state, + goBack = ::goBackAndCancelIfNeeded, + onEnterRecoveryKey = onEnterRecoveryKey + ) } } ) { @@ -96,13 +101,13 @@ fun VerifySelfSessionView( @Composable private fun HeaderContent(verificationFlowStep: FlowStep) { val iconResourceId = when (verificationFlowStep) { - FlowStep.Initial -> R.drawable.ic_verification_devices + is FlowStep.Initial -> R.drawable.ic_verification_devices FlowStep.Canceled -> R.drawable.ic_verification_warning FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji } val titleTextId = when (verificationFlowStep) { - FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_title + is FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_title FlowStep.Canceled -> CommonStrings.common_verification_cancelled FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_title FlowStep.Ready, @@ -113,7 +118,7 @@ private fun HeaderContent(verificationFlowStep: FlowStep) { } } val subtitleTextId = when (verificationFlowStep) { - FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_subtitle + is FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_subtitle FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_subtitle FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle @@ -136,7 +141,7 @@ private fun HeaderContent(verificationFlowStep: FlowStep) { private fun Content(flowState: FlowStep) { Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) { when (flowState) { - FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit + is FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting() is FlowStep.Verifying -> ContentVerifying(flowState) } @@ -203,13 +208,17 @@ private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifie } @Composable -private fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit) { +private fun BottomMenu( + screenState: VerifySelfSessionState, + onEnterRecoveryKey: () -> Unit, + goBack: () -> Unit, +) { val verificationViewState = screenState.verificationFlowStep val eventSink = screenState.eventSink val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is AsyncData.Loading val positiveButtonTitle = when (verificationViewState) { - FlowStep.Initial -> R.string.screen_session_verification_positive_button_initial + is FlowStep.Initial -> R.string.screen_session_verification_positive_button_initial FlowStep.Canceled -> R.string.screen_session_verification_positive_button_canceled is FlowStep.Verifying -> { if (isVerifying) { @@ -222,7 +231,7 @@ private fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit) else -> null } val negativeButtonTitle = when (verificationViewState) { - FlowStep.Initial -> CommonStrings.action_cancel + is FlowStep.Initial -> CommonStrings.action_cancel FlowStep.Canceled -> CommonStrings.action_cancel is FlowStep.Verifying -> R.string.screen_session_verification_they_dont_match else -> null @@ -230,7 +239,7 @@ private fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit) val negativeButtonEnabled = !isVerifying val positiveButtonEvent = when (verificationViewState) { - FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification + is FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification FlowStep.Ready -> VerifySelfSessionViewEvents.StartSasVerification is FlowStep.Verifying -> if (!isVerifying) VerifySelfSessionViewEvents.ConfirmVerification else null FlowStep.Canceled -> VerifySelfSessionViewEvents.Restart @@ -263,6 +272,17 @@ private fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit) enabled = negativeButtonEnabled, ) } + if (verificationViewState is FlowStep.Initial && verificationViewState.canEnterRecoveryKey) { + Text( + text = stringResource(id = CommonStrings.common_or), + color = ElementTheme.colors.textSecondary, + ) + TextButton( + text = stringResource(R.string.screen_session_verification_enter_recovery_key), + modifier = Modifier.fillMaxWidth(), + onClick = onEnterRecoveryKey, + ) + } } } @@ -271,6 +291,7 @@ private fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit) internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = ElementPreview { VerifySelfSessionView( state = state, + onEnterRecoveryKey = {}, goBack = {}, ) } diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt index 240a4f0606..ad128b450d 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt @@ -23,9 +23,13 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep import io.element.android.libraries.architecture.AsyncData +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.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationFlowState +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 kotlinx.coroutines.ExperimentalCoroutinesApi @@ -44,7 +48,21 @@ class VerifySelfSessionPresenterTests { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) + } + } + + @Test + fun `present - Initial state is received, can use recovery key`() = runTest { + val presenter = createVerifySelfSessionPresenter( + encryptionService = FakeEncryptionService().apply { + emitRecoveryState(RecoveryState.INCOMPLETE) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true)) } } @@ -67,7 +85,7 @@ class VerifySelfSessionPresenterTests { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) val eventSink = initialState.eventSink eventSink(VerifySelfSessionViewEvents.StartSasVerification) // Await for other device response: @@ -86,7 +104,7 @@ class VerifySelfSessionPresenterTests { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) val eventSink = initialState.eventSink eventSink(VerifySelfSessionViewEvents.CancelAndClose) expectNoEvents() @@ -203,7 +221,7 @@ class VerifySelfSessionPresenterTests { sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()), ): VerifySelfSessionState { var state = awaitItem() - assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial) + assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) state.eventSink(VerifySelfSessionViewEvents.RequestVerification) // Await for other device response: state = awaitItem() @@ -223,8 +241,13 @@ class VerifySelfSessionPresenterTests { } private fun createVerifySelfSessionPresenter( - service: FakeSessionVerificationService = FakeSessionVerificationService() + service: SessionVerificationService = FakeSessionVerificationService(), + encryptionService: EncryptionService = FakeEncryptionService(), ): VerifySelfSessionPresenter { - return VerifySelfSessionPresenter(service, VerifySelfSessionStateMachine(service)) + return VerifySelfSessionPresenter( + sessionVerificationService = service, + encryptionService = encryptionService, + stateMachine = VerifySelfSessionStateMachine(service), + ) } } diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt new file mode 100644 index 0000000000..4dfad8c9c9 --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.verifysession.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class VerifySelfSessionViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on cancel calls the expected callback and emits the expected Event`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { callback -> + rule.setContent { + VerifySelfSessionView( + aVerifySelfSessionState( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true), + eventSink = eventsRecorder + ), + onEnterRecoveryKey = EnsureNeverCalled(), + goBack = callback, + ) + } + rule.clickOn(CommonStrings.action_cancel) + } + eventsRecorder.assertSingle(VerifySelfSessionViewEvents.CancelAndClose) + } + + @Test + fun `clicking on back key calls the expected callback and emits the expected Event`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { callback -> + rule.setContent { + VerifySelfSessionView( + aVerifySelfSessionState( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true), + eventSink = eventsRecorder + ), + onEnterRecoveryKey = EnsureNeverCalled(), + goBack = callback, + ) + } + rule.pressBackKey() + } + eventsRecorder.assertSingle(VerifySelfSessionViewEvents.CancelAndClose) + } + + @Test + fun `when flow is completed, the expected callback is invoked`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setContent { + VerifySelfSessionView( + aVerifySelfSessionState( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed, + eventSink = eventsRecorder + ), + onEnterRecoveryKey = EnsureNeverCalled(), + goBack = callback, + ) + } + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on enter recovery key calls the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setContent { + VerifySelfSessionView( + aVerifySelfSessionState( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true), + eventSink = eventsRecorder + ), + onEnterRecoveryKey = callback, + goBack = EnsureNeverCalled(), + ) + } + rule.clickOn(R.string.screen_session_verification_enter_recovery_key) + } + } + + @Test + fun `clicking on they match emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + VerifySelfSessionView( + aVerifySelfSessionState( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying( + data = aEmojisSessionVerificationData(), + state = AsyncData.Uninitialized, + ), + eventSink = eventsRecorder + ), + onEnterRecoveryKey = EnsureNeverCalled(), + goBack = EnsureNeverCalled(), + ) + } + rule.clickOn(R.string.screen_session_verification_they_match) + eventsRecorder.assertSingle(VerifySelfSessionViewEvents.ConfirmVerification) + } + + @Test + fun `clicking on they do not match emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + VerifySelfSessionView( + aVerifySelfSessionState( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying( + data = aEmojisSessionVerificationData(), + state = AsyncData.Uninitialized, + ), + eventSink = eventsRecorder + ), + onEnterRecoveryKey = EnsureNeverCalled(), + goBack = EnsureNeverCalled(), + ) + } + rule.clickOn(R.string.screen_session_verification_they_dont_match) + eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification) + } +} diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 3c56c81fae..fea9088c2a 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -68,13 +68,6 @@ enum class FeatureFlags( defaultValue = true, isFinished = false, ), - SecureStorage( - key = "feature.securestorage", - title = "Chat backup", - description = "Allow access to backup and restore chat history settings", - defaultValue = true, - isFinished = false, - ), MarkAsUnread( key = "feature.markAsUnread", title = "Mark as unread", diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt index 8462a33ba5..757f01904f 100644 --- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt @@ -39,7 +39,6 @@ class StaticFeatureFlagProvider @Inject constructor() : FeatureFlags.VoiceMessages -> true FeatureFlags.PinUnlock -> true FeatureFlags.Mentions -> true - FeatureFlags.SecureStorage -> true FeatureFlags.MarkAsUnread -> false } } else { diff --git a/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt b/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt index 80af72d6a9..aae0f1f913 100644 --- a/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt +++ b/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt @@ -24,8 +24,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -37,7 +35,6 @@ import javax.inject.Inject class DefaultIndicatorService @Inject constructor( private val sessionVerificationService: SessionVerificationService, private val encryptionService: EncryptionService, - private val featureFlagService: FeatureFlagService, ) : IndicatorService { @Composable override fun showRoomListTopBarIndicator(): State { @@ -46,15 +43,13 @@ class DefaultIndicatorService @Inject constructor( return remember { derivedStateOf { - !canVerifySession && settingChatBackupIndicator.value + canVerifySession || settingChatBackupIndicator.value } } } @Composable override fun showSettingChatBackupIndicator(): State { - val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) - .collectAsState(initial = null) val backupState by encryptionService.backupStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() @@ -67,7 +62,7 @@ class DefaultIndicatorService @Inject constructor( RecoveryState.DISABLED, RecoveryState.INCOMPLETE, ) - secureStorageFlag == true && (showForBackup || showForRecovery) + showForBackup || showForRecovery } } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index 522445b6c2..36c786a26f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -23,11 +23,10 @@ interface EncryptionService { val backupStateStateFlow: StateFlow val recoveryStateStateFlow: StateFlow val enableRecoveryProgressStateFlow: StateFlow + val isLastDevice: StateFlow suspend fun enableBackups(): Result - suspend fun isLastDevice(): Result - /** * Enable recovery. Observe enableProgressStateFlow to get progress and recovery key. */ diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index a98596d227..e642813e3b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -113,7 +113,11 @@ class RustMatrixClient( private val innerRoomListService = syncService.roomListService() private val sessionDispatcher = dispatchers.io.limitedParallelism(64) private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope) - private val verificationService = RustSessionVerificationService(rustSyncService, sessionCoroutineScope) + private val verificationService = RustSessionVerificationService( + client = client, + syncService = rustSyncService, + sessionCoroutineScope = sessionCoroutineScope, + ).apply { start() } private val pushersService = RustPushersService( client = client, dispatchers = dispatchers, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt index b0c33949d3..84e9fb2f14 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -25,14 +25,20 @@ 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.sync.SyncState import io.element.android.libraries.matrix.impl.sync.RustSyncService +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.BackupStateListener import org.matrix.rustcomponents.sdk.BackupSteadyStateListener @@ -40,6 +46,7 @@ import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.EnableRecoveryProgressListener import org.matrix.rustcomponents.sdk.Encryption import org.matrix.rustcomponents.sdk.RecoveryStateListener +import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.BackupState as RustBackupState import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress @@ -59,6 +66,8 @@ internal class RustEncryptionService( private val enableRecoveryProgressMapper = EnableRecoveryProgressMapper() private val backupUploadStateMapper = BackupUploadStateMapper() private val steadyStateExceptionMapper = SteadyStateExceptionMapper() + private var backupStateListenerTaskHandle: TaskHandle? = null + private var recoveryStateListenerTaskHandle: TaskHandle? = null private val backupStateFlow = MutableStateFlow(service.backupState().let(backupStateMapper::map)) @@ -88,14 +97,28 @@ internal class RustEncryptionService( override val enableRecoveryProgressStateFlow: MutableStateFlow = MutableStateFlow(EnableRecoveryProgress.Starting) + /** + * Check if the session is the last session 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 isLastDevice: StateFlow = flow { + while (currentCoroutineContext().isActive) { + val result = isLastDevice().getOrDefault(false) + emit(result) + delay(5_000) + } + } + .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) + fun start() { - service.backupStateListener(object : BackupStateListener { + backupStateListenerTaskHandle = service.backupStateListener(object : BackupStateListener { override fun onUpdate(status: RustBackupState) { backupStateFlow.value = backupStateMapper.map(status) } }) - service.recoveryStateListener(object : RecoveryStateListener { + recoveryStateListenerTaskHandle = service.recoveryStateListener(object : RecoveryStateListener { override fun onUpdate(status: RustRecoveryState) { recoveryStateFlow.value = recoveryStateMapper.map(status) } @@ -103,7 +126,8 @@ internal class RustEncryptionService( } fun destroy() { - // No way to remove the listeners... + backupStateListenerTaskHandle?.cancelAndDestroy() + recoveryStateListenerTaskHandle?.cancelAndDestroy() service.destroy() } @@ -173,7 +197,7 @@ internal class RustEncryptionService( } } - override suspend fun isLastDevice(): Result = withContext(dispatchers.io) { + private suspend fun isLastDevice(): Result = withContext(dispatchers.io) { runCatching { service.isLastDevice() }.mapFailure { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt index 3872d27ab5..157e751d21 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.impl.verification +import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.verification.SessionVerificationData @@ -24,22 +25,31 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.impl.sync.RustSyncService +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.Encryption +import org.matrix.rustcomponents.sdk.RecoveryState +import org.matrix.rustcomponents.sdk.RecoveryStateListener import org.matrix.rustcomponents.sdk.SessionVerificationController import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface +import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData class RustSessionVerificationService( + client: Client, private val syncService: RustSyncService, private val sessionCoroutineScope: CoroutineScope, ) : SessionVerificationService, SessionVerificationControllerDelegate { + private var recoveryStateListenerTaskHandle: TaskHandle? = null + private val encryptionService: Encryption = client.encryption() var verificationController: SessionVerificationControllerInterface? = null set(value) { field = value @@ -64,6 +74,16 @@ class RustSessionVerificationService( syncState == SyncState.Running && verificationStatus == SessionVerifiedStatus.NotVerified } + fun start() { + recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener { + override fun onUpdate(status: RecoveryState) { + sessionCoroutineScope.launch { + updateVerificationStatus(verificationController?.isVerified().orFalse()) + } + } + }) + } + override suspend fun requestVerification() = tryOrFail { verificationController?.requestVerification() } @@ -125,6 +145,8 @@ class RustSessionVerificationService( } fun destroy() { + recoveryStateListenerTaskHandle?.cancelAndDestroy() + verificationController?.setDelegate(null) (verificationController as? SessionVerificationController)?.destroy() verificationController = null } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index 7593a5ba51..cc7f53eca3 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -31,6 +31,7 @@ class FakeEncryptionService : EncryptionService { override val backupStateStateFlow: MutableStateFlow = MutableStateFlow(BackupState.UNKNOWN) override val recoveryStateStateFlow: MutableStateFlow = MutableStateFlow(RecoveryState.UNKNOWN) override val enableRecoveryProgressStateFlow: MutableStateFlow = MutableStateFlow(EnableRecoveryProgress.Starting) + override val isLastDevice: MutableStateFlow = MutableStateFlow(false) private var waitForBackupUploadSteadyStateFlow: Flow = flowOf() private var recoverFailure: Exception? = null @@ -73,14 +74,8 @@ class FakeEncryptionService : EncryptionService { return Result.success(Unit) } - private var isLastDevice = false - - fun givenIsLastDevice(isLastDevice: Boolean) { - this.isLastDevice = isLastDevice - } - - override suspend fun isLastDevice(): Result = simulateLongTask { - return Result.success(isLastDevice) + fun emitIsLastDevice(isLastDevice: Boolean) { + this.isLastDevice.value = isLastDevice } override suspend fun resetRecoveryKey(): Result = simulateLongTask { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt index d140bafd6f..ffc06e7d18 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt @@ -21,8 +21,10 @@ import io.element.android.libraries.matrix.api.sync.SyncState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class FakeSyncService : SyncService { - private val syncStateFlow = MutableStateFlow(SyncState.Idle) +class FakeSyncService( + initialState: SyncState = SyncState.Idle +) : SyncService { + private val syncStateFlow = MutableStateFlow(initialState) fun simulateError() { syncStateFlow.value = SyncState.Error diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 77248a2339..85a2e78a85 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -141,6 +141,7 @@ "Mute" "No results" "Offline" + "or" "Password" "People" "Permalink" diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index 17e948a1a8..a7d38a2036 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -90,7 +90,6 @@ class RoomListScreen( ) private val presenter = RoomListPresenter( client = matrixClient, - sessionVerificationService = sessionVerificationService, networkMonitor = NetworkMonitorImpl(context, Singleton.appScope), snackbarDispatcher = SnackbarDispatcher(), inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers), @@ -105,7 +104,6 @@ class RoomListScreen( indicatorService = DefaultIndicatorService( sessionVerificationService = sessionVerificationService, encryptionService = encryptionService, - featureFlagService = featureFlagService, ), featureFlagService = featureFlagService, migrationScreenPresenter = MigrationScreenPresenter( @@ -143,6 +141,7 @@ class RoomListScreen( onRoomClicked = ::onRoomClicked, onSettingsClicked = {}, onVerifyClicked = {}, + onConfirmRecoveryKeyClicked = {}, onCreateRoomClicked = {}, onInvitesClicked = {}, onRoomSettingsClicked = {}, diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureCalledOnce.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureCalledOnce.kt index b2c943e1ca..2ea4e44c75 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureCalledOnce.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureCalledOnce.kt @@ -29,7 +29,7 @@ class EnsureCalledOnce : () -> Unit { } } -fun ensureCalledOnce(block: (callback: EnsureCalledOnce) -> Unit) { +fun ensureCalledOnce(block: (callback: () -> Unit) -> Unit) { val callback = EnsureCalledOnce() block(callback) callback.assertSuccess() diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt index 7cf00ca88a..1ebbd80acf 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt @@ -34,11 +34,21 @@ fun AndroidComposeTestRule.clickOn(@StringR .performClick() } +/** + * Press the back button in the app bar. + */ fun AndroidComposeTestRule.pressBack() { val text = activity.getString(CommonStrings.action_back) onNode(hasContentDescription(text)).performClick() } +/** + * Press the back key. + */ +fun AndroidComposeTestRule.pressBackKey() { + activity.onBackPressedDispatcher.onBackPressed() +} + fun SemanticsNodeInteractionsProvider.pressTag(tag: String) { onNode(hasTestTag(tag)).performClick() } diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.components_ConfirmRecoveryKeyBanner_null_ConfirmRecoveryKeyBanner-Day-4_5_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.components_ConfirmRecoveryKeyBanner_null_ConfirmRecoveryKeyBanner-Day-4_5_null,NEXUS_5,1.0,en].png index f8aa6033e7..0b1bbd56bd 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.components_ConfirmRecoveryKeyBanner_null_ConfirmRecoveryKeyBanner-Day-4_5_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.components_ConfirmRecoveryKeyBanner_null_ConfirmRecoveryKeyBanner-Day-4_5_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:392e3aeea8550204b0fb40e6c0c01faa113830d9566343c55ceba8b3040c0dd3 -size 29459 +oid sha256:fed9d57b66ccd88f3ce45c63f81fec72cd3622bb3e15380812dda629dca0b666 +size 29066 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.components_ConfirmRecoveryKeyBanner_null_ConfirmRecoveryKeyBanner-Night-4_6_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.components_ConfirmRecoveryKeyBanner_null_ConfirmRecoveryKeyBanner-Night-4_6_null,NEXUS_5,1.0,en].png index f09e4db204..eae99afbbf 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.components_ConfirmRecoveryKeyBanner_null_ConfirmRecoveryKeyBanner-Night-4_6_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.components_ConfirmRecoveryKeyBanner_null_ConfirmRecoveryKeyBanner-Night-4_6_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a00e061ea1c7bc7fa842fe23ddd639bbab526fa78e1937eeb54edc551bb6b9be -size 28873 +oid sha256:4057457aabe727afb1c74401da6d7f1cd6ddbc453f73cc6754e5a97c566e8a9a +size 28523 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_8,NEXUS_5,1.0,en].png index 4e360c7666..1429d83081 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_8,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_8,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f8802121f779f48dd556a9a3a08e6ac1a63e86d5695f6f8c133fbb346d76ef6 -size 89779 +oid sha256:4ffaf1a24c2adc5a16e2b835b012bb8d4952bb503de4d1303449235e921a6f17 +size 89209 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_8,NEXUS_5,1.0,en].png index a751592e9b..b797d9d35e 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_8,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_8,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7cdd93c157565f7f8d3fa23a43c30e02531dab40578a315c5d0d10a99e74f259 -size 91394 +oid sha256:ca95a64ab30cd9118b81edd08026371f66eeeb0c5fc11dbdc8a756f80465331a +size 90996 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_0,NEXUS_5,1.0,en].png index 6732b1d851..c149b61eba 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a4e895003e43ad5a7fae10cfeeae35ec0443af0f27577dc6ff23b000835700e6 -size 34051 +oid sha256:92f023f5edc4c050932f8a9e3a3f886285ef9cc716e999cc66f32101a58f295d +size 33356 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_1,NEXUS_5,1.0,en].png index 6327827ffc..5d53c122c6 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:81f8b6a636a32c0fa71bb3db89ab1489b16b95b066e41e91bc8f4e1f41f33d04 -size 46077 +oid sha256:380e3d8df049edf96053533d8d22b8bd46215a6961c6d2b6ca6d151b47c2201e +size 45361 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_2,NEXUS_5,1.0,en].png index c28b5835ed..3a7c3ae48c 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:711a565771c8c46474b8a719098453ab753dcbadc573390fdd5c067138dc628c -size 44466 +oid sha256:713d30ae1e8896a410396335ad72dbf75d207096a59caf90e6d3135d4cd55254 +size 43747 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_3,NEXUS_5,1.0,en].png index 2d4da1f4a4..7e4da73521 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Day-2_3_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:69e430ad2e73557c2ca2dbdb3137ba392644a1d2f36146a1ffebdc14ff3eb7a2 -size 43548 +oid sha256:d3f640e29d7029a278172b45e0597e45653beb58f195bcf71f3fc31c8832708b +size 42973 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_0,NEXUS_5,1.0,en].png index eda4f29921..11bf947e5e 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b5beac400ad75bdc6e260e9b653c52108485a3eabb553f05681407cd9e99b2b -size 32408 +oid sha256:58529ffb570bf24dffc2bbc019de2d108467d577ce5b699a09ab62ee22882bdc +size 31854 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_1,NEXUS_5,1.0,en].png index 6f6682950f..c1f40a5686 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67dcdd1627f9e4ac883f7c453d3b7f728d889d2f97541584a02fe563b223fe73 -size 42869 +oid sha256:933b83af0d608e39b8d97a3bedee82040a5a13889e23dce7802955488e0fe227 +size 42290 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_2,NEXUS_5,1.0,en].png index d73027376e..47008a7b67 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4bc8998cb3def15c194bca0ef55b59d15aebcc992baddd8e5a040b86b4e64dd7 -size 41460 +oid sha256:62ae02108b8bf661d7f799b64d5e6c1794acb00f1c91a50086fd279fe6ea62d4 +size 40908 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_3,NEXUS_5,1.0,en].png index 1cab95a338..52594d6e32 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_null_SecureBackupEnterRecoveryKeyView-Night-2_4_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36d192e6fc4ce53fbe5cfc7d1880333cad8727cd18d2472f51ebe69f3afe2396 -size 38506 +oid sha256:24285b1233ff3695d940ee3025b45e83a2b6094f17c9c6b65e8f1bbdccc7b7c8 +size 37961 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.root_SecureBackupRootView_null_SecureBackupRootView-Day-3_4_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.root_SecureBackupRootView_null_SecureBackupRootView-Day-3_4_null_8,NEXUS_5,1.0,en].png index 3c2c7ec9f6..d44ab16581 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.root_SecureBackupRootView_null_SecureBackupRootView-Day-3_4_null_8,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.root_SecureBackupRootView_null_SecureBackupRootView-Day-3_4_null_8,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:75a5fa36d754f794c08f8af3719b9053c05567e75cde2f7723ac4b001e7091fa -size 31848 +oid sha256:501e818e04fe0839ffde3b2860c1fa8ea5773f794b7bbe10d6625d15d2d9b193 +size 31309 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.root_SecureBackupRootView_null_SecureBackupRootView-Night-3_5_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.root_SecureBackupRootView_null_SecureBackupRootView-Night-3_5_null_8,NEXUS_5,1.0,en].png index 58d49595c5..7677836d5a 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.root_SecureBackupRootView_null_SecureBackupRootView-Night-3_5_null_8,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.securebackup.impl.root_SecureBackupRootView_null_SecureBackupRootView-Night-3_5_null_8,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:197a85cabda2d8b733768f33ce0883d260671111ae6fa69f58dd4dd40485248c -size 29924 +oid sha256:9e04b234f7f8ad17638f4dec7d1f8edae6f5ca05f85074793718591da7066e89 +size 29439 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.verifysession.impl_VerifySelfSessionView_null_VerifySelfSessionView-Day-0_1_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.verifysession.impl_VerifySelfSessionView_null_VerifySelfSessionView-Day-0_1_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..609952770e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.verifysession.impl_VerifySelfSessionView_null_VerifySelfSessionView-Day-0_1_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cbaaf6ee91626a9e8db83ce42be14e1a02a8c841230bf7829fa74aff8cbdfab +size 32513 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.verifysession.impl_VerifySelfSessionView_null_VerifySelfSessionView-Night-0_2_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.verifysession.impl_VerifySelfSessionView_null_VerifySelfSessionView-Night-0_2_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cb997ffa7d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.verifysession.impl_VerifySelfSessionView_null_VerifySelfSessionView-Night-0_2_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efcf672eba104371f27b43c9204d207f11f9ce42098da0e4dce3da4aa6639153 +size 30517