Move session verification to FTUE flow, make it mandatory (#2594)

* Move session verification to the FTUE
* Allow session verification flow to be restarted
* Use `EncryptionService` to display session verification faster
* Remove session verification item from settings
* Remove session verification banner from room list
* Remove 'verification needed' variant from the `TimelineEncryptedHistoryBanner`
* Improve verification flow UI and UX
* Remove 'verification successful' snackbar message
* Only register push provider after the session has been verified
* Hide room list while the session hasn't been verified
* Prevent deep links from changing the navigation if the session isn't verified
* Update screenshots
* Renamed `FtueState` to `FtueService`, created an actual `FtueState`.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2024-04-03 16:53:17 +02:00 committed by GitHub
parent 06ba5eaafc
commit 8b335a9125
198 changed files with 822 additions and 761 deletions

View file

@ -23,7 +23,8 @@ appId: ${MAESTRO_APP_ID}
- inputText: ${MAESTRO_PASSWORD} - inputText: ${MAESTRO_PASSWORD}
- pressKey: Enter - pressKey: Enter
- tapOn: "Continue" - tapOn: "Continue"
- runFlow: ../assertions/assertSessionVerificationDisplayed.yaml
- runFlow: ./verifySession.yaml
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml - runFlow: ../assertions/assertAnalyticsDisplayed.yaml
- tapOn: "Not now" - tapOn: "Not now"
- runFlow: ../assertions/assertHomeDisplayed.yaml - runFlow: ../assertions/assertHomeDisplayed.yaml
- runFlow: ./verifySession.yaml

View file

@ -1,6 +1,5 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
--- ---
- tapOn: "Continue"
- takeScreenshot: build/maestro/150-Verify - takeScreenshot: build/maestro/150-Verify
- tapOn: "Enter recovery key" - tapOn: "Enter recovery key"
- tapOn: - tapOn:
@ -8,4 +7,3 @@ appId: ${MAESTRO_APP_ID}
- inputText: ${MAESTRO_RECOVERY_KEY} - inputText: ${MAESTRO_RECOVERY_KEY}
- hideKeyboard - hideKeyboard
- tapOn: "Confirm" - tapOn: "Confirm"
- runFlow: ../assertions/assertHomeDisplayed.yaml

View file

@ -0,0 +1,5 @@
appId: ${MAESTRO_APP_ID}
---
- extendedWaitUntil:
visible: "Confirm that it's you"
timeout: 10000

View file

@ -19,8 +19,6 @@ package io.element.android.appnav
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -34,16 +32,12 @@ import javax.inject.Inject
class LoggedInEventProcessor @Inject constructor( class LoggedInEventProcessor @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver, roomMembershipObserver: RoomMembershipObserver,
sessionVerificationService: SessionVerificationService,
) { ) {
private var observingJob: Job? = null private var observingJob: Job? = null
private val displayLeftRoomMessage = roomMembershipObserver.updates private val displayLeftRoomMessage = roomMembershipObserver.updates
.map { !it.isUserInRoom } .map { !it.isUserInRoom }
private val displayVerificationSuccessfulMessage = sessionVerificationService.verificationFlowState
.map { it == VerificationFlowState.Finished }
fun observeEvents(coroutineScope: CoroutineScope) { fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = coroutineScope.launch { observingJob = coroutineScope.launch {
displayLeftRoomMessage displayLeftRoomMessage
@ -52,13 +46,6 @@ class LoggedInEventProcessor @Inject constructor(
displayMessage(CommonStrings.common_current_user_left_room) displayMessage(CommonStrings.common_current_user_left_room)
} }
.launchIn(this) .launchIn(this)
displayVerificationSuccessfulMessage
.filter { it }
.onEach {
displayMessage(CommonStrings.common_verification_complete)
}
.launchIn(this)
} }
} }

View file

@ -45,6 +45,7 @@ import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.invitelist.api.InviteListEntryPoint import io.element.android.features.invitelist.api.InviteListEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
@ -56,13 +57,13 @@ import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MAIN_SPACE import io.element.android.libraries.matrix.api.core.MAIN_SPACE
@ -75,6 +76,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -88,14 +91,13 @@ class LoggedInFlowNode @AssistedInject constructor(
private val preferencesEntryPoint: PreferencesEntryPoint, private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val secureBackupEntryPoint: SecureBackupEntryPoint, private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint, private val inviteListEntryPoint: InviteListEntryPoint,
private val ftueEntryPoint: FtueEntryPoint, private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager, private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState, private val ftueService: FtueService,
private val lockScreenEntryPoint: LockScreenEntryPoint, private val lockScreenEntryPoint: LockScreenEntryPoint,
private val lockScreenStateService: LockScreenService, private val lockScreenStateService: LockScreenService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
@ -103,7 +105,7 @@ class LoggedInFlowNode @AssistedInject constructor(
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.RoomList, initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
permanentNavModel = PermanentNavModel( permanentNavModel = PermanentNavModel(
@ -121,7 +123,6 @@ class LoggedInFlowNode @AssistedInject constructor(
private val loggedInFlowProcessor = LoggedInEventProcessor( private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher, snackbarDispatcher,
matrixClient.roomMembershipObserver(), matrixClient.roomMembershipObserver(),
matrixClient.sessionVerificationService(),
) )
override fun onBuilt() { override fun onBuilt() {
@ -133,9 +134,15 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope) loggedInFlowProcessor.observeEvents(coroutineScope)
if (ftueState.shouldDisplayFlow.value) { ftueService.state
backstack.push(NavTarget.Ftue) .onEach { ftueState ->
} when (ftueState) {
is FtueState.Unknown -> Unit // Nothing to do
is FtueState.Incomplete -> backstack.safeRoot(NavTarget.Ftue)
is FtueState.Complete -> backstack.safeRoot(NavTarget.RoomList)
}
}
.launchIn(lifecycleScope)
}, },
onStop = { onStop = {
coroutineScope.launch { coroutineScope.launch {
@ -191,6 +198,9 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize
data object Placeholder : NavTarget
@Parcelize @Parcelize
data object LoggedInPermanent : NavTarget data object LoggedInPermanent : NavTarget
@ -214,9 +224,6 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object CreateRoom : NavTarget data object CreateRoom : NavTarget
@Parcelize
data object VerifySession : NavTarget
@Parcelize @Parcelize
data class SecureBackup( data class SecureBackup(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
@ -234,6 +241,7 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext)
NavTarget.LoggedInPermanent -> { NavTarget.LoggedInPermanent -> {
createNode<LoggedInNode>(buildContext) createNode<LoggedInNode>(buildContext)
} }
@ -256,10 +264,6 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.CreateRoom) backstack.push(NavTarget.CreateRoom)
} }
override fun onSessionVerificationClicked() {
backstack.push(NavTarget.VerifySession)
}
override fun onSessionConfirmRecoveryKeyClicked() { override fun onSessionConfirmRecoveryKeyClicked() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey)) backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
} }
@ -308,10 +312,6 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenBugReport() } plugins<Callback>().forEach { it.onOpenBugReport() }
} }
override fun onVerifyClicked() {
backstack.push(NavTarget.VerifySession)
}
override fun onSecureBackupClicked() { override fun onSecureBackupClicked() {
backstack.push(NavTarget.SecureBackup()) backstack.push(NavTarget.SecureBackup())
} }
@ -338,25 +338,6 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback) .callback(callback)
.build() .build()
} }
NavTarget.VerifySession -> {
val callback = object : VerifySessionEntryPoint.Callback {
override fun onEnterRecoveryKey() {
backstack.replace(
NavTarget.SecureBackup(
initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey
)
)
}
override fun onDone() {
backstack.pop()
}
}
verifySessionEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
is NavTarget.SecureBackup -> { is NavTarget.SecureBackup -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext) secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement)) .params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
@ -381,7 +362,7 @@ class LoggedInFlowNode @AssistedInject constructor(
ftueEntryPoint.nodeBuilder(this, buildContext) ftueEntryPoint.nodeBuilder(this, buildContext)
.callback(object : FtueEntryPoint.Callback { .callback(object : FtueEntryPoint.Callback {
override fun onFtueFlowFinished() { override fun onFtueFlowFinished() {
backstack.pop() lifecycleScope.launch { attachRoomList() }
} }
}) })
.build() .build()
@ -398,20 +379,23 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} }
suspend fun attachRoot(): Node { suspend fun attachRoomList() {
return attachChild { if (!canShowRoomList()) return
attachChild<Node> {
backstack.singleTop(NavTarget.RoomList) backstack.singleTop(NavTarget.RoomList)
} }
} }
suspend fun attachRoom(roomId: RoomId): RoomFlowNode { suspend fun attachRoom(roomId: RoomId) {
return attachChild { if (!canShowRoomList()) return
attachChild<RoomFlowNode> {
backstack.singleTop(NavTarget.RoomList) backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId))
} }
} }
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) { internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) {
if (!canShowRoomList()) return@withContext
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId) notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
backstack.singleTop(NavTarget.RoomList) backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.InviteList) backstack.push(NavTarget.InviteList)
@ -420,13 +404,17 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} }
private fun canShowRoomList(): Boolean {
return ftueService.state.value is FtueState.Complete
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
Box(modifier = modifier) { Box(modifier = modifier) {
val lockScreenState by lockScreenStateService.lockState.collectAsState() val lockScreenState by lockScreenStateService.lockState.collectAsState()
val isFtueDisplayed by ftueService.state.collectAsState()
BackstackView() BackstackView()
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState() if (isFtueDisplayed is FtueState.Complete) {
if (!isFtueDisplayed) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
} }
if (lockScreenState == LockScreenLockState.Locked) { if (lockScreenState == LockScreenLockState.Locked) {
@ -434,4 +422,10 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} }
} }
@ContributesNode(AppScope::class)
class PlaceholderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins)
} }

View file

@ -288,7 +288,7 @@ class RootFlowNode @AssistedInject constructor(
.attachSession() .attachSession()
.apply { .apply {
when (deeplinkData) { when (deeplinkData) {
is DeeplinkData.Root -> attachRoot() is DeeplinkData.Root -> attachRoomList()
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId) is DeeplinkData.Room -> attachRoom(deeplinkData.roomId)
is DeeplinkData.InviteList -> attachInviteList(deeplinkData) is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
} }

View file

@ -27,22 +27,32 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
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.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
class LoggedInPresenter @Inject constructor( class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val pushService: PushService, private val pushService: PushService,
private val sessionVerificationService: SessionVerificationService,
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
@Composable @Composable
override fun present(): LoggedInState { override fun present(): LoggedInState {
LaunchedEffect(Unit) { val isVerified by remember {
// Ensure pusher is registered sessionVerificationService.sessionVerifiedStatus.map { it == SessionVerifiedStatus.Verified }
// TODO Manually select push provider for now }.collectAsState(initial = false)
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect LaunchedEffect(isVerified) {
pushService.registerWith(matrixClient, pushProvider, distributor) if (isVerified) {
// Ensure pusher is registered
// TODO Manually select push provider for now
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
pushService.registerWith(matrixClient, pushProvider, distributor)
}
} }
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState() val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()

View file

@ -25,6 +25,7 @@ import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
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.verification.FakeSessionVerificationService
import io.element.android.libraries.push.test.FakePushService import io.element.android.libraries.push.test.FakePushService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
@ -71,6 +72,7 @@ class LoggedInPresenterTest {
matrixClient = FakeMatrixClient(roomListService = roomListService), matrixClient = FakeMatrixClient(roomListService = roomListService),
networkMonitor = FakeNetworkMonitor(networkStatus), networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = FakePushService(), pushService = FakePushService(),
sessionVerificationService = FakeSessionVerificationService(),
) )
} }
} }

1
changelog.d/2580.feature Normal file
View file

@ -0,0 +1 @@
Move session verification to the after login flow and make it mandatory.

View file

@ -18,8 +18,25 @@ package io.element.android.features.ftue.api.state
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface FtueState { /**
val shouldDisplayFlow: StateFlow<Boolean> * Service to manage the First Time User Experience state (aka Onboarding).
*/
interface FtueService {
/** The current state of the FTUE. */
val state: StateFlow<FtueState>
/** Reset the FTUE state. */
suspend fun reset() suspend fun reset()
} }
/** The state of the FTUE. */
sealed interface FtueState {
/** The FTUE state is unknown, nothing to do for now. */
data object Unknown : FtueState
/** The FTUE state is incomplete. The FTUE flow should be displayed. */
data object Incomplete : FtueState
/** The FTUE state is complete. The FTUE flow should not be displayed anymore. */
data object Complete : FtueState
}

View file

@ -42,6 +42,8 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.securebackup.api)
implementation(projects.features.verifysession.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.features.lockscreen.api) implementation(projects.features.lockscreen.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)

