Browse Source

Merge pull request #2421 from element-hq/feature/bma/lastSessionRecovery

Last session recovery
pull/2431/head
Benoit Marty 7 months ago committed by GitHub
parent
commit
f68087bc79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 30
      appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
  2. 1
      changelog.d/2421.bugfix
  3. 1
      features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt
  4. 25
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt
  5. 2
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt
  6. 14
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
  7. 6
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
  8. 25
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt
  9. 14
      features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt
  10. 2
      features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt
  11. 13
      features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt
  12. 5
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
  13. 1
      features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
  14. 1
      features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt
  15. 5
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
  16. 38
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
  17. 9
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
  18. 66
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
  19. 10
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
  20. 4
      features/roomlist/impl/src/main/res/values/localazy.xml
  21. 74
      features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
  22. 210
      features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
  23. 1
      features/securebackup/api/build.gradle.kts
  24. 26
      features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt
  25. 16
      features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt
  26. 6
      features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
  27. 4
      features/securebackup/impl/src/main/res/values/localazy.xml
  28. 18
      features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt
  29. 8
      features/verifysession/impl/build.gradle.kts
  30. 16
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt
  31. 9
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
  32. 17
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt
  33. 2
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt
  34. 26
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt
  35. 41
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
  36. 35
      features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
  37. 151
      features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
  38. 7
      libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
  39. 1
      libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
  40. 9
      libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt
  41. 3
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt
  42. 6
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
  43. 32
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
  44. 22
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt
  45. 11
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt
  46. 6
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt
  47. 1
      libraries/ui-strings/src/main/res/values/localazy.xml
  48. 3
      samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
  49. 2
      tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureCalledOnce.kt
  50. 10
      tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt
  51. BIN
      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
  52. BIN
      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
  53. BIN
      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
  54. BIN
      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
  55. BIN
      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
  56. BIN
      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
  57. BIN
      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
  58. BIN
      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
  59. BIN
      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
  60. BIN
      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
  61. BIN
      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
  62. BIN
      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
  63. BIN
      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
  64. BIN
      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
  65. BIN
      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
  66. BIN
      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

30
appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt

