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:
parent
05f6770d35
commit
41287c5f59
198 changed files with 822 additions and 761 deletions
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:455964d5b4d1652b5b9eeb8b7236f14c2c9490123399e8c65f1b9e920d20071d
|
||||
size 21053
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:84d78e6672aaafd2c7974630e7d9391ee7e649c9a9de470627d1ed6e1241a1f4
|
||||
size 13366
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c90e7168839f33c8ea4ef79308cc2b1c7a1ad4ec07dbeedba14dd080a3a841d8
|
||||
size 20169
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0628f8cd42815e725efc8f7bfe0bc790c2369f8d4cabfd31aabd494a469f7379
|
||||
size 12867
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:be4d708775d03680be450dfd5eebfeda24f1841eedced3e7de2ba036669ac479
|
||||
size 39086
|
||||
oid sha256:75fb611e02345fe1cf7947d1e160e309cf8520f0256ebd25b43bab8c3102f3f8
|
||||
size 37138
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:53660f4354d2a750ab539370151c9ee4ac49f9d06712f7646fdd487bf133753a
|
||||
size 38766
|
||||
oid sha256:a7be4df5a4e391d2253f283acf0fb9362b965fa887235221b794915d74891899
|
||||
size 36783
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8ae1828957e00adcbdbb772a0ebdc1fb6604afbe644cc5d8662da9fcd3973129
|
||||
size 41162
|
||||
oid sha256:67503a61f560ef79d7e981057094dccf46eda167ee84427c1aceb9f83edf0146
|
||||
size 39080
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:58e567815b4dc36abf8422b17f9902d8581dbec1a1ed216df2f120c6198b6fe5
|
||||
size 41124
|
||||
oid sha256:5cb3be70de8e3878bdd90db9945aed60fb2b38d8c66ea610806226c21b1f5a5a
|
||||
size 39052
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f5fd867f51badedc8e9ded0900eb904ac11af348243573dcf34b8aaa3c1548b7
|
||||
size 26926
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:318838247c7d0cee133fd5c98bbbee6f2c5be09a95b6c2fd293c041e21480ab8
|
||||
size 26080
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue