Incoming session verification request
Add more log to the state machines Ensure the block cannot be cancelled, else if the Rust SDK emit a new state during the API execution, the state machine may cancel the api call. Let VerificationFlowState values match the SDK api for code clarity. Rename sub interface for clarity. Migrate tests to the new FakeVerificationService.
This commit is contained in:
committed by
Benoit Marty
parent
c56e9604bf
commit
d1b3ecab36
@@ -25,8 +25,10 @@ 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 com.bumble.appyx.navmodel.backstack.operation.replace
|
||||
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
@@ -50,6 +52,7 @@ import io.element.android.features.roomlist.api.RoomListEntryPoint
|
||||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||
import io.element.android.features.share.api.ShareEntryPoint
|
||||
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
||||
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
@@ -66,6 +69,8 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
||||
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -99,6 +104,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val sendingQueue: SendQueues,
|
||||
private val logoutEntryPoint: LogoutEntryPoint,
|
||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||
@@ -123,6 +129,12 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
matrixClient.roomMembershipObserver(),
|
||||
)
|
||||
|
||||
private val verificationListener = object : SessionVerificationServiceListener {
|
||||
override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
|
||||
backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
@@ -131,6 +143,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
// TODO We do not support Space yet, so directly navigate to main space
|
||||
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
||||
loggedInFlowProcessor.observeEvents(coroutineScope)
|
||||
matrixClient.sessionVerificationService().setListener(verificationListener)
|
||||
|
||||
ftueService.state
|
||||
.onEach { ftueState ->
|
||||
@@ -152,6 +165,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
appNavigationStateService.onLeavingSpace(id)
|
||||
appNavigationStateService.onLeavingSession(id)
|
||||
loggedInFlowProcessor.stopObserving()
|
||||
matrixClient.sessionVerificationService().setListener(null)
|
||||
}
|
||||
)
|
||||
observeSyncStateAndNetworkStatus()
|
||||
@@ -232,6 +246,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
@@ -432,6 +449,16 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
is NavTarget.IncomingVerificationRequest -> {
|
||||
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(IncomingVerificationEntryPoint.Params(navTarget.data))
|
||||
.callback(object : IncomingVerificationEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class DefaultFtueServiceTest {
|
||||
@Test
|
||||
fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Unknown)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Unknown)
|
||||
}
|
||||
val service = createDefaultFtueService(
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
@@ -46,7 +46,7 @@ class DefaultFtueServiceTest {
|
||||
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
|
||||
|
||||
// Verification state is known, we should display the flow if any check is false
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
assertThat(awaitItem()).isEqualTo(FtueState.Incomplete)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class DefaultFtueServiceTest {
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
analyticsService.setDidAskUserConsent()
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
@@ -76,7 +76,7 @@ class DefaultFtueServiceTest {
|
||||
@Test
|
||||
fun `traverse flow`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
}
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||
@@ -91,7 +91,7 @@ class DefaultFtueServiceTest {
|
||||
|
||||
// Session verification
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
|
||||
// Notifications opt in
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
@@ -132,7 +132,7 @@ class DefaultFtueServiceTest {
|
||||
)
|
||||
|
||||
// Skip first 3 steps
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
@@ -155,7 +155,7 @@ class DefaultFtueServiceTest {
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||
|
||||
@@ -136,7 +136,7 @@ class RoomListPresenterTest {
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showAvatarIndicator).isTrue()
|
||||
sessionVerificationService.givenNeedsSessionVerification(false)
|
||||
sessionVerificationService.emitNeedsSessionVerification(false)
|
||||
encryptionService.emitBackupState(BackupState.ENABLED)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.showAvatarIndicator).isFalse()
|
||||
@@ -231,7 +231,7 @@ class RoomListPresenterTest {
|
||||
roomListService = roomListService,
|
||||
encryptionService = encryptionService,
|
||||
sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
emitNeedsSessionVerification(false)
|
||||
},
|
||||
syncService = FakeSyncService(MutableStateFlow(SyncState.Running)),
|
||||
)
|
||||
|
||||
@@ -15,4 +15,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.api
|
||||
|
||||
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 io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
|
||||
interface IncomingVerificationEntryPoint : FeatureEntryPoint {
|
||||
data class Params(
|
||||
val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
||||
) : NodeInputs
|
||||
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun params(params: Params): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ dependencies {
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.dateformatter.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
@@ -43,6 +44,7 @@ dependencies {
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.features.logout.test)
|
||||
testImplementation(projects.libraries.dateformatter.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
|
||||
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
|
||||
override val values: Sequence<VerifySelfSessionState>
|
||||
get() = sequenceOf(
|
||||
aVerifySelfSessionState(displaySkipButton = true),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.AwaitingOtherDeviceResponse
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Canceled
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Ready
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Completed,
|
||||
displaySkipButton = true,
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
signOutAction = AsyncAction.Loading,
|
||||
displaySkipButton = true,
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Loading
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Skipped
|
||||
),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aEmojisSessionVerificationData(
|
||||
emojiList: List<VerificationEmoji> = aVerificationEmojiList(),
|
||||
): SessionVerificationData {
|
||||
return SessionVerificationData.Emojis(emojiList)
|
||||
}
|
||||
|
||||
private fun aDecimalsSessionVerificationData(
|
||||
decimals: List<Int> = listOf(123, 456, 789),
|
||||
): SessionVerificationData {
|
||||
return SessionVerificationData.Decimals(decimals)
|
||||
}
|
||||
|
||||
internal fun aVerifySelfSessionState(
|
||||
verificationFlowStep: VerificationStep = VerificationStep.Initial(canEnterRecoveryKey = false),
|
||||
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
|
||||
displaySkipButton: Boolean = false,
|
||||
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
|
||||
) = VerifySelfSessionState(
|
||||
verificationFlowStep = verificationFlowStep,
|
||||
displaySkipButton = displaySkipButton,
|
||||
eventSink = eventSink,
|
||||
signOutAction = signOutAction,
|
||||
)
|
||||
|
||||
private fun aVerificationEmojiList() = listOf(
|
||||
VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"),
|
||||
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
|
||||
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
|
||||
VerificationEmoji(number = 42, emoji = "📕", description = "Book"),
|
||||
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
|
||||
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
|
||||
VerificationEmoji(number = 63, emoji = "📌", description = "Pin"),
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultIncomingVerificationEntryPoint @Inject constructor() : IncomingVerificationEntryPoint {
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): IncomingVerificationEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
return object : IncomingVerificationEntryPoint.NodeBuilder {
|
||||
override fun callback(callback: IncomingVerificationEntryPoint.Callback): IncomingVerificationEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun params(params: IncomingVerificationEntryPoint.Params): IncomingVerificationEntryPoint.NodeBuilder {
|
||||
plugins += params
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<IncomingVerificationNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
fun interface IncomingVerificationNavigator {
|
||||
fun onFinish()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
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 dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class IncomingVerificationNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: IncomingVerificationPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins),
|
||||
IncomingVerificationNavigator {
|
||||
private val presenter = presenterFactory.create(
|
||||
sessionVerificationRequestDetails = inputs<IncomingVerificationEntryPoint.Params>().sessionVerificationRequestDetails,
|
||||
navigator = this,
|
||||
)
|
||||
|
||||
override fun onFinish() {
|
||||
plugins<IncomingVerificationEntryPoint.Callback>().forEach { it.onDone() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
IncomingVerificationView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import com.freeletics.flowredux.compose.rememberStateAndDispatch
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import timber.log.Timber
|
||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.Event as StateMachineEvent
|
||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState
|
||||
|
||||
class IncomingVerificationPresenter @AssistedInject constructor(
|
||||
@Assisted private val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
||||
@Assisted private val navigator: IncomingVerificationNavigator,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val stateMachine: IncomingVerificationStateMachine,
|
||||
private val dateFormatter: LastMessageTimestampFormatter,
|
||||
) : Presenter<IncomingVerificationState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
||||
navigator: IncomingVerificationNavigator,
|
||||
): IncomingVerificationPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): IncomingVerificationState {
|
||||
LaunchedEffect(Unit) {
|
||||
// Force reset, just in case the service was left in a broken state
|
||||
sessionVerificationService.reset(
|
||||
cancelAnyPendingVerificationAttempt = false
|
||||
)
|
||||
// Acknowledge the request right now
|
||||
sessionVerificationService.acknowledgeVerificationRequest(sessionVerificationRequestDetails)
|
||||
}
|
||||
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
|
||||
val formattedSignInTime = remember {
|
||||
dateFormatter.format(sessionVerificationRequestDetails.firstSeenTimestamp)
|
||||
}
|
||||
val step by remember {
|
||||
derivedStateOf {
|
||||
stateAndDispatch.state.value.toVerificationStep(
|
||||
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
|
||||
formattedSignInTime = formattedSignInTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(stateAndDispatch.state.value) {
|
||||
if ((stateAndDispatch.state.value as? IncomingVerificationStateMachine.State.Initial)?.isCancelled == true) {
|
||||
// The verification was canceled before it was started, maybe because another session accepted it
|
||||
navigator.onFinish()
|
||||
}
|
||||
}
|
||||
|
||||
// Start this after observing state machine
|
||||
LaunchedEffect(Unit) {
|
||||
observeVerificationService()
|
||||
}
|
||||
|
||||
fun handleEvents(event: IncomingVerificationViewEvents) {
|
||||
Timber.d("Verification user action: ${event::class.simpleName}")
|
||||
when (event) {
|
||||
IncomingVerificationViewEvents.StartVerification ->
|
||||
stateAndDispatch.dispatchAction(StateMachineEvent.AcceptIncomingRequest)
|
||||
IncomingVerificationViewEvents.IgnoreVerification ->
|
||||
navigator.onFinish()
|
||||
IncomingVerificationViewEvents.ConfirmVerification ->
|
||||
stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
|
||||
IncomingVerificationViewEvents.DeclineVerification ->
|
||||
stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
|
||||
IncomingVerificationViewEvents.GoBack -> {
|
||||
when (val verificationStep = step) {
|
||||
is Step.Initial -> if (verificationStep.isWaiting) {
|
||||
stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
|
||||
} else {
|
||||
navigator.onFinish()
|
||||
}
|
||||
is Step.Verifying -> if (verificationStep.isWaiting) {
|
||||
// What do we do in this case?
|
||||
} else {
|
||||
stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
|
||||
}
|
||||
Step.Canceled,
|
||||
Step.Completed,
|
||||
Step.Failure -> navigator.onFinish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return IncomingVerificationState(
|
||||
step = step,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
private fun StateMachineState?.toVerificationStep(
|
||||
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
||||
formattedSignInTime: String,
|
||||
): Step =
|
||||
when (val machineState = this) {
|
||||
is StateMachineState.Initial,
|
||||
IncomingVerificationStateMachine.State.AcceptingIncomingVerification,
|
||||
IncomingVerificationStateMachine.State.RejectingIncomingVerification,
|
||||
null -> {
|
||||
Step.Initial(
|
||||
deviceDisplayName = sessionVerificationRequestDetails.displayName ?: sessionVerificationRequestDetails.deviceId.value,
|
||||
deviceId = sessionVerificationRequestDetails.deviceId,
|
||||
formattedSignInTime = formattedSignInTime,
|
||||
isWaiting = machineState == IncomingVerificationStateMachine.State.AcceptingIncomingVerification ||
|
||||
machineState == IncomingVerificationStateMachine.State.RejectingIncomingVerification,
|
||||
)
|
||||
}
|
||||
is IncomingVerificationStateMachine.State.ChallengeReceived ->
|
||||
Step.Verifying(
|
||||
data = machineState.data,
|
||||
isWaiting = false,
|
||||
)
|
||||
IncomingVerificationStateMachine.State.Completed -> Step.Completed
|
||||
IncomingVerificationStateMachine.State.Canceling,
|
||||
IncomingVerificationStateMachine.State.Failure -> Step.Failure
|
||||
is IncomingVerificationStateMachine.State.AcceptingChallenge ->
|
||||
Step.Verifying(
|
||||
data = machineState.data,
|
||||
isWaiting = true,
|
||||
)
|
||||
is IncomingVerificationStateMachine.State.RejectingChallenge ->
|
||||
Step.Verifying(
|
||||
data = machineState.data,
|
||||
isWaiting = true,
|
||||
)
|
||||
IncomingVerificationStateMachine.State.Canceled -> Step.Canceled
|
||||
}
|
||||
|
||||
private fun CoroutineScope.observeVerificationService() {
|
||||
sessionVerificationService.verificationFlowState
|
||||
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
|
||||
.onEach { verificationAttemptState ->
|
||||
when (verificationAttemptState) {
|
||||
VerificationFlowState.Initial,
|
||||
VerificationFlowState.DidAcceptVerificationRequest,
|
||||
VerificationFlowState.DidStartSasVerification -> Unit
|
||||
is VerificationFlowState.DidReceiveVerificationData -> {
|
||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
|
||||
}
|
||||
VerificationFlowState.DidFinish -> {
|
||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidAcceptChallenge)
|
||||
}
|
||||
VerificationFlowState.DidCancel -> {
|
||||
// Can happen when:
|
||||
// - the remote party cancel the verification (before it is started)
|
||||
// - another session has accepted the incoming verification request
|
||||
// - the user reject the challenge from this application (I think this is an error). In this case, the state
|
||||
// machine will ignore this event and change state to Failure.
|
||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidCancel)
|
||||
}
|
||||
VerificationFlowState.DidFail -> {
|
||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidFail)
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
|
||||
@Immutable
|
||||
data class IncomingVerificationState(
|
||||
val step: Step,
|
||||
val eventSink: (IncomingVerificationViewEvents) -> Unit,
|
||||
) {
|
||||
@Stable
|
||||
sealed interface Step {
|
||||
data class Initial(
|
||||
val deviceDisplayName: String,
|
||||
val deviceId: DeviceId,
|
||||
val formattedSignInTime: String,
|
||||
val isWaiting: Boolean,
|
||||
) : Step
|
||||
|
||||
data class Verifying(
|
||||
val data: SessionVerificationData,
|
||||
val isWaiting: Boolean,
|
||||
) : Step
|
||||
|
||||
data object Canceled : Step
|
||||
data object Completed : Step
|
||||
data object Failure : Step
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
|
||||
import io.element.android.features.verifysession.impl.util.andLogStateChange
|
||||
import io.element.android.features.verifysession.impl.util.logReceivedEvents
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import javax.inject.Inject
|
||||
import com.freeletics.flowredux.dsl.State as MachineState
|
||||
|
||||
class IncomingVerificationStateMachine @Inject constructor(
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
) : FlowReduxStateMachine<IncomingVerificationStateMachine.State, IncomingVerificationStateMachine.Event>(
|
||||
initialState = State.Initial(isCancelled = false)
|
||||
) {
|
||||
init {
|
||||
spec {
|
||||
inState<State.Initial> {
|
||||
on { _: Event.AcceptIncomingRequest, state ->
|
||||
state.override { State.AcceptingIncomingVerification.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.AcceptingIncomingVerification> {
|
||||
onEnterEffect {
|
||||
sessionVerificationService.acceptVerificationRequest()
|
||||
}
|
||||
on { event: Event.DidReceiveChallenge, state ->
|
||||
state.override { State.ChallengeReceived(event.data).andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.ChallengeReceived> {
|
||||
on { _: Event.AcceptChallenge, state ->
|
||||
state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() }
|
||||
}
|
||||
on { _: Event.DeclineChallenge, state ->
|
||||
state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.AcceptingChallenge> {
|
||||
onEnterEffect { _ ->
|
||||
sessionVerificationService.approveVerification()
|
||||
}
|
||||
on { _: Event.DidAcceptChallenge, state ->
|
||||
state.override { State.Completed.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.RejectingChallenge> {
|
||||
onEnterEffect { _ ->
|
||||
sessionVerificationService.declineVerification()
|
||||
}
|
||||
}
|
||||
inState<State.Canceling> {
|
||||
onEnterEffect {
|
||||
sessionVerificationService.cancelVerification()
|
||||
}
|
||||
}
|
||||
inState {
|
||||
logReceivedEvents()
|
||||
on { _: Event.Cancel, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
State.Completed, State.Canceled -> state.noChange()
|
||||
else -> {
|
||||
sessionVerificationService.cancelVerification()
|
||||
state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
on { _: Event.DidCancel, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
is State.RejectingChallenge -> {
|
||||
state.override { State.Failure.andLogStateChange() }
|
||||
}
|
||||
is State.Initial -> state.mutate { State.Initial(isCancelled = true).andLogStateChange() }
|
||||
State.AcceptingIncomingVerification,
|
||||
State.RejectingIncomingVerification,
|
||||
is State.ChallengeReceived,
|
||||
is State.AcceptingChallenge,
|
||||
State.Canceling -> state.override { State.Canceled.andLogStateChange() }
|
||||
State.Canceled,
|
||||
State.Completed,
|
||||
State.Failure -> state.noChange()
|
||||
}
|
||||
}
|
||||
on { _: Event.DidFail, state: MachineState<State> ->
|
||||
state.override { State.Failure.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface State {
|
||||
/** The initial state, before verification started. */
|
||||
data class Initial(val isCancelled: Boolean) : State
|
||||
|
||||
/** User is accepting the incoming verification. */
|
||||
data object AcceptingIncomingVerification : State
|
||||
|
||||
/** User is rejecting the incoming verification. */
|
||||
data object RejectingIncomingVerification : State
|
||||
|
||||
/** Verification accepted and emojis received. */
|
||||
data class ChallengeReceived(val data: SessionVerificationData) : State
|
||||
|
||||
/** Accepting the verification challenge. */
|
||||
data class AcceptingChallenge(val data: SessionVerificationData) : State
|
||||
|
||||
/** Rejecting the verification challenge. */
|
||||
data class RejectingChallenge(val data: SessionVerificationData) : State
|
||||
|
||||
/** The verification is being canceled. */
|
||||
data object Canceling : State
|
||||
|
||||
/** The verification has been canceled, remotely or locally. */
|
||||
data object Canceled : State
|
||||
|
||||
/** Verification successful. */
|
||||
data object Completed : State
|
||||
|
||||
/** Verification failure. */
|
||||
data object Failure : State
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
/** User accepts the incoming request. */
|
||||
data object AcceptIncomingRequest : Event
|
||||
|
||||
/** Has received data. */
|
||||
data class DidReceiveChallenge(val data: SessionVerificationData) : Event
|
||||
|
||||
/** Emojis match. */
|
||||
data object AcceptChallenge : Event
|
||||
|
||||
/** Emojis do not match. */
|
||||
data object DeclineChallenge : Event
|
||||
|
||||
/** Remote accepted challenge. */
|
||||
data object DidAcceptChallenge : Event
|
||||
|
||||
/** Request cancellation. */
|
||||
data object Cancel : Event
|
||||
|
||||
/** Verification cancelled. */
|
||||
data object DidCancel : Event
|
||||
|
||||
/** Request failed. */
|
||||
data object DidFail : Event
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
|
||||
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
|
||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
|
||||
open class IncomingVerificationStateProvider : PreviewParameterProvider<IncomingVerificationState> {
|
||||
override val values: Sequence<IncomingVerificationState>
|
||||
get() = sequenceOf(
|
||||
anIncomingVerificationState(),
|
||||
anIncomingVerificationState(step = aStepInitial(isWaiting = true)),
|
||||
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)),
|
||||
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)),
|
||||
anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)),
|
||||
anIncomingVerificationState(step = Step.Completed),
|
||||
anIncomingVerificationState(step = Step.Failure),
|
||||
anIncomingVerificationState(step = Step.Canceled),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aStepInitial(
|
||||
isWaiting: Boolean = false,
|
||||
) = Step.Initial(
|
||||
deviceDisplayName = "Element X Android",
|
||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||
formattedSignInTime = "12:34",
|
||||
isWaiting = isWaiting,
|
||||
)
|
||||
|
||||
internal fun anIncomingVerificationState(
|
||||
step: Step = aStepInitial(),
|
||||
eventSink: (IncomingVerificationViewEvents) -> Unit = {},
|
||||
) = IncomingVerificationState(
|
||||
step = step,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.R
|
||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
|
||||
import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView
|
||||
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
|
||||
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
||||
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.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* [Figma](https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=819-7324).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun IncomingVerificationView(
|
||||
state: IncomingVerificationState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val step = state.step
|
||||
|
||||
BackHandler {
|
||||
state.eventSink(IncomingVerificationViewEvents.GoBack)
|
||||
}
|
||||
HeaderFooterPage(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
)
|
||||
},
|
||||
header = {
|
||||
HeaderContent(step = step)
|
||||
},
|
||||
footer = {
|
||||
IncomingVerificationBottomMenu(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
) {
|
||||
Content(
|
||||
step = step,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderContent(step: Step) {
|
||||
val iconStyle = when (step) {
|
||||
Step.Canceled,
|
||||
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||
is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
Step.Completed -> BigIcon.Style.SuccessSolid
|
||||
Step.Failure -> BigIcon.Style.AlertSolid
|
||||
}
|
||||
val titleTextId = when (step) {
|
||||
Step.Canceled -> CommonStrings.common_verification_cancelled
|
||||
is Step.Initial -> R.string.screen_session_verification_request_title
|
||||
is Step.Verifying -> when (step.data) {
|
||||
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
|
||||
}
|
||||
Step.Completed -> R.string.screen_session_verification_request_success_title
|
||||
Step.Failure -> R.string.screen_session_verification_request_failure_title
|
||||
}
|
||||
val subtitleTextId = when (step) {
|
||||
Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle
|
||||
is Step.Initial -> R.string.screen_session_verification_request_subtitle
|
||||
is Step.Verifying -> when (step.data) {
|
||||
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
|
||||
}
|
||||
Step.Completed -> R.string.screen_session_verification_request_success_subtitle
|
||||
Step.Failure -> R.string.screen_session_verification_request_failure_subtitle
|
||||
}
|
||||
PageTitle(
|
||||
iconStyle = iconStyle,
|
||||
title = stringResource(id = titleTextId),
|
||||
subtitle = stringResource(id = subtitleTextId)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(
|
||||
step: Step,
|
||||
) {
|
||||
when (step) {
|
||||
is Step.Initial -> ContentInitial(step)
|
||||
is Step.Verifying -> VerificationContentVerifying(step.data)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentInitial(
|
||||
initialIncoming: Step.Initial,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
SessionDetailsView(
|
||||
deviceName = initialIncoming.deviceDisplayName,
|
||||
deviceId = initialIncoming.deviceId,
|
||||
signInFormattedTimestamp = initialIncoming.formattedSignInTime,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(bottom = 16.dp),
|
||||
text = stringResource(R.string.screen_session_verification_request_footer),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingVerificationBottomMenu(
|
||||
state: IncomingVerificationState,
|
||||
) {
|
||||
val step = state.step
|
||||
val eventSink = state.eventSink
|
||||
|
||||
when (step) {
|
||||
is Step.Initial -> {
|
||||
if (step.isWaiting) {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_waiting_on_other_device),
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
showProgress = true,
|
||||
)
|
||||
// Placeholder so the 1st button keeps its vertical position
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
} else {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_start),
|
||||
onClick = { eventSink(IncomingVerificationViewEvents.StartVerification) },
|
||||
)
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_ignore),
|
||||
onClick = { eventSink(IncomingVerificationViewEvents.IgnoreVerification) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Step.Verifying -> {
|
||||
if (step.isWaiting) {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing),
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
showProgress = true,
|
||||
)
|
||||
// Placeholder so the 1st button keeps its vertical position
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
} else {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_they_match),
|
||||
onClick = { eventSink(IncomingVerificationViewEvents.ConfirmVerification) },
|
||||
)
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_they_dont_match),
|
||||
onClick = { eventSink(IncomingVerificationViewEvents.DeclineVerification) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Step.Canceled,
|
||||
is Step.Completed,
|
||||
is Step.Failure -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_done),
|
||||
onClick = { eventSink(IncomingVerificationViewEvents.GoBack) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun IncomingVerificationViewPreview(@PreviewParameter(IncomingVerificationStateProvider::class) state: IncomingVerificationState) = ElementPreview {
|
||||
IncomingVerificationView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
sealed interface IncomingVerificationViewEvents {
|
||||
data object GoBack : IncomingVerificationViewEvents
|
||||
data object StartVerification : IncomingVerificationViewEvents
|
||||
data object IgnoreVerification : IncomingVerificationViewEvents
|
||||
data object ConfirmVerification : IncomingVerificationViewEvents
|
||||
data object DeclineVerification : IncomingVerificationViewEvents
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming.ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.verifysession.impl.R
|
||||
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
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun SessionDetailsView(
|
||||
deviceName: String,
|
||||
deviceId: DeviceId,
|
||||
signInFormattedTimestamp: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = ElementTheme.colors.borderDisabled,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RoundedIconAtom(
|
||||
modifier = Modifier,
|
||||
size = RoundedIconAtomSize.Big,
|
||||
resourceId = CompoundDrawables.ic_compound_devices
|
||||
)
|
||||
Text(
|
||||
text = deviceName,
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextWithLabelMolecule(
|
||||
label = stringResource(R.string.screen_session_verification_request_details_timestamp),
|
||||
text = signInFormattedTimestamp,
|
||||
modifier = Modifier.weight(2f),
|
||||
)
|
||||
TextWithLabelMolecule(
|
||||
label = stringResource(CommonStrings.common_device_id),
|
||||
text = deviceId.value,
|
||||
modifier = Modifier.weight(5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextWithLabelMolecule(
|
||||
label: String,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = label,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SessionDetailsViewPreview() = ElementPreview {
|
||||
SessionDetailsView(
|
||||
deviceName = "Element X Android",
|
||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||
signInFormattedTimestamp = "12:34",
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -39,8 +39,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState
|
||||
import timber.log.Timber
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.Event as StateMachineEvent
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState
|
||||
|
||||
class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
@Assisted private val showDeviceVerifiedScreen: Boolean,
|
||||
@@ -61,7 +62,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
// Force reset, just in case the service was left in a broken state
|
||||
sessionVerificationService.reset()
|
||||
sessionVerificationService.reset(true)
|
||||
}
|
||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
|
||||
@@ -70,13 +71,13 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
val signOutAction = remember {
|
||||
mutableStateOf<AsyncAction<String?>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val verificationFlowStep by remember {
|
||||
val step by remember {
|
||||
derivedStateOf {
|
||||
if (skipVerification) {
|
||||
VerifySelfSessionState.VerificationStep.Skipped
|
||||
VerifySelfSessionState.Step.Skipped
|
||||
} else {
|
||||
when (sessionVerifiedStatus) {
|
||||
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.VerificationStep.Loading
|
||||
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
|
||||
SessionVerifiedStatus.NotVerified -> {
|
||||
stateAndDispatch.state.value.toVerificationStep(
|
||||
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
|
||||
@@ -85,10 +86,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
SessionVerifiedStatus.Verified -> {
|
||||
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
|
||||
// The user has verified the session, we need to show the success screen
|
||||
VerifySelfSessionState.VerificationStep.Completed
|
||||
VerifySelfSessionState.Step.Completed
|
||||
} else {
|
||||
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
|
||||
VerifySelfSessionState.VerificationStep.Skipped
|
||||
VerifySelfSessionState.Step.Skipped
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +102,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
}
|
||||
|
||||
fun handleEvents(event: VerifySelfSessionViewEvents) {
|
||||
Timber.d("Verification user action: ${event::class.simpleName}")
|
||||
when (event) {
|
||||
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
|
||||
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
|
||||
@@ -115,7 +117,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
return VerifySelfSessionState(
|
||||
verificationFlowStep = verificationFlowStep,
|
||||
step = step,
|
||||
signOutAction = signOutAction.value,
|
||||
displaySkipButton = buildMeta.isDebuggable,
|
||||
eventSink = ::handleEvents,
|
||||
@@ -124,10 +126,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
|
||||
private fun StateMachineState?.toVerificationStep(
|
||||
canEnterRecoveryKey: Boolean
|
||||
): VerifySelfSessionState.VerificationStep =
|
||||
): VerifySelfSessionState.Step =
|
||||
when (val machineState = this) {
|
||||
StateMachineState.Initial, null -> {
|
||||
VerifySelfSessionState.VerificationStep.Initial(
|
||||
VerifySelfSessionState.Step.Initial(
|
||||
canEnterRecoveryKey = canEnterRecoveryKey,
|
||||
isLastDevice = encryptionService.isLastDevice.value
|
||||
)
|
||||
@@ -136,15 +138,15 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
StateMachineState.StartingSasVerification,
|
||||
StateMachineState.SasVerificationStarted,
|
||||
StateMachineState.Canceling -> {
|
||||
VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse
|
||||
VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
|
||||
}
|
||||
|
||||
StateMachineState.VerificationRequestAccepted -> {
|
||||
VerifySelfSessionState.VerificationStep.Ready
|
||||
VerifySelfSessionState.Step.Ready
|
||||
}
|
||||
|
||||
StateMachineState.Canceled -> {
|
||||
VerifySelfSessionState.VerificationStep.Canceled
|
||||
VerifySelfSessionState.Step.Canceled
|
||||
}
|
||||
|
||||
is StateMachineState.Verifying -> {
|
||||
@@ -152,38 +154,41 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
is StateMachineState.Verifying.Replying -> AsyncData.Loading()
|
||||
else -> AsyncData.Uninitialized
|
||||
}
|
||||
VerifySelfSessionState.VerificationStep.Verifying(machineState.data, async)
|
||||
VerifySelfSessionState.Step.Verifying(machineState.data, async)
|
||||
}
|
||||
|
||||
StateMachineState.Completed -> {
|
||||
VerifySelfSessionState.VerificationStep.Completed
|
||||
VerifySelfSessionState.Step.Completed
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.observeVerificationService() {
|
||||
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
|
||||
sessionVerificationService.verificationFlowState
|
||||
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
|
||||
.onEach { verificationAttemptState ->
|
||||
when (verificationAttemptState) {
|
||||
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
|
||||
VerificationFlowState.AcceptedVerificationRequest -> {
|
||||
VerificationFlowState.DidAcceptVerificationRequest -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
|
||||
}
|
||||
VerificationFlowState.StartedSasVerification -> {
|
||||
VerificationFlowState.DidStartSasVerification -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
|
||||
}
|
||||
is VerificationFlowState.ReceivedVerificationData -> {
|
||||
is VerificationFlowState.DidReceiveVerificationData -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
|
||||
}
|
||||
VerificationFlowState.Finished -> {
|
||||
VerificationFlowState.DidFinish -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
|
||||
}
|
||||
VerificationFlowState.Canceled -> {
|
||||
VerificationFlowState.DidCancel -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
|
||||
}
|
||||
VerificationFlowState.Failed -> {
|
||||
VerificationFlowState.DidFail -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
|
||||
}
|
||||
}
|
||||
}.launchIn(this)
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<String?>>) = launch {
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
@@ -15,22 +15,22 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
|
||||
|
||||
@Immutable
|
||||
data class VerifySelfSessionState(
|
||||
val verificationFlowStep: VerificationStep,
|
||||
val step: Step,
|
||||
val signOutAction: AsyncAction<String?>,
|
||||
val displaySkipButton: Boolean,
|
||||
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
|
||||
) {
|
||||
@Stable
|
||||
sealed interface VerificationStep {
|
||||
data object Loading : VerificationStep
|
||||
sealed interface Step {
|
||||
data object Loading : Step
|
||||
|
||||
// FIXME canEnterRecoveryKey value is never read.
|
||||
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : VerificationStep
|
||||
data object Canceled : VerificationStep
|
||||
data object AwaitingOtherDeviceResponse : VerificationStep
|
||||
data object Ready : VerificationStep
|
||||
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : VerificationStep
|
||||
data object Completed : VerificationStep
|
||||
data object Skipped : VerificationStep
|
||||
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
|
||||
data object Canceled : Step
|
||||
data object AwaitingOtherDeviceResponse : Step
|
||||
data object Ready : Step
|
||||
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step
|
||||
data object Completed : Step
|
||||
data object Skipped : Step
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,11 @@
|
||||
@file:Suppress("WildcardImport")
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
|
||||
import io.element.android.features.verifysession.impl.util.andLogStateChange
|
||||
import io.element.android.features.verifysession.impl.util.logReceivedEvents
|
||||
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
|
||||
@@ -37,10 +39,10 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||
spec {
|
||||
inState<State.Initial> {
|
||||
on { _: Event.RequestVerification, state ->
|
||||
state.override { State.RequestingVerification }
|
||||
state.override { State.RequestingVerification.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.StartSasVerification, state ->
|
||||
state.override { State.StartingSasVerification }
|
||||
state.override { State.StartingSasVerification.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.RequestingVerification> {
|
||||
@@ -48,7 +50,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||
sessionVerificationService.requestVerification()
|
||||
}
|
||||
on { _: Event.DidAcceptVerificationRequest, state ->
|
||||
state.override { State.VerificationRequestAccepted }
|
||||
state.override { State.VerificationRequestAccepted.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.StartingSasVerification> {
|
||||
@@ -58,28 +60,28 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||
}
|
||||
inState<State.VerificationRequestAccepted> {
|
||||
on { _: Event.StartSasVerification, state ->
|
||||
state.override { State.StartingSasVerification }
|
||||
state.override { State.StartingSasVerification.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Canceled> {
|
||||
on { _: Event.RequestVerification, state ->
|
||||
state.override { State.RequestingVerification }
|
||||
state.override { State.RequestingVerification.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.Reset, state ->
|
||||
state.override { State.Initial }
|
||||
state.override { State.Initial.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.SasVerificationStarted> {
|
||||
on { event: Event.DidReceiveChallenge, state ->
|
||||
state.override { State.Verifying.ChallengeReceived(event.data) }
|
||||
state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Verifying.ChallengeReceived> {
|
||||
on { _: Event.AcceptChallenge, state ->
|
||||
state.override { State.Verifying.Replying(state.snapshot.data, accept = true) }
|
||||
state.override { State.Verifying.Replying(state.snapshot.data, accept = true).andLogStateChange() }
|
||||
}
|
||||
on { _: Event.DeclineChallenge, state ->
|
||||
state.override { State.Verifying.Replying(state.snapshot.data, accept = false) }
|
||||
state.override { State.Verifying.Replying(state.snapshot.data, accept = false).andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Verifying.Replying> {
|
||||
@@ -100,7 +102,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||
.first()
|
||||
}
|
||||
}
|
||||
state.override { State.Completed }
|
||||
state.override { State.Completed.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Canceling> {
|
||||
@@ -110,8 +112,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||
}
|
||||
}
|
||||
inState {
|
||||
logReceivedEvents()
|
||||
on { _: Event.DidStartSasVerification, state: MachineState<State> ->
|
||||
state.override { State.SasVerificationStarted }
|
||||
state.override { State.SasVerificationStarted.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.Cancel, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
@@ -120,17 +123,17 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||
// `Canceling` state to `Canceled` automatically anymore
|
||||
else -> {
|
||||
sessionVerificationService.cancelVerification()
|
||||
state.override { State.Canceled }
|
||||
state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
on { _: Event.DidCancel, state: MachineState<State> ->
|
||||
state.override { State.Canceled }
|
||||
state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.DidFail, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
is State.RequestingVerification -> state.override { State.Initial }
|
||||
else -> state.override { State.Canceled }
|
||||
is State.RequestingVerification -> state.override { State.Initial.andLogStateChange() }
|
||||
else -> state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
|
||||
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
|
||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
||||
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
|
||||
override val values: Sequence<VerifySelfSessionState>
|
||||
get() = sequenceOf(
|
||||
aVerifySelfSessionState(displaySkipButton = true),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.AwaitingOtherDeviceResponse
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Canceled
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Ready
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Initial(canEnterRecoveryKey = true)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Initial(canEnterRecoveryKey = true, isLastDevice = true)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Completed,
|
||||
displaySkipButton = true,
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
signOutAction = AsyncAction.Loading,
|
||||
displaySkipButton = true,
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Loading
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Skipped
|
||||
),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aVerifySelfSessionState(
|
||||
step: Step = Step.Initial(canEnterRecoveryKey = false),
|
||||
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
|
||||
displaySkipButton: Boolean = false,
|
||||
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
|
||||
) = VerifySelfSessionState(
|
||||
step = step,
|
||||
displaySkipButton = displaySkipButton,
|
||||
eventSink = eventSink,
|
||||
signOutAction = signOutAction,
|
||||
)
|
||||
@@ -5,25 +5,19 @@
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -31,18 +25,17 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.features.verifysession.impl.R
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
|
||||
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
|
||||
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
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
|
||||
@@ -56,9 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -71,12 +62,13 @@ fun VerifySelfSessionView(
|
||||
onSuccessLogout: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val step = state.step
|
||||
fun cancelOrResetFlow() {
|
||||
when (state.verificationFlowStep) {
|
||||
is FlowStep.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
is FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
is FlowStep.Verifying -> {
|
||||
if (!state.verificationFlowStep.state.isLoading()) {
|
||||
when (step) {
|
||||
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
is Step.AwaitingOtherDeviceResponse, Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
is Step.Verifying -> {
|
||||
if (!step.state.isLoading()) {
|
||||
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
|
||||
}
|
||||
}
|
||||
@@ -85,18 +77,17 @@ fun VerifySelfSessionView(
|
||||
}
|
||||
|
||||
val latestOnFinish by rememberUpdatedState(newValue = onFinish)
|
||||
LaunchedEffect(state.verificationFlowStep, latestOnFinish) {
|
||||
if (state.verificationFlowStep is FlowStep.Skipped) {
|
||||
LaunchedEffect(step, latestOnFinish) {
|
||||
if (step is Step.Skipped) {
|
||||
latestOnFinish()
|
||||
}
|
||||
}
|
||||
BackHandler {
|
||||
cancelOrResetFlow()
|
||||
}
|
||||
val verificationFlowStep = state.verificationFlowStep
|
||||
|
||||
if (state.verificationFlowStep is FlowStep.Loading ||
|
||||
state.verificationFlowStep is FlowStep.Skipped) {
|
||||
if (step is Step.Loading ||
|
||||
step is Step.Skipped) {
|
||||
// Just display a loader in this case, to avoid UI glitch.
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -111,7 +102,7 @@ fun VerifySelfSessionView(
|
||||
TopAppBar(
|
||||
title = {},
|
||||
actions = {
|
||||
if (state.verificationFlowStep !is FlowStep.Completed &&
|
||||
if (step !is Step.Completed &&
|
||||
state.displaySkipButton &&
|
||||
LocalInspectionMode.current.not()) {
|
||||
TextButton(
|
||||
@@ -119,7 +110,7 @@ fun VerifySelfSessionView(
|
||||
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
|
||||
)
|
||||
}
|
||||
if (state.verificationFlowStep is FlowStep.Initial) {
|
||||
if (step is Step.Initial) {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_signout),
|
||||
onClick = { state.eventSink(VerifySelfSessionViewEvents.SignOut) }
|
||||
@@ -129,7 +120,7 @@ fun VerifySelfSessionView(
|
||||
)
|
||||
},
|
||||
header = {
|
||||
HeaderContent(verificationFlowStep = verificationFlowStep)
|
||||
HeaderContent(step = step)
|
||||
},
|
||||
footer = {
|
||||
BottomMenu(
|
||||
@@ -142,7 +133,7 @@ fun VerifySelfSessionView(
|
||||
}
|
||||
) {
|
||||
Content(
|
||||
flowState = verificationFlowStep,
|
||||
flowState = step,
|
||||
onLearnMoreClick = onLearnMoreClick,
|
||||
)
|
||||
}
|
||||
@@ -165,38 +156,38 @@ fun VerifySelfSessionView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderContent(verificationFlowStep: FlowStep) {
|
||||
val iconStyle = when (verificationFlowStep) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
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
|
||||
is FlowStep.Skipped -> return
|
||||
private fun HeaderContent(step: Step) {
|
||||
val iconStyle = when (step) {
|
||||
VerifySelfSessionState.Step.Loading -> error("Should not happen")
|
||||
is Step.Initial, Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||
Step.Canceled -> BigIcon.Style.AlertSolid
|
||||
Step.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
Step.Completed -> BigIcon.Style.SuccessSolid
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
val titleTextId = when (verificationFlowStep) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
|
||||
FlowStep.Canceled -> CommonStrings.common_verification_cancelled
|
||||
FlowStep.Ready -> R.string.screen_session_verification_compare_emojis_title
|
||||
FlowStep.Completed -> R.string.screen_identity_confirmed_title
|
||||
is FlowStep.Verifying -> when (verificationFlowStep.data) {
|
||||
val titleTextId = when (step) {
|
||||
VerifySelfSessionState.Step.Loading -> error("Should not happen")
|
||||
is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
|
||||
Step.Canceled -> CommonStrings.common_verification_cancelled
|
||||
Step.Ready -> R.string.screen_session_verification_compare_emojis_title
|
||||
Step.Completed -> R.string.screen_identity_confirmed_title
|
||||
is Step.Verifying -> when (step.data) {
|
||||
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
val subtitleTextId = when (verificationFlowStep) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
|
||||
FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle
|
||||
FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle
|
||||
FlowStep.Completed -> R.string.screen_identity_confirmed_subtitle
|
||||
is FlowStep.Verifying -> when (verificationFlowStep.data) {
|
||||
val subtitleTextId = when (step) {
|
||||
VerifySelfSessionState.Step.Loading -> error("Should not happen")
|
||||
is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
|
||||
Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle
|
||||
Step.Ready -> R.string.screen_session_verification_ready_subtitle
|
||||
Step.Completed -> R.string.screen_identity_confirmed_subtitle
|
||||
is Step.Verifying -> when (step.data) {
|
||||
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
|
||||
PageTitle(
|
||||
@@ -208,15 +199,15 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
|
||||
|
||||
@Composable
|
||||
private fun Content(
|
||||
flowState: FlowStep,
|
||||
flowState: Step,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
) {
|
||||
when (flowState) {
|
||||
is VerifySelfSessionState.VerificationStep.Initial -> {
|
||||
is VerifySelfSessionState.Step.Initial -> {
|
||||
ContentInitial(onLearnMoreClick)
|
||||
}
|
||||
is FlowStep.Verifying -> {
|
||||
ContentVerifying(flowState)
|
||||
is Step.Verifying -> {
|
||||
VerificationContentVerifying(flowState.data)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
@@ -240,63 +231,6 @@ private fun ContentInitial(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (verificationFlowStep.data) {
|
||||
is SessionVerificationData.Decimals -> {
|
||||
val text = verificationFlowStep.data.decimals.joinToString(separator = " - ") { it.toString() }
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontHeadingLgBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
is SessionVerificationData.Emojis -> {
|
||||
// We want each row to have up to 4 emojis
|
||||
val rows = verificationFlowStep.data.emojis.chunked(4)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(40.dp),
|
||||
) {
|
||||
rows.forEach { emojis ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
for (emoji in emojis) {
|
||||
EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifier) {
|
||||
val emojiResource = emoji.number.toEmojiResource()
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
|
||||
Image(
|
||||
modifier = Modifier.size(48.dp),
|
||||
painter = painterResource(id = emojiResource.drawableRes),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = emojiResource.nameRes),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomMenu(
|
||||
screenState: VerifySelfSessionState,
|
||||
@@ -305,15 +239,15 @@ private fun BottomMenu(
|
||||
onCancelClick: () -> Unit,
|
||||
onContinueClick: () -> Unit,
|
||||
) {
|
||||
val verificationViewState = screenState.verificationFlowStep
|
||||
val verificationViewState = screenState.step
|
||||
val eventSink = screenState.eventSink
|
||||
|
||||
val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is AsyncData.Loading<Unit>
|
||||
val isVerifying = (verificationViewState as? Step.Verifying)?.state is AsyncData.Loading<Unit>
|
||||
|
||||
when (verificationViewState) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial -> {
|
||||
BottomMenu {
|
||||
VerifySelfSessionState.Step.Loading -> error("Should not happen")
|
||||
is Step.Initial -> {
|
||||
VerificationBottomMenu {
|
||||
if (verificationViewState.isLastDevice) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -340,8 +274,8 @@ private fun BottomMenu(
|
||||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Canceled -> {
|
||||
BottomMenu {
|
||||
is Step.Canceled -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_positive_button_canceled),
|
||||
@@ -354,8 +288,8 @@ private fun BottomMenu(
|
||||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Ready -> {
|
||||
BottomMenu {
|
||||
is Step.Ready -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_start),
|
||||
@@ -368,8 +302,8 @@ private fun BottomMenu(
|
||||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.AwaitingOtherDeviceResponse -> {
|
||||
BottomMenu {
|
||||
is Step.AwaitingOtherDeviceResponse -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_waiting_on_other_device),
|
||||
@@ -380,13 +314,13 @@ private fun BottomMenu(
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
is FlowStep.Verifying -> {
|
||||
is Step.Verifying -> {
|
||||
val positiveButtonTitle = if (isVerifying) {
|
||||
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
|
||||
} else {
|
||||
stringResource(R.string.screen_session_verification_they_match)
|
||||
}
|
||||
BottomMenu {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = positiveButtonTitle,
|
||||
@@ -404,8 +338,8 @@ private fun BottomMenu(
|
||||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Completed -> {
|
||||
BottomMenu {
|
||||
is Step.Completed -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
@@ -415,19 +349,7 @@ private fun BottomMenu(
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
}
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomMenu(
|
||||
modifier: Modifier = Modifier,
|
||||
buttons: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
ButtonColumnMolecule(
|
||||
modifier = modifier.padding(bottom = 16.dp)
|
||||
) {
|
||||
buttons()
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
sealed interface VerifySelfSessionViewEvents {
|
||||
data object RequestVerification : VerifySelfSessionViewEvents
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.ui
|
||||
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
|
||||
internal fun aEmojisSessionVerificationData(
|
||||
emojiList: List<VerificationEmoji> = aVerificationEmojiList(),
|
||||
): SessionVerificationData {
|
||||
return SessionVerificationData.Emojis(emojiList)
|
||||
}
|
||||
|
||||
internal fun aDecimalsSessionVerificationData(
|
||||
decimals: List<Int> = listOf(123, 456, 789),
|
||||
): SessionVerificationData {
|
||||
return SessionVerificationData.Decimals(decimals)
|
||||
}
|
||||
|
||||
private fun aVerificationEmojiList() = listOf(
|
||||
VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"),
|
||||
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
|
||||
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
|
||||
VerificationEmoji(number = 42, emoji = "📕", description = "Book"),
|
||||
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
|
||||
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
|
||||
VerificationEmoji(number = 63, emoji = "📌", description = "Pin"),
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.ui
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
|
||||
@Composable
|
||||
internal fun VerificationBottomMenu(
|
||||
modifier: Modifier = Modifier,
|
||||
buttons: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
ButtonColumnMolecule(
|
||||
modifier = modifier.padding(bottom = 16.dp)
|
||||
) {
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.verifysession.impl.emoji.toEmojiResource
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
|
||||
@Composable
|
||||
internal fun VerificationContentVerifying(
|
||||
data: SessionVerificationData,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (data) {
|
||||
is SessionVerificationData.Decimals -> {
|
||||
val text = data.decimals.joinToString(separator = " - ") { it.toString() }
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontHeadingLgBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
is SessionVerificationData.Emojis -> {
|
||||
// We want each row to have up to 4 emojis
|
||||
val rows = data.emojis.chunked(4)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(40.dp),
|
||||
) {
|
||||
rows.forEach { emojis ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
for (emoji in emojis) {
|
||||
EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifier) {
|
||||
val emojiResource = emoji.number.toEmojiResource()
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
|
||||
Image(
|
||||
modifier = Modifier.size(48.dp),
|
||||
painter = painterResource(id = emojiResource.drawableRes),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = emojiResource.nameRes),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.util
|
||||
|
||||
import com.freeletics.flowredux.dsl.InStateBuilderBlock
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import timber.log.Timber
|
||||
import com.freeletics.flowredux.dsl.State as MachineState
|
||||
|
||||
internal fun <T : Any> T.andLogStateChange() = also {
|
||||
Timber.w("Verification: state machine state moved to [${this::class.simpleName}]")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
inline fun <State : Any, reified Event : Any> InStateBuilderBlock<State, State, Event>.logReceivedEvents() {
|
||||
on { event: Event, state: MachineState<State> ->
|
||||
Timber.w("Verification in state [${state.snapshot::class.simpleName}] receiving event [${event::class.simpleName}]")
|
||||
state.noChange()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
|
||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||
import io.element.android.libraries.matrix.api.core.FlowId
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||
import io.element.android.libraries.matrix.test.A_TIMESTAMP
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class IncomingVerificationPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - nominal case - incoming verification successful`() = runTest {
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||
val approveVerificationLambda = lambdaRecorder<Unit> { }
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val fakeSessionVerificationService = FakeSessionVerificationService(
|
||||
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
|
||||
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
|
||||
approveVerificationLambda = approveVerificationLambda,
|
||||
resetLambda = resetLambda,
|
||||
)
|
||||
createPresenter(
|
||||
service = fakeSessionVerificationService,
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = A_FORMATTED_DATE,
|
||||
isWaiting = false,
|
||||
)
|
||||
)
|
||||
resetLambda.assertions().isCalledOnce().with(value(false))
|
||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
|
||||
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
||||
// User accept the incoming verification
|
||||
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
||||
skipItems(1)
|
||||
val initialWaitingState = awaitItem()
|
||||
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
|
||||
advanceUntilIdle()
|
||||
acceptVerificationRequestLambda.assertions().isCalledOnce()
|
||||
// Remote sent the data
|
||||
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
||||
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
|
||||
fakeSessionVerificationService.emitVerificationFlowState(
|
||||
VerificationFlowState.DidReceiveVerificationData(
|
||||
data = aEmojisSessionVerificationData()
|
||||
)
|
||||
)
|
||||
val emojiState = awaitItem()
|
||||
assertThat(emojiState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
isWaiting = false
|
||||
)
|
||||
)
|
||||
// User claims that the emoji matches
|
||||
emojiState.eventSink(IncomingVerificationViewEvents.ConfirmVerification)
|
||||
val emojiWaitingItem = awaitItem()
|
||||
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
|
||||
approveVerificationLambda.assertions().isCalledOnce()
|
||||
// Remote confirm that the emojis match
|
||||
fakeSessionVerificationService.emitVerificationFlowState(
|
||||
VerificationFlowState.DidFinish
|
||||
)
|
||||
val finalItem = awaitItem()
|
||||
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - emoji not matching case - incoming verification failure`() = runTest {
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||
val declineVerificationLambda = lambdaRecorder<Unit> { }
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val fakeSessionVerificationService = FakeSessionVerificationService(
|
||||
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
|
||||
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
|
||||
declineVerificationLambda = declineVerificationLambda,
|
||||
resetLambda = resetLambda,
|
||||
)
|
||||
createPresenter(
|
||||
service = fakeSessionVerificationService,
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = A_FORMATTED_DATE,
|
||||
isWaiting = false,
|
||||
)
|
||||
)
|
||||
resetLambda.assertions().isCalledOnce().with(value(false))
|
||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
|
||||
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
||||
// User accept the incoming verification
|
||||
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
||||
skipItems(1)
|
||||
val initialWaitingState = awaitItem()
|
||||
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
|
||||
advanceUntilIdle()
|
||||
acceptVerificationRequestLambda.assertions().isCalledOnce()
|
||||
// Remote sent the data
|
||||
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
||||
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
|
||||
fakeSessionVerificationService.emitVerificationFlowState(
|
||||
VerificationFlowState.DidReceiveVerificationData(
|
||||
data = aEmojisSessionVerificationData()
|
||||
)
|
||||
)
|
||||
val emojiState = awaitItem()
|
||||
// User claims that the emojis do not match
|
||||
emojiState.eventSink(IncomingVerificationViewEvents.DeclineVerification)
|
||||
val emojiWaitingItem = awaitItem()
|
||||
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
|
||||
declineVerificationLambda.assertions().isCalledOnce()
|
||||
// Remote confirm that there is a failure
|
||||
fakeSessionVerificationService.emitVerificationFlowState(
|
||||
VerificationFlowState.DidFail
|
||||
)
|
||||
val finalItem = awaitItem()
|
||||
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - incoming verification is remotely canceled`() = runTest {
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||
val declineVerificationLambda = lambdaRecorder<Unit> { }
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val onFinishLambda = lambdaRecorder<Unit> { }
|
||||
val fakeSessionVerificationService = FakeSessionVerificationService(
|
||||
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
|
||||
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
|
||||
declineVerificationLambda = declineVerificationLambda,
|
||||
resetLambda = resetLambda,
|
||||
)
|
||||
createPresenter(
|
||||
service = fakeSessionVerificationService,
|
||||
navigator = IncomingVerificationNavigator(onFinishLambda),
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = A_FORMATTED_DATE,
|
||||
isWaiting = false,
|
||||
)
|
||||
)
|
||||
// Remote cancel the verification request
|
||||
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
// The screen is dismissed
|
||||
skipItems(2)
|
||||
onFinishLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - user goes back when comparing emoji - incoming verification failure`() = runTest {
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||
val declineVerificationLambda = lambdaRecorder<Unit> { }
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val fakeSessionVerificationService = FakeSessionVerificationService(
|
||||
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
|
||||
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
|
||||
declineVerificationLambda = declineVerificationLambda,
|
||||
resetLambda = resetLambda,
|
||||
)
|
||||
createPresenter(
|
||||
service = fakeSessionVerificationService,
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = A_FORMATTED_DATE,
|
||||
isWaiting = false,
|
||||
)
|
||||
)
|
||||
resetLambda.assertions().isCalledOnce().with(value(false))
|
||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
|
||||
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
||||
// User accept the incoming verification
|
||||
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
||||
skipItems(1)
|
||||
val initialWaitingState = awaitItem()
|
||||
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
|
||||
advanceUntilIdle()
|
||||
acceptVerificationRequestLambda.assertions().isCalledOnce()
|
||||
// Remote sent the data
|
||||
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
||||
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
|
||||
fakeSessionVerificationService.emitVerificationFlowState(
|
||||
VerificationFlowState.DidReceiveVerificationData(
|
||||
data = aEmojisSessionVerificationData()
|
||||
)
|
||||
)
|
||||
val emojiState = awaitItem()
|
||||
// User goes back
|
||||
emojiState.eventSink(IncomingVerificationViewEvents.GoBack)
|
||||
val emojiWaitingItem = awaitItem()
|
||||
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
|
||||
declineVerificationLambda.assertions().isCalledOnce()
|
||||
// Remote confirm that there is a failure
|
||||
fakeSessionVerificationService.emitVerificationFlowState(
|
||||
VerificationFlowState.DidFail
|
||||
)
|
||||
val finalItem = awaitItem()
|
||||
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - user ignores incoming request`() = runTest {
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val fakeSessionVerificationService = FakeSessionVerificationService(
|
||||
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
|
||||
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
|
||||
resetLambda = resetLambda,
|
||||
)
|
||||
val navigatorLambda = lambdaRecorder<Unit> { }
|
||||
createPresenter(
|
||||
service = fakeSessionVerificationService,
|
||||
navigator = IncomingVerificationNavigator(navigatorLambda),
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(IncomingVerificationViewEvents.IgnoreVerification)
|
||||
skipItems(1)
|
||||
navigatorLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
private val aSessionVerificationRequestDetails = SessionVerificationRequestDetails(
|
||||
senderId = A_USER_ID,
|
||||
flowId = FlowId("flowId"),
|
||||
deviceId = A_DEVICE_ID,
|
||||
displayName = "a device name",
|
||||
firstSeenTimestamp = A_TIMESTAMP,
|
||||
)
|
||||
|
||||
private fun createPresenter(
|
||||
sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails,
|
||||
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
|
||||
service: SessionVerificationService = FakeSessionVerificationService(),
|
||||
dateFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE),
|
||||
) = IncomingVerificationPresenter(
|
||||
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
|
||||
navigator = navigator,
|
||||
sessionVerificationService = service,
|
||||
stateMachine = IncomingVerificationStateMachine(service),
|
||||
dateFormatter = dateFormatter,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.verifysession.impl.R
|
||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IncomingVerificationViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
// region step Initial
|
||||
@Test
|
||||
fun `back key pressed - ignore the verification`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = aStepInitial(),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore incoming verification emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = aStepInitial(),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_ignore)
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.IgnoreVerification)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start incoming verification emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = aStepInitial(),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_start)
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.StartVerification)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back key pressed - when awaiting response cancels the verification`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = aStepInitial(
|
||||
isWaiting = true,
|
||||
),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
|
||||
}
|
||||
// endregion step Initial
|
||||
|
||||
// region step Verifying
|
||||
@Test
|
||||
fun `back key pressed - when ready to verify cancels the verification`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = IncomingVerificationState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
isWaiting = false,
|
||||
),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back key pressed - when verifying and loading emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = IncomingVerificationState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
isWaiting = true,
|
||||
),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on they do not match emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = IncomingVerificationState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
isWaiting = false,
|
||||
),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_session_verification_they_dont_match)
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.DeclineVerification)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on they match emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = IncomingVerificationState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
isWaiting = false,
|
||||
),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_session_verification_they_match)
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.ConfirmVerification)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region step Failure
|
||||
@Test
|
||||
fun `back key pressed - when failure resets the flow`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = IncomingVerificationState.Step.Failure,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on done - when failure resets the flow`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = IncomingVerificationState.Step.Failure,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_done)
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region step Completed
|
||||
@Test
|
||||
fun `back key pressed - on Completed step emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = IncomingVerificationState.Step.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when flow is completed and the user clicks on the done button, the expected event is emitted`() {
|
||||
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
|
||||
rule.setIncomingVerificationView(
|
||||
anIncomingVerificationState(
|
||||
step = IncomingVerificationState.Step.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_done)
|
||||
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
|
||||
}
|
||||
// endregion
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setIncomingVerificationView(
|
||||
state: IncomingVerificationState,
|
||||
) {
|
||||
setContent {
|
||||
IncomingVerificationView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
@@ -14,12 +14,13 @@ import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.logout.api.LogoutUseCase
|
||||
import io.element.android.features.logout.test.FakeLogoutUseCase
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
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.SessionVerificationRequestDetails
|
||||
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
|
||||
@@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -43,12 +45,14 @@ class VerifySelfSessionPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - Initial state is received`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter()
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().run {
|
||||
assertThat(verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(step).isEqualTo(Step.Initial(false))
|
||||
assertThat(displaySkipButton).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -57,7 +61,10 @@ class VerifySelfSessionPresenterTest {
|
||||
@Test
|
||||
fun `present - hides skip verification button on non-debuggable builds`() = runTest {
|
||||
val buildMeta = aBuildMeta(isDebuggable = false)
|
||||
val presenter = createVerifySelfSessionPresenter(buildMeta = buildMeta)
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
buildMeta = buildMeta,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -67,7 +74,11 @@ class VerifySelfSessionPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - Initial state is received, can use recovery key`() = runTest {
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(
|
||||
resetLambda = resetLambda
|
||||
),
|
||||
encryptionService = FakeEncryptionService().apply {
|
||||
emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||
}
|
||||
@@ -75,13 +86,15 @@ class VerifySelfSessionPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(true))
|
||||
resetLambda.assertions().isCalledOnce().with(value(true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Initial state is received, can use recovery key and is last device`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
encryptionService = FakeEncryptionService().apply {
|
||||
emitIsLastDevice(true)
|
||||
emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||
@@ -90,13 +103,16 @@ class VerifySelfSessionPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Handles requestVerification`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -107,32 +123,36 @@ class VerifySelfSessionPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - Handles startSasVerification`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
val eventSink = initialState.eventSink
|
||||
eventSink(VerifySelfSessionViewEvents.StartSasVerification)
|
||||
assertThat(initialState.step).isEqualTo(Step.Initial(false))
|
||||
initialState.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
|
||||
// Await for other device response:
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
|
||||
// ChallengeReceived:
|
||||
service.triggerReceiveVerificationData(SessionVerificationData.Emojis(emptyList()))
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
|
||||
val verifyingState = awaitItem()
|
||||
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
|
||||
assertThat(verifyingState.step).isInstanceOf(Step.Verifying::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Cancelation on initial state does nothing`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter()
|
||||
fun `present - Cancellation on initial state does nothing`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(initialState.step).isEqualTo(Step.Initial(false))
|
||||
val eventSink = initialState.eventSink
|
||||
eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
expectNoEvents()
|
||||
@@ -141,92 +161,110 @@ class VerifySelfSessionPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - A failure when verifying cancels it`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
approveVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
service.shouldFail = true
|
||||
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
// Cancelling
|
||||
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
|
||||
assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidFail)
|
||||
// Cancelled
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
)
|
||||
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))
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidFail)
|
||||
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
cancelVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
requestVerificationAndAwaitVerifyingState(service)
|
||||
service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(SessionVerificationData.Emojis(emptyList())))
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Restart after cancelation returns to requesting verification`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
fun `present - Restart after cancellation returns to requesting verification`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
service.givenVerificationFlowState(VerificationFlowState.Canceled)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
// Went back to requesting verification
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Go back after cancelation returns to initial state`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
fun `present - Go back after cancellation returns to initial state`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
service.givenVerificationFlowState(VerificationFlowState.Canceled)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
// Went back to initial state
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
@@ -236,7 +274,11 @@ class VerifySelfSessionPresenterTest {
|
||||
val emojis = listOf(
|
||||
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
|
||||
)
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
approveVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -246,54 +288,65 @@ class VerifySelfSessionPresenterTest {
|
||||
SessionVerificationData.Emojis(emojis)
|
||||
)
|
||||
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(
|
||||
VerificationStep.Verifying(
|
||||
assertThat(awaitItem().step).isEqualTo(
|
||||
Step.Verifying(
|
||||
SessionVerificationData.Emojis(emojis),
|
||||
AsyncData.Loading(),
|
||||
)
|
||||
)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verification is declined, the flow is canceled`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
declineVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(
|
||||
VerificationStep.Verifying(
|
||||
assertThat(awaitItem().step).isEqualTo(
|
||||
Step.Verifying(
|
||||
SessionVerificationData.Emojis(emptyList()),
|
||||
AsyncData.Loading(),
|
||||
)
|
||||
)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Skip event skips the flow`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verification is done using recovery key, the flow is completed`() = runTest {
|
||||
val service = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
givenVerificationFlowState(VerificationFlowState.Finished)
|
||||
val service = FakeSessionVerificationService(
|
||||
resetLambda = { },
|
||||
).apply {
|
||||
emitNeedsSessionVerification(false)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
}
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = service,
|
||||
@@ -302,16 +355,18 @@ class VerifySelfSessionPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verification is not needed, the flow is skipped`() = runTest {
|
||||
val service = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
givenVerificationFlowState(VerificationFlowState.Finished)
|
||||
val service = FakeSessionVerificationService(
|
||||
resetLambda = { },
|
||||
).apply {
|
||||
emitNeedsSessionVerification(false)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
}
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = service,
|
||||
@@ -321,16 +376,18 @@ class VerifySelfSessionPresenterTest {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
|
||||
val service = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
givenVerificationFlowState(VerificationFlowState.Finished)
|
||||
val service = FakeSessionVerificationService(
|
||||
resetLambda = { },
|
||||
).apply {
|
||||
emitNeedsSessionVerification(false)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
}
|
||||
val signOutLambda = lambdaRecorder<Boolean, String?> { "aUrl" }
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
@@ -356,33 +413,53 @@ class VerifySelfSessionPresenterTest {
|
||||
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
|
||||
): VerifySelfSessionState {
|
||||
var state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(state.step).isEqualTo(Step.Initial(false))
|
||||
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
// Await for other device response:
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
// Await for the state to be Ready
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Ready)
|
||||
assertThat(state.step).isEqualTo(Step.Ready)
|
||||
state.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
|
||||
// Await for other device response (again):
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
fakeService.triggerReceiveVerificationData(sessionVerificationData)
|
||||
assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
// Finally, ChallengeReceived:
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(sessionVerificationData))
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
|
||||
assertThat(state.step).isInstanceOf(Step.Verifying::class.java)
|
||||
return state
|
||||
}
|
||||
|
||||
private fun unverifiedSessionService(): FakeSessionVerificationService {
|
||||
return FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
private suspend fun unverifiedSessionService(
|
||||
requestVerificationLambda: () -> Unit = { lambdaError() },
|
||||
cancelVerificationLambda: () -> Unit = { lambdaError() },
|
||||
approveVerificationLambda: () -> Unit = { lambdaError() },
|
||||
declineVerificationLambda: () -> Unit = { lambdaError() },
|
||||
startVerificationLambda: () -> Unit = { lambdaError() },
|
||||
resetLambda: (Boolean) -> Unit = { },
|
||||
acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
|
||||
acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
|
||||
): FakeSessionVerificationService {
|
||||
return FakeSessionVerificationService(
|
||||
requestVerificationLambda = requestVerificationLambda,
|
||||
cancelVerificationLambda = cancelVerificationLambda,
|
||||
approveVerificationLambda = approveVerificationLambda,
|
||||
declineVerificationLambda = declineVerificationLambda,
|
||||
startVerificationLambda = startVerificationLambda,
|
||||
resetLambda = resetLambda,
|
||||
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
|
||||
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
|
||||
).apply {
|
||||
emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createVerifySelfSessionPresenter(
|
||||
service: SessionVerificationService = unverifiedSessionService(),
|
||||
service: SessionVerificationService,
|
||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
@@ -5,12 +5,14 @@
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.verifysession.impl.R
|
||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -36,7 +38,7 @@ class VerifySelfSessionViewTest {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
|
||||
step = VerifySelfSessionState.Step.Canceled,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
@@ -49,7 +51,7 @@ class VerifySelfSessionViewTest {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
|
||||
step = VerifySelfSessionState.Step.AwaitingOtherDeviceResponse,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
@@ -62,7 +64,7 @@ class VerifySelfSessionViewTest {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
|
||||
step = VerifySelfSessionState.Step.Ready,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
@@ -75,7 +77,7 @@ class VerifySelfSessionViewTest {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
@@ -91,7 +93,7 @@ class VerifySelfSessionViewTest {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Loading(),
|
||||
),
|
||||
@@ -107,7 +109,7 @@ class VerifySelfSessionViewTest {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
|
||||
step = VerifySelfSessionState.Step.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
@@ -121,7 +123,7 @@ class VerifySelfSessionViewTest {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
|
||||
step = VerifySelfSessionState.Step.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onFinished = callback,
|
||||
@@ -137,7 +139,7 @@ class VerifySelfSessionViewTest {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
|
||||
step = VerifySelfSessionState.Step.Initial(true),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = callback,
|
||||
@@ -153,7 +155,7 @@ class VerifySelfSessionViewTest {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
|
||||
step = VerifySelfSessionState.Step.Initial(true),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onLearnMoreClick = callback,
|
||||
@@ -167,7 +169,7 @@ class VerifySelfSessionViewTest {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
@@ -183,7 +185,7 @@ class VerifySelfSessionViewTest {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
@@ -199,7 +201,7 @@ class VerifySelfSessionViewTest {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = true),
|
||||
step = VerifySelfSessionState.Step.Initial(canEnterRecoveryKey = true),
|
||||
displaySkipButton = true,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
@@ -213,7 +215,7 @@ class VerifySelfSessionViewTest {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Skipped,
|
||||
step = VerifySelfSessionState.Step.Skipped,
|
||||
displaySkipButton = true,
|
||||
eventSink = EnsureNeverCalledWithParam(),
|
||||
),
|
||||
@@ -11,8 +11,9 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
|
||||
|
||||
const val A_FORMATTED_DATE = "formatted_date"
|
||||
|
||||
class FakeLastMessageTimestampFormatter : LastMessageTimestampFormatter {
|
||||
private var format = ""
|
||||
class FakeLastMessageTimestampFormatter(
|
||||
var format: String = "",
|
||||
) : LastMessageTimestampFormatter {
|
||||
fun givenFormat(format: String) {
|
||||
this.format = format
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.core
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
@JvmInline
|
||||
value class FlowId(val value: String) : Serializable {
|
||||
override fun toString(): String = value
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.verification
|
||||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.core.FlowId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class SessionVerificationRequestDetails(
|
||||
val senderId: UserId,
|
||||
val flowId: FlowId,
|
||||
val deviceId: DeviceId,
|
||||
val displayName: String?,
|
||||
val firstSeenTimestamp: Long,
|
||||
) : Parcelable
|
||||
@@ -56,7 +56,27 @@ interface SessionVerificationService {
|
||||
/**
|
||||
* Returns the verification service state to the initial step.
|
||||
*/
|
||||
suspend fun reset()
|
||||
suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean)
|
||||
|
||||
/**
|
||||
* Register a listener to be notified of incoming session verification requests.
|
||||
*/
|
||||
fun setListener(listener: SessionVerificationServiceListener?)
|
||||
|
||||
/**
|
||||
* Set this particular request as the currently active one and register for
|
||||
* events pertaining it.
|
||||
*/
|
||||
suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails)
|
||||
|
||||
/**
|
||||
* Accept the previously acknowledged verification request.
|
||||
*/
|
||||
suspend fun acceptVerificationRequest()
|
||||
}
|
||||
|
||||
interface SessionVerificationServiceListener {
|
||||
fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails)
|
||||
}
|
||||
|
||||
/** Verification status of the current session. */
|
||||
@@ -82,20 +102,20 @@ sealed interface VerificationFlowState {
|
||||
data object Initial : VerificationFlowState
|
||||
|
||||
/** Session verification request was accepted by another device. */
|
||||
data object AcceptedVerificationRequest : VerificationFlowState
|
||||
data object DidAcceptVerificationRequest : VerificationFlowState
|
||||
|
||||
/** Short Authentication String (SAS) verification started between the 2 devices. */
|
||||
data object StartedSasVerification : VerificationFlowState
|
||||
data object DidStartSasVerification : VerificationFlowState
|
||||
|
||||
/** Verification data for the SAS verification received. */
|
||||
data class ReceivedVerificationData(val data: SessionVerificationData) : VerificationFlowState
|
||||
data class DidReceiveVerificationData(val data: SessionVerificationData) : VerificationFlowState
|
||||
|
||||
/** Verification completed successfully. */
|
||||
data object Finished : VerificationFlowState
|
||||
data object DidFinish : VerificationFlowState
|
||||
|
||||
/** Verification was cancelled by either device. */
|
||||
data object Canceled : VerificationFlowState
|
||||
data object DidCancel : VerificationFlowState
|
||||
|
||||
/** Verification failed with an error. */
|
||||
data object Failed : VerificationFlowState
|
||||
data object DidFail : VerificationFlowState
|
||||
}
|
||||
|
||||
@@ -9,12 +9,15 @@ package io.element.android.libraries.matrix.impl.verification
|
||||
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
||||
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.util.cancelAndDestroy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -28,6 +31,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.Encryption
|
||||
@@ -35,13 +39,13 @@ 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.SessionVerificationRequestDetails
|
||||
import org.matrix.rustcomponents.sdk.VerificationState
|
||||
import org.matrix.rustcomponents.sdk.VerificationStateListener
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
|
||||
|
||||
class RustSessionVerificationService(
|
||||
private val client: Client,
|
||||
@@ -101,6 +105,16 @@ class RustSessionVerificationService(
|
||||
.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
|
||||
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
|
||||
listener?.onIncomingSessionRequest(details.map())
|
||||
}
|
||||
|
||||
private var listener: SessionVerificationServiceListener? = null
|
||||
|
||||
override fun setListener(listener: SessionVerificationServiceListener?) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
override suspend fun requestVerification() = tryOrFail {
|
||||
initVerificationControllerIfNeeded()
|
||||
verificationController.requestVerification()
|
||||
@@ -120,9 +134,24 @@ class RustSessionVerificationService(
|
||||
verificationController.startSasVerification()
|
||||
}
|
||||
|
||||
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail {
|
||||
verificationController.acknowledgeVerificationRequest(
|
||||
senderId = details.senderId.value,
|
||||
flowId = details.flowId.value,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun acceptVerificationRequest() = tryOrFail {
|
||||
verificationController.acceptVerificationRequest()
|
||||
}
|
||||
|
||||
private suspend fun tryOrFail(block: suspend () -> Unit) {
|
||||
runCatching {
|
||||
// Ensure the block cannot be cancelled, else if the Rust SDK emit a new state during the API execution,
|
||||
// the state machine may cancel the api call.
|
||||
withContext(NonCancellable) {
|
||||
block()
|
||||
}
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to verify session")
|
||||
didFail()
|
||||
@@ -133,16 +162,16 @@ class RustSessionVerificationService(
|
||||
|
||||
// When verification attempt is accepted by the other device
|
||||
override fun didAcceptVerificationRequest() {
|
||||
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
|
||||
_verificationFlowState.value = VerificationFlowState.DidAcceptVerificationRequest
|
||||
}
|
||||
|
||||
override fun didCancel() {
|
||||
_verificationFlowState.value = VerificationFlowState.Canceled
|
||||
_verificationFlowState.value = VerificationFlowState.DidCancel
|
||||
}
|
||||
|
||||
override fun didFail() {
|
||||
Timber.e("Session verification failed with an unknown error")
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
_verificationFlowState.value = VerificationFlowState.DidFail
|
||||
}
|
||||
|
||||
override fun didFinish() {
|
||||
@@ -158,7 +187,7 @@ class RustSessionVerificationService(
|
||||
}
|
||||
.onSuccess {
|
||||
// Order here is important, first set the flow state as finished, then update the verification status
|
||||
_verificationFlowState.value = VerificationFlowState.Finished
|
||||
_verificationFlowState.value = VerificationFlowState.DidFinish
|
||||
updateVerificationStatus()
|
||||
}
|
||||
.onFailure {
|
||||
@@ -169,22 +198,18 @@ class RustSessionVerificationService(
|
||||
}
|
||||
|
||||
override fun didReceiveVerificationData(data: RustSessionVerificationData) {
|
||||
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(data.map())
|
||||
_verificationFlowState.value = VerificationFlowState.DidReceiveVerificationData(data.map())
|
||||
}
|
||||
|
||||
// When the actual SAS verification starts
|
||||
override fun didStartSasVerification() {
|
||||
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
|
||||
}
|
||||
|
||||
override fun didReceiveVerificationRequest(details: SessionVerificationRequestDetails) {
|
||||
// TODO
|
||||
_verificationFlowState.value = VerificationFlowState.DidStartSasVerification
|
||||
}
|
||||
|
||||
// end-region
|
||||
|
||||
override suspend fun reset() {
|
||||
if (isReady.value) {
|
||||
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
|
||||
if (isReady.value && cancelAnyPendingVerificationAttempt) {
|
||||
// Cancel any pending verification attempt
|
||||
tryOrNull { verificationController.cancelVerification() }
|
||||
}
|
||||
@@ -213,7 +238,7 @@ class RustSessionVerificationService(
|
||||
}
|
||||
|
||||
private suspend fun updateVerificationStatus() {
|
||||
if (verificationFlowState.value == VerificationFlowState.Finished) {
|
||||
if (verificationFlowState.value == VerificationFlowState.DidFinish) {
|
||||
// Calling `encryptionService.verificationState()` performs a network call and it will deadlock if there is no network
|
||||
// So we need to check that *only* if we know there is network connection, which is the case when the verification flow just finished
|
||||
Timber.d("Updating verification status: flow just finished")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.verification
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.core.FlowId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
|
||||
|
||||
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
|
||||
senderId = UserId(senderId),
|
||||
flowId = FlowId(flowId),
|
||||
deviceId = DeviceId(deviceId),
|
||||
displayName = displayName,
|
||||
firstSeenTimestamp = firstSeenTimestamp.toLong(),
|
||||
)
|
||||
|
||||
@@ -7,79 +7,84 @@
|
||||
|
||||
package io.element.android.libraries.matrix.test.verification
|
||||
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class FakeSessionVerificationService(
|
||||
initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown,
|
||||
private val requestVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val cancelVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val approveVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val declineVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val startVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val resetLambda: (Boolean) -> Unit = { lambdaError() },
|
||||
private val acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
|
||||
private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
|
||||
) : SessionVerificationService {
|
||||
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
|
||||
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
|
||||
private var _needsSessionVerification = MutableStateFlow(true)
|
||||
var shouldFail = false
|
||||
|
||||
override val verificationFlowState: StateFlow<VerificationFlowState> = _verificationFlowState
|
||||
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
|
||||
override val needsSessionVerification: Flow<Boolean> = _needsSessionVerification
|
||||
|
||||
override suspend fun requestVerification() {
|
||||
if (!shouldFail) {
|
||||
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
|
||||
} else {
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
}
|
||||
requestVerificationLambda()
|
||||
}
|
||||
|
||||
override suspend fun cancelVerification() {
|
||||
_verificationFlowState.value = VerificationFlowState.Canceled
|
||||
cancelVerificationLambda()
|
||||
}
|
||||
|
||||
override suspend fun approveVerification() {
|
||||
if (!shouldFail) {
|
||||
_verificationFlowState.value = VerificationFlowState.Finished
|
||||
} else {
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
}
|
||||
approveVerificationLambda()
|
||||
}
|
||||
|
||||
override suspend fun declineVerification() {
|
||||
if (!shouldFail) {
|
||||
_verificationFlowState.value = VerificationFlowState.Canceled
|
||||
} else {
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerReceiveVerificationData(sessionVerificationData: SessionVerificationData) {
|
||||
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(sessionVerificationData)
|
||||
declineVerificationLambda()
|
||||
}
|
||||
|
||||
override suspend fun startVerification() {
|
||||
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
|
||||
startVerificationLambda()
|
||||
}
|
||||
|
||||
fun givenVerifiedStatus(status: SessionVerifiedStatus) {
|
||||
_sessionVerifiedStatus.value = status
|
||||
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
|
||||
resetLambda(cancelAnyPendingVerificationAttempt)
|
||||
}
|
||||
|
||||
var listener: SessionVerificationServiceListener? = null
|
||||
private set
|
||||
|
||||
override fun setListener(listener: SessionVerificationServiceListener?) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) {
|
||||
acknowledgeVerificationRequestLambda(details)
|
||||
}
|
||||
|
||||
override suspend fun acceptVerificationRequest() = simulateLongTask {
|
||||
acceptVerificationRequestLambda()
|
||||
}
|
||||
|
||||
suspend fun emitVerificationFlowState(state: VerificationFlowState) {
|
||||
_verificationFlowState.emit(state)
|
||||
}
|
||||
|
||||
suspend fun emitVerifiedStatus(status: SessionVerifiedStatus) {
|
||||
_sessionVerifiedStatus.emit(status)
|
||||
}
|
||||
|
||||
fun givenVerificationFlowState(state: VerificationFlowState) {
|
||||
_verificationFlowState.value = state
|
||||
}
|
||||
|
||||
fun givenNeedsSessionVerification(needsVerification: Boolean) {
|
||||
_needsSessionVerification.value = needsVerification
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
_verificationFlowState.value = VerificationFlowState.Initial
|
||||
suspend fun emitNeedsSessionVerification(needsVerification: Boolean) {
|
||||
_needsSessionVerification.emit(needsVerification)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user