Merge pull request #3733 from element-hq/feature/bma/incomingVerification

Incoming session verification
This commit is contained in:
Benoit Marty 2024-10-29 18:03:28 +01:00 committed by GitHub
commit 666cedd66e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 2275 additions and 465 deletions

View file

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

View file

@ -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)),
)

View file

@ -15,4 +15,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View file

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

View file

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

View file

@ -1,95 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState>
get() = sequenceOf(
aVerifySelfSessionState(displaySkipButton = true),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.AwaitingOtherDeviceResponse
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Canceled
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Ready
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true)
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true)
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Completed,
displaySkipButton = true,
),
aVerifySelfSessionState(
signOutAction = AsyncAction.Loading,
displaySkipButton = true,
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Loading
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Skipped
),
// Add other state here
)
}
internal fun aEmojisSessionVerificationData(
emojiList: List<VerificationEmoji> = aVerificationEmojiList(),
): SessionVerificationData {
return SessionVerificationData.Emojis(emojiList)
}
private fun aDecimalsSessionVerificationData(
decimals: List<Int> = listOf(123, 456, 789),
): SessionVerificationData {
return SessionVerificationData.Decimals(decimals)
}
internal fun aVerifySelfSessionState(
verificationFlowStep: VerificationStep = VerificationStep.Initial(canEnterRecoveryKey = false),
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
displaySkipButton: Boolean = false,
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState(
verificationFlowStep = verificationFlowStep,
displaySkipButton = displaySkipButton,
eventSink = eventSink,
signOutAction = signOutAction,
)
private fun aVerificationEmojiList() = listOf(
VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"),
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
VerificationEmoji(number = 42, emoji = "📕", description = "Book"),
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
VerificationEmoji(number = 63, emoji = "📌", description = "Pin"),
)

View file

@ -0,0 +1,40 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultIncomingVerificationEntryPoint @Inject constructor() : IncomingVerificationEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): IncomingVerificationEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : IncomingVerificationEntryPoint.NodeBuilder {
override fun callback(callback: IncomingVerificationEntryPoint.Callback): IncomingVerificationEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun params(params: IncomingVerificationEntryPoint.Params): IncomingVerificationEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun build(): Node {
return parentNode.createNode<IncomingVerificationNode>(buildContext, plugins)
}
}
}
}

View file

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

View file

@ -0,0 +1,47 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class IncomingVerificationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: IncomingVerificationPresenter.Factory,
) : Node(buildContext, plugins = plugins),
IncomingVerificationNavigator {
private val presenter = presenterFactory.create(
sessionVerificationRequestDetails = inputs<IncomingVerificationEntryPoint.Params>().sessionVerificationRequestDetails,
navigator = this,
)
override fun onFinish() {
plugins<IncomingVerificationEntryPoint.Callback>().forEach { it.onDone() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
IncomingVerificationView(
state = state,
modifier = modifier,
)
}
}

View file

@ -0,0 +1,189 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.freeletics.flowredux.compose.rememberStateAndDispatch
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState
class IncomingVerificationPresenter @AssistedInject constructor(
@Assisted private val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
@Assisted private val navigator: IncomingVerificationNavigator,
private val sessionVerificationService: SessionVerificationService,
private val stateMachine: IncomingVerificationStateMachine,
private val dateFormatter: LastMessageTimestampFormatter,
) : Presenter<IncomingVerificationState> {
@AssistedFactory
interface Factory {
fun create(
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
navigator: IncomingVerificationNavigator,
): IncomingVerificationPresenter
}
@Composable
override fun present(): IncomingVerificationState {
LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state
sessionVerificationService.reset(
cancelAnyPendingVerificationAttempt = false
)
// Acknowledge the request right now
sessionVerificationService.acknowledgeVerificationRequest(sessionVerificationRequestDetails)
}
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
val formattedSignInTime = remember {
dateFormatter.format(sessionVerificationRequestDetails.firstSeenTimestamp)
}
val step by remember {
derivedStateOf {
stateAndDispatch.state.value.toVerificationStep(
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
formattedSignInTime = formattedSignInTime,
)
}
}
LaunchedEffect(stateAndDispatch.state.value) {
if ((stateAndDispatch.state.value as? IncomingVerificationStateMachine.State.Initial)?.isCancelled == true) {
// The verification was canceled before it was started, maybe because another session accepted it
navigator.onFinish()
}
}
// Start this after observing state machine
LaunchedEffect(Unit) {
observeVerificationService()
}
fun handleEvents(event: IncomingVerificationViewEvents) {
Timber.d("Verification user action: ${event::class.simpleName}")
when (event) {
IncomingVerificationViewEvents.StartVerification ->
stateAndDispatch.dispatchAction(StateMachineEvent.AcceptIncomingRequest)
IncomingVerificationViewEvents.IgnoreVerification ->
navigator.onFinish()
IncomingVerificationViewEvents.ConfirmVerification ->
stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
IncomingVerificationViewEvents.DeclineVerification ->
stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
IncomingVerificationViewEvents.GoBack -> {
when (val verificationStep = step) {
is Step.Initial -> if (verificationStep.isWaiting) {
stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
} else {
navigator.onFinish()
}
is Step.Verifying -> if (verificationStep.isWaiting) {
// What do we do in this case?
} else {
stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
}
Step.Canceled,
Step.Completed,
Step.Failure -> navigator.onFinish()
}
}
}
}
return IncomingVerificationState(
step = step,
eventSink = ::handleEvents,
)
}
private fun StateMachineState?.toVerificationStep(
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
formattedSignInTime: String,
): Step =
when (val machineState = this) {
is StateMachineState.Initial,
IncomingVerificationStateMachine.State.AcceptingIncomingVerification,
IncomingVerificationStateMachine.State.RejectingIncomingVerification,
null -> {
Step.Initial(
deviceDisplayName = sessionVerificationRequestDetails.displayName ?: sessionVerificationRequestDetails.deviceId.value,
deviceId = sessionVerificationRequestDetails.deviceId,
formattedSignInTime = formattedSignInTime,
isWaiting = machineState == IncomingVerificationStateMachine.State.AcceptingIncomingVerification ||
machineState == IncomingVerificationStateMachine.State.RejectingIncomingVerification,
)
}
is IncomingVerificationStateMachine.State.ChallengeReceived ->
Step.Verifying(
data = machineState.data,
isWaiting = false,
)
IncomingVerificationStateMachine.State.Completed -> Step.Completed
IncomingVerificationStateMachine.State.Canceling,
IncomingVerificationStateMachine.State.Failure -> Step.Failure
is IncomingVerificationStateMachine.State.AcceptingChallenge ->
Step.Verifying(
data = machineState.data,
isWaiting = true,
)
is IncomingVerificationStateMachine.State.RejectingChallenge ->
Step.Verifying(
data = machineState.data,
isWaiting = true,
)
IncomingVerificationStateMachine.State.Canceled -> Step.Canceled
}
private fun CoroutineScope.observeVerificationService() {
sessionVerificationService.verificationFlowState
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
.onEach { verificationAttemptState ->
when (verificationAttemptState) {
VerificationFlowState.Initial,
VerificationFlowState.DidAcceptVerificationRequest,
VerificationFlowState.DidStartSasVerification -> Unit
is VerificationFlowState.DidReceiveVerificationData -> {
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
}
VerificationFlowState.DidFinish -> {
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidAcceptChallenge)
}
VerificationFlowState.DidCancel -> {
// Can happen when:
// - the remote party cancel the verification (before it is started)
// - another session has accepted the incoming verification request
// - the user reject the challenge from this application (I think this is an error). In this case, the state
// machine will ignore this event and change state to Failure.
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidCancel)
}
VerificationFlowState.DidFail -> {
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidFail)
}
}
}
.launchIn(this)
}
}