View file

@ -34,7 +34,8 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
import io.element.android.features.ftue.impl.state.DefaultFtueState import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
@ -55,7 +56,7 @@ import kotlinx.parcelize.Parcelize
class FtueFlowNode @AssistedInject constructor( class FtueFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val ftueState: DefaultFtueState, private val ftueState: DefaultFtueService,
private val analyticsEntryPoint: AnalyticsEntryPoint, private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val lockScreenEntryPoint: LockScreenEntryPoint, private val lockScreenEntryPoint: LockScreenEntryPoint,
@ -72,6 +73,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object Placeholder : NavTarget data object Placeholder : NavTarget
@Parcelize
data object SessionVerification : NavTarget
@Parcelize @Parcelize
data object NotificationsOptIn : NavTarget data object NotificationsOptIn : NavTarget
@ -106,6 +110,14 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.Placeholder -> { NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext) createNode<PlaceholderNode>(buildContext)
} }
NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() {
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
}
NavTarget.NotificationsOptIn -> { NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback { val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() { override fun onNotificationsOptInFinished() {
@ -133,6 +145,9 @@ class FtueFlowNode @AssistedInject constructor(
private fun moveToNextStep() { private fun moveToNextStep() {
when (ftueState.getNextStep()) { when (ftueState.getNextStep()) {
FtueStep.SessionVerification -> {
backstack.newRoot(NavTarget.SessionVerification)
}
FtueStep.NotificationsOptIn -> { FtueStep.NotificationsOptIn -> {
backstack.newRoot(NavTarget.NotificationsOptIn) backstack.newRoot(NavTarget.NotificationsOptIn)
} }

View file

@ -0,0 +1,98 @@
/*
* 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.ftue.impl.sessionverification
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data object EnterRecoveryKey : NavTarget
}
interface Callback : Plugin {
fun onDone()
}
private val callback = plugins<Callback>().first()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Root -> {
verifySessionEntryPoint.nodeBuilder(this, buildContext)
.callback(object : VerifySessionEntryPoint.Callback {
override fun onEnterRecoveryKey() {
backstack.push(NavTarget.EnterRecoveryKey)
}
override fun onDone() {
callback.onDone()
}
})
.build()
}
is NavTarget.EnterRecoveryKey -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
.callback(object : SecureBackupEntryPoint.Callback {
override fun onDone() {
callback.onDone()
}
})
.build()
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}

View file

@ -20,9 +20,12 @@ import android.Manifest
import android.os.Build import android.os.Build
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.permissions.api.PermissionStateProvider import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@ -35,14 +38,15 @@ import kotlinx.coroutines.runBlocking
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
class DefaultFtueState @Inject constructor( class DefaultFtueService @Inject constructor(
private val sdkVersionProvider: BuildVersionSdkIntProvider, private val sdkVersionProvider: BuildVersionSdkIntProvider,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val permissionStateProvider: PermissionStateProvider, private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService, private val lockScreenService: LockScreenService,
) : FtueState { private val sessionVerificationService: SessionVerificationService,
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete()) ) : FtueService {
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
override suspend fun reset() { override suspend fun reset() {
analyticsService.reset() analyticsService.reset()
@ -52,6 +56,10 @@ class DefaultFtueState @Inject constructor(
} }
init { init {
sessionVerificationService.sessionVerifiedStatus
.onEach { updateState() }
.launchIn(coroutineScope)
analyticsService.didAskUserConsent() analyticsService.didAskUserConsent()
.onEach { updateState() } .onEach { updateState() }
.launchIn(coroutineScope) .launchIn(coroutineScope)
@ -59,7 +67,12 @@ class DefaultFtueState @Inject constructor(
fun getNextStep(currentStep: FtueStep? = null): FtueStep? = fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) { when (currentStep) {
null -> if (shouldAskNotificationPermissions()) { null -> if (isSessionNotVerified()) {
FtueStep.SessionVerification
} else {
getNextStep(FtueStep.SessionVerification)
}
FtueStep.SessionVerification -> if (shouldAskNotificationPermissions()) {
FtueStep.NotificationsOptIn FtueStep.NotificationsOptIn
} else { } else {
getNextStep(FtueStep.NotificationsOptIn) getNextStep(FtueStep.NotificationsOptIn)
@ -79,12 +92,21 @@ class DefaultFtueState @Inject constructor(
private fun isAnyStepIncomplete(): Boolean { private fun isAnyStepIncomplete(): Boolean {
return listOf( return listOf(
{ isSessionNotVerified() },
{ shouldAskNotificationPermissions() }, { shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() }, { needsAnalyticsOptIn() },
{ shouldDisplayLockscreenSetup() }, { shouldDisplayLockscreenSetup() },
).any { it() } ).any { it() }
} }
private fun isSessionVerificationServiceReady(): Boolean {
return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown
}
private fun isSessionNotVerified(): Boolean {
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified
}
private fun needsAnalyticsOptIn(): Boolean { private fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking // We need this function to not be suspend, so we need to load the value through runBlocking
return runBlocking { analyticsService.didAskUserConsent().first().not() } return runBlocking { analyticsService.didAskUserConsent().first().not() }
@ -109,11 +131,16 @@ class DefaultFtueState @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() { internal fun updateState() {
shouldDisplayFlow.value = isAnyStepIncomplete() state.value = when {
!isSessionVerificationServiceReady() -> FtueState.Unknown
isAnyStepIncomplete() -> FtueState.Incomplete
else -> FtueState.Complete
}
} }
} }
sealed interface FtueStep { sealed interface FtueStep {
data object SessionVerification : FtueStep
data object NotificationsOptIn : FtueStep data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep data object AnalyticsOptIn : FtueStep
data object LockscreenSetup : FtueStep data object LockscreenSetup : FtueStep

View file

@ -17,11 +17,15 @@
package io.element.android.features.ftue.impl package io.element.android.features.ftue.impl
import android.os.Build import android.os.Build
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.impl.state.DefaultFtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
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
@ -32,38 +36,51 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class DefaultFtueStateTests { class DefaultFtueServiceTests {
@Test @Test
fun `given any check being false, should display flow is true`() = runTest { fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.Unknown)
}
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope) val state = createState(coroutineScope, sessionVerificationService)
assertThat(state.shouldDisplayFlow.value).isTrue() state.state.test {
// Verification state is unknown, we don't display the flow yet
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
// Verification state is known, we should display the flow if any check is false
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
assertThat(awaitItem()).isEqualTo(FtueState.Incomplete)
}
// Cleanup // Cleanup
coroutineScope.cancel() coroutineScope.cancel()
} }
@Test @Test
fun `given all checks being true, should display flow is false`() = runTest { fun `given all checks being true, FtueState is Complete`() = runTest {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val sessionVerificationService = FakeSessionVerificationService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val lockScreenService = FakeLockScreenService() val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState( val state = createState(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService, analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
state.updateState() state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse() assertThat(state.state.value).isEqualTo(FtueState.Complete)
// Cleanup // Cleanup
coroutineScope.cancel() coroutineScope.cancel()
@ -71,6 +88,9 @@ class DefaultFtueStateTests {
@Test @Test
fun `traverse flow`() = runTest { fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
}
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService() val lockScreenService = FakeLockScreenService()
@ -78,12 +98,17 @@ class DefaultFtueStateTests {
val state = createState( val state = createState(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService, analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
val steps = mutableListOf<FtueStep?>() val steps = mutableListOf<FtueStep?>()
// Session verification
steps.add(state.getNextStep(steps.lastOrNull()))
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
// Notifications opt in // Notifications opt in
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
@ -100,6 +125,7 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly( assertThat(steps).containsExactly(
FtueStep.SessionVerification,
FtueStep.NotificationsOptIn, FtueStep.NotificationsOptIn,
FtueStep.LockscreenSetup, FtueStep.LockscreenSetup,
FtueStep.AnalyticsOptIn, FtueStep.AnalyticsOptIn,
@ -114,17 +140,20 @@ class DefaultFtueStateTests {
@Test @Test
fun `if a check for a step is true, start from the next one`() = runTest { fun `if a check for a step is true, start from the next one`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val sessionVerificationService = FakeSessionVerificationService()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService() val lockScreenService = FakeLockScreenService()
val state = createState( val state = createState(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService, analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
// Skip first 2 steps // Skip first 3 steps
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
@ -140,16 +169,19 @@ class DefaultFtueStateTests {
@Test @Test
fun `if version is older than 13 we don't display the notification opt in screen`() = runTest { fun `if version is older than 13 we don't display the notification opt in screen`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val sessionVerificationService = FakeSessionVerificationService()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val lockScreenService = FakeLockScreenService() val lockScreenService = FakeLockScreenService()
val state = createState( val state = createState(
sdkIntVersion = Build.VERSION_CODES.M, sdkIntVersion = Build.VERSION_CODES.M,
sessionVerificationService = sessionVerificationService,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
analyticsService = analyticsService, analyticsService = analyticsService,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
@ -163,14 +195,16 @@ class DefaultFtueStateTests {
private fun createState( private fun createState(
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false), permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(), lockScreenService: LockScreenService = FakeLockScreenService(),
// First version where notification permission is required // First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueState( ) = DefaultFtueService(
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
sessionVerificationService = sessionVerificationService,
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
analyticsService = analyticsService, analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,

View file

@ -33,7 +33,6 @@ import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
@ -41,15 +40,11 @@ import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
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.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
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.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -68,8 +63,6 @@ class TimelinePresenter @AssistedInject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope, private val appScope: CoroutineScope,
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
private val verificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
private val redactedVoiceMessageManager: RedactedVoiceMessageManager, private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
private val sendPollResponseAction: SendPollResponseAction, private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction, private val endPollAction: EndPollAction,
@ -101,21 +94,9 @@ class TimelinePresenter @AssistedInject constructor(
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) } val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val newItemState = remember { mutableStateOf(NewEventState.None) } val newItemState = remember { mutableStateOf(NewEventState.None) }
val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState()
val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true) val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true) val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
val sessionState by remember {
derivedStateOf {
SessionState(
isSessionVerified = sessionVerifiedStatus == SessionVerifiedStatus.Verified,
isKeyBackupEnabled = keyBackupState == BackupState.ENABLED
)
}
}
fun handleEvents(event: TimelineEvents) { fun handleEvents(event: TimelineEvents) {
when (event) { when (event) {
TimelineEvents.LoadMore -> localScope.paginateBackwards() TimelineEvents.LoadMore -> localScope.paginateBackwards()
@ -184,7 +165,6 @@ class TimelinePresenter @AssistedInject constructor(
timelineItems = timelineItems, timelineItems = timelineItems,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
newEventState = newItemState.value, newEventState = newItemState.value,
sessionState = sessionState,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }

View file

@ -19,7 +19,6 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -32,7 +31,6 @@ data class TimelineState(
val highlightedEventId: EventId?, val highlightedEventId: EventId?,
val paginationState: MatrixTimeline.PaginationState, val paginationState: MatrixTimeline.PaginationState,
val newEventState: NewEventState, val newEventState: NewEventState,
val sessionState: SessionState,
val eventSink: (TimelineEvents) -> Unit val eventSink: (TimelineEvents) -> Unit
) )

View file

@ -29,7 +29,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.session.aSessionState
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -58,10 +57,6 @@ fun aTimelineState(
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
highlightedEventId = null, highlightedEventId = null,
newEventState = NewEventState.None, newEventState = NewEventState.None,
sessionState = aSessionState(
isSessionVerified = true,
isKeyBackupEnabled = true,
),
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -148,7 +148,6 @@ fun TimelineView(
onMoreReactionsClick = onMoreReactionsClicked, onMoreReactionsClick = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
sessionState = state.sessionState,
eventSink = state.eventSink, eventSink = state.eventSink,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
) )

View file

@ -32,8 +32,6 @@ import io.element.android.features.messages.impl.timeline.components.group.Group
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.timeline.session.aSessionState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -46,7 +44,6 @@ fun TimelineItemGroupedEventsRow(
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
highlightedItem: String?, highlightedItem: String?,
sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
@ -74,7 +71,6 @@ fun TimelineItemGroupedEventsRow(
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
sessionState = sessionState,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
@ -99,7 +95,6 @@ private fun TimelineItemGroupedEventsRowContent(
highlightedItem: String?, highlightedItem: String?,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
@ -133,7 +128,6 @@ private fun TimelineItemGroupedEventsRowContent(
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
sessionState = sessionState,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
@ -174,7 +168,6 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
highlightedItem = null, highlightedItem = null,
renderReadReceipts = true, renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
sessionState = aSessionState(),
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
inReplyToClick = {}, inReplyToClick = {},
@ -200,7 +193,6 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
highlightedItem = null, highlightedItem = null,
renderReadReceipts = true, renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
sessionState = aSessionState(),
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
inReplyToClick = {}, inReplyToClick = {},

View file

@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -34,7 +33,6 @@ internal fun TimelineItemRow(
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
highlightedItem: String?, highlightedItem: String?,
sessionState: SessionState,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClicked: (String) -> Unit, onLinkClicked: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
@ -53,7 +51,6 @@ internal fun TimelineItemRow(
is TimelineItem.Virtual -> { is TimelineItem.Virtual -> {
TimelineItemVirtualRow( TimelineItemVirtualRow(
virtual = timelineItem, virtual = timelineItem,
sessionState = sessionState,
modifier = modifier, modifier = modifier,
) )
} }
@ -100,7 +97,6 @@ internal fun TimelineItemRow(
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
sessionState = sessionState,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,

View file

@ -25,17 +25,15 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.session.SessionState
@Composable @Composable
fun TimelineItemVirtualRow( fun TimelineItemVirtualRow(
virtual: TimelineItem.Virtual, virtual: TimelineItem.Virtual,
sessionState: SessionState,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
when (virtual.model) { when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier) is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView() TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(sessionState, modifier) is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
} }
} }

View file

@ -16,7 +16,6 @@
package io.element.android.features.messages.impl.timeline.components.virtual package io.element.android.features.messages.impl.timeline.components.virtual
import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -29,20 +28,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.timeline.session.SessionStateProvider
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@Composable @Composable
fun TimelineEncryptedHistoryBannerView( fun TimelineEncryptedHistoryBannerView(
sessionState: SessionState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@ -61,26 +56,15 @@ fun TimelineEncryptedHistoryBannerView(
tint = ElementTheme.colors.iconInfoPrimary tint = ElementTheme.colors.iconInfoPrimary
) )
Text( Text(
text = stringResource(sessionState.toStringResId()), text = stringResource(R.string.screen_room_encrypted_history_banner),
style = ElementTheme.typography.fontBodyMdMedium, style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textInfoPrimary color = ElementTheme.colors.textInfoPrimary
) )
} }
} }
@StringRes
private fun SessionState.toStringResId(): Int {
return when {
isSessionVerified.not() -> R.string.screen_room_encrypted_history_banner_unverified
isKeyBackupEnabled.not() -> R.string.screen_room_encrypted_history_banner
else -> R.string.screen_room_encrypted_history_banner // TODO strings need to be updated
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun EncryptedHistoryBannerViewPreview( internal fun EncryptedHistoryBannerViewPreview() = ElementPreview {
@PreviewParameter(SessionStateProvider::class) sessionState: SessionState, TimelineEncryptedHistoryBannerView()
) = ElementPreview {
TimelineEncryptedHistoryBannerView(sessionState = sessionState)
} }

View file

@ -1,36 +0,0 @@
/*
* Copyright (c) 2023 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.messages.impl.timeline.session
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class SessionStateProvider : PreviewParameterProvider<SessionState> {
override val values: Sequence<SessionState>
get() = sequenceOf(
aSessionState(isSessionVerified = false, isKeyBackupEnabled = false),
aSessionState(isSessionVerified = true, isKeyBackupEnabled = false),
aSessionState(isSessionVerified = true, isKeyBackupEnabled = true),
)
}
internal fun aSessionState(
isSessionVerified: Boolean = false,
isKeyBackupEnabled: Boolean = false,
) = SessionState(
isSessionVerified = isSessionVerified,
isKeyBackupEnabled = isKeyBackupEnabled,
)

View file

@ -76,13 +76,11 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom 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.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
@ -745,8 +743,6 @@ class MessagesPresenterTest {
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
appScope = this, appScope = this,
navigator = navigator, navigator = navigator,
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
endPollAction = endPollAction, endPollAction = endPollAction,
sendPollResponseAction = FakeSendPollResponseAction(), sendPollResponseAction = FakeSendPollResponseAction(),

View file

@ -26,7 +26,6 @@ import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.aRedactedMatrixTimeline import io.element.android.features.messages.impl.voicemessages.timeline.aRedactedMatrixTimeline
@ -47,13 +46,11 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
@ -89,7 +86,6 @@ class TimelinePresenterTest {
assertThat(initialState.timelineItems).isEmpty() assertThat(initialState.timelineItems).isEmpty()
val loadedNoTimelineState = awaitItem() val loadedNoTimelineState = awaitItem()
assertThat(loadedNoTimelineState.timelineItems).isEmpty() assertThat(loadedNoTimelineState.timelineItems).isEmpty()
assertThat(loadedNoTimelineState.sessionState).isEqualTo(SessionState(isSessionVerified = false, isKeyBackupEnabled = false))
} }
} }
@ -512,8 +508,6 @@ class TimelinePresenterTest {
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
appScope = this, appScope = this,
navigator = messagesNavigator, navigator = messagesNavigator,
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
redactedVoiceMessageManager = redactedVoiceMessageManager, redactedVoiceMessageManager = redactedVoiceMessageManager,
endPollAction = endPollAction, endPollAction = endPollAction,
sendPollResponseAction = sendPollResponseAction, sendPollResponseAction = sendPollResponseAction,

View file

@ -45,7 +45,6 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenBugReport() fun onOpenBugReport()
fun onVerifyClicked()
fun onSecureBackupClicked() fun onSecureBackupClicked()
fun onOpenRoomNotificationSettings(roomId: RoomId) fun onOpenRoomNotificationSettings(roomId: RoomId)
} }

View file

@ -115,10 +115,6 @@ class PreferencesFlowNode @AssistedInject constructor(
plugins<PreferencesEntryPoint.Callback>().forEach { it.onOpenBugReport() } plugins<PreferencesEntryPoint.Callback>().forEach { it.onOpenBugReport() }
} }
override fun onVerifyClicked() {
plugins<PreferencesEntryPoint.Callback>().forEach { it.onVerifyClicked() }
}
override fun onSecureBackupClicked() { override fun onSecureBackupClicked() {
plugins<PreferencesEntryPoint.Callback>().forEach { it.onSecureBackupClicked() } plugins<PreferencesEntryPoint.Callback>().forEach { it.onSecureBackupClicked() }
} }

View file

@ -44,7 +44,6 @@ class PreferencesRootNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenBugReport() fun onOpenBugReport()
fun onVerifyClicked()
fun onSecureBackupClicked() fun onSecureBackupClicked()
fun onOpenAnalytics() fun onOpenAnalytics()
fun onOpenAbout() fun onOpenAbout()
@ -61,10 +60,6 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenBugReport() } plugins<Callback>().forEach { it.onOpenBugReport() }
} }
private fun onVerifyClicked() {
plugins<Callback>().forEach { it.onVerifyClicked() }
}
private fun onSecureBackupClicked() { private fun onSecureBackupClicked() {
plugins<Callback>().forEach { it.onSecureBackupClicked() } plugins<Callback>().forEach { it.onSecureBackupClicked() }
} }
@ -138,7 +133,6 @@ class PreferencesRootNode @AssistedInject constructor(
onOpenRageShake = this::onOpenBugReport, onOpenRageShake = this::onOpenBugReport,
onOpenAnalytics = this::onOpenAnalytics, onOpenAnalytics = this::onOpenAnalytics,
onOpenAbout = this::onOpenAbout, onOpenAbout = this::onOpenAbout,
onVerifyClicked = this::onVerifyClicked,
onSecureBackupClicked = this::onSecureBackupClicked, onSecureBackupClicked = this::onSecureBackupClicked,
onOpenDeveloperSettings = this::onOpenDeveloperSettings, onOpenDeveloperSettings = this::onOpenDeveloperSettings,
onOpenAdvancedSettings = this::onOpenAdvancedSettings, onOpenAdvancedSettings = this::onOpenAdvancedSettings,

View file

@ -74,7 +74,7 @@ class PreferencesRootPresenter @Inject constructor(
} }
// We should display the 'complete verification' option if the current session can be verified // We should display the 'complete verification' option if the current session can be verified
val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false) val canVerifyUserSession by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator()
@ -102,8 +102,7 @@ class PreferencesRootPresenter @Inject constructor(
myUser = matrixUser.value, myUser = matrixUser.value,
version = versionFormatter.get(), version = versionFormatter.get(),
deviceId = matrixClient.deviceId, deviceId = matrixClient.deviceId,
showCompleteVerification = showCompleteVerification, showSecureBackup = !canVerifyUserSession,
showSecureBackup = !showCompleteVerification,
showSecureBackupBadge = showSecureBackupIndicator, showSecureBackupBadge = showSecureBackupIndicator,
accountManagementUrl = accountManagementUrl.value, accountManagementUrl = accountManagementUrl.value,
devicesManagementUrl = devicesManagementUrl.value, devicesManagementUrl = devicesManagementUrl.value,

View file

@ -24,7 +24,6 @@ data class PreferencesRootState(
val myUser: MatrixUser, val myUser: MatrixUser,
val version: String, val version: String,
val deviceId: String?, val deviceId: String?,
val showCompleteVerification: Boolean,
val showSecureBackup: Boolean, val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean, val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?, val accountManagementUrl: String?,

View file

@ -27,7 +27,6 @@ fun aPreferencesRootState(
myUser = myUser, myUser = myUser,
version = "Version 1.1 (1)", version = "Version 1.1 (1)",
deviceId = "ILAKNDNASDLK", deviceId = "ILAKNDNASDLK",
showCompleteVerification = true,
showSecureBackup = true, showSecureBackup = true,
showSecureBackupBadge = true, showSecureBackupBadge = true,
accountManagementUrl = "aUrl", accountManagementUrl = "aUrl",

View file

@ -51,7 +51,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun PreferencesRootView( fun PreferencesRootView(
state: PreferencesRootState, state: PreferencesRootState,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
onVerifyClicked: () -> Unit,
onSecureBackupClicked: () -> Unit, onSecureBackupClicked: () -> Unit,
onManageAccountClicked: (url: String) -> Unit, onManageAccountClicked: (url: String) -> Unit,
onOpenAnalytics: () -> Unit, onOpenAnalytics: () -> Unit,
@ -81,13 +80,6 @@ fun PreferencesRootView(
}, },
user = state.myUser, user = state.myUser,
) )
if (state.showCompleteVerification) {
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.common_verify_device)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.CheckCircle())),
onClick = onVerifyClicked
)
}
if (state.showSecureBackup) { if (state.showSecureBackup) {
ListItem( ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) }, headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) },
@ -95,8 +87,6 @@ fun PreferencesRootView(
trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
onClick = onSecureBackupClicked, onClick = onSecureBackupClicked,
) )
}
if (state.showCompleteVerification || state.showSecureBackup) {
HorizontalDivider() HorizontalDivider()
} }
if (state.accountManagementUrl != null) { if (state.accountManagementUrl != null) {
@ -232,7 +222,6 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onOpenDeveloperSettings = {}, onOpenDeveloperSettings = {},
onOpenAdvancedSettings = {}, onOpenAdvancedSettings = {},
onOpenAbout = {}, onOpenAbout = {},
onVerifyClicked = {},
onSecureBackupClicked = {}, onSecureBackupClicked = {},
onManageAccountClicked = {}, onManageAccountClicked = {},
onOpenNotificationSettings = {}, onOpenNotificationSettings = {},

View file

@ -22,7 +22,7 @@ import android.content.Context
import coil.Coil import coil.Coil
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.features.roomlist.api.migration.MigrationScreenStore import io.element.android.features.roomlist.api.migration.MigrationScreenStore
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -45,7 +45,7 @@ class DefaultClearCacheUseCase @Inject constructor(
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val defaultCacheIndexProvider: DefaultCacheService, private val defaultCacheIndexProvider: DefaultCacheService,
private val okHttpClient: Provider<OkHttpClient>, private val okHttpClient: Provider<OkHttpClient>,
private val ftueState: FtueState, private val ftueService: FtueService,
private val migrationScreenStore: MigrationScreenStore, private val migrationScreenStore: MigrationScreenStore,
) : ClearCacheUseCase { ) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) { override suspend fun invoke() = withContext(coroutineDispatchers.io) {
@ -61,7 +61,7 @@ class DefaultClearCacheUseCase @Inject constructor(
// Clear app cache // Clear app cache
context.cacheDir.deleteRecursively() context.cacheDir.deleteRecursively()
// Clear some settings // Clear some settings
ftueState.reset() ftueService.reset()
// Clear migration screen store // Clear migration screen store
migrationScreenStore.reset() migrationScreenStore.reset()
// Ensure the app is restarted // Ensure the app is restarted

View file

@ -92,7 +92,6 @@ class PreferencesRootPresenterTest {
) )
) )
assertThat(initialState.version).isEqualTo("A Version") assertThat(initialState.version).isEqualTo("A Version")
assertThat(loadedState.showCompleteVerification).isTrue()
assertThat(loadedState.showSecureBackup).isFalse() assertThat(loadedState.showSecureBackup).isFalse()
assertThat(loadedState.showSecureBackupBadge).isTrue() assertThat(loadedState.showSecureBackupBadge).isTrue()
assertThat(loadedState.accountManagementUrl).isNull() assertThat(loadedState.accountManagementUrl).isNull()

View file

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

View file

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

View file

@ -58,7 +58,6 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncService 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.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.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -92,7 +91,6 @@ 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() private val syncService: SyncService = client.syncService()
@Composable @Composable
@ -159,19 +157,12 @@ class RoomListPresenter @Inject constructor(
securityBannerDismissed: Boolean, securityBannerDismissed: Boolean,
): State<SecurityBannerState> { ): State<SecurityBannerState> {
val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed) val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed)
val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false)
val isLastDevice by encryptionService.isLastDevice.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val syncState by syncService.syncState.collectAsState() val syncState by syncService.syncState.collectAsState()
return remember { return remember {
derivedStateOf { derivedStateOf {
when { when {
currentSecurityBannerDismissed -> SecurityBannerState.None currentSecurityBannerDismissed -> SecurityBannerState.None
canVerifySession -> if (isLastDevice) {
SecurityBannerState.RecoveryKeyConfirmation
} else {
SecurityBannerState.SessionVerification
}
recoveryState == RecoveryState.INCOMPLETE && recoveryState == RecoveryState.INCOMPLETE &&
syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation
else -> SecurityBannerState.None else -> SecurityBannerState.None

View file

@ -63,7 +63,6 @@ enum class InvitesState {
enum class SecurityBannerState { enum class SecurityBannerState {
None, None,
SessionVerification,
RecoveryKeyConfirmation, RecoveryKeyConfirmation,
} }

View file

@ -45,7 +45,6 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)), aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)),
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")), aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)), aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()), aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()), aRoomListState(contentState = aSkeletonContentState()),

View file

@ -53,7 +53,6 @@ fun RoomListView(
state: RoomListState, state: RoomListState,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onSettingsClicked: () -> Unit, onSettingsClicked: () -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
onCreateRoomClicked: () -> Unit, onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit, onInvitesClicked: () -> Unit,
@ -86,7 +85,6 @@ fun RoomListView(
RoomListScaffold( RoomListScaffold(
modifier = Modifier.padding(top = topPadding), modifier = Modifier.padding(top = topPadding),
state = state, state = state,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) }, onRoomLongClicked = { onRoomLongClicked(it) },
@ -115,7 +113,6 @@ fun RoomListView(
@Composable @Composable
private fun RoomListScaffold( private fun RoomListScaffold(
state: RoomListState, state: RoomListState,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit, onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@ -154,7 +151,6 @@ private fun RoomListScaffold(
contentState = state.contentState, contentState = state.contentState,
filtersState = state.filtersState, filtersState = state.filtersState,
eventSink = state.eventSink, eventSink = state.eventSink,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = ::onRoomClicked, onRoomClicked = ::onRoomClicked,
onRoomLongClicked = onRoomLongClicked, onRoomLongClicked = onRoomLongClicked,
@ -193,7 +189,6 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
state = state, state = state,
onRoomClicked = {}, onRoomClicked = {},
onSettingsClicked = {}, onSettingsClicked = {},
onVerifyClicked = {},
onConfirmRecoveryKeyClicked = {}, onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {}, onCreateRoomClicked = {},
onInvitesClicked = {}, onInvitesClicked = {},

View file

@ -1,49 +0,0 @@
/*
* Copyright (c) 2023 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.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable
internal fun RequestVerificationHeader(
onVerifyClicked: () -> Unit,
onDismissClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
DialogLikeBannerMolecule(
modifier = modifier,
title = stringResource(R.string.session_verification_banner_title),
content = stringResource(R.string.session_verification_banner_message),
onSubmitClicked = onVerifyClicked,
onDismissClicked = onDismissClicked,
)
}
@PreviewsDayNight
@Composable
internal fun RequestVerificationHeaderPreview() = ElementPreview {
RequestVerificationHeader(
onVerifyClicked = {},
onDismissClicked = {},
)
}

View file

@ -73,7 +73,6 @@ fun RoomListContentView(
contentState: RoomListContentState, contentState: RoomListContentState,
filtersState: RoomListFiltersState, filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvents) -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit, onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit, onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@ -103,7 +102,6 @@ fun RoomListContentView(
state = contentState, state = contentState,
filtersState = filtersState, filtersState = filtersState,
eventSink = eventSink, eventSink = eventSink,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked, onRoomLongClicked = onRoomLongClicked,
@ -161,7 +159,6 @@ private fun RoomsView(
state: RoomListContentState.Rooms, state: RoomListContentState.Rooms,
filtersState: RoomListFiltersState, filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvents) -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit, onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit, onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@ -177,7 +174,6 @@ private fun RoomsView(
RoomsViewList( RoomsViewList(
state = state, state = state,
eventSink = eventSink, eventSink = eventSink,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked, onRoomLongClicked = onRoomLongClicked,
@ -191,7 +187,6 @@ private fun RoomsView(
private fun RoomsViewList( private fun RoomsViewList(
state: RoomListContentState.Rooms, state: RoomListContentState.Rooms,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvents) -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit, onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit, onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@ -222,14 +217,6 @@ private fun RoomsViewList(
contentPadding = PaddingValues(bottom = 80.dp) contentPadding = PaddingValues(bottom = 80.dp)
) { ) {
when (state.securityBannerState) { when (state.securityBannerState) {
SecurityBannerState.SessionVerification -> {
item {
RequestVerificationHeader(
onVerifyClicked = onVerifyClicked,
onDismissClicked = { eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
}
}
SecurityBannerState.RecoveryKeyConfirmation -> { SecurityBannerState.RecoveryKeyConfirmation -> {
item { item {
ConfirmRecoveryKeyBanner( ConfirmRecoveryKeyBanner(
@ -316,10 +303,10 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) } filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
), ),
eventSink = {}, eventSink = {},
onVerifyClicked = { }, onConfirmRecoveryKeyClicked = {},
onConfirmRecoveryKeyClicked = { },
onRoomClicked = {}, onRoomClicked = {},
onRoomLongClicked = {}, onRoomLongClicked = {},
onCreateRoomClicked = { }, onCreateRoomClicked = {},
onInvitesClicked = { }) onInvitesClicked = {}
)
} }

View file

@ -239,52 +239,28 @@ class RoomListPresenterTests {
} }
} }
@Test
fun `present - handle RecoveryKeyConfirmation last session`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val presenter = createRoomListPresenter(
coroutineScope = scope,
client = FakeMatrixClient(
encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true)
},
roomListService = roomListService
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val eventSink = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last().eventSink
// For the last session, the state is not SessionVerification, but RecoveryKeyConfirmation
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
}
}
@Test @Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest { fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob()) val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply { val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
} }
val encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE)
}
val syncService = FakeSyncService(initialState = SyncState.Running)
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = FakeMatrixClient(roomListService = roomListService), client = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService, syncService = syncService),
coroutineScope = scope, coroutineScope = scope,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val eventSink = consumeItemsUntilPredicate { val eventWithContentAsRooms = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms it.contentState is RoomListContentState.Rooms
}.last().eventSink }.last()
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification) val eventSink = eventWithContentAsRooms.eventSink
assertThat(eventWithContentAsRooms.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
eventSink(RoomListEvents.DismissRequestVerificationPrompt) eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None) assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel() scope.cancel()
@ -342,10 +318,10 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
consumeItemsUntilPredicate { val firstItem = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms it.contentState is RoomListContentState.Rooms
} }.last()
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites) assertThat(firstItem.contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
inviteStateFlow.value = InvitesState.SeenInvites inviteStateFlow.value = InvitesState.SeenInvites
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites) assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites)

View file

@ -43,35 +43,6 @@ import org.junit.runner.RunWith
class RoomListViewTest { class RoomListViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @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(
contentState = aRoomsContentState(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(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
eventSink = eventsRecorder,
),
onVerifyClicked = callback,
)
rule.clickOn(CommonStrings.action_continue)
}
}
@Test @Test
fun `clicking on close recovery key banner emits the expected Event`() { fun `clicking on close recovery key banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
@ -185,7 +156,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
state: RoomListState, state: RoomListState,
onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onSettingsClicked: () -> Unit = EnsureNeverCalled(), onSettingsClicked: () -> Unit = EnsureNeverCalled(),
onVerifyClicked: () -> Unit = EnsureNeverCalled(),
onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(), onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(),
onCreateRoomClicked: () -> Unit = EnsureNeverCalled(), onCreateRoomClicked: () -> Unit = EnsureNeverCalled(),
onInvitesClicked: () -> Unit = EnsureNeverCalled(), onInvitesClicked: () -> Unit = EnsureNeverCalled(),
@ -198,7 +168,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
state = state, state = state,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onSettingsClicked = onSettingsClicked, onSettingsClicked = onSettingsClicked,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onCreateRoomClicked = onCreateRoomClicked, onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked, onInvitesClicked = onInvitesClicked,

View file

@ -19,6 +19,7 @@ package io.element.android.features.securebackup.api
import android.os.Parcelable import android.os.Parcelable
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 io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -36,8 +37,13 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface Callback : Plugin {
fun onDone()
}
interface NodeBuilder { interface NodeBuilder {
fun params(params: Params): NodeBuilder fun params(params: Params): NodeBuilder
fun callback(callback: Callback): NodeBuilder
fun build(): Node fun build(): Node
} }
} }

View file

@ -36,6 +36,11 @@ class DefaultSecureBackupEntryPoint @Inject constructor() : SecureBackupEntryPoi
return this return this
} }
override fun callback(callback: SecureBackupEntryPoint.Callback): SecureBackupEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node { override fun build(): Node {
return parentNode.createNode<SecureBackupFlowNode>(buildContext, plugins) return parentNode.createNode<SecureBackupFlowNode>(buildContext, plugins)
} }

View file

@ -22,7 +22,9 @@ 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 com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -74,6 +76,8 @@ class SecureBackupFlowNode @AssistedInject constructor(
data object EnterRecoveryKey : NavTarget data object EnterRecoveryKey : NavTarget
} }
private val callback = plugins<SecureBackupEntryPoint.Callback>().firstOrNull()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Root -> { NavTarget.Root -> {
@ -119,7 +123,16 @@ class SecureBackupFlowNode @AssistedInject constructor(
createNode<SecureBackupEnableNode>(buildContext) createNode<SecureBackupEnableNode>(buildContext)
} }
NavTarget.EnterRecoveryKey -> { NavTarget.EnterRecoveryKey -> {
createNode<SecureBackupEnterRecoveryKeyNode>(buildContext) val callback = object : SecureBackupEnterRecoveryKeyNode.Callback {
override fun onEnterRecoveryKeySuccess() {
if (callback != null) {
callback.onDone()
} else {
backstack.pop()
}
}
}
createNode<SecureBackupEnterRecoveryKeyNode>(buildContext, plugins = listOf(callback))
} }
} }
} }

View file

@ -17,48 +17,36 @@
package io.element.android.features.securebackup.impl.enter package io.element.android.features.securebackup.impl.enter
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier 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.securebackup.impl.R
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor( class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: SecureBackupEnterRecoveryKeyPresenter, private val presenter: SecureBackupEnterRecoveryKeyPresenter,
private val snackbarDispatcher: SnackbarDispatcher,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onEnterRecoveryKeySuccess()
}
private val callback = plugins<Callback>().first()
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val coroutineScope = rememberCoroutineScope()
val state = presenter.present() val state = presenter.present()
SecureBackupEnterRecoveryKeyView( SecureBackupEnterRecoveryKeyView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onDone = { onDone = callback::onEnterRecoveryKeySuccess,
coroutineScope.postSuccessSnackbar()
navigateUp()
},
onBackClicked = ::navigateUp, onBackClicked = ::navigateUp,
) )
} }
private fun CoroutineScope.postSuccessSnackbar() = launch {
snackbarDispatcher.post(
SnackbarMessage(
messageResId = R.string.screen_recovery_key_confirm_success
)
)
}
} }

View file

@ -53,7 +53,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
state = state, state = state,
modifier = modifier, modifier = modifier,
onEnterRecoveryKey = ::onEnterRecoveryKey, onEnterRecoveryKey = ::onEnterRecoveryKey,
goBack = ::onDone, onFinished = ::onDone,
) )
} }
} }

View file

@ -68,10 +68,10 @@ class VerifySelfSessionPresenter @Inject constructor(
when (event) { when (event) {
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification) VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification) VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
VerifySelfSessionViewEvents.Restart -> stateAndDispatch.dispatchAction(StateMachineEvent.Restart)
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge) VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge) VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
VerifySelfSessionViewEvents.CancelAndClose -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel) VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
} }
} }
return VerifySelfSessionState( return VerifySelfSessionState(
@ -118,7 +118,7 @@ class VerifySelfSessionPresenter @Inject constructor(
private fun CoroutineScope.observeVerificationService() { private fun CoroutineScope.observeVerificationService() {
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState -> sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
when (verificationAttemptState) { when (verificationAttemptState) {
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Restart) VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
VerificationFlowState.AcceptedVerificationRequest -> { VerificationFlowState.AcceptedVerificationRequest -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest) stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
} }

View file

@ -20,24 +20,35 @@
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl
import com.freeletics.flowredux.dsl.FlowReduxStateMachine import com.freeletics.flowredux.dsl.FlowReduxStateMachine
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.data.tryOrNull
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.SessionVerificationService
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.timeout
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import com.freeletics.flowredux.dsl.State as MachineState import com.freeletics.flowredux.dsl.State as MachineState
@OptIn(FlowPreview::class)
class VerifySelfSessionStateMachine @Inject constructor( class VerifySelfSessionStateMachine @Inject constructor(
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>( ) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>(
initialState = State.Initial initialState = State.Initial
) { ) {
init { init {
spec { spec {
inState<State.Initial> { inState<State.Initial> {
on { _: Event.RequestVerification, state: MachineState<State.Initial> -> on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification } state.override { State.RequestingVerification }
} }
on { _: Event.StartSasVerification, state: MachineState<State.Initial> -> on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification } state.override { State.StartingSasVerification }
} }
} }
@ -45,12 +56,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
onEnterEffect { onEnterEffect {
sessionVerificationService.requestVerification() sessionVerificationService.requestVerification()
} }
on { _: Event.DidAcceptVerificationRequest, state: MachineState<State.RequestingVerification> -> on { _: Event.DidAcceptVerificationRequest, state ->
state.override { State.VerificationRequestAccepted } state.override { State.VerificationRequestAccepted }
} }
on { _: Event.DidFail, state: MachineState<State.RequestingVerification> ->
state.override { State.Initial }
}
} }
inState<State.StartingSasVerification> { inState<State.StartingSasVerification> {
onEnterEffect { onEnterEffect {
@ -58,25 +66,28 @@ class VerifySelfSessionStateMachine @Inject constructor(
} }
} }
inState<State.VerificationRequestAccepted> { inState<State.VerificationRequestAccepted> {
on { _: Event.StartSasVerification, state: MachineState<State.VerificationRequestAccepted> -> on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification } state.override { State.StartingSasVerification }
} }
} }
inState<State.Canceled> { inState<State.Canceled> {
on { _: Event.Restart, state: MachineState<State.Canceled> -> on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification } state.override { State.RequestingVerification }
} }
on { _: Event.Reset, state ->
state.override { State.Initial }
}
} }
inState<State.SasVerificationStarted> { inState<State.SasVerificationStarted> {
on { event: Event.DidReceiveChallenge, state: MachineState<State.SasVerificationStarted> -> on { event: Event.DidReceiveChallenge, state ->
state.override { State.Verifying.ChallengeReceived(event.data) } state.override { State.Verifying.ChallengeReceived(event.data) }
} }
} }
inState<State.Verifying.ChallengeReceived> { inState<State.Verifying.ChallengeReceived> {
on { _: Event.AcceptChallenge, state: MachineState<State.Verifying.ChallengeReceived> -> on { _: Event.AcceptChallenge, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = true) } state.override { State.Verifying.Replying(state.snapshot.data, accept = true) }
} }
on { _: Event.DeclineChallenge, state: MachineState<State.Verifying.ChallengeReceived> -> on { _: Event.DeclineChallenge, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = false) } state.override { State.Verifying.Replying(state.snapshot.data, accept = false) }
} }
} }
@ -88,11 +99,21 @@ class VerifySelfSessionStateMachine @Inject constructor(
sessionVerificationService.declineVerification() sessionVerificationService.declineVerification()
} }
} }
on { _: Event.DidAcceptChallenge, state: MachineState<State.Verifying.Replying> -> on { _: Event.DidAcceptChallenge, state ->
// If a key backup exists, wait until it's restored or a timeout happens
val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse()
if (hasBackup) {
tryOrNull {
encryptionService.recoveryStateStateFlow.filter { it == RecoveryState.ENABLED }
.timeout(10.seconds)
.first()
}
}
state.override { State.Completed } state.override { State.Completed }
} }
} }
inState<State.Canceling> { inState<State.Canceling> {
// TODO The 'Canceling' -> 'Canceled' transitions doesn't seem to work anymore, check if something changed in the Rust SDK
onEnterEffect { onEnterEffect {
sessionVerificationService.cancelVerification() sessionVerificationService.cancelVerification()
} }
@ -102,21 +123,24 @@ class VerifySelfSessionStateMachine @Inject constructor(
state.override { State.SasVerificationStarted } state.override { State.SasVerificationStarted }
} }
on { _: Event.Cancel, state: MachineState<State> -> on { _: Event.Cancel, state: MachineState<State> ->
if (state.snapshot in sequenceOf( when (state.snapshot) {
State.Initial, State.Initial, State.Completed, State.Canceled -> state.noChange()
State.Completed, // For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
State.Canceled // `Canceling` state to `Canceled` automatically anymore
)) { else -> {
state.noChange() sessionVerificationService.cancelVerification()
} else { state.override { State.Canceled }
state.override { State.Canceling } }
} }
} }
on { _: Event.DidCancel, state: MachineState<State> -> on { _: Event.DidCancel, state: MachineState<State> ->
state.override { State.Canceled } state.override { State.Canceled }
} }
on { _: Event.DidFail, state: MachineState<State> -> on { _: Event.DidFail, state: MachineState<State> ->
state.override { State.Canceled } when (state.snapshot) {
is State.RequestingVerification -> state.override { State.Initial }
else -> state.override { State.Canceled }
}
} }
} }
} }
@ -190,7 +214,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
/** Request failed. */ /** Request failed. */
data object DidFail : Event data object DidFail : Event
/** Restart the verification flow. */ /** Reset the verification flow to the initial state. */
data object Restart : Event data object Reset : Event
} }
} }

View file

@ -30,9 +30,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -42,15 +39,16 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.verifysession.impl.emoji.toEmojiResource import io.element.android.features.verifysession.impl.emoji.toEmojiResource
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
@ -62,36 +60,37 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver
fun VerifySelfSessionView( fun VerifySelfSessionView(
state: VerifySelfSessionState, state: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit, onEnterRecoveryKey: () -> Unit,
goBack: () -> Unit, onFinished: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun goBackAndCancelIfNeeded() { fun resetFlow() {
state.eventSink(VerifySelfSessionViewEvents.CancelAndClose) state.eventSink(VerifySelfSessionViewEvents.Reset)
goBack()
}
if (state.verificationFlowStep is FlowStep.Completed) {
goBack()
} }
BackHandler { BackHandler {
goBackAndCancelIfNeeded() when (state.verificationFlowStep) {
is FlowStep.Canceled -> resetFlow()
is FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
is FlowStep.Verifying -> {
if (!state.verificationFlowStep.state.isLoading()) {
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
}
}
else -> Unit
}
} }
val verificationFlowStep = state.verificationFlowStep val verificationFlowStep = state.verificationFlowStep
val buttonsVisible by remember(verificationFlowStep) {
derivedStateOf { verificationFlowStep != FlowStep.AwaitingOtherDeviceResponse && verificationFlowStep != FlowStep.Completed }
}
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, modifier = modifier,
header = { header = {
HeaderContent(verificationFlowStep = verificationFlowStep) HeaderContent(verificationFlowStep = verificationFlowStep)
}, },
footer = { footer = {
if (buttonsVisible) { BottomMenu(
BottomMenu( screenState = state,
screenState = state, goBack = ::resetFlow,
goBack = ::goBackAndCancelIfNeeded, onEnterRecoveryKey = onEnterRecoveryKey,
onEnterRecoveryKey = onEnterRecoveryKey onFinished = onFinished,
) )
}
} }
) { ) {
Content(flowState = verificationFlowStep) Content(flowState = verificationFlowStep)
@ -100,40 +99,38 @@ fun VerifySelfSessionView(
@Composable @Composable
private fun HeaderContent(verificationFlowStep: FlowStep) { private fun HeaderContent(verificationFlowStep: FlowStep) {
val iconResourceId = when (verificationFlowStep) { val iconStyle = when (verificationFlowStep) {
is FlowStep.Initial -> R.drawable.ic_verification_devices is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
FlowStep.Canceled -> R.drawable.ic_verification_warning FlowStep.Canceled -> BigIcon.Style.AlertSolid
FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji FlowStep.Completed -> BigIcon.Style.SuccessSolid
} }
val titleTextId = when (verificationFlowStep) { val titleTextId = when (verificationFlowStep) {
is FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_title is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_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.Ready -> R.string.screen_session_verification_compare_emojis_title
FlowStep.Ready, FlowStep.Completed -> R.string.screen_identity_confirmed_title
FlowStep.Completed -> R.string.screen_session_verification_compare_emojis_title
is FlowStep.Verifying -> when (verificationFlowStep.data) { is FlowStep.Verifying -> when (verificationFlowStep.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
} }
} }
val subtitleTextId = when (verificationFlowStep) { val subtitleTextId = when (verificationFlowStep) {
is FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_subtitle is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_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.Ready -> R.string.screen_session_verification_ready_subtitle FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle
FlowStep.Completed -> R.string.screen_session_verification_compare_emojis_subtitle FlowStep.Completed -> R.string.screen_identity_confirmation_subtitle
is FlowStep.Verifying -> when (verificationFlowStep.data) { is FlowStep.Verifying -> when (verificationFlowStep.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
} }
} }
IconTitleSubtitleMolecule( PageTitle(
modifier = Modifier.padding(top = 60.dp), modifier = Modifier.padding(top = 60.dp),
iconResourceId = iconResourceId, iconStyle = iconStyle,
title = stringResource(id = titleTextId), title = stringResource(id = titleTextId),
subTitle = stringResource(id = subtitleTextId) subtitle = stringResource(id = subtitleTextId)
) )
} }
@ -141,20 +138,12 @@ 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) {
is FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting()
is FlowStep.Verifying -> ContentVerifying(flowState) is FlowStep.Verifying -> ContentVerifying(flowState)
} }
} }
} }
@Composable
private fun ContentWaiting() {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
}
@Composable @Composable
private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) { private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) {
when (verificationFlowStep.data) { when (verificationFlowStep.data) {
@ -212,76 +201,102 @@ private fun BottomMenu(
screenState: VerifySelfSessionState, screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit, onEnterRecoveryKey: () -> Unit,
goBack: () -> Unit, goBack: () -> Unit,
onFinished: () -> 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) {
is FlowStep.Initial -> R.string.screen_session_verification_positive_button_initial when (verificationViewState) {
FlowStep.Canceled -> R.string.screen_session_verification_positive_button_canceled is FlowStep.Initial -> {
BottomMenu(
positiveButtonTitle = stringResource(R.string.screen_identity_use_another_device),
onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
negativeButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
onNegativeButtonClicked = onEnterRecoveryKey,
)
}
is FlowStep.Canceled -> {
BottomMenu(
positiveButtonTitle = stringResource(R.string.screen_session_verification_positive_button_canceled),
onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
negativeButtonTitle = stringResource(CommonStrings.action_cancel),
onNegativeButtonClicked = goBack,
)
}
is FlowStep.Ready -> {
BottomMenu(
positiveButtonTitle = stringResource(CommonStrings.action_start),
onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) },
negativeButtonTitle = stringResource(CommonStrings.action_cancel),
onNegativeButtonClicked = goBack,
)
}
is FlowStep.AwaitingOtherDeviceResponse -> {
BottomMenu(
positiveButtonTitle = stringResource(R.string.screen_identity_waiting_on_other_device),
onPositiveButtonClicked = {},
isLoading = true,
)
}
is FlowStep.Verifying -> { is FlowStep.Verifying -> {
if (isVerifying) { val positiveButtonTitle = if (isVerifying) {
R.string.screen_session_verification_positive_button_verifying_ongoing stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
} else { } else {
R.string.screen_session_verification_they_match stringResource(R.string.screen_session_verification_they_match)
} }
BottomMenu(
positiveButtonTitle = positiveButtonTitle,
onPositiveButtonClicked = {
if (!isVerifying) {
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
}
},
negativeButtonTitle = stringResource(R.string.screen_session_verification_they_dont_match),
onNegativeButtonClicked = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
isLoading = isVerifying,
)
} }
FlowStep.Ready -> CommonStrings.action_start is FlowStep.Completed -> {
else -> null BottomMenu(
} positiveButtonTitle = stringResource(CommonStrings.action_continue),
val negativeButtonTitle = when (verificationViewState) { onPositiveButtonClicked = onFinished,
is FlowStep.Initial -> CommonStrings.action_cancel )
FlowStep.Canceled -> CommonStrings.action_cancel
is FlowStep.Verifying -> R.string.screen_session_verification_they_dont_match
else -> null
}
val negativeButtonEnabled = !isVerifying
val positiveButtonEvent = when (verificationViewState) {
is FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification
FlowStep.Ready -> VerifySelfSessionViewEvents.StartSasVerification
is FlowStep.Verifying -> if (!isVerifying) VerifySelfSessionViewEvents.ConfirmVerification else null
FlowStep.Canceled -> VerifySelfSessionViewEvents.Restart
else -> null
}
val negativeButtonCallback: () -> Unit = when (verificationViewState) {
is FlowStep.Verifying -> {
{ eventSink(VerifySelfSessionViewEvents.DeclineVerification) }
} }
else -> goBack
} }
}
@Composable
private fun BottomMenu(
positiveButtonTitle: String?,
onPositiveButtonClicked: () -> Unit,
modifier: Modifier = Modifier,
negativeButtonTitle: String? = null,
negativeButtonEnabled: Boolean = negativeButtonTitle != null,
onNegativeButtonClicked: () -> Unit = {},
isLoading: Boolean = false,
) {
ButtonColumnMolecule( ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 20.dp) modifier = modifier.padding(bottom = 16.dp)
) { ) {
if (positiveButtonTitle != null) { if (positiveButtonTitle != null) {
Button( Button(
text = stringResource(positiveButtonTitle), text = positiveButtonTitle,
showProgress = isVerifying, showProgress = isLoading,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = { positiveButtonEvent?.let { eventSink(it) } } onClick = onPositiveButtonClicked,
) )
} }
if (negativeButtonTitle != null) { if (negativeButtonTitle != null) {
TextButton( TextButton(
text = stringResource(negativeButtonTitle), text = negativeButtonTitle,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = negativeButtonCallback, onClick = onNegativeButtonClicked,
enabled = negativeButtonEnabled, enabled = negativeButtonEnabled,
) )
} } else {
if (verificationViewState is FlowStep.Initial && verificationViewState.canEnterRecoveryKey) { Spacer(modifier = Modifier.height(48.dp))
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,
)
} }
} }
} }
@ -292,6 +307,6 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
VerifySelfSessionView( VerifySelfSessionView(
state = state, state = state,
onEnterRecoveryKey = {}, onEnterRecoveryKey = {},
goBack = {}, onFinished = {},
) )
} }

