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

Last session recovery
This commit is contained in:
Benoit Marty 2024-02-22 08:47:50 +01:00 committed by GitHub
commit efa56e6124
66 changed files with 841 additions and 268 deletions

View file

@ -216,7 +216,9 @@ class LoggedInFlowNode @AssistedInject constructor(
data object VerifySession : NavTarget data object VerifySession : NavTarget
@Parcelize @Parcelize
data object SecureBackup : NavTarget data class SecureBackup(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
) : NavTarget
@Parcelize @Parcelize
data object InviteList : NavTarget data object InviteList : NavTarget
@ -253,6 +255,10 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.VerifySession) backstack.push(NavTarget.VerifySession)
} }
override fun onSessionConfirmRecoveryKeyClicked() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
}
override fun onInvitesClicked() { override fun onInvitesClicked() {
backstack.push(NavTarget.InviteList) backstack.push(NavTarget.InviteList)
} }
@ -298,7 +304,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onSecureBackupClicked() { override fun onSecureBackupClicked() {
backstack.push(NavTarget.SecureBackup) backstack.push(NavTarget.SecureBackup())
} }
override fun onOpenRoomNotificationSettings(roomId: RoomId) { override fun onOpenRoomNotificationSettings(roomId: RoomId) {
@ -324,10 +330,24 @@ class LoggedInFlowNode @AssistedInject constructor(
.build() .build()
} }
NavTarget.VerifySession -> { 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 -> { is NavTarget.SecureBackup -> {
secureBackupEntryPoint.createNode(this, buildContext) secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
.build()
} }
NavTarget.InviteList -> { NavTarget.InviteList -> {
val callback = object : InviteListEntryPoint.Callback { val callback = object : InviteListEntryPoint.Callback {

1
changelog.d/2421.bugfix Normal file
View file

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

View file

@ -78,7 +78,6 @@ class ShowLocationViewTest {
), ),
onBackPressed = EnsureNeverCalled(), onBackPressed = EnsureNeverCalled(),
) )
val shareContentDescription = rule.activity.getString(CommonStrings.action_share)
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true)) eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true))
} }

View file

@ -24,28 +24,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.bool.orTrue 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.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupState 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.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class LogoutPresenter @Inject constructor( class LogoutPresenter @Inject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
) : Presenter<LogoutState> { ) : Presenter<LogoutState> {
@Composable @Composable
override fun present(): LogoutState { override fun present(): LogoutState {
@ -54,23 +48,12 @@ class LogoutPresenter @Inject constructor(
mutableStateOf(AsyncAction.Uninitialized) mutableStateOf(AsyncAction.Uninitialized)
} }
val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) val backupUploadState: BackupUploadState by remember {
.collectAsState(initial = null) encryptionService.waitForBackupUploadSteadyState()
val backupUploadState: BackupUploadState by remember(secureStorageFlag) {
when (secureStorageFlag) {
true -> encryptionService.waitForBackupUploadSteadyState()
false -> flowOf(BackupUploadState.Done)
else -> emptyFlow()
}
} }
.collectAsState(initial = BackupUploadState.Unknown) .collectAsState(initial = BackupUploadState.Unknown)
var isLastSession by remember { mutableStateOf(false) } val isLastDevice by encryptionService.isLastDevice.collectAsState()
LaunchedEffect(Unit) {
isLastSession = encryptionService.isLastDevice().getOrNull() ?: false
}
val backupState by encryptionService.backupStateStateFlow.collectAsState() val backupState by encryptionService.backupStateStateFlow.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
@ -100,7 +83,7 @@ class LogoutPresenter @Inject constructor(
} }
return LogoutState( return LogoutState(
isLastSession = isLastSession, isLastDevice = isLastDevice,
backupState = backupState, backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(), doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
recoveryState = recoveryState, recoveryState = recoveryState,

View file

@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
data class LogoutState( data class LogoutState(
val isLastSession: Boolean, val isLastDevice: Boolean,
val backupState: BackupState, val backupState: BackupState,
val doesBackupExistOnServer: Boolean, val doesBackupExistOnServer: Boolean,
val recoveryState: RecoveryState, val recoveryState: RecoveryState,

View file

@ -27,22 +27,22 @@ open class LogoutStateProvider : PreviewParameterProvider<LogoutState> {
override val values: Sequence<LogoutState> override val values: Sequence<LogoutState>
get() = sequenceOf( get() = sequenceOf(
aLogoutState(), aLogoutState(),
aLogoutState(isLastSession = true), aLogoutState(isLastDevice = true),
aLogoutState(isLastSession = false, backupUploadState = BackupUploadState.Uploading(66, 200)), aLogoutState(isLastDevice = false, backupUploadState = BackupUploadState.Uploading(66, 200)),
aLogoutState(isLastSession = true, backupUploadState = BackupUploadState.Done), aLogoutState(isLastDevice = true, backupUploadState = BackupUploadState.Done),
aLogoutState(logoutAction = AsyncAction.Confirming), aLogoutState(logoutAction = AsyncAction.Confirming),
aLogoutState(logoutAction = AsyncAction.Loading), aLogoutState(logoutAction = AsyncAction.Loading),
aLogoutState(logoutAction = AsyncAction.Failure(Exception("Failed to logout"))), aLogoutState(logoutAction = AsyncAction.Failure(Exception("Failed to logout"))),
aLogoutState(backupUploadState = BackupUploadState.SteadyException(SteadyStateException.Connection("No network"))), aLogoutState(backupUploadState = BackupUploadState.SteadyException(SteadyStateException.Connection("No network"))),
// Last session no recovery // Last session no recovery
aLogoutState(isLastSession = true, recoveryState = RecoveryState.DISABLED), aLogoutState(isLastDevice = true, recoveryState = RecoveryState.DISABLED),
// Last session no backup // Last session no backup
aLogoutState(isLastSession = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false), aLogoutState(isLastDevice = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false),
) )
} }
fun aLogoutState( fun aLogoutState(
isLastSession: Boolean = false, isLastDevice: Boolean = false,
backupState: BackupState = BackupState.ENABLED, backupState: BackupState = BackupState.ENABLED,
doesBackupExistOnServer: Boolean = true, doesBackupExistOnServer: Boolean = true,
recoveryState: RecoveryState = RecoveryState.ENABLED, recoveryState: RecoveryState = RecoveryState.ENABLED,
@ -50,7 +50,7 @@ fun aLogoutState(
logoutAction: AsyncAction<String?> = AsyncAction.Uninitialized, logoutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
eventSink: (LogoutEvents) -> Unit = {}, eventSink: (LogoutEvents) -> Unit = {},
) = LogoutState( ) = LogoutState(
isLastSession = isLastSession, isLastDevice = isLastDevice,
backupState = backupState, backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServer, doesBackupExistOnServer = doesBackupExistOnServer,
recoveryState = recoveryState, recoveryState = recoveryState,

View file

@ -97,7 +97,7 @@ fun LogoutView(
private fun title(state: LogoutState): String { private fun title(state: LogoutState): String {
return when { return when {
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title) state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title)
state.isLastSession -> { state.isLastDevice -> {
if (state.recoveryState != RecoveryState.ENABLED) { if (state.recoveryState != RecoveryState.ENABLED) {
stringResource(id = R.string.screen_signout_recovery_disabled_title) stringResource(id = R.string.screen_signout_recovery_disabled_title)
} else if (state.backupState == BackupState.UNKNOWN && state.doesBackupExistOnServer.not()) { } else if (state.backupState == BackupState.UNKNOWN && state.doesBackupExistOnServer.not()) {
@ -116,7 +116,7 @@ private fun subtitle(state: LogoutState): String? {
(state.backupUploadState as? BackupUploadState.SteadyException)?.exception is SteadyStateException.Connection -> (state.backupUploadState as? BackupUploadState.SteadyException)?.exception is SteadyStateException.Connection ->
stringResource(id = R.string.screen_signout_key_backup_offline_subtitle) stringResource(id = R.string.screen_signout_key_backup_offline_subtitle)
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_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 else -> null
} }
} }
@ -128,7 +128,7 @@ private fun ColumnScope.Buttons(
onChangeRecoveryKeyClicked: () -> Unit, onChangeRecoveryKeyClicked: () -> Unit,
) { ) {
val logoutAction = state.logoutAction val logoutAction = state.logoutAction
if (state.isLastSession) { if (state.isLastDevice) {
OutlinedButton( OutlinedButton(
text = stringResource(id = CommonStrings.common_settings), text = stringResource(id = CommonStrings.common_settings),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View file

@ -17,14 +17,12 @@
package io.element.android.features.logout.impl.direct package io.element.android.features.logout.impl.direct
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutPresenter import io.element.android.features.logout.api.direct.DirectLogoutPresenter
@ -33,14 +31,10 @@ import io.element.android.features.logout.impl.tools.isBackingUp
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.di.SessionScope 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.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -48,7 +42,6 @@ import javax.inject.Inject
class DefaultDirectLogoutPresenter @Inject constructor( class DefaultDirectLogoutPresenter @Inject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
) : DirectLogoutPresenter { ) : DirectLogoutPresenter {
@Composable @Composable
override fun present(): DirectLogoutState { override fun present(): DirectLogoutState {
@ -58,22 +51,12 @@ class DefaultDirectLogoutPresenter @Inject constructor(
mutableStateOf(AsyncAction.Uninitialized) mutableStateOf(AsyncAction.Uninitialized)
} }
val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) val backupUploadState: BackupUploadState by remember {
.collectAsState(initial = null) encryptionService.waitForBackupUploadSteadyState()
val backupUploadState: BackupUploadState by remember(secureStorageFlag) {
when (secureStorageFlag) {
true -> encryptionService.waitForBackupUploadSteadyState()
false -> flowOf(BackupUploadState.Done)
else -> emptyFlow()
}
} }
.collectAsState(initial = BackupUploadState.Unknown) .collectAsState(initial = BackupUploadState.Unknown)
var isLastSession by remember { mutableStateOf(false) } val isLastDevice by encryptionService.isLastDevice.collectAsState()
LaunchedEffect(Unit) {
isLastSession = encryptionService.isLastDevice().getOrNull() ?: false
}
fun handleEvents(event: DirectLogoutEvents) { fun handleEvents(event: DirectLogoutEvents) {
when (event) { when (event) {
@ -91,7 +74,7 @@ class DefaultDirectLogoutPresenter @Inject constructor(
} }
return DirectLogoutState( return DirectLogoutState(
canDoDirectSignOut = !isLastSession && canDoDirectSignOut = !isLastDevice &&
!backupUploadState.isBackingUp(), !backupUploadState.isBackingUp(),
logoutAction = logoutAction.value, logoutAction = logoutAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents

View file

@ -22,8 +22,6 @@ import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction 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.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupState 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.BackupUploadState
@ -50,7 +48,7 @@ class LogoutPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.isLastSession).isFalse() assertThat(initialState.isLastDevice).isFalse()
assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN)
assertThat(initialState.doesBackupExistOnServer).isTrue() assertThat(initialState.doesBackupExistOnServer).isTrue()
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN) assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
@ -63,15 +61,15 @@ class LogoutPresenterTest {
fun `present - initial state - last session`() = runTest { fun `present - initial state - last session`() = runTest {
val presenter = createLogoutPresenter( val presenter = createLogoutPresenter(
encryptionService = FakeEncryptionService().apply { encryptionService = FakeEncryptionService().apply {
givenIsLastDevice(true) emitIsLastDevice(true)
} }
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(3) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isLastSession).isTrue() assertThat(initialState.isLastDevice).isTrue()
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
} }
@ -96,10 +94,9 @@ class LogoutPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isLastSession).isFalse() assertThat(initialState.isLastDevice).isFalse()
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
skipItems(1)
val waitingState = awaitItem() val waitingState = awaitItem()
assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting) assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
skipItems(1) skipItems(1)
@ -209,6 +206,5 @@ class LogoutPresenterTest {
): LogoutPresenter = LogoutPresenter( ): LogoutPresenter = LogoutPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
encryptionService = encryptionService, encryptionService = encryptionService,
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
) )
} }

View file

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

View file

@ -23,8 +23,6 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.libraries.architecture.AsyncAction 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.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
@ -57,14 +55,13 @@ class DefaultDirectLogoutPresenterTest {
fun `present - initial state - last session`() = runTest { fun `present - initial state - last session`() = runTest {
val presenter = createDefaultDirectLogoutPresenter( val presenter = createDefaultDirectLogoutPresenter(
encryptionService = FakeEncryptionService().apply { encryptionService = FakeEncryptionService().apply {
givenIsLastDevice(true) emitIsLastDevice(true)
} }
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem()
assertThat(initialState.canDoDirectSignOut).isFalse() assertThat(initialState.canDoDirectSignOut).isFalse()
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
} }
@ -84,8 +81,8 @@ class DefaultDirectLogoutPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(2) skipItems(1)
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.canDoDirectSignOut).isFalse() assertThat(initialState.canDoDirectSignOut).isFalse()
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
} }
@ -180,7 +177,6 @@ class DefaultDirectLogoutPresenterTest {
} }
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem() return awaitItem()
} }
@ -190,6 +186,5 @@ class DefaultDirectLogoutPresenterTest {
): DefaultDirectLogoutPresenter = DefaultDirectLogoutPresenter( ): DefaultDirectLogoutPresenter = DefaultDirectLogoutPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
encryptionService = encryptionService, encryptionService = encryptionService,
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
) )
} }

View file

@ -79,9 +79,6 @@ class PreferencesRootPresenter @Inject constructor(
val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator()
val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage)
.collectAsState(initial = null)
val accountManagementUrl: MutableState<String?> = remember { val accountManagementUrl: MutableState<String?> = remember {
mutableStateOf(null) mutableStateOf(null)
} }
@ -101,7 +98,7 @@ class PreferencesRootPresenter @Inject constructor(
version = versionFormatter.get(), version = versionFormatter.get(),
deviceId = matrixClient.deviceId, deviceId = matrixClient.deviceId,
showCompleteVerification = showCompleteVerification, showCompleteVerification = showCompleteVerification,
showSecureBackup = !showCompleteVerification && secureStorageFlag == true, showSecureBackup = !showCompleteVerification,
showSecureBackupBadge = showSecureBackupIndicator, showSecureBackupBadge = showSecureBackupIndicator,
accountManagementUrl = accountManagementUrl.value, accountManagementUrl = accountManagementUrl.value,
devicesManagementUrl = devicesManagementUrl.value, devicesManagementUrl = devicesManagementUrl.value,

View file

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

View file

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

View file

@ -68,6 +68,10 @@ class RoomListNode @AssistedInject constructor(
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionVerificationClicked() } plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionVerificationClicked() }
} }
private fun onSessionConfirmRecoveryKeyClicked() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClicked() }
}
private fun onInvitesClicked() { private fun onInvitesClicked() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onInvitesClicked() } plugins<RoomListEntryPoint.Callback>().forEach { it.onInvitesClicked() }
} }
@ -97,6 +101,7 @@ class RoomListNode @AssistedInject constructor(
onSettingsClicked = this::onOpenSettings, onSettingsClicked = this::onOpenSettings,
onCreateRoomClicked = this::onCreateRoomClicked, onCreateRoomClicked = this::onCreateRoomClicked,
onVerifyClicked = this::onSessionVerificationClicked, onVerifyClicked = this::onSessionVerificationClicked,
onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked,
onInvitesClicked = this::onInvitesClicked, onInvitesClicked = this::onInvitesClicked,
onRoomSettingsClicked = this::onRoomSettingsClicked, onRoomSettingsClicked = this::onRoomSettingsClicked,
onMenuActionClicked = { onMenuActionClicked(activity, it) }, onMenuActionClicked = { onMenuActionClicked(activity, it) },

View file

@ -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.core.RoomId
import io.element.android.libraries.matrix.api.encryption.EncryptionService 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.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.timeline.ReceiptType
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser import io.element.android.libraries.matrix.api.user.getCurrentUser
@ -73,7 +75,6 @@ private const val EXTENDED_RANGE_SIZE = 40
class RoomListPresenter @Inject constructor( class RoomListPresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val sessionVerificationService: SessionVerificationService,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val inviteStateDataSource: InviteStateDataSource, private val inviteStateDataSource: InviteStateDataSource,
@ -87,6 +88,8 @@ class RoomListPresenter @Inject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService: EncryptionService = client.encryptionService() private val encryptionService: EncryptionService = client.encryptionService()
private val sessionVerificationService: SessionVerificationService = client.sessionVerificationService()
private val syncService: SyncService = client.syncService()
@Composable @Composable
override fun present(): RoomListState { override fun present(): RoomListState {
@ -108,22 +111,24 @@ class RoomListPresenter @Inject constructor(
val isMigrating = migrationScreenPresenter.present().isMigrating 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) val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false)
var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) } val isLastDevice by encryptionService.isLastDevice.collectAsState()
// 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 recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) val syncState by syncService.syncState.collectAsState()
.collectAsState(initial = null) val securityBannerState by remember {
var recoveryKeyPromptDismissed by rememberSaveable { mutableStateOf(false) }
val displayRecoveryKeyPrompt by remember {
derivedStateOf { derivedStateOf {
secureStorageFlag == true && when {
securityBannerDismissed -> SecurityBannerState.None
canVerifySession -> if (isLastDevice) {
SecurityBannerState.RecoveryKeyConfirmation
} else {
SecurityBannerState.SessionVerification
}
recoveryState == RecoveryState.INCOMPLETE && recoveryState == RecoveryState.INCOMPLETE &&
!recoveryKeyPromptDismissed syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation
else -> SecurityBannerState.None
}
} }
} }
@ -135,8 +140,8 @@ class RoomListPresenter @Inject constructor(
fun handleEvents(event: RoomListEvents) { fun handleEvents(event: RoomListEvents) {
when (event) { when (event) {
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true RoomListEvents.DismissRecoveryKeyPrompt -> securityBannerDismissed = true
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility) RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
is RoomListEvents.ShowContextMenu -> { is RoomListEvents.ShowContextMenu -> {
coroutineScope.showContextMenu(event, contextMenu) coroutineScope.showContextMenu(event, contextMenu)
@ -157,8 +162,7 @@ class RoomListPresenter @Inject constructor(
matrixUser = matrixUser.value, matrixUser = matrixUser.value,
showAvatarIndicator = showAvatarIndicator, showAvatarIndicator = showAvatarIndicator,
roomList = roomList, roomList = roomList,
displayVerificationPrompt = displayVerificationPrompt, securityBannerState = securityBannerState,
displayRecoveryKeyPrompt = displayRecoveryKeyPrompt,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
invitesState = inviteStateDataSource.inviteState(), invitesState = inviteStateDataSource.inviteState(),

View file

@ -31,8 +31,7 @@ data class RoomListState(
val matrixUser: MatrixUser?, val matrixUser: MatrixUser?,
val showAvatarIndicator: Boolean, val showAvatarIndicator: Boolean,
val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>, val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>,
val displayVerificationPrompt: Boolean, val securityBannerState: SecurityBannerState,
val displayRecoveryKeyPrompt: Boolean,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val invitesState: InvitesState, val invitesState: InvitesState,
@ -62,3 +61,9 @@ enum class InvitesState {
SeenInvites, SeenInvites,
NewInvites, NewInvites,
} }
enum class SecurityBannerState {
None,
SessionVerification,
RecoveryKeyConfirmation,
}

View file

@ -17,10 +17,12 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory 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.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary 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.features.roomlist.impl.search.aRoomListSearchState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -37,35 +39,47 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
override val values: Sequence<RoomListState> override val values: Sequence<RoomListState>
get() = sequenceOf( get() = sequenceOf(
aRoomListState(), aRoomListState(),
aRoomListState().copy(displayVerificationPrompt = true), aRoomListState(securityBannerState = SecurityBannerState.SessionVerification),
aRoomListState().copy(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)), aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aRoomListState().copy(hasNetworkConnection = false), aRoomListState(hasNetworkConnection = false),
aRoomListState().copy(invitesState = InvitesState.SeenInvites), aRoomListState(invitesState = InvitesState.SeenInvites),
aRoomListState().copy(invitesState = InvitesState.NewInvites), aRoomListState(invitesState = InvitesState.NewInvites),
aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")), aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState().copy(contextMenu = aContextMenuShown(isFavorite = true)), aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState().copy(displayRecoveryKeyPrompt = true), aRoomListState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())), aRoomListState(roomList = AsyncData.Success(persistentListOf())),
aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())), aRoomListState(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
aRoomListState().copy(matrixUser = null, displayMigrationStatus = true), aRoomListState(matrixUser = null, displayMigrationStatus = true),
aRoomListState().copy(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
) )
} }
internal fun aRoomListState() = RoomListState( internal fun aRoomListState(
matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), matrixUser: MatrixUser? = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator = false, showAvatarIndicator: Boolean = false,
roomList = AsyncData.Success(aRoomListRoomSummaryList()), roomList: AsyncData<ImmutableList<RoomListRoomSummary>> = AsyncData.Success(aRoomListRoomSummaryList()),
hasNetworkConnection = true, hasNetworkConnection: Boolean = true,
snackbarMessage = null, snackbarMessage: SnackbarMessage? = null,
displayVerificationPrompt = false, securityBannerState: SecurityBannerState = SecurityBannerState.None,
displayRecoveryKeyPrompt = false, invitesState: InvitesState = InvitesState.NoInvites,
invitesState = InvitesState.NoInvites, contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden,
contextMenu = RoomListState.ContextMenu.Hidden, leaveRoomState: LeaveRoomState = aLeaveRoomState(),
leaveRoomState = aLeaveRoomState(), searchState: RoomListSearchState = aRoomListSearchState(),
searchState = aRoomListSearchState(), displayMigrationStatus: Boolean = false,
displayMigrationStatus = false, eventSink: (RoomListEvents) -> Unit = {}
eventSink = {} ) = 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> { internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {

View file

@ -78,6 +78,7 @@ fun RoomListView(
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onSettingsClicked: () -> Unit, onSettingsClicked: () -> Unit,
onVerifyClicked: () -> Unit, onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onCreateRoomClicked: () -> Unit, onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit, onInvitesClicked: () -> Unit,
onRoomSettingsClicked: (roomId: RoomId) -> Unit, onRoomSettingsClicked: (roomId: RoomId) -> Unit,
@ -109,6 +110,7 @@ fun RoomListView(
modifier = Modifier.padding(top = topPadding), modifier = Modifier.padding(top = topPadding),
state = state, state = state,
onVerifyClicked = onVerifyClicked, onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) }, onRoomLongClicked = { onRoomLongClicked(it) },
onOpenSettings = onSettingsClicked, onOpenSettings = onSettingsClicked,
@ -166,6 +168,7 @@ private fun EmptyRoomListView(
private fun RoomListContent( private fun RoomListContent(
state: RoomListState, state: RoomListState,
onVerifyClicked: () -> Unit, onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit, onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
@ -227,7 +230,7 @@ private fun RoomListContent(
) { ) {
when { when {
state.displayEmptyState -> Unit state.displayEmptyState -> Unit
state.displayVerificationPrompt -> { state.securityBannerState == SecurityBannerState.SessionVerification -> {
item { item {
RequestVerificationHeader( RequestVerificationHeader(
onVerifyClicked = onVerifyClicked, onVerifyClicked = onVerifyClicked,
@ -235,10 +238,10 @@ private fun RoomListContent(
) )
} }
} }
state.displayRecoveryKeyPrompt -> { state.securityBannerState == SecurityBannerState.RecoveryKeyConfirmation -> {
item { item {
ConfirmRecoveryKeyBanner( ConfirmRecoveryKeyBanner(
onContinueClicked = onOpenSettings, onContinueClicked = onConfirmRecoveryKeyClicked,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) } onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
) )
} }
@ -302,6 +305,7 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
onRoomClicked = {}, onRoomClicked = {},
onSettingsClicked = {}, onSettingsClicked = {},
onVerifyClicked = {}, onVerifyClicked = {},
onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {}, onCreateRoomClicked = {},
onInvitesClicked = {}, onInvitesClicked = {},
onRoomSettingsClicked = {}, onRoomSettingsClicked = {},

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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">"Confirm your recovery key"</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_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</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> <string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string>

View file

@ -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.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService 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.FakeFeatureFlagService
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.libraries.indicator.impl.DefaultIndicatorService import io.element.android.libraries.indicator.impl.DefaultIndicatorService
@ -54,9 +53,8 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode 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.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.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_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -70,6 +68,7 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled 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.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
@ -105,7 +104,7 @@ class RoomListPresenterTests {
assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID) assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID)
assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME) assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME)
assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL) assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(withUserState.showAvatarIndicator).isFalse() assertThat(withUserState.showAvatarIndicator).isTrue()
scope.cancel() scope.cancel()
} }
} }
@ -114,13 +113,13 @@ class RoomListPresenterTests {
fun `present - show avatar indicator`() = runTest { fun `present - show avatar indicator`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob()) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val encryptionService = FakeEncryptionService() val encryptionService = FakeEncryptionService()
val sessionVerificationService = FakeSessionVerificationService()
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
encryptionService = encryptionService, encryptionService = encryptionService,
sessionVerificationService = sessionVerificationService,
) )
val sessionVerificationService = FakeSessionVerificationService()
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
sessionVerificationService = sessionVerificationService,
coroutineScope = scope coroutineScope = scope
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -128,12 +127,12 @@ class RoomListPresenterTests {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isFalse() assertThat(initialState.showAvatarIndicator).isTrue()
sessionVerificationService.givenCanVerifySession(false) sessionVerificationService.givenCanVerifySession(false)
assertThat(awaitItem().showAvatarIndicator).isFalse() assertThat(awaitItem().showAvatarIndicator).isTrue()
encryptionService.emitBackupState(BackupState.UNKNOWN) encryptionService.emitBackupState(BackupState.ENABLED)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.showAvatarIndicator).isTrue() assertThat(finalState.showAvatarIndicator).isFalse()
scope.cancel() scope.cancel()
} }
} }
@ -238,28 +237,42 @@ class RoomListPresenterTests {
} }
@Test @Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest { fun `present - handle RecoveryKeyConfirmation last session`() = runTest {
val roomListService = FakeRoomListService() val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val matrixClient = FakeMatrixClient( val presenter = createRoomListPresenter(
roomListService = roomListService, coroutineScope = scope,
) client = FakeMatrixClient(
encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true)
}
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val eventSink = awaitItem().eventSink
// For the last session, the state is not SessionVerification, but RecoveryKeyConfirmation
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
}
}
@Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob()) val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient,
sessionVerificationService = FakeSessionVerificationService().apply {
givenIsReady(true)
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
},
coroutineScope = scope, coroutineScope = scope,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val eventSink = awaitItem().eventSink val eventSink = awaitItem().eventSink
assertThat(awaitItem().displayVerificationPrompt).isTrue() assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification)
eventSink(RoomListEvents.DismissRequestVerificationPrompt) eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().displayVerificationPrompt).isFalse() assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel() scope.cancel()
} }
} }
@ -269,6 +282,10 @@ class RoomListPresenterTests {
val encryptionService = FakeEncryptionService() val encryptionService = FakeEncryptionService()
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
encryptionService = encryptionService, encryptionService = encryptionService,
sessionVerificationService = FakeSessionVerificationService().apply {
givenCanVerifySession(false)
},
syncService = FakeSyncService(initialState = SyncState.Running)
) )
val scope = CoroutineScope(context = coroutineContext + SupervisorJob()) val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
@ -280,13 +297,13 @@ class RoomListPresenterTests {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.displayRecoveryKeyPrompt).isFalse() assertThat(initialState.securityBannerState).isEqualTo(SecurityBannerState.None)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
val nextState = awaitItem() val nextState = awaitItem()
assertThat(nextState.displayRecoveryKeyPrompt).isTrue() assertThat(nextState.securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.displayRecoveryKeyPrompt).isFalse() assertThat(finalState.securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel() scope.cancel()
} }
} }
@ -579,7 +596,6 @@ class RoomListPresenterTests {
private fun TestScope.createRoomListPresenter( private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
networkMonitor: NetworkMonitor = FakeNetworkMonitor(), networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
inviteStateDataSource: InviteStateDataSource = FakeInviteDataSource(), inviteStateDataSource: InviteStateDataSource = FakeInviteDataSource(),
@ -589,7 +605,7 @@ class RoomListPresenterTests {
}, },
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(), roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter( migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter(
matrixClient = client, matrixClient = client,
@ -599,7 +615,6 @@ class RoomListPresenterTests {
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() }, searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
sessionVerificationService = sessionVerificationService,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
inviteStateDataSource = inviteStateDataSource, inviteStateDataSource = inviteStateDataSource,
@ -616,9 +631,8 @@ class RoomListPresenterTests {
), ),
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
indicatorService = DefaultIndicatorService( indicatorService = DefaultIndicatorService(
sessionVerificationService = sessionVerificationService, sessionVerificationService = client.sessionVerificationService(),
encryptionService = client.encryptionService(), encryptionService = client.encryptionService(),
featureFlagService = featureFlagService,
), ),
migrationScreenPresenter = migrationScreenPresenter, migrationScreenPresenter = migrationScreenPresenter,
searchPresenter = searchPresenter, searchPresenter = searchPresenter,

View file

@ -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,
)
}
}