View file

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

View file

@ -0,0 +1,158 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.verifysession.impl.incoming
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
import io.element.android.features.verifysession.impl.util.andLogStateChange
import io.element.android.features.verifysession.impl.util.logReceivedEvents
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import javax.inject.Inject
import com.freeletics.flowredux.dsl.State as MachineState
class IncomingVerificationStateMachine @Inject constructor(
private val sessionVerificationService: SessionVerificationService,
) : FlowReduxStateMachine<IncomingVerificationStateMachine.State, IncomingVerificationStateMachine.Event>(
initialState = State.Initial(isCancelled = false)
) {
init {
spec {
inState<State.Initial> {
on { _: Event.AcceptIncomingRequest, state ->
state.override { State.AcceptingIncomingVerification.andLogStateChange() }
}
}
inState<State.AcceptingIncomingVerification> {
onEnterEffect {
sessionVerificationService.acceptVerificationRequest()
}
on { event: Event.DidReceiveChallenge, state ->
state.override { State.ChallengeReceived(event.data).andLogStateChange() }
}
}
inState<State.ChallengeReceived> {
on { _: Event.AcceptChallenge, state ->
state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() }
}
on { _: Event.DeclineChallenge, state ->
state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() }
}
}
inState<State.AcceptingChallenge> {
onEnterEffect { _ ->
sessionVerificationService.approveVerification()
}
on { _: Event.DidAcceptChallenge, state ->
state.override { State.Completed.andLogStateChange() }
}
}
inState<State.RejectingChallenge> {
onEnterEffect { _ ->
sessionVerificationService.declineVerification()
}
}
inState<State.Canceling> {
onEnterEffect {
sessionVerificationService.cancelVerification()
}
}
inState {
logReceivedEvents()
on { _: Event.Cancel, state: MachineState<State> ->
when (state.snapshot) {
State.Completed, State.Canceled -> state.noChange()
else -> {
sessionVerificationService.cancelVerification()
state.override { State.Canceled.andLogStateChange() }
}
}
}
on { _: Event.DidCancel, state: MachineState<State> ->
when (state.snapshot) {
is State.RejectingChallenge -> {
state.override { State.Failure.andLogStateChange() }
}
is State.Initial -> state.mutate { State.Initial(isCancelled = true).andLogStateChange() }
State.AcceptingIncomingVerification,
State.RejectingIncomingVerification,
is State.ChallengeReceived,
is State.AcceptingChallenge,
State.Canceling -> state.override { State.Canceled.andLogStateChange() }
State.Canceled,
State.Completed,
State.Failure -> state.noChange()
}
}
on { _: Event.DidFail, state: MachineState<State> ->
state.override { State.Failure.andLogStateChange() }
}
}
}
}
sealed interface State {
/** The initial state, before verification started. */
data class Initial(val isCancelled: Boolean) : State
/** User is accepting the incoming verification. */
data object AcceptingIncomingVerification : State
/** User is rejecting the incoming verification. */
data object RejectingIncomingVerification : State
/** Verification accepted and emojis received. */
data class ChallengeReceived(val data: SessionVerificationData) : State
/** Accepting the verification challenge. */
data class AcceptingChallenge(val data: SessionVerificationData) : State
/** Rejecting the verification challenge. */
data class RejectingChallenge(val data: SessionVerificationData) : State
/** The verification is being canceled. */
data object Canceling : State
/** The verification has been canceled, remotely or locally. */
data object Canceled : State
/** Verification successful. */
data object Completed : State
/** Verification failure. */
data object Failure : State
}
sealed interface Event {
/** User accepts the incoming request. */
data object AcceptIncomingRequest : Event
/** Has received data. */
data class DidReceiveChallenge(val data: SessionVerificationData) : Event
/** Emojis match. */
data object AcceptChallenge : Event
/** Emojis do not match. */
data object DeclineChallenge : Event
/** Remote accepted challenge. */
data object DidAcceptChallenge : Event
/** Request cancellation. */
data object Cancel : Event
/** Verification cancelled. */
data object DidCancel : Event
/** Request failed. */
data object DidFail : Event
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.matrix.api.core.DeviceId
open class IncomingVerificationStateProvider : PreviewParameterProvider<IncomingVerificationState> {
override val values: Sequence<IncomingVerificationState>
get() = sequenceOf(
anIncomingVerificationState(),
anIncomingVerificationState(step = aStepInitial(isWaiting = true)),
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)),
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)),
anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)),
anIncomingVerificationState(step = Step.Completed),
anIncomingVerificationState(step = Step.Failure),
anIncomingVerificationState(step = Step.Canceled),
// Add other state here
)
}
internal fun aStepInitial(
isWaiting: Boolean = false,
) = Step.Initial(
deviceDisplayName = "Element X Android",
deviceId = DeviceId("ILAKNDNASDLK"),
formattedSignInTime = "12:34",
isWaiting = isWaiting,
)
internal fun anIncomingVerificationState(
step: Step = aStepInitial(),
eventSink: (IncomingVerificationViewEvents) -> Unit = {},
) = IncomingVerificationState(
step = step,
eventSink = eventSink,
)