View file

@ -19,8 +19,8 @@ package io.element.android.features.verifysession.impl
sealed interface VerifySelfSessionViewEvents { sealed interface VerifySelfSessionViewEvents {
data object RequestVerification : VerifySelfSessionViewEvents data object RequestVerification : VerifySelfSessionViewEvents
data object StartSasVerification : VerifySelfSessionViewEvents data object StartSasVerification : VerifySelfSessionViewEvents
data object Restart : VerifySelfSessionViewEvents
data object ConfirmVerification : VerifySelfSessionViewEvents data object ConfirmVerification : VerifySelfSessionViewEvents
data object DeclineVerification : VerifySelfSessionViewEvents data object DeclineVerification : VerifySelfSessionViewEvents
data object CancelAndClose : VerifySelfSessionViewEvents data object Cancel : VerifySelfSessionViewEvents
data object Reset : VerifySelfSessionViewEvents
} }

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M8.3,35.5V11Q8.3,9.8 9.2,8.9Q10.1,8 11.3,8H40.8Q41.45,8 41.875,8.425Q42.3,8.85 42.3,9.5Q42.3,10.15 41.875,10.575Q41.45,11 40.8,11H11.3Q11.3,11 11.3,11Q11.3,11 11.3,11V35.5H20.75Q21.7,35.5 22.35,36.15Q23,36.8 23,37.75Q23,38.7 22.35,39.35Q21.7,40 20.75,40H6.25Q5.3,40 4.65,39.35Q4,38.7 4,37.75Q4,36.8 4.65,36.15Q5.3,35.5 6.25,35.5ZM27.95,40Q27.15,40 26.575,39.4Q26,38.8 26,37.8V15.95Q26,15.15 26.575,14.575Q27.15,14 27.95,14H41.55Q42.55,14 43.275,14.575Q44,15.15 44,15.95V37.8Q44,38.8 43.275,39.4Q42.55,40 41.55,40ZM29,35.5H41V17H29Z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M31.3,21.35Q32.45,21.35 33.225,20.575Q34,19.8 34,18.65Q34,17.5 33.225,16.725Q32.45,15.95 31.3,15.95Q30.15,15.95 29.375,16.725Q28.6,17.5 28.6,18.65Q28.6,19.8 29.375,20.575Q30.15,21.35 31.3,21.35ZM16.7,21.35Q17.85,21.35 18.625,20.575Q19.4,19.8 19.4,18.65Q19.4,17.5 18.625,16.725Q17.85,15.95 16.7,15.95Q15.55,15.95 14.775,16.725Q14,17.5 14,18.65Q14,19.8 14.775,20.575Q15.55,21.35 16.7,21.35ZM24,34.95Q26.85,34.95 29.375,33.6Q31.9,32.25 33.35,29.85Q33.75,29.25 33.425,28.8Q33.1,28.35 32.4,28.35H15.6Q14.9,28.35 14.6,28.8Q14.3,29.25 14.7,29.85Q16.15,32.25 18.65,33.6Q21.15,34.95 24,34.95ZM24,44Q19.9,44 16.25,42.425Q12.6,40.85 9.875,38.125Q7.15,35.4 5.575,31.75Q4,28.1 4,23.95Q4,19.85 5.575,16.2Q7.15,12.55 9.875,9.85Q12.6,7.15 16.25,5.575Q19.9,4 24.05,4Q28.15,4 31.8,5.575Q35.45,7.15 38.15,9.85Q40.85,12.55 42.425,16.2Q44,19.85 44,24Q44,28.1 42.425,31.75Q40.85,35.4 38.15,38.125Q35.45,40.85 31.8,42.425Q28.15,44 24,44ZM24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24ZM24,41Q31.1,41 36.05,36.025Q41,31.05 41,24Q41,16.9 36.05,11.95Q31.1,7 24,7Q16.95,7 11.975,11.95Q7,16.9 7,24Q7,31.05 11.975,36.025Q16.95,41 24,41Z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M15.8,41H32.2V34.65Q32.2,31.15 29.825,28.625Q27.45,26.1 24,26.1Q20.55,26.1 18.175,28.625Q15.8,31.15 15.8,34.65ZM38.5,44H9.5Q8.85,44 8.425,43.575Q8,43.15 8,42.5Q8,41.85 8.425,41.425Q8.85,41 9.5,41H12.8V34.65Q12.8,31.15 14.625,28.225Q16.45,25.3 19.7,24Q16.45,22.7 14.625,19.75Q12.8,16.8 12.8,13.3V7H9.5Q8.85,7 8.425,6.575Q8,6.15 8,5.5Q8,4.85 8.425,4.425Q8.85,4 9.5,4H38.5Q39.15,4 39.575,4.425Q40,4.85 40,5.5Q40,6.15 39.575,6.575Q39.15,7 38.5,7H35.2V13.3Q35.2,16.8 33.35,19.75Q31.5,22.7 28.3,24Q31.55,25.3 33.375,28.225Q35.2,31.15 35.2,34.65V41H38.5Q39.15,41 39.575,41.425Q40,41.85 40,42.5Q40,43.15 39.575,43.575Q39.15,44 38.5,44Z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M24,24.6Q24.65,24.6 25.075,24.175Q25.5,23.75 25.5,23.1V15.75Q25.5,15.1 25.075,14.675Q24.65,14.25 24,14.25Q23.35,14.25 22.925,14.675Q22.5,15.1 22.5,15.75V23.1Q22.5,23.75 22.925,24.175Q23.35,24.6 24,24.6ZM24,31.3Q24.7,31.3 25.2,30.8Q25.7,30.3 25.7,29.6Q25.7,28.9 25.2,28.4Q24.7,27.9 24,27.9Q23.3,27.9 22.8,28.4Q22.3,28.9 22.3,29.6Q22.3,30.3 22.8,30.8Q23.3,31.3 24,31.3ZM24,43.85Q23.8,43.85 23.625,43.825Q23.45,43.8 23.3,43.75Q16.6,41.75 12.3,35.525Q8,29.3 8,21.85V12.05Q8,11.1 8.55,10.325Q9.1,9.55 9.95,9.2L22.95,4.35Q23.5,4.15 24,4.15Q24.5,4.15 25.05,4.35L38.05,9.2Q38.9,9.55 39.45,10.325Q40,11.1 40,12.05V21.85Q40,29.3 35.7,35.525Q31.4,41.75 24.7,43.75Q24.7,43.75 24,43.85ZM24,40.85Q29.75,38.95 33.375,33.675Q37,28.4 37,21.85V12.05Q37,12.05 37,12.05Q37,12.05 37,12.05L24,7.15Q24,7.15 24,7.15Q24,7.15 24,7.15L11,12.05Q11,12.05 11,12.05Q11,12.05 11,12.05V21.85Q11,28.4 14.625,33.675Q18.25,38.95 24,40.85ZM24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Z"/>
</vector>

