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) } }