View file

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

View file

@ -16,6 +16,28 @@
package io.element.android.features.securebackup.api 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
}
}

View file

@ -18,6 +18,7 @@ package io.element.android.features.securebackup.impl
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -26,7 +27,18 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultSecureBackupEntryPoint @Inject constructor() : SecureBackupEntryPoint { class DefaultSecureBackupEntryPoint @Inject constructor() : SecureBackupEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SecureBackupEntryPoint.NodeBuilder {
return parentNode.createNode<SecureBackupFlowNode>(buildContext) 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)
}
}
} }
} }

View file

@ -27,6 +27,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode 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.disable.SecureBackupDisableNode
import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
@ -44,7 +45,10 @@ class SecureBackupFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
) : BaseFlowNode<SecureBackupFlowNode.NavTarget>( ) : BaseFlowNode<SecureBackupFlowNode.NavTarget>(
backstack = BackStack( 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, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,

View file

@ -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_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_key_backup_title">"Backup"</string>
<string name="screen_chat_backup_recovery_action_change">"Change recovery key"</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_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">"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> <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 @@
<string name="screen_recovery_key_confirm_key_description">"Enter the 48 character code."</string> <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_key_placeholder">"Enter…"</string>
<string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</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_copied_to_clipboard">"Copied recovery key"</string>
<string name="screen_recovery_key_generating_key">"Generating…"</string> <string name="screen_recovery_key_generating_key">"Generating…"</string>
<string name="screen_recovery_key_save_action">"Save recovery key"</string> <string name="screen_recovery_key_save_action">"Save recovery key"</string>

View file

@ -16,6 +16,20 @@
package io.element.android.features.verifysession.api 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()
}
}

