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 05f6770d35
commit 41287c5f59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
198 changed files with 822 additions and 761 deletions

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.SnackbarMessage
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@ -34,16 +32,12 @@ import javax.inject.Inject
class LoggedInEventProcessor @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver,
sessionVerificationService: SessionVerificationService,
) {
private var observingJob: Job? = null
private val displayLeftRoomMessage = roomMembershipObserver.updates
.map { !it.isUserInRoom }
private val displayVerificationSuccessfulMessage = sessionVerificationService.verificationFlowState
.map { it == VerificationFlowState.Finished }
fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = coroutineScope.launch {
displayLeftRoomMessage
@ -52,13 +46,6 @@ class LoggedInEventProcessor @Inject constructor(
displayMessage(CommonStrings.common_current_user_left_room)
}
.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.features.createroom.api.CreateRoomEntryPoint
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.invitelist.api.InviteListEntryPoint
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.roomlist.api.RoomListEntryPoint
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.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.deeplink.DeeplinkData
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.matrix.api.MatrixClient
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.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
@ -88,14 +91,13 @@ class LoggedInFlowNode @AssistedInject constructor(
private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState,
private val ftueService: FtueService,
private val lockScreenEntryPoint: LockScreenEntryPoint,
private val lockScreenStateService: LockScreenService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
@ -103,7 +105,7 @@ class LoggedInFlowNode @AssistedInject constructor(
snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.RoomList,
initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap,
),
permanentNavModel = PermanentNavModel(
@ -121,7 +123,6 @@ class LoggedInFlowNode @AssistedInject constructor(
private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher,
matrixClient.roomMembershipObserver(),
matrixClient.sessionVerificationService(),
)
override fun onBuilt() {
@ -133,9 +134,15 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope)
if (ftueState.shouldDisplayFlow.value) {
backstack.push(NavTarget.Ftue)
}
ftueService.state
.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 = {
coroutineScope.launch {
@ -191,6 +198,9 @@ class LoggedInFlowNode @AssistedInject constructor(
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Placeholder : NavTarget
@Parcelize
data object LoggedInPermanent : NavTarget
@ -214,9 +224,6 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize
data object CreateRoom : NavTarget
@Parcelize
data object VerifySession : NavTarget
@Parcelize
data class SecureBackup(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
@ -234,6 +241,7 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext)
NavTarget.LoggedInPermanent -> {
createNode<LoggedInNode>(buildContext)
}
@ -256,10 +264,6 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.CreateRoom)
}
override fun onSessionVerificationClicked() {
backstack.push(NavTarget.VerifySession)
}
override fun onSessionConfirmRecoveryKeyClicked() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
}
@ -308,10 +312,6 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenBugReport() }
}
override fun onVerifyClicked() {
backstack.push(NavTarget.VerifySession)
}
override fun onSecureBackupClicked() {
backstack.push(NavTarget.SecureBackup())
}
@ -338,25 +338,6 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback)
.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 -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
@ -381,7 +362,7 @@ class LoggedInFlowNode @AssistedInject constructor(
ftueEntryPoint.nodeBuilder(this, buildContext)
.callback(object : FtueEntryPoint.Callback {
override fun onFtueFlowFinished() {
backstack.pop()
lifecycleScope.launch { attachRoomList() }
}
})
.build()
@ -398,20 +379,23 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
suspend fun attachRoot(): Node {
return attachChild {
suspend fun attachRoomList() {
if (!canShowRoomList()) return
attachChild<Node> {
backstack.singleTop(NavTarget.RoomList)
}
}
suspend fun attachRoom(roomId: RoomId): RoomFlowNode {
return attachChild {
suspend fun attachRoom(roomId: RoomId) {
if (!canShowRoomList()) return
attachChild<RoomFlowNode> {
backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.Room(roomId))
}
}
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) {
if (!canShowRoomList()) return@withContext
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.InviteList)
@ -420,13 +404,17 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
private fun canShowRoomList(): Boolean {
return ftueService.state.value is FtueState.Complete
}
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
val lockScreenState by lockScreenStateService.lockState.collectAsState()
val isFtueDisplayed by ftueService.state.collectAsState()
BackstackView()
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
if (!isFtueDisplayed) {
if (isFtueDisplayed is FtueState.Complete) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
}
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()
.apply {
when (deeplinkData) {
is DeeplinkData.Root -> attachRoot()
is DeeplinkData.Root -> attachRoomList()
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId)
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.matrix.api.MatrixClient
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 kotlinx.coroutines.flow.map
import javax.inject.Inject
class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val networkMonitor: NetworkMonitor,
private val pushService: PushService,
private val sessionVerificationService: SessionVerificationService,
) : Presenter<LoggedInState> {
@Composable
override fun present(): LoggedInState {
LaunchedEffect(Unit) {
// 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 isVerified by remember {
sessionVerificationService.sessionVerifiedStatus.map { it == SessionVerifiedStatus.Verified }
}.collectAsState(initial = false)
LaunchedEffect(isVerified) {
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()

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