@ -216,7 +216,9 @@ class LoggedInFlowNode @AssistedInject constructor( @@ -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( @@ -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( @@ -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( @@ -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 {

1
changelog.d/2421.bugfix

@ -0,0 +1 @@ @@ -0,0 +1 @@
Add ability to enter a recovery key to verify the session. Also fixes some refresh issues with the verification session state.

1
features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt

@ -78,7 +78,6 @@ class ShowLocationViewTest { @@ -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))
}

25
features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt

@ -24,28 +24,22 @@ import androidx.compose.runtime.getValue @@ -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<LogoutState> {
@Composable
override fun present(): LogoutState {
@ -54,23 +48,12 @@ class LogoutPresenter @Inject constructor( @@ -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( @@ -100,7 +83,7 @@ class LogoutPresenter @Inject constructor(
}
return LogoutState(
isLastSession = isLastSession,
isLastDevice = isLastDevice,
backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
recoveryState = recoveryState,

2
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 @@ -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,

14
features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt

@ -27,22 +27,22 @@ open class LogoutStateProvider : PreviewParameterProvider<LogoutState> { @@ -27,22 +27,22 @@ open class LogoutStateProvider : PreviewParameterProvider<LogoutState> {
override val values: Sequence<LogoutState>
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( @@ -50,7 +50,7 @@ fun aLogoutState(
logoutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
eventSink: (LogoutEvents) -> Unit = {},
) = LogoutState(
isLastSession = isLastSession,
isLastDevice = isLastDevice,
backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServer,
recoveryState = recoveryState,

6
features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt

@ -97,7 +97,7 @@ fun LogoutView( @@ -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? { @@ -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( @@ -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(),

25
features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt

@ -17,14 +17,12 @@ @@ -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 @@ -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 @@ -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( @@ -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( @@ -91,7 +74,7 @@ class DefaultDirectLogoutPresenter @Inject constructor(
}
return DirectLogoutState(
canDoDirectSignOut = !isLastSession &&
canDoDirectSignOut = !isLastDevice &&
!backupUploadState.isBackingUp(),
logoutAction = logoutAction.value,
eventSink = ::handleEvents

14
features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt

@ -22,8 +22,6 @@ import app.cash.turbine.ReceiveTurbine @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -209,6 +206,5 @@ class LogoutPresenterTest {
): LogoutPresenter = LogoutPresenter(
matrixClient = matrixClient,
encryptionService = encryptionService,
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
)
}

2
features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt

@ -153,7 +153,7 @@ class LogoutViewTest { @@ -153,7 +153,7 @@ class LogoutViewTest {
rule.setContent {
LogoutView(
aLogoutState(
isLastSession = true,
isLastDevice = true,
eventSink = eventsRecorder
),
onChangeRecoveryKeyClicked = callback,

13
features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt

@ -23,8 +23,6 @@ import app.cash.turbine.test @@ -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 { @@ -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 { @@ -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 { @@ -180,7 +177,6 @@ class DefaultDirectLogoutPresenterTest {
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
}
@ -190,6 +186,5 @@ class DefaultDirectLogoutPresenterTest { @@ -190,6 +186,5 @@ class DefaultDirectLogoutPresenterTest {
): DefaultDirectLogoutPresenter = DefaultDirectLogoutPresenter(
matrixClient = matrixClient,
encryptionService = encryptionService,
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
)
}

5
features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt

@ -79,9 +79,6 @@ class PreferencesRootPresenter @Inject constructor( @@ -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<String?> = remember {
mutableStateOf(null)
}
@ -101,7 +98,7 @@ class PreferencesRootPresenter @Inject constructor( @@ -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,

1
features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt

@ -65,7 +65,6 @@ class PreferencesRootPresenterTest { @@ -65,7 +65,6 @@ class PreferencesRootPresenterTest {
indicatorService = DefaultIndicatorService(
sessionVerificationService = sessionVerificationService,
encryptionService = FakeEncryptionService(),
featureFlagService = FakeFeatureFlagService(),
),
directLogoutPresenter = object : DirectLogoutPresenter {
@Composable

1
features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt

@ -34,6 +34,7 @@ interface RoomListEntryPoint : FeatureEntryPoint { @@ -34,6 +34,7 @@ interface RoomListEntryPoint : FeatureEntryPoint {
fun onCreateRoomClicked()
fun onSettingsClicked()
fun onSessionVerificationClicked()
fun onSessionConfirmRecoveryKeyClicked()
fun onInvitesClicked()
fun onRoomSettingsClicked(roomId: RoomId)
fun onReportBugClicked()

5
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt

@ -68,6 +68,10 @@ class RoomListNode @AssistedInject constructor( @@ -68,6 +68,10 @@ class RoomListNode @AssistedInject constructor(
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionVerificationClicked() }
}
private fun onSessionConfirmRecoveryKeyClicked() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClicked() }
}
private fun onInvitesClicked() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onInvitesClicked() }
}
@ -97,6 +101,7 @@ class RoomListNode @AssistedInject constructor( @@ -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) },

38
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 @@ -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 @@ -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( @@ -87,6 +88,8 @@ class RoomListPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
) : Presenter<RoomListState> {
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( @@ -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( @@ -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( @@ -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(),

9
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt

@ -31,8 +31,7 @@ data class RoomListState( @@ -31,8 +31,7 @@ data class RoomListState(
val matrixUser: MatrixUser?,
val showAvatarIndicator: Boolean,
val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>,
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 { @@ -62,3 +61,9 @@ enum class InvitesState {
SeenInvites,
NewInvites,
}
enum class SecurityBannerState {
None,
SessionVerification,
RecoveryKeyConfirmation,
}

66
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt

@ -17,10 +17,12 @@ @@ -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<RoomListState> { @@ -37,35 +39,47 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
override val values: Sequence<RoomListState>
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<ImmutableList<RoomListRoomSummary>> = 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<RoomListRoomSummary> {

10
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt

@ -78,6 +78,7 @@ fun RoomListView( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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) @@ -302,6 +305,7 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
onRoomClicked = {},
onSettingsClicked = {},
onVerifyClicked = {},
onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {},
onInvitesClicked = {},
onRoomSettingsClicked = {},

4
features/roomlist/impl/src/main/res/values/localazy.xml

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to confirm your recovery key to maintain access to your chat backup."</string>
<string name="confirm_recovery_key_banner_title">"Confirm your recovery key"</string>
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."</string>
<string name="confirm_recovery_key_banner_title">"Enter your recovery key"</string>
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string>

74
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 @@ -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 @@ -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 @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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(
client = matrixClient,
sessionVerificationService = FakeSessionVerificationService().apply {
givenIsReady(true)
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
},
coroutineScope = scope,
client = FakeMatrixClient(
encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true)
}
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val eventSink = awaitItem().eventSink
assertThat(awaitItem().displayVerificationPrompt).isTrue()
// 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(
coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val eventSink = awaitItem().eventSink
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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -599,7 +615,6 @@ class RoomListPresenterTests {
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
) = RoomListPresenter(
client = client,
sessionVerificationService = sessionVerificationService,
networkMonitor = networkMonitor,
snackbarDispatcher = snackbarDispatcher,
inviteStateDataSource = inviteStateDataSource,
@ -616,9 +631,8 @@ class RoomListPresenterTests { @@ -616,9 +631,8 @@ class RoomListPresenterTests {
),
featureFlagService = featureFlagService,
indicatorService = DefaultIndicatorService(
sessionVerificationService = sessionVerificationService,
sessionVerificationService = client.sessionVerificationService(),
encryptionService = client.encryptionService(),
featureFlagService = featureFlagService,
),
migrationScreenPresenter = migrationScreenPresenter,
searchPresenter = searchPresenter,

210
features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt

@ -0,0 +1,210 @@ @@ -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<ComponentActivity>()
@Test
fun `clicking on close verification banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
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<RoomListEvents>(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<RoomListEvents>()
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<RoomListEvents>(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<RoomListEvents>(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<RoomListEvents>(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<RoomListEvents>()
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<RoomListEvents>()
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<RoomListEvents>()
val state = aRoomListState(
invitesState = InvitesState.NewInvites,
eventSink = eventsRecorder,
)
ensureCalledOnce { callback ->
rule.setRoomListView(
state = state,
onInvitesClicked = callback,
)
rule.clickOn(CommonStrings.action_invites_list)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.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,
)
}
}

1
features/securebackup/api/build.gradle.kts

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
}
android {

26
features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt

@ -16,6 +16,28 @@ @@ -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
}
}

16
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 @@ -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 @@ -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<SecureBackupFlowNode>(buildContext)
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SecureBackupEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : SecureBackupEntryPoint.NodeBuilder {
override fun params(params: SecureBackupEntryPoint.Params): SecureBackupEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun build(): Node {
return parentNode.createNode<SecureBackupFlowNode>(buildContext, plugins)
}
}
}
}

6
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 @@ -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( @@ -44,7 +45,10 @@ class SecureBackupFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<SecureBackupFlowNode.NavTarget>(
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,

4
features/securebackup/impl/src/main/res/values/localazy.xml

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
<string name="screen_chat_backup_key_backup_description">"Backup ensures that you don\'t lose your message history. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Backup"</string>
<string name="screen_chat_backup_recovery_action_change">"Change recovery key"</string>
<string name="screen_chat_backup_recovery_action_confirm">"Confirm recovery key"</string>
<string name="screen_chat_backup_recovery_action_confirm">"Enter recovery key"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Your chat backup is currently out of sync."</string>
<string name="screen_chat_backup_recovery_action_setup">"Set up recovery"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere."</string>
@ -27,7 +27,7 @@ @@ -27,7 +27,7 @@
<string name="screen_recovery_key_confirm_key_description">"Enter the 48 character code."</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Enter…"</string>
<string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</string>
<string name="screen_recovery_key_confirm_title">"Confirm your recovery key"</string>
<string name="screen_recovery_key_confirm_title">"Enter your recovery key"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Copied recovery key"</string>
<string name="screen_recovery_key_generating_key">"Generating…"</string>
<string name="screen_recovery_key_save_action">"Save recovery key"</string>

18
features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt

@ -16,6 +16,20 @@ @@ -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()
}
}

8
features/verifysession/impl/build.gradle.kts

@ -22,6 +22,11 @@ plugins { @@ -22,6 +22,11 @@ plugins {
android {
namespace = "io.element.android.features.verifysession.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
anvil {
@ -44,10 +49,13 @@ dependencies { @@ -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)
}

16
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 @@ -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 @@ -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<VerifySelfSessionNode>(buildContext)
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): VerifySessionEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : VerifySessionEntryPoint.NodeBuilder {
override fun callback(callback: VerifySessionEntryPoint.Callback): VerifySessionEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<VerifySelfSessionNode>(buildContext, plugins)
}
}
}
}

9
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt

@ -21,9 +21,11 @@ import androidx.compose.ui.Modifier @@ -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( @@ -32,12 +34,19 @@ class VerifySelfSessionNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val presenter: VerifySelfSessionPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onEnterRecoveryKey() {
plugins<VerifySessionEntryPoint.Callback>().forEach {
it.onEnterRecoveryKey()
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
VerifySelfSessionView(
state = state,
modifier = modifier,
onEnterRecoveryKey = { onEnterRecoveryKey() },
goBack = { navigateUp() }
)
}

17
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 @@ -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 @@ -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<VerifySelfSessionState> {
@Composable
@ -46,9 +50,14 @@ class VerifySelfSessionPresenter @Inject constructor( @@ -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( @@ -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,

2
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt

@ -28,7 +28,7 @@ data class VerifySelfSessionState( @@ -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

26
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt

@ -25,29 +25,32 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS @@ -25,29 +25,32 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
override val values: Sequence<VerifySelfSessionState>
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<VerificationEmoji> = aVerificationEmojiList(),
): SessionVerificationData {
return SessionVerificationData.Emojis(emojiList)
@ -59,9 +62,12 @@ private fun aDecimalsSessionVerificationData( @@ -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(

41
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 @@ -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( @@ -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( @@ -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) { @@ -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) { @@ -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 @@ -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<Unit>
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) @@ -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) @@ -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) @@ -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) @@ -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 = {},
)
}

35
features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt

@ -23,9 +23,13 @@ import app.cash.turbine.test @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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),
)
}
}

151
features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt

@ -0,0 +1,151 @@ @@ -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<ComponentActivity>()
@Test
fun `clicking on cancel calls the expected callback and emits the expected Event`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
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<VerifySelfSessionViewEvents>()
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<VerifySelfSessionViewEvents>(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<VerifySelfSessionViewEvents>(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<VerifySelfSessionViewEvents>()
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<VerifySelfSessionViewEvents>()
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)
}
}

7
libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt

@ -68,13 +68,6 @@ enum class FeatureFlags( @@ -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",

1
libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt

@ -39,7 +39,6 @@ class StaticFeatureFlagProvider @Inject constructor() : @@ -39,7 +39,6 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.VoiceMessages -> true
FeatureFlags.PinUnlock -> true
FeatureFlags.Mentions -> true
FeatureFlags.SecureStorage -> true
FeatureFlags.MarkAsUnread -> false
}
} else {

9
libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt

@ -24,8 +24,6 @@ import androidx.compose.runtime.getValue @@ -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 @@ -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<Boolean> {
@ -46,15 +43,13 @@ class DefaultIndicatorService @Inject constructor( @@ -46,15 +43,13 @@ class DefaultIndicatorService @Inject constructor(
return remember {
derivedStateOf {
!canVerifySession && settingChatBackupIndicator.value
canVerifySession || settingChatBackupIndicator.value
}
}
}
@Composable
override fun showSettingChatBackupIndicator(): State<Boolean> {
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( @@ -67,7 +62,7 @@ class DefaultIndicatorService @Inject constructor(
RecoveryState.DISABLED,
RecoveryState.INCOMPLETE,
)
secureStorageFlag == true && (showForBackup || showForRecovery)
showForBackup || showForRecovery
}
}
}

3
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt

@ -23,11 +23,10 @@ interface EncryptionService { @@ -23,11 +23,10 @@ interface EncryptionService {
val backupStateStateFlow: StateFlow<BackupState>
val recoveryStateStateFlow: StateFlow<RecoveryState>
val enableRecoveryProgressStateFlow: StateFlow<EnableRecoveryProgress>
val isLastDevice: StateFlow<Boolean>
suspend fun enableBackups(): Result<Unit>
suspend fun isLastDevice(): Result<Boolean>
/**
* Enable recovery. Observe enableProgressStateFlow to get progress and recovery key.
*/

6
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt

@ -113,7 +113,11 @@ class RustMatrixClient( @@ -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,

32
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 @@ -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 @@ -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( @@ -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( @@ -88,14 +97,28 @@ internal class RustEncryptionService(
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = 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<Boolean> = 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( @@ -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( @@ -173,7 +197,7 @@ internal class RustEncryptionService(
}
}
override suspend fun isLastDevice(): Result<Boolean> = withContext(dispatchers.io) {
private suspend fun isLastDevice(): Result<Boolean> = withContext(dispatchers.io) {
runCatching {
service.isLastDevice()
}.mapFailure {

22
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt

@ -16,6 +16,7 @@ @@ -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 @@ -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( @@ -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( @@ -125,6 +145,8 @@ class RustSessionVerificationService(
}
fun destroy() {
recoveryStateListenerTaskHandle?.cancelAndDestroy()
verificationController?.setDelegate(null)
(verificationController as? SessionVerificationController)?.destroy()
verificationController = null
}

11
libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt

@ -31,6 +31,7 @@ class FakeEncryptionService : EncryptionService { @@ -31,6 +31,7 @@ class FakeEncryptionService : EncryptionService {
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(RecoveryState.UNKNOWN)
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Starting)
override val isLastDevice: MutableStateFlow<Boolean> = MutableStateFlow(false)
private var waitForBackupUploadSteadyStateFlow: Flow<BackupUploadState> = flowOf()
private var recoverFailure: Exception? = null
@ -73,14 +74,8 @@ class FakeEncryptionService : EncryptionService { @@ -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<Boolean> = simulateLongTask {
return Result.success(isLastDevice)
fun emitIsLastDevice(isLastDevice: Boolean) {
this.isLastDevice.value = isLastDevice
}
override suspend fun resetRecoveryKey(): Result<String> = simulateLongTask {

6
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 @@ -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

1
libraries/ui-strings/src/main/res/values/localazy.xml

@ -141,6 +141,7 @@ @@ -141,6 +141,7 @@
<string name="common_mute">"Mute"</string>
<string name="common_no_results">"No results"</string>
<string name="common_offline">"Offline"</string>
<string name="common_or">"or"</string>
<string name="common_password">"Password"</string>
<string name="common_people">"People"</string>
<string name="common_permalink">"Permalink"</string>

3
samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt

@ -90,7 +90,6 @@ class RoomListScreen( @@ -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( @@ -105,7 +104,6 @@ class RoomListScreen(
indicatorService = DefaultIndicatorService(
sessionVerificationService = sessionVerificationService,
encryptionService = encryptionService,
featureFlagService = featureFlagService,
),
featureFlagService = featureFlagService,
migrationScreenPresenter = MigrationScreenPresenter(
@ -143,6 +141,7 @@ class RoomListScreen( @@ -143,6 +141,7 @@ class RoomListScreen(
onRoomClicked = ::onRoomClicked,
onSettingsClicked = {},
onVerifyClicked = {},
onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {},
onInvitesClicked = {},
onRoomSettingsClicked = {},

2
tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureCalledOnce.kt

@ -29,7 +29,7 @@ class EnsureCalledOnce : () -> Unit { @@ -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()

10
tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt

@ -34,11 +34,21 @@ fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOn(@StringR @@ -34,11 +34,21 @@ fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOn(@StringR
.performClick()
}
/**
* Press the back button in the app bar.
*/
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBack() {
val text = activity.getString(CommonStrings.action_back)
onNode(hasContentDescription(text)).performClick()
}
/**
* Press the back key.
*/
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBackKey() {
activity.onBackPressedDispatcher.onBackPressed()
}
fun SemanticsNodeInteractionsProvider.pressTag(tag: String) {
onNode(hasTestTag(tag)).performClick()
}

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save