View file

@ -22,6 +22,11 @@ plugins {
android { android {
namespace = "io.element.android.features.verifysession.impl" namespace = "io.element.android.features.verifysession.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
anvil { anvil {
@ -44,10 +49,13 @@ dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.verifysession.impl
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.verifysession.api.VerifySessionEntryPoint import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -26,7 +27,18 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultVerifySessionEntryPoint @Inject constructor() : VerifySessionEntryPoint { class DefaultVerifySessionEntryPoint @Inject constructor() : VerifySessionEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): VerifySessionEntryPoint.NodeBuilder {
return parentNode.createNode<VerifySelfSessionNode>(buildContext) 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)
}
}
} }
} }

View file

@ -21,9 +21,11 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -32,12 +34,19 @@ class VerifySelfSessionNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: VerifySelfSessionPresenter, private val presenter: VerifySelfSessionPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private fun onEnterRecoveryKey() {
plugins<VerifySessionEntryPoint.Callback>().forEach {
it.onEnterRecoveryKey()
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
VerifySelfSessionView( VerifySelfSessionView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onEnterRecoveryKey = { onEnterRecoveryKey() },
goBack = { navigateUp() } goBack = { navigateUp() }
) )
} }

View file

@ -20,12 +20,15 @@ package io.element.android.features.verifysession.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.freeletics.flowredux.compose.rememberStateAndDispatch import com.freeletics.flowredux.compose.rememberStateAndDispatch
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter 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.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -38,6 +41,7 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionStateMach
class VerifySelfSessionPresenter @Inject constructor( class VerifySelfSessionPresenter @Inject constructor(
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
private val stateMachine: VerifySelfSessionStateMachine, private val stateMachine: VerifySelfSessionStateMachine,
) : Presenter<VerifySelfSessionState> { ) : Presenter<VerifySelfSessionState> {
@Composable @Composable
@ -46,9 +50,14 @@ class VerifySelfSessionPresenter @Inject constructor(
// Force reset, just in case the service was left in a broken state // Force reset, just in case the service was left in a broken state
sessionVerificationService.reset() sessionVerificationService.reset()
} }
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val stateAndDispatch = stateMachine.rememberStateAndDispatch() val stateAndDispatch = stateMachine.rememberStateAndDispatch()
val verificationFlowStep by remember { val verificationFlowStep by remember {
derivedStateOf { stateAndDispatch.state.value.toVerificationStep() } derivedStateOf {
stateAndDispatch.state.value.toVerificationStep(
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
)
}
} }
// Start this after observing state machine // Start this after observing state machine
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -71,10 +80,12 @@ class VerifySelfSessionPresenter @Inject constructor(
) )
} }
private fun StateMachineState?.toVerificationStep(): VerifySelfSessionState.VerificationStep = private fun StateMachineState?.toVerificationStep(
canEnterRecoveryKey: Boolean
): VerifySelfSessionState.VerificationStep =
when (val machineState = this) { when (val machineState = this) {
StateMachineState.Initial, null -> { StateMachineState.Initial, null -> {
VerifySelfSessionState.VerificationStep.Initial VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = canEnterRecoveryKey)
} }
StateMachineState.RequestingVerification, StateMachineState.RequestingVerification,
StateMachineState.StartingSasVerification, StateMachineState.StartingSasVerification,

View file

@ -28,7 +28,7 @@ data class VerifySelfSessionState(
) { ) {
@Stable @Stable
sealed interface VerificationStep { sealed interface VerificationStep {
data object Initial : VerificationStep data class Initial(val canEnterRecoveryKey: Boolean) : VerificationStep
data object Canceled : VerificationStep data object Canceled : VerificationStep
data object AwaitingOtherDeviceResponse : VerificationStep data object AwaitingOtherDeviceResponse : VerificationStep
data object Ready : VerificationStep data object Ready : VerificationStep

View file

@ -25,29 +25,32 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
override val values: Sequence<VerifySelfSessionState> override val values: Sequence<VerifySelfSessionState>
get() = sequenceOf( get() = sequenceOf(
aVerifySelfSessionState(), aVerifySelfSessionState(),
aVerifySelfSessionState().copy( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse
), ),
aVerifySelfSessionState().copy( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized) verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
), ),
aVerifySelfSessionState().copy( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading()) verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
), ),
aVerifySelfSessionState().copy( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled
), ),
aVerifySelfSessionState().copy( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready
), ),
aVerifySelfSessionState().copy( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized) verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
), ),
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true)
),
// Add other state here // Add other state here
) )
} }
private fun aEmojisSessionVerificationData( internal fun aEmojisSessionVerificationData(
emojiList: List<VerificationEmoji> = aVerificationEmojiList(), emojiList: List<VerificationEmoji> = aVerificationEmojiList(),
): SessionVerificationData { ): SessionVerificationData {
return SessionVerificationData.Emojis(emojiList) return SessionVerificationData.Emojis(emojiList)
@ -59,9 +62,12 @@ private fun aDecimalsSessionVerificationData(
return SessionVerificationData.Decimals(decimals) return SessionVerificationData.Decimals(decimals)
} }
private fun aVerifySelfSessionState() = VerifySelfSessionState( internal fun aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial, verificationFlowStep: VerifySelfSessionState.VerificationStep = VerifySelfSessionState.VerificationStep.Initial(false),
eventSink = {}, eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState(
verificationFlowStep = verificationFlowStep,
eventSink = eventSink,
) )
private fun aVerificationEmojiList() = listOf( private fun aVerificationEmojiList() = listOf(

View file

@ -61,8 +61,9 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver
@Composable @Composable
fun VerifySelfSessionView( fun VerifySelfSessionView(
state: VerifySelfSessionState, state: VerifySelfSessionState,
modifier: Modifier = Modifier, onEnterRecoveryKey: () -> Unit,
goBack: () -> Unit, goBack: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
fun goBackAndCancelIfNeeded() { fun goBackAndCancelIfNeeded() {
state.eventSink(VerifySelfSessionViewEvents.CancelAndClose) state.eventSink(VerifySelfSessionViewEvents.CancelAndClose)
@ -85,7 +86,11 @@ fun VerifySelfSessionView(
}, },
footer = { footer = {
if (buttonsVisible) { if (buttonsVisible) {
BottomMenu(screenState = state, goBack = ::goBackAndCancelIfNeeded) BottomMenu(
screenState = state,
goBack = ::goBackAndCancelIfNeeded,
onEnterRecoveryKey = onEnterRecoveryKey
)
} }
} }
) { ) {
@ -96,13 +101,13 @@ fun VerifySelfSessionView(
@Composable @Composable
private fun HeaderContent(verificationFlowStep: FlowStep) { private fun HeaderContent(verificationFlowStep: FlowStep) {
val iconResourceId = when (verificationFlowStep) { 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.Canceled -> R.drawable.ic_verification_warning
FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting
FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji
} }
val titleTextId = when (verificationFlowStep) { 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.Canceled -> CommonStrings.common_verification_cancelled
FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_title FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_title
FlowStep.Ready, FlowStep.Ready,
@ -113,7 +118,7 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
} }
} }
val subtitleTextId = when (verificationFlowStep) { 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.Canceled -> R.string.screen_session_verification_cancelled_subtitle
FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_subtitle FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_subtitle
FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle
@ -136,7 +141,7 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
private fun Content(flowState: FlowStep) { private fun Content(flowState: FlowStep) {
Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) { Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) {
when (flowState) { when (flowState) {
FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit is FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting() FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting()
is FlowStep.Verifying -> ContentVerifying(flowState) is FlowStep.Verifying -> ContentVerifying(flowState)
} }
@ -203,13 +208,17 @@ private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifie
} }
@Composable @Composable
private fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit) { private fun BottomMenu(
screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
goBack: () -> Unit,
) {
val verificationViewState = screenState.verificationFlowStep val verificationViewState = screenState.verificationFlowStep
val eventSink = screenState.eventSink val eventSink = screenState.eventSink
val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is AsyncData.Loading<Unit> val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is AsyncData.Loading<Unit>
val positiveButtonTitle = when (verificationViewState) { 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 FlowStep.Canceled -> R.string.screen_session_verification_positive_button_canceled
is FlowStep.Verifying -> { is FlowStep.Verifying -> {
if (isVerifying) { if (isVerifying) {
@ -222,7 +231,7 @@ private fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit)
else -> null else -> null
} }
val negativeButtonTitle = when (verificationViewState) { val negativeButtonTitle = when (verificationViewState) {
FlowStep.Initial -> CommonStrings.action_cancel is FlowStep.Initial -> CommonStrings.action_cancel
FlowStep.Canceled -> CommonStrings.action_cancel FlowStep.Canceled -> CommonStrings.action_cancel
is FlowStep.Verifying -> R.string.screen_session_verification_they_dont_match is FlowStep.Verifying -> R.string.screen_session_verification_they_dont_match
else -> null else -> null
@ -230,7 +239,7 @@ private fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit)
val negativeButtonEnabled = !isVerifying val negativeButtonEnabled = !isVerifying
val positiveButtonEvent = when (verificationViewState) { val positiveButtonEvent = when (verificationViewState) {
FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification is FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification
FlowStep.Ready -> VerifySelfSessionViewEvents.StartSasVerification FlowStep.Ready -> VerifySelfSessionViewEvents.StartSasVerification
is FlowStep.Verifying -> if (!isVerifying) VerifySelfSessionViewEvents.ConfirmVerification else null is FlowStep.Verifying -> if (!isVerifying) VerifySelfSessionViewEvents.ConfirmVerification else null
FlowStep.Canceled -> VerifySelfSessionViewEvents.Restart FlowStep.Canceled -> VerifySelfSessionViewEvents.Restart
@ -263,6 +272,17 @@ private fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit)
enabled = negativeButtonEnabled, enabled = negativeButtonEnabled,
) )
} }
if (verificationViewState is FlowStep.Initial && verificationViewState.canEnterRecoveryKey) {
Text(
text = stringResource(id = CommonStrings.common_or),
color = ElementTheme.colors.textSecondary,
)
TextButton(
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
modifier = Modifier.fillMaxWidth(),
onClick = onEnterRecoveryKey,
)
}
} }
} }
@ -271,6 +291,7 @@ private fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit)
internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = ElementPreview { internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = ElementPreview {
VerifySelfSessionView( VerifySelfSessionView(
state = state, state = state,
onEnterRecoveryKey = {},
goBack = {}, goBack = {},
) )
} }

View file

@ -23,9 +23,13 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
import io.element.android.libraries.architecture.AsyncData 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.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.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState 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.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -44,7 +48,21 @@ class VerifySelfSessionPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
}
}
@Test
fun `present - Initial state is received, can use recovery key`() = runTest {
val presenter = createVerifySelfSessionPresenter(
encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true))
} }
} }
@ -67,7 +85,7 @@ class VerifySelfSessionPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
val eventSink = initialState.eventSink val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.StartSasVerification) eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response: // Await for other device response:
@ -86,7 +104,7 @@ class VerifySelfSessionPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
val eventSink = initialState.eventSink val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.CancelAndClose) eventSink(VerifySelfSessionViewEvents.CancelAndClose)
expectNoEvents() expectNoEvents()
@ -203,7 +221,7 @@ class VerifySelfSessionPresenterTests {
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()), sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
): VerifySelfSessionState { ): VerifySelfSessionState {
var state = awaitItem() var state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial) assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
state.eventSink(VerifySelfSessionViewEvents.RequestVerification) state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response: // Await for other device response:
state = awaitItem() state = awaitItem()
@ -223,8 +241,13 @@ class VerifySelfSessionPresenterTests {
} }
private fun createVerifySelfSessionPresenter( private fun createVerifySelfSessionPresenter(
service: FakeSessionVerificationService = FakeSessionVerificationService() service: SessionVerificationService = FakeSessionVerificationService(),
encryptionService: EncryptionService = FakeEncryptionService(),
): VerifySelfSessionPresenter { ): VerifySelfSessionPresenter {
return VerifySelfSessionPresenter(service, VerifySelfSessionStateMachine(service)) return VerifySelfSessionPresenter(
sessionVerificationService = service,
encryptionService = encryptionService,
stateMachine = VerifySelfSessionStateMachine(service),
)
} }
} }

