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:
committed by
GitHub
parent
06ba5eaafc
commit
8b335a9125
@@ -23,7 +23,8 @@ appId: ${MAESTRO_APP_ID}
|
||||
- inputText: ${MAESTRO_PASSWORD}
|
||||
- pressKey: Enter
|
||||
- tapOn: "Continue"
|
||||
- runFlow: ../assertions/assertSessionVerificationDisplayed.yaml
|
||||
- runFlow: ./verifySession.yaml
|
||||
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
|
||||
- tapOn: "Not now"
|
||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||
- runFlow: ./verifySession.yaml
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- tapOn: "Continue"
|
||||
- takeScreenshot: build/maestro/150-Verify
|
||||
- tapOn: "Enter recovery key"
|
||||
- tapOn:
|
||||
@@ -8,4 +7,3 @@ appId: ${MAESTRO_APP_ID}
|
||||
- inputText: ${MAESTRO_RECOVERY_KEY}
|
||||
- hideKeyboard
|
||||
- tapOn: "Confirm"
|
||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Confirm that it's you"
|
||||
timeout: 10000
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
1
changelog.d/2580.feature
Normal file
1
changelog.d/2580.feature
Normal file
@@ -0,0 +1 @@
|
||||
Move session verification to the after login flow and make it mandatory.
|
||||
@@ -18,8 +18,25 @@ package io.element.android.features.ftue.api.state
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -42,6 +42,8 @@ dependencies {
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.features.analytics.api)
|
||||
implementation(projects.features.securebackup.api)
|
||||
implementation(projects.features.verifysession.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.features.lockscreen.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
|
||||
@@ -34,7 +34,8 @@ import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.analytics.api.AnalyticsEntryPoint
|
||||
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||
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.lockscreen.api.LockScreenEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
@@ -55,7 +56,7 @@ import kotlinx.parcelize.Parcelize
|
||||
class FtueFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val ftueState: DefaultFtueState,
|
||||
private val ftueState: DefaultFtueService,
|
||||
private val analyticsEntryPoint: AnalyticsEntryPoint,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||
@@ -72,6 +73,9 @@ class FtueFlowNode @AssistedInject constructor(
|
||||
@Parcelize
|
||||
data object Placeholder : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object SessionVerification : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object NotificationsOptIn : NavTarget
|
||||
|
||||
@@ -106,6 +110,14 @@ class FtueFlowNode @AssistedInject constructor(
|
||||
NavTarget.Placeholder -> {
|
||||
createNode<PlaceholderNode>(buildContext)
|
||||
}
|
||||
NavTarget.SessionVerification -> {
|
||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||
override fun onDone() {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
}
|
||||
}
|
||||
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
|
||||
}
|
||||
NavTarget.NotificationsOptIn -> {
|
||||
val callback = object : NotificationsOptInNode.Callback {
|
||||
override fun onNotificationsOptInFinished() {
|
||||
@@ -133,6 +145,9 @@ class FtueFlowNode @AssistedInject constructor(
|
||||
|
||||
private fun moveToNextStep() {
|
||||
when (ftueState.getNextStep()) {
|
||||
FtueStep.SessionVerification -> {
|
||||
backstack.newRoot(NavTarget.SessionVerification)
|
||||
}
|
||||
FtueStep.NotificationsOptIn -> {
|
||||
backstack.newRoot(NavTarget.NotificationsOptIn)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,12 @@ import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.annotation.VisibleForTesting
|
||||
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.lockscreen.api.LockScreenService
|
||||
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.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
@@ -35,14 +38,15 @@ import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultFtueState @Inject constructor(
|
||||
class DefaultFtueService @Inject constructor(
|
||||
private val sdkVersionProvider: BuildVersionSdkIntProvider,
|
||||
coroutineScope: CoroutineScope,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val permissionStateProvider: PermissionStateProvider,
|
||||
private val lockScreenService: LockScreenService,
|
||||
) : FtueState {
|
||||
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
) : FtueService {
|
||||
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
|
||||
|
||||
override suspend fun reset() {
|
||||
analyticsService.reset()
|
||||
@@ -52,6 +56,10 @@ class DefaultFtueState @Inject constructor(
|
||||
}
|
||||
|
||||
init {
|
||||
sessionVerificationService.sessionVerifiedStatus
|
||||
.onEach { updateState() }
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
analyticsService.didAskUserConsent()
|
||||
.onEach { updateState() }
|
||||
.launchIn(coroutineScope)
|
||||
@@ -59,7 +67,12 @@ class DefaultFtueState @Inject constructor(
|
||||
|
||||
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
||||
when (currentStep) {
|
||||
null -> if (shouldAskNotificationPermissions()) {
|
||||
null -> if (isSessionNotVerified()) {
|
||||
FtueStep.SessionVerification
|
||||
} else {
|
||||
getNextStep(FtueStep.SessionVerification)
|
||||
}
|
||||
FtueStep.SessionVerification -> if (shouldAskNotificationPermissions()) {
|
||||
FtueStep.NotificationsOptIn
|
||||
} else {
|
||||
getNextStep(FtueStep.NotificationsOptIn)
|
||||
@@ -79,12 +92,21 @@ class DefaultFtueState @Inject constructor(
|
||||
|
||||
private fun isAnyStepIncomplete(): Boolean {
|
||||
return listOf(
|
||||
{ isSessionNotVerified() },
|
||||
{ shouldAskNotificationPermissions() },
|
||||
{ needsAnalyticsOptIn() },
|
||||
{ shouldDisplayLockscreenSetup() },
|
||||
).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 {
|
||||
// We need this function to not be suspend, so we need to load the value through runBlocking
|
||||
return runBlocking { analyticsService.didAskUserConsent().first().not() }
|
||||
@@ -109,11 +131,16 @@ class DefaultFtueState @Inject constructor(
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun updateState() {
|
||||
shouldDisplayFlow.value = isAnyStepIncomplete()
|
||||
state.value = when {
|
||||
!isSessionVerificationServiceReady() -> FtueState.Unknown
|
||||
isAnyStepIncomplete() -> FtueState.Incomplete
|
||||
else -> FtueState.Complete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface FtueStep {
|
||||
data object SessionVerification : FtueStep
|
||||
data object NotificationsOptIn : FtueStep
|
||||
data object AnalyticsOptIn : FtueStep
|
||||
data object LockscreenSetup : FtueStep
|
||||
@@ -17,11 +17,15 @@
|
||||
package io.element.android.features.ftue.impl
|
||||
|
||||
import android.os.Build
|
||||
import app.cash.turbine.test
|
||||
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.lockscreen.api.LockScreenService
|
||||
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.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
@@ -32,38 +36,51 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultFtueStateTests {
|
||||
class DefaultFtueServiceTests {
|
||||
@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 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
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
@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 sessionVerificationService = FakeSessionVerificationService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
|
||||
val lockScreenService = FakeLockScreenService()
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
|
||||
val state = createState(
|
||||
coroutineScope = coroutineScope,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
analyticsService = analyticsService,
|
||||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
analyticsService.setDidAskUserConsent()
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
state.updateState()
|
||||
|
||||
assertThat(state.shouldDisplayFlow.value).isFalse()
|
||||
assertThat(state.state.value).isEqualTo(FtueState.Complete)
|
||||
|
||||
// Cleanup
|
||||
coroutineScope.cancel()
|
||||
@@ -71,6 +88,9 @@ class DefaultFtueStateTests {
|
||||
|
||||
@Test
|
||||
fun `traverse flow`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
}
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||
val lockScreenService = FakeLockScreenService()
|
||||
@@ -78,12 +98,17 @@ class DefaultFtueStateTests {
|
||||
|
||||
val state = createState(
|
||||
coroutineScope = coroutineScope,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
analyticsService = analyticsService,
|
||||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
val steps = mutableListOf<FtueStep?>()
|
||||
|
||||
// Session verification
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
|
||||
// Notifications opt in
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
@@ -100,6 +125,7 @@ class DefaultFtueStateTests {
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
|
||||
assertThat(steps).containsExactly(
|
||||
FtueStep.SessionVerification,
|
||||
FtueStep.NotificationsOptIn,
|
||||
FtueStep.LockscreenSetup,
|
||||
FtueStep.AnalyticsOptIn,
|
||||
@@ -114,17 +140,20 @@ class DefaultFtueStateTests {
|
||||
@Test
|
||||
fun `if a check for a step is true, start from the next one`() = runTest {
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val sessionVerificationService = FakeSessionVerificationService()
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||
val lockScreenService = FakeLockScreenService()
|
||||
val state = createState(
|
||||
coroutineScope = coroutineScope,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
analyticsService = analyticsService,
|
||||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
// Skip first 2 steps
|
||||
// Skip first 3 steps
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
@@ -140,16 +169,19 @@ class DefaultFtueStateTests {
|
||||
@Test
|
||||
fun `if version is older than 13 we don't display the notification opt in screen`() = runTest {
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val sessionVerificationService = FakeSessionVerificationService()
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val lockScreenService = FakeLockScreenService()
|
||||
|
||||
val state = createState(
|
||||
sdkIntVersion = Build.VERSION_CODES.M,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
coroutineScope = coroutineScope,
|
||||
analyticsService = analyticsService,
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||
@@ -163,14 +195,16 @@ class DefaultFtueStateTests {
|
||||
|
||||
private fun createState(
|
||||
coroutineScope: CoroutineScope,
|
||||
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
|
||||
lockScreenService: LockScreenService = FakeLockScreenService(),
|
||||
// First version where notification permission is required
|
||||
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
|
||||
) = DefaultFtueState(
|
||||
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
|
||||
) = DefaultFtueService(
|
||||
coroutineScope = coroutineScope,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
|
||||
analyticsService = analyticsService,
|
||||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
@@ -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.model.NewEventState
|
||||
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.poll.api.actions.EndPollAction
|
||||
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.core.coroutine.CoroutineDispatchers
|
||||
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.MessageEventType
|
||||
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.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 kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -68,8 +63,6 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val appScope: CoroutineScope,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
private val verificationService: SessionVerificationService,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
|
||||
private val sendPollResponseAction: SendPollResponseAction,
|
||||
private val endPollAction: EndPollAction,
|
||||
@@ -101,21 +94,9 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
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 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) {
|
||||
when (event) {
|
||||
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
||||
@@ -184,7 +165,6 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
timelineItems = timelineItems,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
newEventState = newItemState.value,
|
||||
sessionState = sessionState,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ package io.element.android.features.messages.impl.timeline
|
||||
import androidx.compose.runtime.Immutable
|
||||
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.session.SessionState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -32,7 +31,6 @@ data class TimelineState(
|
||||
val highlightedEventId: EventId?,
|
||||
val paginationState: MatrixTimeline.PaginationState,
|
||||
val newEventState: NewEventState,
|
||||
val sessionState: SessionState,
|
||||
val eventSink: (TimelineEvents) -> Unit
|
||||
)
|
||||
|
||||
|
||||
@@ -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.aTimelineItemTextContent
|
||||
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.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -58,10 +57,6 @@ fun aTimelineState(
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
highlightedEventId = null,
|
||||
newEventState = NewEventState.None,
|
||||
sessionState = aSessionState(
|
||||
isSessionVerified = true,
|
||||
isKeyBackupEnabled = true,
|
||||
),
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
|
||||
@@ -148,7 +148,6 @@ fun TimelineView(
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
sessionState = state.sessionState,
|
||||
eventSink = state.eventSink,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
|
||||
@@ -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.TimelineItemReadReceiptView
|
||||
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.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -46,7 +44,6 @@ fun TimelineItemGroupedEventsRow(
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
highlightedItem: String?,
|
||||
sessionState: SessionState,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
@@ -74,7 +71,6 @@ fun TimelineItemGroupedEventsRow(
|
||||
highlightedItem = highlightedItem,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
sessionState = sessionState,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
@@ -99,7 +95,6 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
highlightedItem: String?,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
sessionState: SessionState,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
@@ -133,7 +128,6 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
highlightedItem = highlightedItem,
|
||||
sessionState = sessionState,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
@@ -174,7 +168,6 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
|
||||
highlightedItem = null,
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
sessionState = aSessionState(),
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
inReplyToClick = {},
|
||||
@@ -200,7 +193,6 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
|
||||
highlightedItem = null,
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
sessionState = aSessionState(),
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
inReplyToClick = {},
|
||||
|
||||
@@ -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.event.TimelineItemLegacyCallInviteContent
|
||||
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.UserId
|
||||
|
||||
@@ -34,7 +33,6 @@ internal fun TimelineItemRow(
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
highlightedItem: String?,
|
||||
sessionState: SessionState,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
@@ -53,7 +51,6 @@ internal fun TimelineItemRow(
|
||||
is TimelineItem.Virtual -> {
|
||||
TimelineItemVirtualRow(
|
||||
virtual = timelineItem,
|
||||
sessionState = sessionState,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -100,7 +97,6 @@ internal fun TimelineItemRow(
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
highlightedItem = highlightedItem,
|
||||
sessionState = sessionState,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
|
||||
@@ -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.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.session.SessionState
|
||||
|
||||
@Composable
|
||||
fun TimelineItemVirtualRow(
|
||||
virtual: TimelineItem.Virtual,
|
||||
sessionState: SessionState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (virtual.model) {
|
||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
|
||||
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
|
||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(sessionState, modifier)
|
||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.virtual
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -29,20 +28,16 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
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.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
|
||||
@Composable
|
||||
fun TimelineEncryptedHistoryBannerView(
|
||||
sessionState: SessionState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
@@ -61,26 +56,15 @@ fun TimelineEncryptedHistoryBannerView(
|
||||
tint = ElementTheme.colors.iconInfoPrimary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(sessionState.toStringResId()),
|
||||
text = stringResource(R.string.screen_room_encrypted_history_banner),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
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
|
||||
@Composable
|
||||
internal fun EncryptedHistoryBannerViewPreview(
|
||||
@PreviewParameter(SessionStateProvider::class) sessionState: SessionState,
|
||||
) = ElementPreview {
|
||||
TimelineEncryptedHistoryBannerView(sessionState = sessionState)
|
||||
internal fun EncryptedHistoryBannerViewPreview() = ElementPreview {
|
||||
TimelineEncryptedHistoryBannerView()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.FakeMatrixClient
|
||||
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.FakePermalinkParser
|
||||
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.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
@@ -745,8 +743,6 @@ class MessagesPresenterTest {
|
||||
dispatchers = coroutineDispatchers,
|
||||
appScope = this,
|
||||
navigator = navigator,
|
||||
encryptionService = FakeEncryptionService(),
|
||||
verificationService = FakeSessionVerificationService(),
|
||||
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
||||
endPollAction = endPollAction,
|
||||
sendPollResponseAction = FakeSendPollResponseAction(),
|
||||
|
||||
@@ -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.model.NewEventState
|
||||
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.RedactedVoiceMessageManager
|
||||
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_2
|
||||
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.aRoomMember
|
||||
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.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
@@ -89,7 +86,6 @@ class TimelinePresenterTest {
|
||||
assertThat(initialState.timelineItems).isEmpty()
|
||||
val loadedNoTimelineState = awaitItem()
|
||||
assertThat(loadedNoTimelineState.timelineItems).isEmpty()
|
||||
assertThat(loadedNoTimelineState.sessionState).isEqualTo(SessionState(isSessionVerified = false, isKeyBackupEnabled = false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,8 +508,6 @@ class TimelinePresenterTest {
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
appScope = this,
|
||||
navigator = messagesNavigator,
|
||||
encryptionService = FakeEncryptionService(),
|
||||
verificationService = FakeSessionVerificationService(),
|
||||
redactedVoiceMessageManager = redactedVoiceMessageManager,
|
||||
endPollAction = endPollAction,
|
||||
sendPollResponseAction = sendPollResponseAction,
|
||||
|
||||
@@ -45,7 +45,6 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onOpenBugReport()
|
||||
fun onVerifyClicked()
|
||||
fun onSecureBackupClicked()
|
||||
fun onOpenRoomNotificationSettings(roomId: RoomId)
|
||||
}
|
||||
|
||||
@@ -115,10 +115,6 @@ class PreferencesFlowNode @AssistedInject constructor(
|
||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.onOpenBugReport() }
|
||||
}
|
||||
|
||||
override fun onVerifyClicked() {
|
||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.onVerifyClicked() }
|
||||
}
|
||||
|
||||
override fun onSecureBackupClicked() {
|
||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.onSecureBackupClicked() }
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun onOpenBugReport()
|
||||
fun onVerifyClicked()
|
||||
fun onSecureBackupClicked()
|
||||
fun onOpenAnalytics()
|
||||
fun onOpenAbout()
|
||||
@@ -61,10 +60,6 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||
}
|
||||
|
||||
private fun onVerifyClicked() {
|
||||
plugins<Callback>().forEach { it.onVerifyClicked() }
|
||||
}
|
||||
|
||||
private fun onSecureBackupClicked() {
|
||||
plugins<Callback>().forEach { it.onSecureBackupClicked() }
|
||||
}
|
||||
@@ -138,7 +133,6 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||
onOpenRageShake = this::onOpenBugReport,
|
||||
onOpenAnalytics = this::onOpenAnalytics,
|
||||
onOpenAbout = this::onOpenAbout,
|
||||
onVerifyClicked = this::onVerifyClicked,
|
||||
onSecureBackupClicked = this::onSecureBackupClicked,
|
||||
onOpenDeveloperSettings = this::onOpenDeveloperSettings,
|
||||
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
|
||||
|
||||
@@ -74,7 +74,7 @@ class PreferencesRootPresenter @Inject constructor(
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -102,8 +102,7 @@ class PreferencesRootPresenter @Inject constructor(
|
||||
myUser = matrixUser.value,
|
||||
version = versionFormatter.get(),
|
||||
deviceId = matrixClient.deviceId,
|
||||
showCompleteVerification = showCompleteVerification,
|
||||
showSecureBackup = !showCompleteVerification,
|
||||
showSecureBackup = !canVerifyUserSession,
|
||||
showSecureBackupBadge = showSecureBackupIndicator,
|
||||
accountManagementUrl = accountManagementUrl.value,
|
||||
devicesManagementUrl = devicesManagementUrl.value,
|
||||
|
||||
@@ -24,7 +24,6 @@ data class PreferencesRootState(
|
||||
val myUser: MatrixUser,
|
||||
val version: String,
|
||||
val deviceId: String?,
|
||||
val showCompleteVerification: Boolean,
|
||||
val showSecureBackup: Boolean,
|
||||
val showSecureBackupBadge: Boolean,
|
||||
val accountManagementUrl: String?,
|
||||
|
||||
@@ -27,7 +27,6 @@ fun aPreferencesRootState(
|
||||
myUser = myUser,
|
||||
version = "Version 1.1 (1)",
|
||||
deviceId = "ILAKNDNASDLK",
|
||||
showCompleteVerification = true,
|
||||
showSecureBackup = true,
|
||||
showSecureBackupBadge = true,
|
||||
accountManagementUrl = "aUrl",
|
||||
|
||||
@@ -51,7 +51,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun PreferencesRootView(
|
||||
state: PreferencesRootState,
|
||||
onBackPressed: () -> Unit,
|
||||
onVerifyClicked: () -> Unit,
|
||||
onSecureBackupClicked: () -> Unit,
|
||||
onManageAccountClicked: (url: String) -> Unit,
|
||||
onOpenAnalytics: () -> Unit,
|
||||
@@ -81,13 +80,6 @@ fun PreferencesRootView(
|
||||
},
|
||||
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) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) },
|
||||
@@ -95,8 +87,6 @@ fun PreferencesRootView(
|
||||
trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
|
||||
onClick = onSecureBackupClicked,
|
||||
)
|
||||
}
|
||||
if (state.showCompleteVerification || state.showSecureBackup) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (state.accountManagementUrl != null) {
|
||||
@@ -232,7 +222,6 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
|
||||
onOpenDeveloperSettings = {},
|
||||
onOpenAdvancedSettings = {},
|
||||
onOpenAbout = {},
|
||||
onVerifyClicked = {},
|
||||
onSecureBackupClicked = {},
|
||||
onManageAccountClicked = {},
|
||||
onOpenNotificationSettings = {},
|
||||
|
||||
@@ -22,7 +22,7 @@ import android.content.Context
|
||||
import coil.Coil
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
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.roomlist.api.migration.MigrationScreenStore
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
@@ -45,7 +45,7 @@ class DefaultClearCacheUseCase @Inject constructor(
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val defaultCacheIndexProvider: DefaultCacheService,
|
||||
private val okHttpClient: Provider<OkHttpClient>,
|
||||
private val ftueState: FtueState,
|
||||
private val ftueService: FtueService,
|
||||
private val migrationScreenStore: MigrationScreenStore,
|
||||
) : ClearCacheUseCase {
|
||||
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
|
||||
@@ -61,7 +61,7 @@ class DefaultClearCacheUseCase @Inject constructor(
|
||||
// Clear app cache
|
||||
context.cacheDir.deleteRecursively()
|
||||
// Clear some settings
|
||||
ftueState.reset()
|
||||
ftueService.reset()
|
||||
// Clear migration screen store
|
||||
migrationScreenStore.reset()
|
||||
// Ensure the app is restarted
|
||||
|
||||
@@ -92,7 +92,6 @@ class PreferencesRootPresenterTest {
|
||||
)
|
||||
)
|
||||
assertThat(initialState.version).isEqualTo("A Version")
|
||||
assertThat(loadedState.showCompleteVerification).isTrue()
|
||||
assertThat(loadedState.showSecureBackup).isFalse()
|
||||
assertThat(loadedState.showSecureBackupBadge).isTrue()
|
||||
assertThat(loadedState.accountManagementUrl).isNull()
|
||||
|
||||
@@ -33,7 +33,6 @@ interface RoomListEntryPoint : FeatureEntryPoint {
|
||||
fun onRoomClicked(roomId: RoomId)
|
||||
fun onCreateRoomClicked()
|
||||
fun onSettingsClicked()
|
||||
fun onSessionVerificationClicked()
|
||||
fun onSessionConfirmRecoveryKeyClicked()
|
||||
fun onInvitesClicked()
|
||||
fun onRoomSettingsClicked(roomId: RoomId)
|
||||
|
||||
@@ -64,10 +64,6 @@ class RoomListNode @AssistedInject constructor(
|
||||
plugins<RoomListEntryPoint.Callback>().forEach { it.onCreateRoomClicked() }
|
||||
}
|
||||
|
||||
private fun onSessionVerificationClicked() {
|
||||
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionVerificationClicked() }
|
||||
}
|
||||
|
||||
private fun onSessionConfirmRecoveryKeyClicked() {
|
||||
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClicked() }
|
||||
}
|
||||
@@ -104,7 +100,6 @@ class RoomListNode @AssistedInject constructor(
|
||||
onRoomClicked = this::onRoomClicked,
|
||||
onSettingsClicked = this::onOpenSettings,
|
||||
onCreateRoomClicked = this::onCreateRoomClicked,
|
||||
onVerifyClicked = this::onSessionVerificationClicked,
|
||||
onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked,
|
||||
onInvitesClicked = this::onInvitesClicked,
|
||||
onRoomSettingsClicked = this::onRoomSettingsClicked,
|
||||
|
||||
@@ -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.SyncState
|
||||
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.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
@@ -92,7 +91,6 @@ class RoomListPresenter @Inject constructor(
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : Presenter<RoomListState> {
|
||||
private val encryptionService: EncryptionService = client.encryptionService()
|
||||
private val sessionVerificationService: SessionVerificationService = client.sessionVerificationService()
|
||||
private val syncService: SyncService = client.syncService()
|
||||
|
||||
@Composable
|
||||
@@ -159,19 +157,12 @@ class RoomListPresenter @Inject constructor(
|
||||
securityBannerDismissed: Boolean,
|
||||
): State<SecurityBannerState> {
|
||||
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 syncState by syncService.syncState.collectAsState()
|
||||
return remember {
|
||||
derivedStateOf {
|
||||
when {
|
||||
currentSecurityBannerDismissed -> SecurityBannerState.None
|
||||
canVerifySession -> if (isLastDevice) {
|
||||
SecurityBannerState.RecoveryKeyConfirmation
|
||||
} else {
|
||||
SecurityBannerState.SessionVerification
|
||||
}
|
||||
recoveryState == RecoveryState.INCOMPLETE &&
|
||||
syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation
|
||||
else -> SecurityBannerState.None
|
||||
|
||||
@@ -63,7 +63,6 @@ enum class InvitesState {
|
||||
|
||||
enum class SecurityBannerState {
|
||||
None,
|
||||
SessionVerification,
|
||||
RecoveryKeyConfirmation,
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)),
|
||||
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
|
||||
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
|
||||
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification)),
|
||||
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
|
||||
aRoomListState(contentState = anEmptyContentState()),
|
||||
aRoomListState(contentState = aSkeletonContentState()),
|
||||
|
||||
@@ -53,7 +53,6 @@ fun RoomListView(
|
||||
state: RoomListState,
|
||||
onRoomClicked: (RoomId) -> Unit,
|
||||
onSettingsClicked: () -> Unit,
|
||||
onVerifyClicked: () -> Unit,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
onCreateRoomClicked: () -> Unit,
|
||||
onInvitesClicked: () -> Unit,
|
||||
@@ -86,7 +85,6 @@ fun RoomListView(
|
||||
RoomListScaffold(
|
||||
modifier = Modifier.padding(top = topPadding),
|
||||
state = state,
|
||||
onVerifyClicked = onVerifyClicked,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = { onRoomLongClicked(it) },
|
||||
@@ -115,7 +113,6 @@ fun RoomListView(
|
||||
@Composable
|
||||
private fun RoomListScaffold(
|
||||
state: RoomListState,
|
||||
onVerifyClicked: () -> Unit,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
onRoomClicked: (RoomId) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
@@ -154,7 +151,6 @@ private fun RoomListScaffold(
|
||||
contentState = state.contentState,
|
||||
filtersState = state.filtersState,
|
||||
eventSink = state.eventSink,
|
||||
onVerifyClicked = onVerifyClicked,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onRoomClicked = ::onRoomClicked,
|
||||
onRoomLongClicked = onRoomLongClicked,
|
||||
@@ -193,7 +189,6 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
|
||||
state = state,
|
||||
onRoomClicked = {},
|
||||
onSettingsClicked = {},
|
||||
onVerifyClicked = {},
|
||||
onConfirmRecoveryKeyClicked = {},
|
||||
onCreateRoomClicked = {},
|
||||
onInvitesClicked = {},
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -73,7 +73,6 @@ fun RoomListContentView(
|
||||
contentState: RoomListContentState,
|
||||
filtersState: RoomListFiltersState,
|
||||
eventSink: (RoomListEvents) -> Unit,
|
||||
onVerifyClicked: () -> Unit,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
onRoomClicked: (RoomListRoomSummary) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
@@ -103,7 +102,6 @@ fun RoomListContentView(
|
||||
state = contentState,
|
||||
filtersState = filtersState,
|
||||
eventSink = eventSink,
|
||||
onVerifyClicked = onVerifyClicked,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = onRoomLongClicked,
|
||||
@@ -161,7 +159,6 @@ private fun RoomsView(
|
||||
state: RoomListContentState.Rooms,
|
||||
filtersState: RoomListFiltersState,
|
||||
eventSink: (RoomListEvents) -> Unit,
|
||||
onVerifyClicked: () -> Unit,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
onRoomClicked: (RoomListRoomSummary) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
@@ -177,7 +174,6 @@ private fun RoomsView(
|
||||
RoomsViewList(
|
||||
state = state,
|
||||
eventSink = eventSink,
|
||||
onVerifyClicked = onVerifyClicked,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = onRoomLongClicked,
|
||||
@@ -191,7 +187,6 @@ private fun RoomsView(
|
||||
private fun RoomsViewList(
|
||||
state: RoomListContentState.Rooms,
|
||||
eventSink: (RoomListEvents) -> Unit,
|
||||
onVerifyClicked: () -> Unit,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
onRoomClicked: (RoomListRoomSummary) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
@@ -222,14 +217,6 @@ private fun RoomsViewList(
|
||||
contentPadding = PaddingValues(bottom = 80.dp)
|
||||
) {
|
||||
when (state.securityBannerState) {
|
||||
SecurityBannerState.SessionVerification -> {
|
||||
item {
|
||||
RequestVerificationHeader(
|
||||
onVerifyClicked = onVerifyClicked,
|
||||
onDismissClicked = { eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
|
||||
)
|
||||
}
|
||||
}
|
||||
SecurityBannerState.RecoveryKeyConfirmation -> {
|
||||
item {
|
||||
ConfirmRecoveryKeyBanner(
|
||||
@@ -316,10 +303,10 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
|
||||
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
|
||||
),
|
||||
eventSink = {},
|
||||
onVerifyClicked = { },
|
||||
onConfirmRecoveryKeyClicked = { },
|
||||
onConfirmRecoveryKeyClicked = {},
|
||||
onRoomClicked = {},
|
||||
onRoomLongClicked = {},
|
||||
onCreateRoomClicked = { },
|
||||
onInvitesClicked = { })
|
||||
onCreateRoomClicked = {},
|
||||
onInvitesClicked = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
|
||||
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
|
||||
val roomListService = FakeRoomListService().apply {
|
||||
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
|
||||
}
|
||||
val encryptionService = FakeEncryptionService().apply {
|
||||
emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||
}
|
||||
val syncService = FakeSyncService(initialState = SyncState.Running)
|
||||
val presenter = createRoomListPresenter(
|
||||
client = FakeMatrixClient(roomListService = roomListService),
|
||||
client = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService, syncService = syncService),
|
||||
coroutineScope = scope,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val eventSink = consumeItemsUntilPredicate {
|
||||
val eventWithContentAsRooms = consumeItemsUntilPredicate {
|
||||
it.contentState is RoomListContentState.Rooms
|
||||
}.last().eventSink
|
||||
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification)
|
||||
}.last()
|
||||
val eventSink = eventWithContentAsRooms.eventSink
|
||||
assertThat(eventWithContentAsRooms.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
|
||||
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
|
||||
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
|
||||
scope.cancel()
|
||||
@@ -342,10 +318,10 @@ class RoomListPresenterTests {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
consumeItemsUntilPredicate {
|
||||
val firstItem = consumeItemsUntilPredicate {
|
||||
it.contentState is RoomListContentState.Rooms
|
||||
}
|
||||
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
|
||||
}.last()
|
||||
assertThat(firstItem.contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
|
||||
|
||||
inviteStateFlow.value = InvitesState.SeenInvites
|
||||
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites)
|
||||
|
||||
@@ -43,35 +43,6 @@ import org.junit.runner.RunWith
|
||||
class RoomListViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on close verification banner emits the expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomListEvents>()
|
||||
rule.setRoomListView(
|
||||
state = aRoomListState(
|
||||
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
|
||||
fun `clicking on close recovery key banner emits the expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomListEvents>()
|
||||
@@ -185,7 +156,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
|
||||
state: RoomListState,
|
||||
onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onSettingsClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onVerifyClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onCreateRoomClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onInvitesClicked: () -> Unit = EnsureNeverCalled(),
|
||||
@@ -198,7 +168,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
|
||||
state = state,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onSettingsClicked = onSettingsClicked,
|
||||
onVerifyClicked = onVerifyClicked,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onCreateRoomClicked = onCreateRoomClicked,
|
||||
onInvitesClicked = onInvitesClicked,
|
||||
|
||||
@@ -19,6 +19,7 @@ package io.element.android.features.securebackup.api
|
||||
import android.os.Parcelable
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@@ -36,8 +37,13 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
|
||||
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
}
|
||||
|
||||
interface NodeBuilder {
|
||||
fun params(params: Params): NodeBuilder
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ class DefaultSecureBackupEntryPoint @Inject constructor() : SecureBackupEntryPoi
|
||||
return this
|
||||
}
|
||||
|
||||
override fun callback(callback: SecureBackupEntryPoint.Callback): SecureBackupEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<SecureBackupFlowNode>(buildContext, plugins)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ 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.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
@@ -74,6 +76,8 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
||||
data object EnterRecoveryKey : NavTarget
|
||||
}
|
||||
|
||||
private val callback = plugins<SecureBackupEntryPoint.Callback>().firstOrNull()
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
@@ -119,7 +123,16 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
||||
createNode<SecureBackupEnableNode>(buildContext)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,48 +17,36 @@
|
||||
package io.element.android.features.securebackup.impl.enter
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: SecureBackupEnterRecoveryKeyPresenter,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun onEnterRecoveryKeySuccess()
|
||||
}
|
||||
|
||||
private val callback = plugins<Callback>().first()
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val state = presenter.present()
|
||||
SecureBackupEnterRecoveryKeyView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onDone = {
|
||||
coroutineScope.postSuccessSnackbar()
|
||||
navigateUp()
|
||||
},
|
||||
onDone = callback::onEnterRecoveryKeySuccess,
|
||||
onBackClicked = ::navigateUp,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.postSuccessSnackbar() = launch {
|
||||
snackbarDispatcher.post(
|
||||
SnackbarMessage(
|
||||
messageResId = R.string.screen_recovery_key_confirm_success
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onEnterRecoveryKey = ::onEnterRecoveryKey,
|
||||
goBack = ::onDone,
|
||||
onFinished = ::onDone,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,10 @@ class VerifySelfSessionPresenter @Inject constructor(
|
||||
when (event) {
|
||||
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
|
||||
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
|
||||
VerifySelfSessionViewEvents.Restart -> stateAndDispatch.dispatchAction(StateMachineEvent.Restart)
|
||||
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
|
||||
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(
|
||||
@@ -118,7 +118,7 @@ class VerifySelfSessionPresenter @Inject constructor(
|
||||
private fun CoroutineScope.observeVerificationService() {
|
||||
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
|
||||
when (verificationAttemptState) {
|
||||
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Restart)
|
||||
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
|
||||
VerificationFlowState.AcceptedVerificationRequest -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
|
||||
}
|
||||
|
||||
@@ -20,24 +20,35 @@
|
||||
package io.element.android.features.verifysession.impl
|
||||
|
||||
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.SessionVerificationService
|
||||
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 kotlin.time.Duration.Companion.seconds
|
||||
import com.freeletics.flowredux.dsl.State as MachineState
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class VerifySelfSessionStateMachine @Inject constructor(
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val encryptionService: EncryptionService,
|
||||
) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>(
|
||||
initialState = State.Initial
|
||||
) {
|
||||
init {
|
||||
spec {
|
||||
inState<State.Initial> {
|
||||
on { _: Event.RequestVerification, state: MachineState<State.Initial> ->
|
||||
on { _: Event.RequestVerification, state ->
|
||||
state.override { State.RequestingVerification }
|
||||
}
|
||||
on { _: Event.StartSasVerification, state: MachineState<State.Initial> ->
|
||||
on { _: Event.StartSasVerification, state ->
|
||||
state.override { State.StartingSasVerification }
|
||||
}
|
||||
}
|
||||
@@ -45,12 +56,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||
onEnterEffect {
|
||||
sessionVerificationService.requestVerification()
|
||||
}
|
||||
on { _: Event.DidAcceptVerificationRequest, state: MachineState<State.RequestingVerification> ->
|
||||
on { _: Event.DidAcceptVerificationRequest, state ->
|
||||
state.override { State.VerificationRequestAccepted }
|
||||
}
|
||||
on { _: Event.DidFail, state: MachineState<State.RequestingVerification> ->
|
||||
state.override { State.Initial }
|
||||
}
|
||||
}
|
||||
inState<State.StartingSasVerification> {
|
||||
onEnterEffect {
|
||||
@@ -58,25 +66,28 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||
}
|
||||
}
|
||||
inState<State.VerificationRequestAccepted> {
|
||||
on { _: Event.StartSasVerification, state: MachineState<State.VerificationRequestAccepted> ->
|
||||
on { _: Event.StartSasVerification, state ->
|
||||
state.override { State.StartingSasVerification }
|
||||
}
|
||||
}
|
||||
inState<State.Canceled> {
|
||||
on { _: Event.Restart, state: MachineState<State.Canceled> ->
|
||||
on { _: Event.RequestVerification, state ->
|
||||
state.override { State.RequestingVerification }
|
||||
}
|
||||
on { _: Event.Reset, state ->
|
||||
state.override { State.Initial }
|
||||
}
|
||||
}
|
||||
inState<State.SasVerificationStarted> {
|
||||
on { event: Event.DidReceiveChallenge, state: MachineState<State.SasVerificationStarted> ->
|
||||
on { event: Event.DidReceiveChallenge, state ->
|
||||
state.override { State.Verifying.ChallengeReceived(event.data) }
|
||||
}
|
||||
}
|
||||
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) }
|
||||
}
|
||||
on { _: Event.DeclineChallenge, state: MachineState<State.Verifying.ChallengeReceived> ->
|
||||
on { _: Event.DeclineChallenge, state ->
|
||||
state.override { State.Verifying.Replying(state.snapshot.data, accept = false) }
|
||||
}
|
||||
}
|
||||
@@ -88,11 +99,21 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||
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 }
|
||||
}
|
||||
}
|
||||
inState<State.Canceling> {
|
||||
// TODO The 'Canceling' -> 'Canceled' transitions doesn't seem to work anymore, check if something changed in the Rust SDK
|
||||
onEnterEffect {
|
||||
sessionVerificationService.cancelVerification()
|
||||
}
|
||||
@@ -102,21 +123,24 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||
state.override { State.SasVerificationStarted }
|
||||
}
|
||||
on { _: Event.Cancel, state: MachineState<State> ->
|
||||
if (state.snapshot in sequenceOf(
|
||||
State.Initial,
|
||||
State.Completed,
|
||||
State.Canceled
|
||||
)) {
|
||||
state.noChange()
|
||||
} else {
|
||||
state.override { State.Canceling }
|
||||
when (state.snapshot) {
|
||||
State.Initial, State.Completed, State.Canceled -> state.noChange()
|
||||
// For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
|
||||
// `Canceling` state to `Canceled` automatically anymore
|
||||
else -> {
|
||||
sessionVerificationService.cancelVerification()
|
||||
state.override { State.Canceled }
|
||||
}
|
||||
}
|
||||
}
|
||||
on { _: Event.DidCancel, state: MachineState<State> ->
|
||||
state.override { State.Canceled }
|
||||
}
|
||||
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. */
|
||||
data object DidFail : Event
|
||||
|
||||
/** Restart the verification flow. */
|
||||
data object Restart : Event
|
||||
/** Reset the verification flow to the initial state. */
|
||||
data object Reset : Event
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.Modifier
|
||||
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.unit.dp
|
||||
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.libraries.architecture.AsyncData
|
||||
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.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.PreviewsDayNight
|
||||
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.TextButton
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
@@ -62,36 +60,37 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver
|
||||
fun VerifySelfSessionView(
|
||||
state: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit,
|
||||
goBack: () -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun goBackAndCancelIfNeeded() {
|
||||
state.eventSink(VerifySelfSessionViewEvents.CancelAndClose)
|
||||
goBack()
|
||||
}
|
||||
if (state.verificationFlowStep is FlowStep.Completed) {
|
||||
goBack()
|
||||
fun resetFlow() {
|
||||
state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
}
|
||||
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 buttonsVisible by remember(verificationFlowStep) {
|
||||
derivedStateOf { verificationFlowStep != FlowStep.AwaitingOtherDeviceResponse && verificationFlowStep != FlowStep.Completed }
|
||||
}
|
||||
HeaderFooterPage(
|
||||
modifier = modifier,
|
||||
header = {
|
||||
HeaderContent(verificationFlowStep = verificationFlowStep)
|
||||
},
|
||||
footer = {
|
||||
if (buttonsVisible) {
|
||||
BottomMenu(
|
||||
screenState = state,
|
||||
goBack = ::goBackAndCancelIfNeeded,
|
||||
onEnterRecoveryKey = onEnterRecoveryKey
|
||||
)
|
||||
}
|
||||
BottomMenu(
|
||||
screenState = state,
|
||||
goBack = ::resetFlow,
|
||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
||||
onFinished = onFinished,
|
||||
)
|
||||
}
|
||||
) {
|
||||
Content(flowState = verificationFlowStep)
|
||||
@@ -100,40 +99,38 @@ fun VerifySelfSessionView(
|
||||
|
||||
@Composable
|
||||
private fun HeaderContent(verificationFlowStep: FlowStep) {
|
||||
val iconResourceId = when (verificationFlowStep) {
|
||||
is FlowStep.Initial -> R.drawable.ic_verification_devices
|
||||
FlowStep.Canceled -> R.drawable.ic_verification_warning
|
||||
FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting
|
||||
FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji
|
||||
val iconStyle = when (verificationFlowStep) {
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||
FlowStep.Canceled -> BigIcon.Style.AlertSolid
|
||||
FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
FlowStep.Completed -> BigIcon.Style.SuccessSolid
|
||||
}
|
||||
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.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_title
|
||||
FlowStep.Ready,
|
||||
FlowStep.Completed -> R.string.screen_session_verification_compare_emojis_title
|
||||
FlowStep.Ready -> R.string.screen_session_verification_compare_emojis_title
|
||||
FlowStep.Completed -> R.string.screen_identity_confirmed_title
|
||||
is FlowStep.Verifying -> when (verificationFlowStep.data) {
|
||||
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
|
||||
}
|
||||
}
|
||||
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.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_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 SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
|
||||
}
|
||||
}
|
||||
|
||||
IconTitleSubtitleMolecule(
|
||||
PageTitle(
|
||||
modifier = Modifier.padding(top = 60.dp),
|
||||
iconResourceId = iconResourceId,
|
||||
iconStyle = iconStyle,
|
||||
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) {
|
||||
Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) {
|
||||
when (flowState) {
|
||||
is FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
|
||||
FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting()
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
|
||||
is FlowStep.Verifying -> ContentVerifying(flowState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentWaiting() {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) {
|
||||
when (verificationFlowStep.data) {
|
||||
@@ -212,76 +201,102 @@ private fun BottomMenu(
|
||||
screenState: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit,
|
||||
goBack: () -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
) {
|
||||
val verificationViewState = screenState.verificationFlowStep
|
||||
val eventSink = screenState.eventSink
|
||||
|
||||
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
|
||||
FlowStep.Canceled -> R.string.screen_session_verification_positive_button_canceled
|
||||
|
||||
when (verificationViewState) {
|
||||
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 -> {
|
||||
if (isVerifying) {
|
||||
R.string.screen_session_verification_positive_button_verifying_ongoing
|
||||
val positiveButtonTitle = if (isVerifying) {
|
||||
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
|
||||
} 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
|
||||
else -> null
|
||||
}
|
||||
val negativeButtonTitle = when (verificationViewState) {
|
||||
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) }
|
||||
is FlowStep.Completed -> {
|
||||
BottomMenu(
|
||||
positiveButtonTitle = stringResource(CommonStrings.action_continue),
|
||||
onPositiveButtonClicked = onFinished,
|
||||
)
|
||||
}
|
||||
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(
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
modifier = modifier.padding(bottom = 16.dp)
|
||||
) {
|
||||
if (positiveButtonTitle != null) {
|
||||
Button(
|
||||
text = stringResource(positiveButtonTitle),
|
||||
showProgress = isVerifying,
|
||||
text = positiveButtonTitle,
|
||||
showProgress = isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { positiveButtonEvent?.let { eventSink(it) } }
|
||||
onClick = onPositiveButtonClicked,
|
||||
)
|
||||
}
|
||||
if (negativeButtonTitle != null) {
|
||||
TextButton(
|
||||
text = stringResource(negativeButtonTitle),
|
||||
text = negativeButtonTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = negativeButtonCallback,
|
||||
onClick = onNegativeButtonClicked,
|
||||
enabled = negativeButtonEnabled,
|
||||
)
|
||||
}
|
||||
if (verificationViewState is FlowStep.Initial && verificationViewState.canEnterRecoveryKey) {
|
||||
Text(
|
||||
text = stringResource(id = CommonStrings.common_or),
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
TextButton(
|
||||
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onEnterRecoveryKey,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,6 +307,6 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
|
||||
VerifySelfSessionView(
|
||||
state = state,
|
||||
onEnterRecoveryKey = {},
|
||||
goBack = {},
|
||||
onFinished = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ package io.element.android.features.verifysession.impl
|
||||
sealed interface VerifySelfSessionViewEvents {
|
||||
data object RequestVerification : VerifySelfSessionViewEvents
|
||||
data object StartSasVerification : VerifySelfSessionViewEvents
|
||||
data object Restart : VerifySelfSessionViewEvents
|
||||
data object ConfirmVerification : VerifySelfSessionViewEvents
|
||||
data object DeclineVerification : VerifySelfSessionViewEvents
|
||||
data object CancelAndClose : VerifySelfSessionViewEvents
|
||||
data object Cancel : VerifySelfSessionViewEvents
|
||||
data object Reset : VerifySelfSessionViewEvents
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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 doesn’t 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_title">"Compare emojis"</string>
|
||||
|
||||
@@ -106,13 +106,13 @@ class VerifySelfSessionPresenterTests {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
val eventSink = initialState.eventSink
|
||||
eventSink(VerifySelfSessionViewEvents.CancelAndClose)
|
||||
eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - A fail in the flow cancels it`() = runTest {
|
||||
fun `present - A failure when verifying cancels it`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
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
|
||||
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
@@ -136,8 +151,7 @@ class VerifySelfSessionPresenterTests {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.CancelAndClose)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
}
|
||||
}
|
||||
@@ -165,13 +179,30 @@ class VerifySelfSessionPresenterTests {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
service.givenVerificationFlowState(VerificationFlowState.Canceled)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
state.eventSink(VerifySelfSessionViewEvents.Restart)
|
||||
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
// Went back to requesting verification
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
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
|
||||
fun `present - When verification is approved, the flow completes if there is no error`() = runTest {
|
||||
val emojis = listOf(
|
||||
@@ -247,7 +278,7 @@ class VerifySelfSessionPresenterTests {
|
||||
return VerifySelfSessionPresenter(
|
||||
sessionVerificationService = service,
|
||||
encryptionService = encryptionService,
|
||||
stateMachine = VerifySelfSessionStateMachine(service),
|
||||
stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,45 +36,98 @@ class VerifySelfSessionViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@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>()
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
goBack = callback,
|
||||
)
|
||||
}
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.CancelAndClose)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Reset)
|
||||
}
|
||||
|
||||
@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>()
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
goBack = callback,
|
||||
)
|
||||
}
|
||||
rule.pressBackKey()
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.CancelAndClose)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
|
||||
}
|
||||
|
||||
@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)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setContent {
|
||||
@@ -84,9 +137,10 @@ class VerifySelfSessionViewTest {
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
goBack = callback,
|
||||
onFinished = callback,
|
||||
)
|
||||
}
|
||||
rule.clickOn(CommonStrings.action_continue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +156,7 @@ class VerifySelfSessionViewTest {
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = callback,
|
||||
goBack = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
rule.clickOn(R.string.screen_session_verification_enter_recovery_key)
|
||||
@@ -122,7 +176,7 @@ class VerifySelfSessionViewTest {
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
goBack = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
rule.clickOn(R.string.screen_session_verification_they_match)
|
||||
@@ -142,7 +196,7 @@ class VerifySelfSessionViewTest {
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
goBack = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
rule.clickOn(R.string.screen_session_verification_they_dont_match)
|
||||
|
||||
@@ -32,6 +32,7 @@ import io.element.android.compound.theme.ElementTheme
|
||||
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.RoundedIconAtomSize
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
@@ -95,3 +96,14 @@ internal fun IconTitleSubtitleMoleculePreview() = ElementPreview {
|
||||
subTitle = "Subtitle",
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun IconTitleSubtitleMoleculeWithResIconPreview() = ElementPreview {
|
||||
IconTitleSubtitleMolecule(
|
||||
iconResourceId = CompoundDrawables.ic_compound_admin,
|
||||
iconTint = Color.Black,
|
||||
title = "Title",
|
||||
subTitle = "Subtitle",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,9 +52,7 @@ fun PageTitle(
|
||||
callToAction: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 40.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
BigIcon(style = iconStyle)
|
||||
|
||||
@@ -85,6 +85,9 @@ sealed interface SessionVerifiedStatus {
|
||||
|
||||
/** Verified session status. */
|
||||
data object Verified : SessionVerifiedStatus
|
||||
|
||||
/** Returns whether the session is [Verified]. */
|
||||
fun isVerified(): Boolean = this is Verified
|
||||
}
|
||||
|
||||
/** States produced by the [SessionVerificationService]. */
|
||||
|
||||
@@ -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.awaitLoaded
|
||||
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.MatrixUser
|
||||
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.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -130,11 +130,6 @@ class RustMatrixClient(
|
||||
private val innerRoomListService = syncService.roomListService()
|
||||
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
|
||||
private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope)
|
||||
private val verificationService = RustSessionVerificationService(
|
||||
client = client,
|
||||
syncService = rustSyncService,
|
||||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
).apply { start() }
|
||||
private val pushersService = RustPushersService(
|
||||
client = client,
|
||||
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
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { listStateEventType ->
|
||||
@@ -274,11 +275,6 @@ class RustMatrixClient(
|
||||
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf())
|
||||
|
||||
init {
|
||||
roomListService.state.onEach { state ->
|
||||
if (state == RoomListService.State.Running) {
|
||||
setupVerificationControllerIfNeeded()
|
||||
}
|
||||
}.launchIn(sessionCoroutineScope)
|
||||
sessionCoroutineScope.launch {
|
||||
// Force a refresh of the profile
|
||||
getUserProfile()
|
||||
@@ -490,6 +486,7 @@ class RustMatrixClient(
|
||||
ignoreSdkError: Boolean,
|
||||
): String? {
|
||||
var result: String? = null
|
||||
syncService.stop()
|
||||
withContext(sessionDispatcher) {
|
||||
if (doRequest) {
|
||||
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
|
||||
|
||||
private suspend fun File.getCacheSize(
|
||||
|
||||
@@ -16,92 +16,135 @@
|
||||
|
||||
package io.element.android.libraries.matrix.impl.verification
|
||||
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
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.VerificationEmoji
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.impl.sync.RustSyncService
|
||||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.Encryption
|
||||
import org.matrix.rustcomponents.sdk.RecoveryState
|
||||
import org.matrix.rustcomponents.sdk.RecoveryStateListener
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationController
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface
|
||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||
import org.matrix.rustcomponents.sdk.VerificationState
|
||||
import org.matrix.rustcomponents.sdk.VerificationStateListener
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData
|
||||
|
||||
class RustSessionVerificationService(
|
||||
client: Client,
|
||||
private val syncService: RustSyncService,
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
isSyncServiceReady: Flow<Boolean>,
|
||||
sessionCoroutineScope: CoroutineScope,
|
||||
) : SessionVerificationService, SessionVerificationControllerDelegate {
|
||||
private var verificationStateListenerTaskHandle: TaskHandle? = null
|
||||
private var recoveryStateListenerTaskHandle: TaskHandle? = null
|
||||
private val encryptionService: Encryption = client.encryption()
|
||||
var verificationController: SessionVerificationControllerInterface? = null
|
||||
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 lateinit var verificationController: SessionVerificationController
|
||||
|
||||
private val _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
|
||||
override val verificationFlowState = _verificationFlowState.asStateFlow()
|
||||
|
||||
private val _isReady = MutableStateFlow(false)
|
||||
override val isReady = _isReady.asStateFlow()
|
||||
|
||||
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
|
||||
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
|
||||
|
||||
override val canVerifySessionFlow = combine(sessionVerifiedStatus, syncService.syncState) { verificationStatus, syncState ->
|
||||
syncState == SyncState.Running && verificationStatus == SessionVerifiedStatus.NotVerified
|
||||
override val isReady = MutableStateFlow(false)
|
||||
|
||||
override val canVerifySessionFlow = combine(sessionVerifiedStatus, isReady) { verificationStatus, isReady ->
|
||||
isReady && verificationStatus == SessionVerifiedStatus.NotVerified
|
||||
}
|
||||
|
||||
fun start() {
|
||||
recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener {
|
||||
override fun onUpdate(status: RecoveryState) {
|
||||
sessionCoroutineScope.launch {
|
||||
updateVerificationStatus(verificationController?.isVerified().orFalse())
|
||||
init {
|
||||
isSyncServiceReady
|
||||
.onEach { syncServiceReady ->
|
||||
if (syncServiceReady) {
|
||||
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 {
|
||||
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 {
|
||||
verificationController?.startSasVerification()
|
||||
verificationController.startSasVerification()
|
||||
}
|
||||
|
||||
private suspend fun tryOrFail(block: suspend () -> Unit) {
|
||||
runCatching {
|
||||
block()
|
||||
}.onFailure { didFail() }
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to verify session")
|
||||
didFail()
|
||||
}
|
||||
}
|
||||
|
||||
// region Delegate implementation
|
||||
@@ -116,13 +159,14 @@ class RustSessionVerificationService(
|
||||
}
|
||||
|
||||
override fun didFail() {
|
||||
Timber.e("Session verification failed with an unknown error")
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
}
|
||||
|
||||
override fun didFinish() {
|
||||
_verificationFlowState.value = VerificationFlowState.Finished
|
||||
// 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) {
|
||||
@@ -139,25 +183,26 @@ class RustSessionVerificationService(
|
||||
override suspend fun reset() {
|
||||
if (isReady.value) {
|
||||
// Cancel any pending verification attempt
|
||||
tryOrNull { verificationController?.cancelVerification() }
|
||||
tryOrNull { verificationController.cancelVerification() }
|
||||
}
|
||||
_verificationFlowState.value = VerificationFlowState.Initial
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
Timber.d("Destroying RustSessionVerificationService")
|
||||
recoveryStateListenerTaskHandle?.cancelAndDestroy()
|
||||
verificationController?.setDelegate(null)
|
||||
(verificationController as? SessionVerificationController)?.destroy()
|
||||
verificationController = null
|
||||
if (this::verificationController.isInitialized) {
|
||||
verificationController.setDelegate(null)
|
||||
(verificationController as? SessionVerificationController)?.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateVerificationStatus(isVerified: Boolean) {
|
||||
val newValue = when {
|
||||
!isReady.value -> SessionVerifiedStatus.Unknown
|
||||
!isVerified -> SessionVerifiedStatus.NotVerified
|
||||
else -> SessionVerifiedStatus.Verified
|
||||
private fun updateVerificationStatus(verificationState: VerificationState) {
|
||||
_sessionVerifiedStatus.value = when (verificationState) {
|
||||
VerificationState.UNKNOWN -> SessionVerifiedStatus.Unknown
|
||||
VerificationState.VERIFIED -> SessionVerifiedStatus.Verified
|
||||
VerificationState.UNVERIFIED -> SessionVerifiedStatus.NotVerified
|
||||
}
|
||||
_sessionVerifiedStatus.value = newValue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,11 @@ class FakeSessionVerificationService : SessionVerificationService {
|
||||
override val isReady: StateFlow<Boolean> = _isReady
|
||||
|
||||
override suspend fun requestVerification() {
|
||||
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
|
||||
if (!shouldFail) {
|
||||
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
|
||||
} else {
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelVerification() {
|
||||
|
||||
@@ -254,6 +254,8 @@
|
||||
<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_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_retrieving_user_details">"Could not retrieve user details"</string>
|
||||
<string name="screen_room_member_details_block_alert_action">"Block"</string>
|
||||
|
||||
@@ -152,7 +152,6 @@ class RoomListScreen(
|
||||
state = state,
|
||||
onRoomClicked = ::onRoomClicked,
|
||||
onSettingsClicked = {},
|
||||
onVerifyClicked = {},
|
||||
onConfirmRecoveryKeyClicked = {},
|
||||
onCreateRoomClicked = {},
|
||||
onInvitesClicked = {},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user