View file

@ -1,5 +1,11 @@
<?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="screen_identity_confirmation_subtitle">"Verify this device to set up secure messaging."</string>
<string name="screen_identity_confirmation_title">"Confirm that it\'s you"</string>
<string name="screen_identity_confirmed_subtitle">"Now you can read or send messages securely, and anyone you chat with can also trust this device."</string>
<string name="screen_identity_confirmed_title">"Device verified"</string>
<string name="screen_identity_use_another_device">"Use another device"</string>
<string name="screen_identity_waiting_on_other_device">"Waiting on other device…"</string>
<string name="screen_session_verification_cancelled_subtitle">"Something doesnt seem right. Either the request timed out or the request was denied."</string> <string name="screen_session_verification_cancelled_subtitle">"Something doesnt seem right. Either the request timed out or the request was denied."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Confirm that the emojis below match those shown on your other session."</string> <string name="screen_session_verification_compare_emojis_subtitle">"Confirm that the emojis below match those shown on your other session."</string>
<string name="screen_session_verification_compare_emojis_title">"Compare emojis"</string> <string name="screen_session_verification_compare_emojis_title">"Compare emojis"</string>

View file

@ -106,13 +106,13 @@ class VerifySelfSessionPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
val eventSink = initialState.eventSink val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.CancelAndClose) eventSink(VerifySelfSessionViewEvents.Cancel)
expectNoEvents() expectNoEvents()
} }
} }
@Test @Test
fun `present - A fail in the flow cancels it`() = runTest { fun `present - A failure when verifying cancels it`() = runTest {
val service = FakeSessionVerificationService() val service = FakeSessionVerificationService()
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -128,6 +128,21 @@ class VerifySelfSessionPresenterTests {
} }
} }
@Test
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest {
val service = FakeSessionVerificationService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
service.shouldFail = true
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
service.shouldFail = false
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
}
}
@Test @Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest { fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
val service = FakeSessionVerificationService() val service = FakeSessionVerificationService()
@ -136,8 +151,7 @@ class VerifySelfSessionPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.CancelAndClose) state.eventSink(VerifySelfSessionViewEvents.Cancel)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
} }
} }
@ -165,13 +179,30 @@ class VerifySelfSessionPresenterTests {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.Canceled) service.givenVerificationFlowState(VerificationFlowState.Canceled)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Restart) state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Went back to requesting verification // Went back to requesting verification
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test
fun `present - Go back after cancelation returns to initial state`() = runTest {
val service = FakeSessionVerificationService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.Canceled)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset)
// Went back to initial state
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - When verification is approved, the flow completes if there is no error`() = runTest { fun `present - When verification is approved, the flow completes if there is no error`() = runTest {
val emojis = listOf( val emojis = listOf(
@ -247,7 +278,7 @@ class VerifySelfSessionPresenterTests {
return VerifySelfSessionPresenter( return VerifySelfSessionPresenter(
sessionVerificationService = service, sessionVerificationService = service,
encryptionService = encryptionService, encryptionService = encryptionService,
stateMachine = VerifySelfSessionStateMachine(service), stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
) )
} }
} }

View file

@ -36,45 +36,98 @@ class VerifySelfSessionViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on cancel calls the expected callback and emits the expected Event`() { fun `back key pressed - when canceled resets the flow`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
ensureCalledOnce { callback -> rule.setContent {
rule.setContent { VerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true), eventSink = eventsRecorder
eventSink = eventsRecorder ),
), onEnterRecoveryKey = EnsureNeverCalled(),
onEnterRecoveryKey = EnsureNeverCalled(), onFinished = EnsureNeverCalled(),
goBack = callback, )
)
}
rule.clickOn(CommonStrings.action_cancel)
} }
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.CancelAndClose) rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Reset)
} }
@Test @Test
fun `clicking on back key calls the expected callback and emits the expected Event`() { fun `back key pressed - when awaiting response cancels the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
ensureCalledOnce { callback -> rule.setContent {
rule.setContent { VerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true), eventSink = eventsRecorder
eventSink = eventsRecorder ),
), onEnterRecoveryKey = EnsureNeverCalled(),
onEnterRecoveryKey = EnsureNeverCalled(), onFinished = EnsureNeverCalled(),
goBack = callback, )
)
}
rule.pressBackKey()
} }
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.CancelAndClose) rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
} }
@Test @Test
fun `when flow is completed, the expected callback is invoked`() { fun `back key pressed - when ready to verify cancels the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
}
@Test
fun `back key pressed - when verifying and not loading declines the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
}
@Test
fun `back key pressed - when verifying and loading does nothing`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Loading(),
),
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.pressBackKey()
eventsRecorder.assertEmpty()
}
@Test
fun `when flow is completed and the user clicks on the continue button, the expected callback is invoked`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setContent { rule.setContent {
@ -84,9 +137,10 @@ class VerifySelfSessionViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onEnterRecoveryKey = EnsureNeverCalled(), onEnterRecoveryKey = EnsureNeverCalled(),
goBack = callback, onFinished = callback,
) )
} }
rule.clickOn(CommonStrings.action_continue)
} }
} }
@ -102,7 +156,7 @@ class VerifySelfSessionViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onEnterRecoveryKey = callback, onEnterRecoveryKey = callback,
goBack = EnsureNeverCalled(), onFinished = EnsureNeverCalled(),
) )
} }
rule.clickOn(R.string.screen_session_verification_enter_recovery_key) rule.clickOn(R.string.screen_session_verification_enter_recovery_key)
@ -122,7 +176,7 @@ class VerifySelfSessionViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onEnterRecoveryKey = EnsureNeverCalled(), onEnterRecoveryKey = EnsureNeverCalled(),
goBack = EnsureNeverCalled(), onFinished = EnsureNeverCalled(),
) )
} }
rule.clickOn(R.string.screen_session_verification_they_match) rule.clickOn(R.string.screen_session_verification_they_match)
@ -142,7 +196,7 @@ class VerifySelfSessionViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onEnterRecoveryKey = EnsureNeverCalled(), onEnterRecoveryKey = EnsureNeverCalled(),
goBack = EnsureNeverCalled(), onFinished = EnsureNeverCalled(),
) )
} }
rule.clickOn(R.string.screen_session_verification_they_dont_match) rule.clickOn(R.string.screen_session_verification_they_dont_match)

View file

@ -32,6 +32,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -95,3 +96,14 @@ internal fun IconTitleSubtitleMoleculePreview() = ElementPreview {
subTitle = "Subtitle", subTitle = "Subtitle",
) )
} }
@PreviewsDayNight
@Composable
internal fun IconTitleSubtitleMoleculeWithResIconPreview() = ElementPreview {
IconTitleSubtitleMolecule(
iconResourceId = CompoundDrawables.ic_compound_admin,
iconTint = Color.Black,
title = "Title",
subTitle = "Subtitle",
)
}

View file

@ -52,9 +52,7 @@ fun PageTitle(
callToAction: @Composable (() -> Unit)? = null, callToAction: @Composable (() -> Unit)? = null,
) { ) {
Column( Column(
modifier = modifier modifier = modifier.fillMaxWidth(),
.fillMaxWidth()
.padding(bottom = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
BigIcon(style = iconStyle) BigIcon(style = iconStyle)

View file

@ -85,6 +85,9 @@ sealed interface SessionVerifiedStatus {
/** Verified session status. */ /** Verified session status. */
data object Verified : SessionVerifiedStatus data object Verified : SessionVerifiedStatus
/** Returns whether the session is [Verified]. */
fun isVerified(): Boolean = this is Verified
} }
/** States produced by the [SessionVerificationService]. */ /** States produced by the [SessionVerificationService]. */

View file

@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryServic
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.roomlist.awaitLoaded import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
import io.element.android.libraries.matrix.api.sync.SyncService 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.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
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.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -84,8 +85,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -130,11 +130,6 @@ 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(
client = client,
syncService = rustSyncService,
sessionCoroutineScope = sessionCoroutineScope,
).apply { start() }
private val pushersService = RustPushersService( private val pushersService = RustPushersService(
client = client, client = client,
dispatchers = dispatchers, dispatchers = dispatchers,
@ -230,6 +225,12 @@ class RustMatrixClient(
), ),
) )
private val verificationService = RustSessionVerificationService(
client = client,
isSyncServiceReady = rustSyncService.syncState.map { it == SyncState.Running },
sessionCoroutineScope = sessionCoroutineScope,
)
private val eventFilters = TimelineConfig.excludedEvents private val eventFilters = TimelineConfig.excludedEvents
.takeIf { it.isNotEmpty() } .takeIf { it.isNotEmpty() }
?.let { listStateEventType -> ?.let { listStateEventType ->
@ -274,11 +275,6 @@ class RustMatrixClient(
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf()) .stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf())
init { init {
roomListService.state.onEach { state ->
if (state == RoomListService.State.Running) {
setupVerificationControllerIfNeeded()
}
}.launchIn(sessionCoroutineScope)
sessionCoroutineScope.launch { sessionCoroutineScope.launch {
// Force a refresh of the profile // Force a refresh of the profile
getUserProfile() getUserProfile()
@ -490,6 +486,7 @@ class RustMatrixClient(
ignoreSdkError: Boolean, ignoreSdkError: Boolean,
): String? { ): String? {
var result: String? = null var result: String? = null
syncService.stop()
withContext(sessionDispatcher) { withContext(sessionDispatcher) {
if (doRequest) { if (doRequest) {
try { try {
@ -525,16 +522,6 @@ class RustMatrixClient(
} }
} }
private fun setupVerificationControllerIfNeeded() {
if (verificationService.verificationController == null) {
try {
verificationService.verificationController = client.getSessionVerificationController()
} catch (e: Throwable) {
Timber.e(e, "Could not start verification service. Will try again on the next sliding sync update.")
}
}
}
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
private suspend fun File.getCacheSize( private suspend fun File.getCacheSize(

View file

@ -16,92 +16,135 @@
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.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.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
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.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
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.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.Encryption import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.RecoveryState import org.matrix.rustcomponents.sdk.RecoveryState
import org.matrix.rustcomponents.sdk.RecoveryStateListener 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.TaskHandle import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.VerificationState
import org.matrix.rustcomponents.sdk.VerificationStateListener
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData
class RustSessionVerificationService( class RustSessionVerificationService(
client: Client, client: Client,
private val syncService: RustSyncService, isSyncServiceReady: Flow<Boolean>,
private val sessionCoroutineScope: CoroutineScope, sessionCoroutineScope: CoroutineScope,
) : SessionVerificationService, SessionVerificationControllerDelegate { ) : SessionVerificationService, SessionVerificationControllerDelegate {
private var verificationStateListenerTaskHandle: TaskHandle? = null
private var recoveryStateListenerTaskHandle: TaskHandle? = null private var recoveryStateListenerTaskHandle: TaskHandle? = null
private val encryptionService: Encryption = client.encryption() private val encryptionService: Encryption = client.encryption()
var verificationController: SessionVerificationControllerInterface? = null private lateinit var verificationController: SessionVerificationController
set(value) {
field = value
_isReady.value = value != null
// If status was 'Unknown', move it to either 'Verified' or 'NotVerified'
if (value != null) {
value.setDelegate(this)
sessionCoroutineScope.launch { updateVerificationStatus(value.isVerified()) }
}
}
private val _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial) private val _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
override val verificationFlowState = _verificationFlowState.asStateFlow() override val verificationFlowState = _verificationFlowState.asStateFlow()
private val _isReady = MutableStateFlow(false)
override val isReady = _isReady.asStateFlow()
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown) private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow() override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
override val canVerifySessionFlow = combine(sessionVerifiedStatus, syncService.syncState) { verificationStatus, syncState -> override val isReady = MutableStateFlow(false)
syncState == SyncState.Running && verificationStatus == SessionVerifiedStatus.NotVerified
override val canVerifySessionFlow = combine(sessionVerifiedStatus, isReady) { verificationStatus, isReady ->
isReady && verificationStatus == SessionVerifiedStatus.NotVerified
} }
fun start() { init {
recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener { isSyncServiceReady
override fun onUpdate(status: RecoveryState) { .onEach { syncServiceReady ->
sessionCoroutineScope.launch { if (syncServiceReady) {
updateVerificationStatus(verificationController?.isVerified().orFalse()) isReady.value = true
runCatching {
// If the controller was failed to initialize before, we try to get it again
if (!this::verificationController.isInitialized) {
verificationController = client.getSessionVerificationController()
}
}
.onFailure {
isReady.value = false
Timber.e(it, "Failed to get verification controller. Trying again in next sync.")
}
} else {
isReady.value = false
} }
} }
}) .launchIn(sessionCoroutineScope)
isReady.onEach { isReady ->
if (isReady) {
Timber.d("Starting verification service")
// Setup delegate
verificationController.setDelegate(this)
// Immediate status update
updateVerificationStatus(encryptionService.verificationState())
// Listen for changes in verification status and update accordingly
verificationStateListenerTaskHandle?.cancelAndDestroy()
verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
override fun onUpdate(status: VerificationState) {
Timber.d("New verification state: $status")
updateVerificationStatus(status)
}
})
// In case we enter the recovery key instead we check changes in the recovery state, since the listener above won't be triggered
recoveryStateListenerTaskHandle?.cancelAndDestroy()
recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener {
override fun onUpdate(status: RecoveryState) {
Timber.d("New recovery state: $status")
// We could check the `RecoveryState`, but it's easier to just use the verification state directly
updateVerificationStatus(encryptionService.verificationState())
}
})
} else {
Timber.d("Stopping verification service")
if (this::verificationController.isInitialized) {
verificationController.setDelegate(null)
}
}
}
.launchIn(sessionCoroutineScope)
} }
override suspend fun requestVerification() = tryOrFail { override suspend fun requestVerification() = tryOrFail {
verificationController?.requestVerification() verificationController.requestVerification()
} }
override suspend fun cancelVerification() = tryOrFail { verificationController?.cancelVerification() } override suspend fun cancelVerification() = tryOrFail { verificationController.cancelVerification() }
override suspend fun approveVerification() = tryOrFail { verificationController?.approveVerification() } override suspend fun approveVerification() = tryOrFail { verificationController.approveVerification() }
override suspend fun declineVerification() = tryOrFail { verificationController?.declineVerification() } override suspend fun declineVerification() = tryOrFail { verificationController.declineVerification() }
override suspend fun startVerification() = tryOrFail { override suspend fun startVerification() = tryOrFail {
verificationController?.startSasVerification() verificationController.startSasVerification()
} }
private suspend fun tryOrFail(block: suspend () -> Unit) { private suspend fun tryOrFail(block: suspend () -> Unit) {
runCatching { runCatching {
block() block()
}.onFailure { didFail() } }.onFailure {
Timber.e(it, "Failed to verify session")
didFail()
}
} }
// region Delegate implementation // region Delegate implementation
@ -116,13 +159,14 @@ class RustSessionVerificationService(
} }
override fun didFail() { override fun didFail() {
Timber.e("Session verification failed with an unknown error")
_verificationFlowState.value = VerificationFlowState.Failed _verificationFlowState.value = VerificationFlowState.Failed
} }
override fun didFinish() { override fun didFinish() {
_verificationFlowState.value = VerificationFlowState.Finished _verificationFlowState.value = VerificationFlowState.Finished
// Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it always returns false // Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it always returns false
updateVerificationStatus(isVerified = true) updateVerificationStatus(VerificationState.VERIFIED)
} }
override fun didReceiveVerificationData(data: RustSessionVerificationData) { override fun didReceiveVerificationData(data: RustSessionVerificationData) {
@ -139,25 +183,26 @@ class RustSessionVerificationService(
override suspend fun reset() { override suspend fun reset() {
if (isReady.value) { if (isReady.value) {
// Cancel any pending verification attempt // Cancel any pending verification attempt
tryOrNull { verificationController?.cancelVerification() } tryOrNull { verificationController.cancelVerification() }
} }
_verificationFlowState.value = VerificationFlowState.Initial _verificationFlowState.value = VerificationFlowState.Initial
} }
fun destroy() { fun destroy() {
Timber.d("Destroying RustSessionVerificationService")
recoveryStateListenerTaskHandle?.cancelAndDestroy() recoveryStateListenerTaskHandle?.cancelAndDestroy()
verificationController?.setDelegate(null) if (this::verificationController.isInitialized) {
(verificationController as? SessionVerificationController)?.destroy() verificationController.setDelegate(null)
verificationController = null (verificationController as? SessionVerificationController)?.destroy()
}
} }
private fun updateVerificationStatus(isVerified: Boolean) { private fun updateVerificationStatus(verificationState: VerificationState) {
val newValue = when { _sessionVerifiedStatus.value = when (verificationState) {
!isReady.value -> SessionVerifiedStatus.Unknown VerificationState.UNKNOWN -> SessionVerifiedStatus.Unknown
!isVerified -> SessionVerifiedStatus.NotVerified VerificationState.VERIFIED -> SessionVerifiedStatus.Verified
else -> SessionVerifiedStatus.Verified VerificationState.UNVERIFIED -> SessionVerifiedStatus.NotVerified
} }
_sessionVerifiedStatus.value = newValue
} }
} }

View file

@ -38,7 +38,11 @@ class FakeSessionVerificationService : SessionVerificationService {
override val isReady: StateFlow<Boolean> = _isReady override val isReady: StateFlow<Boolean> = _isReady
override suspend fun requestVerification() { override suspend fun requestVerification() {
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest if (!shouldFail) {
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
} else {
_verificationFlowState.value = VerificationFlowState.Failed
}
} }
override suspend fun cancelVerification() { override suspend fun cancelVerification() {

View file

@ -254,6 +254,8 @@
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string> <string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string> <string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string> <string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
<string name="screen_room_directory_search_loading_error">"Failed loading"</string>
<string name="screen_room_directory_search_title">"Room directory"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string> <string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string> <string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_member_details_block_alert_action">"Block"</string> <string name="screen_room_member_details_block_alert_action">"Block"</string>

View file

@ -152,7 +152,6 @@ class RoomListScreen(
state = state, state = state,
onRoomClicked = ::onRoomClicked, onRoomClicked = ::onRoomClicked,
onSettingsClicked = {}, onSettingsClicked = {},
onVerifyClicked = {},
onConfirmRecoveryKeyClicked = {}, onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {}, onCreateRoomClicked = {},
onInvitesClicked = {}, onInvitesClicked = {},

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:be4d708775d03680be450dfd5eebfeda24f1841eedced3e7de2ba036669ac479 oid sha256:75fb611e02345fe1cf7947d1e160e309cf8520f0256ebd25b43bab8c3102f3f8
size 39086 size 37138

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:53660f4354d2a750ab539370151c9ee4ac49f9d06712f7646fdd487bf133753a oid sha256:a7be4df5a4e391d2253f283acf0fb9362b965fa887235221b794915d74891899
size 38766 size 36783

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8ae1828957e00adcbdbb772a0ebdc1fb6604afbe644cc5d8662da9fcd3973129 oid sha256:67503a61f560ef79d7e981057094dccf46eda167ee84427c1aceb9f83edf0146
size 41162 size 39080

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:58e567815b4dc36abf8422b17f9902d8581dbec1a1ed216df2f120c6198b6fe5 oid sha256:5cb3be70de8e3878bdd90db9945aed60fb2b38d8c66ea610806226c21b1f5a5a
size 41124 size 39052

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more