View file

@ -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)
}
}

View file

@ -68,13 +68,6 @@ enum class FeatureFlags(
defaultValue = true, defaultValue = true,
isFinished = false, isFinished = false,
), ),
SecureStorage(
key = "feature.securestorage",
title = "Chat backup",
description = "Allow access to backup and restore chat history settings",
defaultValue = true,
isFinished = false,
),
MarkAsUnread( MarkAsUnread(
key = "feature.markAsUnread", key = "feature.markAsUnread",
title = "Mark as unread", title = "Mark as unread",

View file

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

View file

@ -24,8 +24,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope 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.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
@ -37,7 +35,6 @@ import javax.inject.Inject
class DefaultIndicatorService @Inject constructor( class DefaultIndicatorService @Inject constructor(
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
) : IndicatorService { ) : IndicatorService {
@Composable @Composable
override fun showRoomListTopBarIndicator(): State<Boolean> { override fun showRoomListTopBarIndicator(): State<Boolean> {
@ -46,15 +43,13 @@ class DefaultIndicatorService @Inject constructor(
return remember { return remember {
derivedStateOf { derivedStateOf {
!canVerifySession && settingChatBackupIndicator.value canVerifySession || settingChatBackupIndicator.value
} }
} }
} }
@Composable @Composable
override fun showSettingChatBackupIndicator(): State<Boolean> { override fun showSettingChatBackupIndicator(): State<Boolean> {
val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage)
.collectAsState(initial = null)
val backupState by encryptionService.backupStateStateFlow.collectAsState() val backupState by encryptionService.backupStateStateFlow.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
@ -67,7 +62,7 @@ class DefaultIndicatorService @Inject constructor(
RecoveryState.DISABLED, RecoveryState.DISABLED,
RecoveryState.INCOMPLETE, RecoveryState.INCOMPLETE,
) )
secureStorageFlag == true && (showForBackup || showForRecovery) showForBackup || showForRecovery
} }
} }
} }