View file

@ -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 = {
IncomingVerificationHeader(step = step)
},
footer = {
IncomingVerificationBottomMenu(
state = state,
)
}
) {
IncomingVerificationContent(
step = step,
)
}
}
@Composable
private fun IncomingVerificationHeader(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 IncomingVerificationContent(
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,
)
}

View file

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

View file

@ -0,0 +1,92 @@
/*
* 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.atomic.molecules.TextWithLabelMolecule
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),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun SessionDetailsViewPreview() = ElementPreview {
SessionDetailsView(
deviceName = "Element X Android",
deviceId = DeviceId("ILAKNDNASDLK"),
signInFormattedTimestamp = "12:34",
)
}

View file

@ -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

View file

@ -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

View file

@ -7,7 +7,7 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.verifysession.impl
package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -39,8 +39,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState
import timber.log.Timber
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState
class VerifySelfSessionPresenter @AssistedInject constructor(
@Assisted private val showDeviceVerifiedScreen: Boolean,
@ -61,7 +62,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state
sessionVerificationService.reset()
sessionVerificationService.reset(true)
}
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
@ -70,13 +71,13 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
val signOutAction = remember {
mutableStateOf<AsyncAction<String?>>(AsyncAction.Uninitialized)
}
val verificationFlowStep by remember {
val step by remember {
derivedStateOf {
if (skipVerification) {
VerifySelfSessionState.VerificationStep.Skipped
VerifySelfSessionState.Step.Skipped
} else {
when (sessionVerifiedStatus) {
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.VerificationStep.Loading
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
SessionVerifiedStatus.NotVerified -> {
stateAndDispatch.state.value.toVerificationStep(
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
@ -85,10 +86,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
SessionVerifiedStatus.Verified -> {
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
// The user has verified the session, we need to show the success screen
VerifySelfSessionState.VerificationStep.Completed
VerifySelfSessionState.Step.Completed
} else {
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
VerifySelfSessionState.VerificationStep.Skipped
VerifySelfSessionState.Step.Skipped
}
}
}
@ -101,6 +102,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
}
fun handleEvents(event: VerifySelfSessionViewEvents) {
Timber.d("Verification user action: ${event::class.simpleName}")
when (event) {
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
@ -115,7 +117,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
}
}
return VerifySelfSessionState(
verificationFlowStep = verificationFlowStep,
step = step,
signOutAction = signOutAction.value,
displaySkipButton = buildMeta.isDebuggable,
eventSink = ::handleEvents,
@ -124,10 +126,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
private fun StateMachineState?.toVerificationStep(
canEnterRecoveryKey: Boolean
): VerifySelfSessionState.VerificationStep =
): VerifySelfSessionState.Step =
when (val machineState = this) {
StateMachineState.Initial, null -> {
VerifySelfSessionState.VerificationStep.Initial(
VerifySelfSessionState.Step.Initial(
canEnterRecoveryKey = canEnterRecoveryKey,
isLastDevice = encryptionService.isLastDevice.value
)
@ -136,15 +138,15 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
StateMachineState.StartingSasVerification,
StateMachineState.SasVerificationStarted,
StateMachineState.Canceling -> {
VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse
VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
}
StateMachineState.VerificationRequestAccepted -> {
VerifySelfSessionState.VerificationStep.Ready
VerifySelfSessionState.Step.Ready
}
StateMachineState.Canceled -> {
VerifySelfSessionState.VerificationStep.Canceled
VerifySelfSessionState.Step.Canceled
}
is StateMachineState.Verifying -> {
@ -152,38 +154,41 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
is StateMachineState.Verifying.Replying -> AsyncData.Loading()
else -> AsyncData.Uninitialized
}
VerifySelfSessionState.VerificationStep.Verifying(machineState.data, async)
VerifySelfSessionState.Step.Verifying(machineState.data, async)
}
StateMachineState.Completed -> {
VerifySelfSessionState.VerificationStep.Completed
VerifySelfSessionState.Step.Completed
}
}
private fun CoroutineScope.observeVerificationService() {
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
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<AsyncAction<String?>>) = launch {

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl
package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
@ -15,22 +15,22 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
@Immutable
data class VerifySelfSessionState(
val verificationFlowStep: VerificationStep,
val step: Step,
val signOutAction: AsyncAction<String?>,
val displaySkipButton: Boolean,
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
) {
@Stable
sealed interface VerificationStep {
data object Loading : VerificationStep
sealed interface Step {
data object Loading : Step
// FIXME canEnterRecoveryKey value is never read.
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : VerificationStep
data object Canceled : VerificationStep
data object AwaitingOtherDeviceResponse : VerificationStep
data object Ready : VerificationStep
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : VerificationStep
data object Completed : VerificationStep
data object Skipped : VerificationStep
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
data object Canceled : Step
data object AwaitingOtherDeviceResponse : Step
data object Ready : Step
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step
data object Completed : Step
data object Skipped : Step
}
}

View file

@ -8,9 +8,11 @@
@file:Suppress("WildcardImport")
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.verifysession.impl
package io.element.android.features.verifysession.impl.outgoing
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
import io.element.android.features.verifysession.impl.util.andLogStateChange
import io.element.android.features.verifysession.impl.util.logReceivedEvents
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@ -37,10 +39,10 @@ class VerifySelfSessionStateMachine @Inject constructor(
spec {
inState<State.Initial> {
on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification }
state.override { State.RequestingVerification.andLogStateChange() }
}
on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification }
state.override { State.StartingSasVerification.andLogStateChange() }
}
}
inState<State.RequestingVerification> {
@ -48,7 +50,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
sessionVerificationService.requestVerification()
}
on { _: Event.DidAcceptVerificationRequest, state ->
state.override { State.VerificationRequestAccepted }
state.override { State.VerificationRequestAccepted.andLogStateChange() }
}
}
inState<State.StartingSasVerification> {
@ -58,28 +60,28 @@ class VerifySelfSessionStateMachine @Inject constructor(
}
inState<State.VerificationRequestAccepted> {
on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification }
state.override { State.StartingSasVerification.andLogStateChange() }
}
}
inState<State.Canceled> {
on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification }
state.override { State.RequestingVerification.andLogStateChange() }
}
on { _: Event.Reset, state ->
state.override { State.Initial }
state.override { State.Initial.andLogStateChange() }
}
}
inState<State.SasVerificationStarted> {
on { event: Event.DidReceiveChallenge, state ->
state.override { State.Verifying.ChallengeReceived(event.data) }
state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() }
}
}
inState<State.Verifying.ChallengeReceived> {
on { _: Event.AcceptChallenge, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = true) }
state.override { State.Verifying.Replying(state.snapshot.data, accept = true).andLogStateChange() }
}
on { _: Event.DeclineChallenge, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = false) }
state.override { State.Verifying.Replying(state.snapshot.data, accept = false).andLogStateChange() }
}
}
inState<State.Verifying.Replying> {
@ -100,7 +102,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
.first()
}
}
state.override { State.Completed }
state.override { State.Completed.andLogStateChange() }
}
}
inState<State.Canceling> {
@ -110,8 +112,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
}
}
inState {
logReceivedEvents()
on { _: Event.DidStartSasVerification, state: MachineState<State> ->
state.override { State.SasVerificationStarted }
state.override { State.SasVerificationStarted.andLogStateChange() }
}
on { _: Event.Cancel, state: MachineState<State> ->
when (state.snapshot) {
@ -120,17 +123,17 @@ class VerifySelfSessionStateMachine @Inject constructor(
// `Canceling` state to `Canceled` automatically anymore
else -> {
sessionVerificationService.cancelVerification()
state.override { State.Canceled }
state.override { State.Canceled.andLogStateChange() }
}
}
}
on { _: Event.DidCancel, state: MachineState<State> ->
state.override { State.Canceled }
state.override { State.Canceled.andLogStateChange() }
}
on { _: Event.DidFail, state: MachineState<State> ->
when (state.snapshot) {
is State.RequestingVerification -> state.override { State.Initial }
else -> state.override { State.Canceled }
is State.RequestingVerification -> state.override { State.Initial.andLogStateChange() }
else -> state.override { State.Canceled.andLogStateChange() }
}
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState>
get() = sequenceOf(
aVerifySelfSessionState(displaySkipButton = true),
aVerifySelfSessionState(
step = Step.AwaitingOtherDeviceResponse
),
aVerifySelfSessionState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
),
aVerifySelfSessionState(
step = Step.Canceled
),
aVerifySelfSessionState(
step = Step.Ready
),
aVerifySelfSessionState(
step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
step = Step.Initial(canEnterRecoveryKey = true)
),
aVerifySelfSessionState(
step = Step.Initial(canEnterRecoveryKey = true, isLastDevice = true)
),
aVerifySelfSessionState(
step = Step.Completed,
displaySkipButton = true,
),
aVerifySelfSessionState(
signOutAction = AsyncAction.Loading,
displaySkipButton = true,
),
aVerifySelfSessionState(
step = Step.Loading
),
aVerifySelfSessionState(
step = Step.Skipped
),
// Add other state here
)
}
internal fun aVerifySelfSessionState(
step: Step = Step.Initial(canEnterRecoveryKey = false),
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
displaySkipButton: Boolean = false,
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState(
step = step,
displaySkipButton = displaySkipButton,
eventSink = eventSink,
signOutAction = signOutAction,
)

View file

@ -5,25 +5,19 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl
package io.element.android.features.verifysession.impl.outgoing
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -31,18 +25,17 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.verifysession.impl.emoji.toEmojiResource
import io.element.android.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
@ -56,9 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -71,12 +62,13 @@ fun VerifySelfSessionView(
onSuccessLogout: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
val step = state.step
fun cancelOrResetFlow() {
when (state.verificationFlowStep) {
is FlowStep.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
is FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
is FlowStep.Verifying -> {
if (!state.verificationFlowStep.state.isLoading()) {
when (step) {
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
is Step.AwaitingOtherDeviceResponse, Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
is Step.Verifying -> {
if (!step.state.isLoading()) {
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
}
}
@ -85,18 +77,17 @@ fun VerifySelfSessionView(
}
val latestOnFinish by rememberUpdatedState(newValue = onFinish)
LaunchedEffect(state.verificationFlowStep, latestOnFinish) {
if (state.verificationFlowStep is FlowStep.Skipped) {
LaunchedEffect(step, latestOnFinish) {
if (step is Step.Skipped) {
latestOnFinish()
}
}
BackHandler {
cancelOrResetFlow()
}
val verificationFlowStep = state.verificationFlowStep
if (state.verificationFlowStep is FlowStep.Loading ||
state.verificationFlowStep is FlowStep.Skipped) {
if (step is Step.Loading ||
step is Step.Skipped) {
// Just display a loader in this case, to avoid UI glitch.
Box(
modifier = Modifier.fillMaxSize(),
@ -111,7 +102,7 @@ fun VerifySelfSessionView(
TopAppBar(
title = {},
actions = {
if (state.verificationFlowStep !is FlowStep.Completed &&
if (step !is Step.Completed &&
state.displaySkipButton &&
LocalInspectionMode.current.not()) {
TextButton(
@ -119,7 +110,7 @@ fun VerifySelfSessionView(
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
)
}
if (state.verificationFlowStep is FlowStep.Initial) {
if (step is Step.Initial) {
TextButton(
text = stringResource(CommonStrings.action_signout),
onClick = { state.eventSink(VerifySelfSessionViewEvents.SignOut) }
@ -129,10 +120,10 @@ fun VerifySelfSessionView(
)
},
header = {
HeaderContent(verificationFlowStep = verificationFlowStep)
VerifySelfSessionHeader(step = step)
},
footer = {
BottomMenu(
VerifySelfSessionBottomMenu(
screenState = state,
onCancelClick = ::cancelOrResetFlow,
onEnterRecoveryKey = onEnterRecoveryKey,
@ -141,8 +132,8 @@ fun VerifySelfSessionView(
)
}
) {
Content(
flowState = verificationFlowStep,
VerifySelfSessionContent(
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 VerifySelfSessionHeader(step: Step) {
val iconStyle = when (step) {
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) {
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) {
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(
@ -207,16 +198,16 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
}
@Composable
private fun Content(
flowState: FlowStep,
private fun VerifySelfSessionContent(
flowState: Step,
onLearnMoreClick: () -> Unit,
) {
when (flowState) {
is VerifySelfSessionState.VerificationStep.Initial -> {
is Step.Initial -> {
ContentInitial(onLearnMoreClick)
}
is FlowStep.Verifying -> {
ContentVerifying(flowState)
is Step.Verifying -> {
VerificationContentVerifying(flowState.data)
}
else -> Unit
}
@ -241,79 +232,22 @@ 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(
private fun VerifySelfSessionBottomMenu(
screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
onResetKey: () -> Unit,
onCancelClick: () -> Unit,
onContinueClick: () -> Unit,
) {
val verificationViewState = screenState.verificationFlowStep
val verificationViewState = screenState.step
val eventSink = screenState.eventSink
val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is AsyncData.Loading<Unit>
val isVerifying = (verificationViewState as? Step.Verifying)?.state is AsyncData.Loading<Unit>
when (verificationViewState) {
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
is FlowStep.Initial -> {
BottomMenu {
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
}
}

View file

@ -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

View file

@ -0,0 +1,33 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.ui
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
internal fun aEmojisSessionVerificationData(
emojiList: List<VerificationEmoji> = aVerificationEmojiList(),
): SessionVerificationData {
return SessionVerificationData.Emojis(emojiList)
}
internal fun aDecimalsSessionVerificationData(
decimals: List<Int> = listOf(123, 456, 789),
): SessionVerificationData {
return SessionVerificationData.Decimals(decimals)
}
private fun aVerificationEmojiList() = listOf(
VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"),
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
VerificationEmoji(number = 42, emoji = "📕", description = "Book"),
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
VerificationEmoji(number = 63, emoji = "📌", description = "Pin"),
)

View file

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

View file

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

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.util
import com.freeletics.flowredux.dsl.InStateBuilderBlock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import timber.log.Timber
import com.freeletics.flowredux.dsl.State as MachineState
internal fun <T : Any> T.andLogStateChange() = also {
Timber.w("Verification: state machine state moved to [${this::class.simpleName}]")
}
@OptIn(ExperimentalCoroutinesApi::class)
inline fun <State : Any, reified Event : Any> InStateBuilderBlock<State, State, Event>.logReceivedEvents() {
on { event: Event, state: MachineState<State> ->
Timber.w("Verification in state [${state.snapshot::class.simpleName}] receiving event [${event::class.simpleName}]")
state.noChange()
}
}

View file

@ -0,0 +1,292 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
import com.google.common.truth.Truth.assertThat
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class IncomingVerificationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - nominal case - incoming verification successful`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val approveVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
approveVerificationLambda = approveVerificationLambda,
resetLambda = resetLambda,
)
createPresenter(
service = fakeSessionVerificationService,
).test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
isWaiting = false,
)
)
resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
skipItems(1)
val initialWaitingState = awaitItem()
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
advanceUntilIdle()
acceptVerificationRequestLambda.assertions().isCalledOnce()
// Remote sent the data
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidReceiveVerificationData(
data = aEmojisSessionVerificationData()
)
)
val emojiState = awaitItem()
assertThat(emojiState.step).isEqualTo(
IncomingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
isWaiting = false
)
)
// User claims that the emoji matches
emojiState.eventSink(IncomingVerificationViewEvents.ConfirmVerification)
val emojiWaitingItem = awaitItem()
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
approveVerificationLambda.assertions().isCalledOnce()
// Remote confirm that the emojis match
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidFinish
)
val finalItem = awaitItem()
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Completed)
}
}
@Test
fun `present - emoji not matching case - incoming verification failure`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
declineVerificationLambda = declineVerificationLambda,
resetLambda = resetLambda,
)
createPresenter(
service = fakeSessionVerificationService,
).test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
isWaiting = false,
)
)
resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
skipItems(1)
val initialWaitingState = awaitItem()
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
advanceUntilIdle()
acceptVerificationRequestLambda.assertions().isCalledOnce()
// Remote sent the data
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidReceiveVerificationData(
data = aEmojisSessionVerificationData()
)
)
val emojiState = awaitItem()
// User claims that the emojis do not match
emojiState.eventSink(IncomingVerificationViewEvents.DeclineVerification)
val emojiWaitingItem = awaitItem()
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
declineVerificationLambda.assertions().isCalledOnce()
// Remote confirm that there is a failure
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidFail
)
val finalItem = awaitItem()
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure)
}
}
@Test
fun `present - incoming verification is remotely canceled`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val onFinishLambda = lambdaRecorder<Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
declineVerificationLambda = declineVerificationLambda,
resetLambda = resetLambda,
)
createPresenter(
service = fakeSessionVerificationService,
navigator = IncomingVerificationNavigator(onFinishLambda),
).test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
isWaiting = false,
)
)
// Remote cancel the verification request
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidCancel)
// The screen is dismissed
skipItems(2)
onFinishLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - user goes back when comparing emoji - incoming verification failure`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
declineVerificationLambda = declineVerificationLambda,
resetLambda = resetLambda,
)
createPresenter(
service = fakeSessionVerificationService,
).test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
isWaiting = false,
)
)
resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
skipItems(1)
val initialWaitingState = awaitItem()
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
advanceUntilIdle()
acceptVerificationRequestLambda.assertions().isCalledOnce()
// Remote sent the data
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidReceiveVerificationData(
data = aEmojisSessionVerificationData()
)
)
val emojiState = awaitItem()
// User goes back
emojiState.eventSink(IncomingVerificationViewEvents.GoBack)
val emojiWaitingItem = awaitItem()
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
declineVerificationLambda.assertions().isCalledOnce()
// Remote confirm that there is a failure
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidFail
)
val finalItem = awaitItem()
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure)
}
}
@Test
fun `present - user ignores incoming request`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
resetLambda = resetLambda,
)
val navigatorLambda = lambdaRecorder<Unit> { }
createPresenter(
service = fakeSessionVerificationService,
navigator = IncomingVerificationNavigator(navigatorLambda),
).test {
val initialState = awaitItem()
initialState.eventSink(IncomingVerificationViewEvents.IgnoreVerification)
skipItems(1)
navigatorLambda.assertions().isCalledOnce()
}
}
private val aSessionVerificationRequestDetails = SessionVerificationRequestDetails(
senderId = A_USER_ID,
flowId = FlowId("flowId"),
deviceId = A_DEVICE_ID,
displayName = "a device name",
firstSeenTimestamp = A_TIMESTAMP,
)
private fun createPresenter(
sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails,
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
service: SessionVerificationService = FakeSessionVerificationService(),
dateFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE),
) = IncomingVerificationPresenter(
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
navigator = navigator,
sessionVerificationService = service,
stateMachine = IncomingVerificationStateMachine(service),
dateFormatter = dateFormatter,
)
}

View file

@ -0,0 +1,217 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class IncomingVerificationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
// region step Initial
@Test
fun `back key pressed - ignore the verification`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = aStepInitial(),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
@Test
fun `ignore incoming verification emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = aStepInitial(),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_ignore)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.IgnoreVerification)
}
@Test
fun `start incoming verification emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = aStepInitial(),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_start)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.StartVerification)
}
@Test
fun `back key pressed - when awaiting response cancels the verification`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = aStepInitial(
isWaiting = true,
),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
// endregion step Initial
// region step Verifying
@Test
fun `back key pressed - when ready to verify cancels the verification`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
isWaiting = false,
),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
@Test
fun `back key pressed - when verifying and loading emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
isWaiting = true,
),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
@Test
fun `clicking on they do not match emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
isWaiting = false,
),
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_session_verification_they_dont_match)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.DeclineVerification)
}
@Test
fun `clicking on they match emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
isWaiting = false,
),
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_session_verification_they_match)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.ConfirmVerification)
}
// endregion
// region step Failure
@Test
fun `back key pressed - when failure resets the flow`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Failure,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
@Test
fun `click on done - when failure resets the flow`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Failure,
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_done)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
// endregion
// region step Completed
@Test
fun `back key pressed - on Completed step emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Completed,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
@Test
fun `when flow is completed and the user clicks on the done button, the expected event is emitted`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Completed,
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_done)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
// endregion
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setIncomingVerificationView(
state: IncomingVerificationState,
) {
setContent {
IncomingVerificationView(
state = state,
)
}
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl
package io.element.android.features.verifysession.impl.outgoing
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
@ -14,12 +14,13 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.features.logout.test.FakeLogoutUseCase
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -43,12 +45,14 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - Initial state is received`() = runTest {
val presenter = createVerifySelfSessionPresenter()
val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().run {
assertThat(verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
assertThat(step).isEqualTo(Step.Initial(false))
assertThat(displaySkipButton).isTrue()
}
}
@ -57,7 +61,10 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - hides skip verification button on non-debuggable builds`() = runTest {
val buildMeta = aBuildMeta(isDebuggable = false)
val presenter = createVerifySelfSessionPresenter(buildMeta = buildMeta)
val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(),
buildMeta = buildMeta,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -67,7 +74,11 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - Initial state is received, can use recovery key`() = runTest {
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(
resetLambda = resetLambda
),
encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE)
}
@ -75,13 +86,15 @@ class VerifySelfSessionPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true))
assertThat(awaitItem().step).isEqualTo(Step.Initial(true))
resetLambda.assertions().isCalledOnce().with(value(true))
}
}
@Test
fun `present - Initial state is received, can use recovery key and is last device`() = runTest {
val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(),
encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true)
emitRecoveryState(RecoveryState.INCOMPLETE)
@ -90,13 +103,16 @@ class VerifySelfSessionPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true))
assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true))
}
}
@Test
fun `present - Handles requestVerification`() = runTest {
val service = unverifiedSessionService()
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -107,32 +123,36 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - Handles startSasVerification`() = runTest {
val service = unverifiedSessionService()
val service = unverifiedSessionService(
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.StartSasVerification)
assertThat(initialState.step).isEqualTo(Step.Initial(false))
initialState.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response:
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
service.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
// ChallengeReceived:
service.triggerReceiveVerificationData(SessionVerificationData.Emojis(emptyList()))
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
val verifyingState = awaitItem()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
assertThat(verifyingState.step).isInstanceOf(Step.Verifying::class.java)
}
}
@Test
fun `present - Cancelation on initial state does nothing`() = runTest {
val presenter = createVerifySelfSessionPresenter()
fun `present - Cancellation on initial state does nothing`() = runTest {
val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
assertThat(initialState.step).isEqualTo(Step.Initial(false))
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.Cancel)
expectNoEvents()
@ -141,92 +161,110 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - A failure when verifying cancels it`() = runTest {
val service = unverifiedSessionService()
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
approveVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
service.shouldFail = true
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
// Cancelling
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java)
service.emitVerificationFlowState(VerificationFlowState.DidFail)
// Cancelled
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
}
}
@Test
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest {
val service = unverifiedSessionService()
val service = unverifiedSessionService(
requestVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
service.shouldFail = true
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
service.shouldFail = false
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
service.emitVerificationFlowState(VerificationFlowState.DidFail)
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
}
}
@Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
val service = unverifiedSessionService()
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
cancelVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.Cancel)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
}
}
@Test
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
val service = unverifiedSessionService()
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(SessionVerificationData.Emojis(emptyList())))
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
ensureAllEventsConsumed()
}
}
@Test
fun `present - Restart after cancelation returns to requesting verification`() = runTest {
val service = unverifiedSessionService()
fun `present - Restart after cancellation returns to requesting verification`() = runTest {
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.Canceled)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Went back to requesting verification
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - Go back after cancelation returns to initial state`() = runTest {
val service = unverifiedSessionService()
fun `present - Go back after cancellation returns to initial state`() = runTest {
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.Canceled)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset)
// Went back to initial state
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
cancelAndIgnoreRemainingEvents()
}
}
@ -236,7 +274,11 @@ class VerifySelfSessionPresenterTest {
val emojis = listOf(
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
)
val service = unverifiedSessionService()
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
approveVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -246,54 +288,65 @@ class VerifySelfSessionPresenterTest {
SessionVerificationData.Emojis(emojis)
)
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
assertThat(awaitItem().verificationFlowStep).isEqualTo(
VerificationStep.Verifying(
assertThat(awaitItem().step).isEqualTo(
Step.Verifying(
SessionVerificationData.Emojis(emojis),
AsyncData.Loading(),
)
)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
service.emitVerificationFlowState(VerificationFlowState.DidFinish)
assertThat(awaitItem().step).isEqualTo(Step.Completed)
}
}
@Test
fun `present - When verification is declined, the flow is canceled`() = runTest {
val service = unverifiedSessionService()
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
declineVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
assertThat(awaitItem().verificationFlowStep).isEqualTo(
VerificationStep.Verifying(
assertThat(awaitItem().step).isEqualTo(
Step.Verifying(
SessionVerificationData.Emojis(emptyList()),
AsyncData.Loading(),
)
)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
}
}
@Test
fun `present - Skip event skips the flow`() = runTest {
val service = unverifiedSessionService()
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
}
}
@Test
fun `present - When verification is done using recovery key, the flow is completed`() = runTest {
val service = FakeSessionVerificationService().apply {
givenNeedsSessionVerification(false)
givenVerifiedStatus(SessionVerifiedStatus.Verified)
givenVerificationFlowState(VerificationFlowState.Finished)
val service = FakeSessionVerificationService(
resetLambda = { },
).apply {
emitNeedsSessionVerification(false)
emitVerifiedStatus(SessionVerifiedStatus.Verified)
emitVerificationFlowState(VerificationFlowState.DidFinish)
}
val presenter = createVerifySelfSessionPresenter(
service = service,
@ -302,16 +355,18 @@ class VerifySelfSessionPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
assertThat(awaitItem().step).isEqualTo(Step.Completed)
}
}
@Test
fun `present - When verification is not needed, the flow is skipped`() = runTest {
val service = FakeSessionVerificationService().apply {
givenNeedsSessionVerification(false)
givenVerifiedStatus(SessionVerifiedStatus.Verified)
givenVerificationFlowState(VerificationFlowState.Finished)
val service = FakeSessionVerificationService(
resetLambda = { },
).apply {
emitNeedsSessionVerification(false)
emitVerifiedStatus(SessionVerifiedStatus.Verified)
emitVerificationFlowState(VerificationFlowState.DidFinish)
}
val presenter = createVerifySelfSessionPresenter(
service = service,
@ -321,16 +376,18 @@ class VerifySelfSessionPresenterTest {
presenter.present()
}.test {
skipItems(1)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
}
}
@Test
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
val service = FakeSessionVerificationService().apply {
givenNeedsSessionVerification(false)
givenVerifiedStatus(SessionVerifiedStatus.Verified)
givenVerificationFlowState(VerificationFlowState.Finished)
val service = FakeSessionVerificationService(
resetLambda = { },
).apply {
emitNeedsSessionVerification(false)
emitVerifiedStatus(SessionVerifiedStatus.Verified)
emitVerificationFlowState(VerificationFlowState.DidFinish)
}
val signOutLambda = lambdaRecorder<Boolean, String?> { "aUrl" }
val presenter = createVerifySelfSessionPresenter(
@ -356,33 +413,53 @@ class VerifySelfSessionPresenterTest {
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
): VerifySelfSessionState {
var state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
assertThat(state.step).isEqualTo(Step.Initial(false))
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response:
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
// Await for the state to be Ready
state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Ready)
assertThat(state.step).isEqualTo(Step.Ready)
state.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response (again):
fakeService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
fakeService.triggerReceiveVerificationData(sessionVerificationData)
assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
// Finally, ChallengeReceived:
fakeService.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(sessionVerificationData))
state = awaitItem()
assertThat(state.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
assertThat(state.step).isInstanceOf(Step.Verifying::class.java)
return state
}
private fun unverifiedSessionService(): FakeSessionVerificationService {
return FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
private suspend fun unverifiedSessionService(
requestVerificationLambda: () -> Unit = { lambdaError() },
cancelVerificationLambda: () -> Unit = { lambdaError() },
approveVerificationLambda: () -> Unit = { lambdaError() },
declineVerificationLambda: () -> Unit = { lambdaError() },
startVerificationLambda: () -> Unit = { lambdaError() },
resetLambda: (Boolean) -> Unit = { },
acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
): FakeSessionVerificationService {
return FakeSessionVerificationService(
requestVerificationLambda = requestVerificationLambda,
cancelVerificationLambda = cancelVerificationLambda,
approveVerificationLambda = approveVerificationLambda,
declineVerificationLambda = declineVerificationLambda,
startVerificationLambda = startVerificationLambda,
resetLambda = resetLambda,
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
).apply {
emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
}
}
private fun createVerifySelfSessionPresenter(
service: SessionVerificationService = unverifiedSessionService(),
service: SessionVerificationService,
encryptionService: EncryptionService = FakeEncryptionService(),
buildMeta: BuildMeta = aBuildMeta(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),

View file

@ -5,12 +5,14 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl
package io.element.android.features.verifysession.impl.outgoing
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
@ -36,7 +38,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
step = VerifySelfSessionState.Step.Canceled,
eventSink = eventsRecorder
),
)
@ -49,7 +51,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
step = VerifySelfSessionState.Step.AwaitingOtherDeviceResponse,
eventSink = eventsRecorder
),
)
@ -62,7 +64,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
step = VerifySelfSessionState.Step.Ready,
eventSink = eventsRecorder
),
)
@ -75,7 +77,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
@ -91,7 +93,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Loading(),
),
@ -107,7 +109,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
step = VerifySelfSessionState.Step.Completed,
eventSink = eventsRecorder
),
)
@ -121,7 +123,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
step = VerifySelfSessionState.Step.Completed,
eventSink = eventsRecorder
),
onFinished = callback,
@ -137,7 +139,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
step = VerifySelfSessionState.Step.Initial(true),
eventSink = eventsRecorder
),
onEnterRecoveryKey = callback,
@ -153,7 +155,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
step = VerifySelfSessionState.Step.Initial(true),
eventSink = eventsRecorder
),
onLearnMoreClick = callback,
@ -167,7 +169,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
@ -183,7 +185,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
@ -199,7 +201,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = true),
step = VerifySelfSessionState.Step.Initial(canEnterRecoveryKey = true),
displaySkipButton = true,
eventSink = eventsRecorder
),
@ -213,7 +215,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Skipped,
step = VerifySelfSessionState.Step.Skipped,
displaySkipButton = true,
eventSink = EnsureNeverCalledWithParam(),
),