From 6059fca2d01649757471e83b145d6ec41447ed4e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 24 Oct 2024 15:32:46 +0200 Subject: [PATCH] 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. --- .../android/appnav/LoggedInFlowNode.kt | 27 ++ .../ftue/impl/DefaultFtueServiceTest.kt | 14 +- .../roomlist/impl/RoomListPresenterTest.kt | 4 +- features/verifysession/api/build.gradle.kts | 1 + .../api/IncomingVerificationEntryPoint.kt | 33 ++ features/verifysession/impl/build.gradle.kts | 2 + .../impl/VerifySelfSessionStateProvider.kt | 95 ------ .../DefaultIncomingVerificationEntryPoint.kt | 40 +++ .../incoming/IncomingVerificationNavigator.kt | 12 + .../impl/incoming/IncomingVerificationNode.kt | 47 +++ .../incoming/IncomingVerificationPresenter.kt | 189 ++++++++++++ .../incoming/IncomingVerificationState.kt | 38 +++ .../IncomingVerificationStateMachine.kt | 158 ++++++++++ .../IncomingVerificationStateProvider.kt | 46 +++ .../impl/incoming/IncomingVerificationView.kt | 235 ++++++++++++++ .../IncomingVerificationViewEvents.kt | 16 + .../impl/incoming/ui/SessionDetailsView.kt | 111 +++++++ .../DefaultVerifySessionEntryPoint.kt | 2 +- .../{ => outgoing}/VerifySelfSessionNode.kt | 2 +- .../VerifySelfSessionPresenter.kt | 81 ++--- .../{ => outgoing}/VerifySelfSessionState.kt | 22 +- .../VerifySelfSessionStateMachine.kt | 35 ++- .../VerifySelfSessionStateProvider.kt | 73 +++++ .../{ => outgoing}/VerifySelfSessionView.kt | 204 ++++-------- .../VerifySelfSessionViewEvents.kt | 2 +- .../features/verifysession/impl/ui/Common.kt | 33 ++ .../impl/ui/VerificationBottomMenu.kt | 27 ++ .../impl/ui/VerificationContentVerifying.kt | 94 ++++++ .../impl/util/StateMachineUtil.kt | 25 ++ .../impl/src/main/res/values/localazy.xml | 6 +- .../IncomingVerificationPresenterTest.kt | 292 ++++++++++++++++++ .../incoming/IncomingVerificationViewTest.kt | 217 +++++++++++++ .../VerifySelfSessionPresenterTest.kt | 227 +++++++++----- .../VerifySelfSessionViewTest.kt | 30 +- .../test/FakeLastMessageTimestampFormatter.kt | 5 +- .../libraries/matrix/api/core/FlowId.kt | 15 + .../SessionVerificationRequestDetails.kt | 23 ++ .../SessionVerificationService.kt | 34 +- .../RustSessionVerificationService.kt | 50 ++- .../SessionVerificationRequestDetails.kt | 23 ++ .../FakeSessionVerificationService.kt | 69 +++-- 41 files changed, 2205 insertions(+), 454 deletions(-) create mode 100644 features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt delete mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt rename features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/{ => outgoing}/DefaultVerifySessionEntryPoint.kt (95%) rename features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/{ => outgoing}/VerifySelfSessionNode.kt (97%) rename features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/{ => outgoing}/VerifySelfSessionPresenter.kt (72%) rename features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/{ => outgoing}/VerifySelfSessionState.kt (60%) rename features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/{ => outgoing}/VerifySelfSessionStateMachine.kt (89%) create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt rename features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/{ => outgoing}/VerifySelfSessionView.kt (62%) rename features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/{ => outgoing}/VerifySelfSessionViewEvents.kt (91%) create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt create mode 100644 features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt create mode 100644 features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt rename features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/{ => outgoing}/VerifySelfSessionPresenterTest.kt (58%) rename features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/{ => outgoing}/VerifySelfSessionViewTest.kt (87%) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 5a8db185ef..09ac82c441 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -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( @@ -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() + } } } diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt index 10189c3c67..4788857e21 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt @@ -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) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt index cb7ef2852a..69e9a7d401 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt @@ -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)), ) diff --git a/features/verifysession/api/build.gradle.kts b/features/verifysession/api/build.gradle.kts index 37914dc0ba..748051b623 100644 --- a/features/verifysession/api/build.gradle.kts +++ b/features/verifysession/api/build.gradle.kts @@ -15,4 +15,5 @@ android { dependencies { implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) } diff --git a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt new file mode 100644 index 0000000000..908816ac00 --- /dev/null +++ b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt @@ -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() + } +} diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts index 8f5f6cae24..85d3b463ce 100644 --- a/features/verifysession/impl/build.gradle.kts +++ b/features/verifysession/impl/build.gradle.kts @@ -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) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt deleted file mode 100644 index b5762b6d5f..0000000000 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt +++ /dev/null @@ -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 { - override val values: Sequence - 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 = aVerificationEmojiList(), -): SessionVerificationData { - return SessionVerificationData.Emojis(emojiList) -} - -private fun aDecimalsSessionVerificationData( - decimals: List = listOf(123, 456, 789), -): SessionVerificationData { - return SessionVerificationData.Decimals(decimals) -} - -internal fun aVerifySelfSessionState( - verificationFlowStep: VerificationStep = VerificationStep.Initial(canEnterRecoveryKey = false), - signOutAction: AsyncAction = 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"), -) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt new file mode 100644 index 0000000000..24ea086720 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt @@ -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() + + 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(buildContext, plugins) + } + } + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt new file mode 100644 index 0000000000..9ae5a09dd8 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt @@ -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() +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt new file mode 100644 index 0000000000..14df3ef8d9 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt @@ -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, + presenterFactory: IncomingVerificationPresenter.Factory, +) : Node(buildContext, plugins = plugins), + IncomingVerificationNavigator { + private val presenter = presenterFactory.create( + sessionVerificationRequestDetails = inputs().sessionVerificationRequestDetails, + navigator = this, + ) + + override fun onFinish() { + plugins().forEach { it.onDone() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + IncomingVerificationView( + state = state, + modifier = modifier, + ) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt new file mode 100644 index 0000000000..bed1575543 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt @@ -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 { + @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 _step = step) { + is Step.Initial -> if (_step.isWaiting) { + stateAndDispatch.dispatchAction(StateMachineEvent.Cancel) + } else { + navigator.onFinish() + } + is Step.Verifying -> if (_step.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) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt new file mode 100644 index 0000000000..4fcb86dfb8 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt @@ -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 + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt new file mode 100644 index 0000000000..b8c3276af4 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt @@ -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( + initialState = State.Initial(isCancelled = false) +) { + init { + spec { + inState { + on { _: Event.AcceptIncomingRequest, state -> + state.override { State.AcceptingIncomingVerification.andLogStateChange() } + } + } + inState { + onEnterEffect { + sessionVerificationService.acceptVerificationRequest() + } + on { event: Event.DidReceiveChallenge, state -> + state.override { State.ChallengeReceived(event.data).andLogStateChange() } + } + } + inState { + 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 { + onEnterEffect { _ -> + sessionVerificationService.approveVerification() + } + on { _: Event.DidAcceptChallenge, state -> + state.override { State.Completed.andLogStateChange() } + } + } + inState { + onEnterEffect { _ -> + sessionVerificationService.declineVerification() + } + } + inState { + onEnterEffect { + sessionVerificationService.cancelVerification() + } + } + inState { + logReceivedEvents() + on { _: Event.Cancel, state: MachineState -> + when (state.snapshot) { + State.Completed, State.Canceled -> state.noChange() + else -> { + sessionVerificationService.cancelVerification() + state.override { State.Canceled.andLogStateChange() } + } + } + } + on { _: Event.DidCancel, state: MachineState -> + 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.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 + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt new file mode 100644 index 0000000000..0b43dcdd3c --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt @@ -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 { + override val values: Sequence + 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, +) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt new file mode 100644 index 0000000000..052b88f0dc --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt @@ -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, + ) +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt new file mode 100644 index 0000000000..c1fef2ff88 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt @@ -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 +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt new file mode 100644 index 0000000000..c1eecbdf39 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt @@ -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", + ) +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultVerifySessionEntryPoint.kt similarity index 95% rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultVerifySessionEntryPoint.kt index def5c4c84c..4563c8db56 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultVerifySessionEntryPoint.kt @@ -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 diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionNode.kt similarity index 97% rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionNode.kt index 3eb33b0c8d..cf4c7ae084 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionNode.kt @@ -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 diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt similarity index 72% rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt index a8667217fe..360bf64875 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt @@ -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.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 -> - when (verificationAttemptState) { - VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset) - VerificationFlowState.AcceptedVerificationRequest -> { - stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest) - } - VerificationFlowState.StartedSasVerification -> { - stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification) - } - is VerificationFlowState.ReceivedVerificationData -> { - stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data)) - } - VerificationFlowState.Finished -> { - stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge) - } - VerificationFlowState.Canceled -> { - stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel) - } - VerificationFlowState.Failed -> { - stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail) + sessionVerificationService.verificationFlowState + .onEach { Timber.d("Verification flow state: ${it::class.simpleName}") } + .onEach { verificationAttemptState -> + when (verificationAttemptState) { + VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset) + VerificationFlowState.DidAcceptVerificationRequest -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest) + } + VerificationFlowState.DidStartSasVerification -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification) + } + is VerificationFlowState.DidReceiveVerificationData -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data)) + } + VerificationFlowState.DidFinish -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge) + } + VerificationFlowState.DidCancel -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel) + } + VerificationFlowState.DidFail -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail) + } } } - }.launchIn(this) + .launchIn(this) } private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch { diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt similarity index 60% rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt index 81062d57c7..e763305caa 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt @@ -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, 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) : 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) : Step + data object Completed : Step + data object Skipped : Step } } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt similarity index 89% rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt index 55c3d7e94f..f423b14aae 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt @@ -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 { 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 { @@ -48,7 +50,7 @@ class VerifySelfSessionStateMachine @Inject constructor( sessionVerificationService.requestVerification() } on { _: Event.DidAcceptVerificationRequest, state -> - state.override { State.VerificationRequestAccepted } + state.override { State.VerificationRequestAccepted.andLogStateChange() } } } inState { @@ -58,28 +60,28 @@ class VerifySelfSessionStateMachine @Inject constructor( } inState { on { _: Event.StartSasVerification, state -> - state.override { State.StartingSasVerification } + state.override { State.StartingSasVerification.andLogStateChange() } } } inState { 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 { on { event: Event.DidReceiveChallenge, state -> - state.override { State.Verifying.ChallengeReceived(event.data) } + state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() } } } inState { 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 { @@ -100,7 +102,7 @@ class VerifySelfSessionStateMachine @Inject constructor( .first() } } - state.override { State.Completed } + state.override { State.Completed.andLogStateChange() } } } inState { @@ -110,8 +112,9 @@ class VerifySelfSessionStateMachine @Inject constructor( } } inState { + logReceivedEvents() on { _: Event.DidStartSasVerification, state: MachineState -> - state.override { State.SasVerificationStarted } + state.override { State.SasVerificationStarted.andLogStateChange() } } on { _: Event.Cancel, state: MachineState -> 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.override { State.Canceled } + state.override { State.Canceled.andLogStateChange() } } on { _: Event.DidFail, state: MachineState -> 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() } } } } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt new file mode 100644 index 0000000000..8cb60a822f --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt @@ -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 { + override val values: Sequence + 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 = AsyncAction.Uninitialized, + displaySkipButton: Boolean = false, + eventSink: (VerifySelfSessionViewEvents) -> Unit = {}, +) = VerifySelfSessionState( + step = step, + displaySkipButton = displaySkipButton, + eventSink = eventSink, + signOutAction = signOutAction, +) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt similarity index 62% rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt index 5b0c9105ad..4ab80784ec 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt @@ -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,16 @@ 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.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 +48,8 @@ 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 +import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step @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 + val isVerifying = (verificationViewState as? Step.Verifying)?.state is AsyncData.Loading 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 } } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt similarity index 91% rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt index 1f0c235842..869bdc7051 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt @@ -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 diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt new file mode 100644 index 0000000000..345663fa11 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt @@ -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 = aVerificationEmojiList(), +): SessionVerificationData { + return SessionVerificationData.Emojis(emojiList) +} + +internal fun aDecimalsSessionVerificationData( + decimals: List = 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"), +) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt new file mode 100644 index 0000000000..33ab0fa378 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt @@ -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() + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt new file mode 100644 index 0000000000..7f988a0d3d --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt @@ -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, + ) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt new file mode 100644 index 0000000000..096a0be9ea --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt @@ -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.andLogStateChange() = also { + Timber.w("Verification: state machine state moved to [${this::class.simpleName}]") +} + +@OptIn(ExperimentalCoroutinesApi::class) +inline fun InStateBuilderBlock.logReceivedEvents() { + on { event: Event, state: MachineState -> + Timber.w("Verification in state [${state.snapshot::class.simpleName}] receiving event [${event::class.simpleName}]") + state.noChange() + } +} diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml index be10ce5aa1..f67a2024b9 100644 --- a/features/verifysession/impl/src/main/res/values/localazy.xml +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -22,12 +22,16 @@ "Open an existing session" "Retry verification" "I am ready" - "Waiting to match" + "Waiting to match…" "Compare a unique set of emojis." "Compare the unique emoji, ensuring they appear in the same order." "Signed in" + "Either the request timed out, the request was denied, or there was a verification mismatch." + "Verification failed" "Only continue if you initiated this verification." "Verify the other device to keep your message history secure." + "Now you can read or send messages securely on your other device." + "Device verified" "Verification requested" "They don’t match" "They match" diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt new file mode 100644 index 0000000000..773b7b390b --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt @@ -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 { _ -> } + val acceptVerificationRequestLambda = lambdaRecorder { } + val approveVerificationLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder { } + 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 { _ -> } + val acceptVerificationRequestLambda = lambdaRecorder { } + val declineVerificationLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder { } + 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 { _ -> } + val acceptVerificationRequestLambda = lambdaRecorder { } + val declineVerificationLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder { } + val onFinishLambda = lambdaRecorder { } + 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 { _ -> } + val acceptVerificationRequestLambda = lambdaRecorder { } + val declineVerificationLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder { } + 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 { _ -> } + val acceptVerificationRequestLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder { } + val fakeSessionVerificationService = FakeSessionVerificationService( + acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, + acceptVerificationRequestLambda = acceptVerificationRequestLambda, + resetLambda = resetLambda, + ) + val navigatorLambda = lambdaRecorder { } + 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, + ) +} diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt new file mode 100644 index 0000000000..7517486c00 --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt @@ -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() + + // region step Initial + @Test + fun `back key pressed - ignore the verification`() { + val eventsRecorder = EventsRecorder() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = IncomingVerificationState.Step.Completed, + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_done) + eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) + } + // endregion + + private fun AndroidComposeTestRule.setIncomingVerificationView( + state: IncomingVerificationState, + ) { + setContent { + IncomingVerificationView( + state = state, + ) + } + } +} diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt similarity index 58% rename from features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTest.kt rename to features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt index 188c895f5c..99e8846748 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt @@ -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 { } 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 { "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(), diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewTest.kt similarity index 87% rename from features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt rename to features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewTest.kt index dfe8aaf85d..5429ac3637 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewTest.kt @@ -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() rule.setVerifySelfSessionView( aVerifySelfSessionState( - verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled, + step = VerifySelfSessionState.Step.Canceled, eventSink = eventsRecorder ), ) @@ -49,7 +51,7 @@ class VerifySelfSessionViewTest { val eventsRecorder = EventsRecorder() rule.setVerifySelfSessionView( aVerifySelfSessionState( - verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse, + step = VerifySelfSessionState.Step.AwaitingOtherDeviceResponse, eventSink = eventsRecorder ), ) @@ -62,7 +64,7 @@ class VerifySelfSessionViewTest { val eventsRecorder = EventsRecorder() rule.setVerifySelfSessionView( aVerifySelfSessionState( - verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready, + step = VerifySelfSessionState.Step.Ready, eventSink = eventsRecorder ), ) @@ -75,7 +77,7 @@ class VerifySelfSessionViewTest { val eventsRecorder = EventsRecorder() 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() 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() 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() 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() 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() 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(), ), diff --git a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt index db68141a8e..7edcf321cb 100644 --- a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt +++ b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt @@ -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 } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt new file mode 100644 index 0000000000..c1b298d62a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt @@ -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 +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt new file mode 100644 index 0000000000..93d791a21c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt @@ -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 diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt index 193d5eb48e..4bd30a21fc 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt @@ -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 } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt index aea8cb34bd..c9518499bd 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt @@ -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 @@ -41,6 +45,7 @@ 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, @@ -100,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() @@ -119,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 { - block() + // 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() @@ -132,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() { @@ -157,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 { @@ -168,18 +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 + _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() } } @@ -208,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") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt new file mode 100644 index 0000000000..e12cfbeb3f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt @@ -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(), +) + diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt index f6a9ea9fb5..2449ced681 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt @@ -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.Initial) private var _needsSessionVerification = MutableStateFlow(true) - var shouldFail = false override val verificationFlowState: StateFlow = _verificationFlowState override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus override val needsSessionVerification: Flow = _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 - } + declineVerificationLambda() } - fun triggerReceiveVerificationData(sessionVerificationData: SessionVerificationData) { - _verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(sessionVerificationData) + override suspend fun startVerification() { + startVerificationLambda() } - override suspend fun startVerification() { - _verificationFlowState.value = VerificationFlowState.StartedSasVerification + override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) { + resetLambda(cancelAnyPendingVerificationAttempt) } - fun givenVerifiedStatus(status: SessionVerifiedStatus) { - _sessionVerifiedStatus.value = status + var listener: SessionVerificationServiceListener? = null + private set + + override fun setListener(listener: SessionVerificationServiceListener?) { + this.listener = listener } - suspend fun emitVerifiedStatus(status: SessionVerifiedStatus) { - _sessionVerifiedStatus.emit(status) + override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) { + acknowledgeVerificationRequestLambda(details) } - fun givenVerificationFlowState(state: VerificationFlowState) { - _verificationFlowState.value = state + override suspend fun acceptVerificationRequest() = simulateLongTask { + acceptVerificationRequestLambda() } - fun givenNeedsSessionVerification(needsVerification: Boolean) { - _needsSessionVerification.value = needsVerification + suspend fun emitVerificationFlowState(state: VerificationFlowState) { + _verificationFlowState.emit(state) + } + + suspend fun emitVerifiedStatus(status: SessionVerifiedStatus) { + _sessionVerifiedStatus.emit(status) } - override suspend fun reset() { - _verificationFlowState.value = VerificationFlowState.Initial + suspend fun emitNeedsSessionVerification(needsVerification: Boolean) { + _needsSessionVerification.emit(needsVerification) } }