View file

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

View file

@ -113,7 +113,11 @@ class RustMatrixClient(
private val innerRoomListService = syncService.roomListService() private val innerRoomListService = syncService.roomListService()
private val sessionDispatcher = dispatchers.io.limitedParallelism(64) private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope) 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( private val pushersService = RustPushersService(
client = client, client = client,
dispatchers = dispatchers, dispatchers = dispatchers,

View file

@ -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.encryption.RecoveryState
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.sync.RustSyncService 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.CoroutineScope
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.BackupStateListener import org.matrix.rustcomponents.sdk.BackupStateListener
import org.matrix.rustcomponents.sdk.BackupSteadyStateListener import org.matrix.rustcomponents.sdk.BackupSteadyStateListener
@ -40,6 +46,7 @@ import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.EnableRecoveryProgressListener import org.matrix.rustcomponents.sdk.EnableRecoveryProgressListener
import org.matrix.rustcomponents.sdk.Encryption import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.RecoveryStateListener 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.BackupState as RustBackupState
import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState
import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress
@ -59,6 +66,8 @@ internal class RustEncryptionService(
private val enableRecoveryProgressMapper = EnableRecoveryProgressMapper() private val enableRecoveryProgressMapper = EnableRecoveryProgressMapper()
private val backupUploadStateMapper = BackupUploadStateMapper() private val backupUploadStateMapper = BackupUploadStateMapper()
private val steadyStateExceptionMapper = SteadyStateExceptionMapper() private val steadyStateExceptionMapper = SteadyStateExceptionMapper()
private var backupStateListenerTaskHandle: TaskHandle? = null
private var recoveryStateListenerTaskHandle: TaskHandle? = null
private val backupStateFlow = MutableStateFlow(service.backupState().let(backupStateMapper::map)) private val backupStateFlow = MutableStateFlow(service.backupState().let(backupStateMapper::map))
@ -88,14 +97,28 @@ internal class RustEncryptionService(
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Starting) 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() { fun start() {
service.backupStateListener(object : BackupStateListener { backupStateListenerTaskHandle = service.backupStateListener(object : BackupStateListener {
override fun onUpdate(status: RustBackupState) { override fun onUpdate(status: RustBackupState) {
backupStateFlow.value = backupStateMapper.map(status) backupStateFlow.value = backupStateMapper.map(status)
} }
}) })
service.recoveryStateListener(object : RecoveryStateListener { recoveryStateListenerTaskHandle = service.recoveryStateListener(object : RecoveryStateListener {
override fun onUpdate(status: RustRecoveryState) { override fun onUpdate(status: RustRecoveryState) {
recoveryStateFlow.value = recoveryStateMapper.map(status) recoveryStateFlow.value = recoveryStateMapper.map(status)
} }
@ -103,7 +126,8 @@ internal class RustEncryptionService(
} }
fun destroy() { fun destroy() {
// No way to remove the listeners... backupStateListenerTaskHandle?.cancelAndDestroy()
recoveryStateListenerTaskHandle?.cancelAndDestroy()
service.destroy() service.destroy()
} }
@ -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 { runCatching {
service.isLastDevice() service.isLastDevice()
}.mapFailure { }.mapFailure {

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl.verification 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.core.data.tryOrNull
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
@ -24,22 +25,31 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState 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.sync.RustSyncService
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch 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.SessionVerificationController
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData
class RustSessionVerificationService( class RustSessionVerificationService(
client: Client,
private val syncService: RustSyncService, private val syncService: RustSyncService,
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
) : SessionVerificationService, SessionVerificationControllerDelegate { ) : SessionVerificationService, SessionVerificationControllerDelegate {
private var recoveryStateListenerTaskHandle: TaskHandle? = null
private val encryptionService: Encryption = client.encryption()
var verificationController: SessionVerificationControllerInterface? = null var verificationController: SessionVerificationControllerInterface? = null
set(value) { set(value) {
field = value field = value
@ -64,6 +74,16 @@ class RustSessionVerificationService(
syncState == SyncState.Running && verificationStatus == SessionVerifiedStatus.NotVerified 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 { override suspend fun requestVerification() = tryOrFail {
verificationController?.requestVerification() verificationController?.requestVerification()
} }
@ -125,6 +145,8 @@ class RustSessionVerificationService(
} }
fun destroy() { fun destroy() {
recoveryStateListenerTaskHandle?.cancelAndDestroy()
verificationController?.setDelegate(null)
(verificationController as? SessionVerificationController)?.destroy() (verificationController as? SessionVerificationController)?.destroy()
verificationController = null verificationController = null
} }

View file

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

View file

@ -21,8 +21,10 @@ import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class FakeSyncService : SyncService { class FakeSyncService(
private val syncStateFlow = MutableStateFlow(SyncState.Idle) initialState: SyncState = SyncState.Idle
) : SyncService {
private val syncStateFlow = MutableStateFlow(initialState)
fun simulateError() { fun simulateError() {
syncStateFlow.value = SyncState.Error syncStateFlow.value = SyncState.Error

View file

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

View file

@ -90,7 +90,6 @@ class RoomListScreen(
) )
private val presenter = RoomListPresenter( private val presenter = RoomListPresenter(
client = matrixClient, client = matrixClient,
sessionVerificationService = sessionVerificationService,
networkMonitor = NetworkMonitorImpl(context, Singleton.appScope), networkMonitor = NetworkMonitorImpl(context, Singleton.appScope),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers), inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers),
@ -105,7 +104,6 @@ class RoomListScreen(
indicatorService = DefaultIndicatorService( indicatorService = DefaultIndicatorService(
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
encryptionService = encryptionService, encryptionService = encryptionService,
featureFlagService = featureFlagService,
), ),
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
migrationScreenPresenter = MigrationScreenPresenter( migrationScreenPresenter = MigrationScreenPresenter(
@ -143,6 +141,7 @@ class RoomListScreen(
onRoomClicked = ::onRoomClicked, onRoomClicked = ::onRoomClicked,
onSettingsClicked = {}, onSettingsClicked = {},
onVerifyClicked = {}, onVerifyClicked = {},
onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {}, onCreateRoomClicked = {},
onInvitesClicked = {}, onInvitesClicked = {},
onRoomSettingsClicked = {}, onRoomSettingsClicked = {},

View file

@ -29,7 +29,7 @@ class EnsureCalledOnce : () -> Unit {
} }
} }
fun ensureCalledOnce(block: (callback: EnsureCalledOnce) -> Unit) { fun ensureCalledOnce(block: (callback: () -> Unit) -> Unit) {
val callback = EnsureCalledOnce() val callback = EnsureCalledOnce()
block(callback) block(callback)
callback.assertSuccess() callback.assertSuccess()

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:392e3aeea8550204b0fb40e6c0c01faa113830d9566343c55ceba8b3040c0dd3 oid sha256:fed9d57b66ccd88f3ce45c63f81fec72cd3622bb3e15380812dda629dca0b666
size 29459 size 29066

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a00e061ea1c7bc7fa842fe23ddd639bbab526fa78e1937eeb54edc551bb6b9be oid sha256:4057457aabe727afb1c74401da6d7f1cd6ddbc453f73cc6754e5a97c566e8a9a
size 28873 size 28523

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5f8802121f779f48dd556a9a3a08e6ac1a63e86d5695f6f8c133fbb346d76ef6 oid sha256:4ffaf1a24c2adc5a16e2b835b012bb8d4952bb503de4d1303449235e921a6f17
size 89779 size 89209

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7cdd93c157565f7f8d3fa23a43c30e02531dab40578a315c5d0d10a99e74f259 oid sha256:ca95a64ab30cd9118b81edd08026371f66eeeb0c5fc11dbdc8a756f80465331a
size 91394 size 90996

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a4e895003e43ad5a7fae10cfeeae35ec0443af0f27577dc6ff23b000835700e6 oid sha256:92f023f5edc4c050932f8a9e3a3f886285ef9cc716e999cc66f32101a58f295d
size 34051 size 33356

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:81f8b6a636a32c0fa71bb3db89ab1489b16b95b066e41e91bc8f4e1f41f33d04 oid sha256:380e3d8df049edf96053533d8d22b8bd46215a6961c6d2b6ca6d151b47c2201e
size 46077 size 45361

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:711a565771c8c46474b8a719098453ab753dcbadc573390fdd5c067138dc628c oid sha256:713d30ae1e8896a410396335ad72dbf75d207096a59caf90e6d3135d4cd55254
size 44466 size 43747

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:69e430ad2e73557c2ca2dbdb3137ba392644a1d2f36146a1ffebdc14ff3eb7a2 oid sha256:d3f640e29d7029a278172b45e0597e45653beb58f195bcf71f3fc31c8832708b
size 43548 size 42973

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2b5beac400ad75bdc6e260e9b653c52108485a3eabb553f05681407cd9e99b2b oid sha256:58529ffb570bf24dffc2bbc019de2d108467d577ce5b699a09ab62ee22882bdc
size 32408 size 31854

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:67dcdd1627f9e4ac883f7c453d3b7f728d889d2f97541584a02fe563b223fe73 oid sha256:933b83af0d608e39b8d97a3bedee82040a5a13889e23dce7802955488e0fe227
size 42869 size 42290

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4bc8998cb3def15c194bca0ef55b59d15aebcc992baddd8e5a040b86b4e64dd7 oid sha256:62ae02108b8bf661d7f799b64d5e6c1794acb00f1c91a50086fd279fe6ea62d4
size 41460 size 40908

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:36d192e6fc4ce53fbe5cfc7d1880333cad8727cd18d2472f51ebe69f3afe2396 oid sha256:24285b1233ff3695d940ee3025b45e83a2b6094f17c9c6b65e8f1bbdccc7b7c8
size 38506 size 37961

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:75a5fa36d754f794c08f8af3719b9053c05567e75cde2f7723ac4b001e7091fa oid sha256:501e818e04fe0839ffde3b2860c1fa8ea5773f794b7bbe10d6625d15d2d9b193
size 31848 size 31309

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:197a85cabda2d8b733768f33ce0883d260671111ae6fa69f58dd4dd40485248c oid sha256:9e04b234f7f8ad17638f4dec7d1f8edae6f5ca05f85074793718591da7066e89
size 29924 size 29439

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7cbaaf6ee91626a9e8db83ce42be14e1a02a8c841230bf7829fa74aff8cbdfab
size 32513

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:efcf672eba104371f27b43c9204d207f11f9ce42098da0e4dce3da4aa6639153
size 30517