Browse Source
Add more log to the state machines Ensure the block cannot be cancelled, else if the Rust SDK emit a new state during the API execution, the state machine may cancel the api call. Let VerificationFlowState values match the SDK api for code clarity. Rename sub interface for clarity. Migrate tests to the new FakeVerificationService.pull/3733/head
Benoit Marty
20 hours ago
committed by
Benoit Marty
40 changed files with 2200 additions and 458 deletions
@ -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() |
||||||
|
} |
||||||
|
} |
@ -1,95 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2023, 2024 New Vector Ltd. |
|
||||||
* |
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only |
|
||||||
* Please see LICENSE in the repository root for full details. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.verifysession.impl |
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
|
||||||
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep |
|
||||||
import io.element.android.libraries.architecture.AsyncAction |
|
||||||
import io.element.android.libraries.architecture.AsyncData |
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData |
|
||||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji |
|
||||||
|
|
||||||
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> { |
|
||||||
override val values: Sequence<VerifySelfSessionState> |
|
||||||
get() = sequenceOf( |
|
||||||
aVerifySelfSessionState(displaySkipButton = true), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.AwaitingOtherDeviceResponse |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized) |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading()) |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.Canceled |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.Ready |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized) |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true) |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true) |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.Completed, |
|
||||||
displaySkipButton = true, |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
signOutAction = AsyncAction.Loading, |
|
||||||
displaySkipButton = true, |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.Loading |
|
||||||
), |
|
||||||
aVerifySelfSessionState( |
|
||||||
verificationFlowStep = VerificationStep.Skipped |
|
||||||
), |
|
||||||
// Add other state here |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
internal fun aEmojisSessionVerificationData( |
|
||||||
emojiList: List<VerificationEmoji> = aVerificationEmojiList(), |
|
||||||
): SessionVerificationData { |
|
||||||
return SessionVerificationData.Emojis(emojiList) |
|
||||||
} |
|
||||||
|
|
||||||
private fun aDecimalsSessionVerificationData( |
|
||||||
decimals: List<Int> = listOf(123, 456, 789), |
|
||||||
): SessionVerificationData { |
|
||||||
return SessionVerificationData.Decimals(decimals) |
|
||||||
} |
|
||||||
|
|
||||||
internal fun aVerifySelfSessionState( |
|
||||||
verificationFlowStep: VerificationStep = VerificationStep.Initial(canEnterRecoveryKey = false), |
|
||||||
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized, |
|
||||||
displaySkipButton: Boolean = false, |
|
||||||
eventSink: (VerifySelfSessionViewEvents) -> Unit = {}, |
|
||||||
) = VerifySelfSessionState( |
|
||||||
verificationFlowStep = verificationFlowStep, |
|
||||||
displaySkipButton = displaySkipButton, |
|
||||||
eventSink = eventSink, |
|
||||||
signOutAction = signOutAction, |
|
||||||
) |
|
||||||
|
|
||||||
private fun aVerificationEmojiList() = listOf( |
|
||||||
VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"), |
|
||||||
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"), |
|
||||||
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"), |
|
||||||
VerificationEmoji(number = 42, emoji = "📕", description = "Book"), |
|
||||||
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"), |
|
||||||
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"), |
|
||||||
VerificationEmoji(number = 63, emoji = "📌", description = "Pin"), |
|
||||||
) |
|
@ -0,0 +1,40 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext |
||||||
|
import com.bumble.appyx.core.node.Node |
||||||
|
import com.bumble.appyx.core.plugin.Plugin |
||||||
|
import com.squareup.anvil.annotations.ContributesBinding |
||||||
|
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint |
||||||
|
import io.element.android.libraries.architecture.createNode |
||||||
|
import io.element.android.libraries.di.AppScope |
||||||
|
import javax.inject.Inject |
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class) |
||||||
|
class DefaultIncomingVerificationEntryPoint @Inject constructor() : IncomingVerificationEntryPoint { |
||||||
|
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): IncomingVerificationEntryPoint.NodeBuilder { |
||||||
|
val plugins = ArrayList<Plugin>() |
||||||
|
|
||||||
|
return object : IncomingVerificationEntryPoint.NodeBuilder { |
||||||
|
override fun callback(callback: IncomingVerificationEntryPoint.Callback): IncomingVerificationEntryPoint.NodeBuilder { |
||||||
|
plugins += callback |
||||||
|
return this |
||||||
|
} |
||||||
|
|
||||||
|
override fun params(params: IncomingVerificationEntryPoint.Params): IncomingVerificationEntryPoint.NodeBuilder { |
||||||
|
plugins += params |
||||||
|
return this |
||||||
|
} |
||||||
|
|
||||||
|
override fun build(): Node { |
||||||
|
return parentNode.createNode<IncomingVerificationNode>(buildContext, plugins) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,12 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
fun interface IncomingVerificationNavigator { |
||||||
|
fun onFinish() |
||||||
|
} |
@ -0,0 +1,47 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import com.bumble.appyx.core.modality.BuildContext |
||||||
|
import com.bumble.appyx.core.node.Node |
||||||
|
import com.bumble.appyx.core.plugin.Plugin |
||||||
|
import com.bumble.appyx.core.plugin.plugins |
||||||
|
import dagger.assisted.Assisted |
||||||
|
import dagger.assisted.AssistedInject |
||||||
|
import io.element.android.anvilannotations.ContributesNode |
||||||
|
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint |
||||||
|
import io.element.android.libraries.architecture.inputs |
||||||
|
import io.element.android.libraries.di.SessionScope |
||||||
|
|
||||||
|
@ContributesNode(SessionScope::class) |
||||||
|
class IncomingVerificationNode @AssistedInject constructor( |
||||||
|
@Assisted buildContext: BuildContext, |
||||||
|
@Assisted plugins: List<Plugin>, |
||||||
|
presenterFactory: IncomingVerificationPresenter.Factory, |
||||||
|
) : Node(buildContext, plugins = plugins), |
||||||
|
IncomingVerificationNavigator { |
||||||
|
private val presenter = presenterFactory.create( |
||||||
|
sessionVerificationRequestDetails = inputs<IncomingVerificationEntryPoint.Params>().sessionVerificationRequestDetails, |
||||||
|
navigator = this, |
||||||
|
) |
||||||
|
|
||||||
|
override fun onFinish() { |
||||||
|
plugins<IncomingVerificationEntryPoint.Callback>().forEach { it.onDone() } |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
override fun View(modifier: Modifier) { |
||||||
|
val state = presenter.present() |
||||||
|
IncomingVerificationView( |
||||||
|
state = state, |
||||||
|
modifier = modifier, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,189 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class) |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.runtime.LaunchedEffect |
||||||
|
import androidx.compose.runtime.derivedStateOf |
||||||
|
import androidx.compose.runtime.getValue |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import com.freeletics.flowredux.compose.rememberStateAndDispatch |
||||||
|
import dagger.assisted.Assisted |
||||||
|
import dagger.assisted.AssistedFactory |
||||||
|
import dagger.assisted.AssistedInject |
||||||
|
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step |
||||||
|
import io.element.android.libraries.architecture.Presenter |
||||||
|
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter |
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails |
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService |
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationFlowState |
||||||
|
import kotlinx.coroutines.CoroutineScope |
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi |
||||||
|
import kotlinx.coroutines.flow.launchIn |
||||||
|
import kotlinx.coroutines.flow.onEach |
||||||
|
import timber.log.Timber |
||||||
|
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.Event as StateMachineEvent |
||||||
|
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState |
||||||
|
|
||||||
|
class IncomingVerificationPresenter @AssistedInject constructor( |
||||||
|
@Assisted private val sessionVerificationRequestDetails: SessionVerificationRequestDetails, |
||||||
|
@Assisted private val navigator: IncomingVerificationNavigator, |
||||||
|
private val sessionVerificationService: SessionVerificationService, |
||||||
|
private val stateMachine: IncomingVerificationStateMachine, |
||||||
|
private val dateFormatter: LastMessageTimestampFormatter, |
||||||
|
) : Presenter<IncomingVerificationState> { |
||||||
|
@AssistedFactory |
||||||
|
interface Factory { |
||||||
|
fun create( |
||||||
|
sessionVerificationRequestDetails: SessionVerificationRequestDetails, |
||||||
|
navigator: IncomingVerificationNavigator, |
||||||
|
): IncomingVerificationPresenter |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
override fun present(): IncomingVerificationState { |
||||||
|
LaunchedEffect(Unit) { |
||||||
|
// Force reset, just in case the service was left in a broken state |
||||||
|
sessionVerificationService.reset( |
||||||
|
cancelAnyPendingVerificationAttempt = false |
||||||
|
) |
||||||
|
// Acknowledge the request right now |
||||||
|
sessionVerificationService.acknowledgeVerificationRequest(sessionVerificationRequestDetails) |
||||||
|
} |
||||||
|
val stateAndDispatch = stateMachine.rememberStateAndDispatch() |
||||||
|
val formattedSignInTime = remember { |
||||||
|
dateFormatter.format(sessionVerificationRequestDetails.firstSeenTimestamp) |
||||||
|
} |
||||||
|
val step by remember { |
||||||
|
derivedStateOf { |
||||||
|
stateAndDispatch.state.value.toVerificationStep( |
||||||
|
sessionVerificationRequestDetails = sessionVerificationRequestDetails, |
||||||
|
formattedSignInTime = formattedSignInTime, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
LaunchedEffect(stateAndDispatch.state.value) { |
||||||
|
if ((stateAndDispatch.state.value as? IncomingVerificationStateMachine.State.Initial)?.isCancelled == true) { |
||||||
|
// The verification was canceled before it was started, maybe because another session accepted it |
||||||
|
navigator.onFinish() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Start this after observing state machine |
||||||
|
LaunchedEffect(Unit) { |
||||||
|
observeVerificationService() |
||||||
|
} |
||||||
|
|
||||||
|
fun handleEvents(event: IncomingVerificationViewEvents) { |
||||||
|
Timber.d("Verification user action: ${event::class.simpleName}") |
||||||
|
when (event) { |
||||||
|
IncomingVerificationViewEvents.StartVerification -> |
||||||
|
stateAndDispatch.dispatchAction(StateMachineEvent.AcceptIncomingRequest) |
||||||
|
IncomingVerificationViewEvents.IgnoreVerification -> |
||||||
|
navigator.onFinish() |
||||||
|
IncomingVerificationViewEvents.ConfirmVerification -> |
||||||
|
stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge) |
||||||
|
IncomingVerificationViewEvents.DeclineVerification -> |
||||||
|
stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge) |
||||||
|
IncomingVerificationViewEvents.GoBack -> { |
||||||
|
when (val verificationStep = step) { |
||||||
|
is Step.Initial -> if (verificationStep.isWaiting) { |
||||||
|
stateAndDispatch.dispatchAction(StateMachineEvent.Cancel) |
||||||
|
} else { |
||||||
|
navigator.onFinish() |
||||||
|
} |
||||||
|
is Step.Verifying -> if (verificationStep.isWaiting) { |
||||||
|
// What do we do in this case? |
||||||
|
} else { |
||||||
|
stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge) |
||||||
|
} |
||||||
|
Step.Canceled, |
||||||
|
Step.Completed, |
||||||
|
Step.Failure -> navigator.onFinish() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return IncomingVerificationState( |
||||||
|
step = step, |
||||||
|
eventSink = ::handleEvents, |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
private fun StateMachineState?.toVerificationStep( |
||||||
|
sessionVerificationRequestDetails: SessionVerificationRequestDetails, |
||||||
|
formattedSignInTime: String, |
||||||
|
): Step = |
||||||
|
when (val machineState = this) { |
||||||
|
is StateMachineState.Initial, |
||||||
|
IncomingVerificationStateMachine.State.AcceptingIncomingVerification, |
||||||
|
IncomingVerificationStateMachine.State.RejectingIncomingVerification, |
||||||
|
null -> { |
||||||
|
Step.Initial( |
||||||
|
deviceDisplayName = sessionVerificationRequestDetails.displayName ?: sessionVerificationRequestDetails.deviceId.value, |
||||||
|
deviceId = sessionVerificationRequestDetails.deviceId, |
||||||
|
formattedSignInTime = formattedSignInTime, |
||||||
|
isWaiting = machineState == IncomingVerificationStateMachine.State.AcceptingIncomingVerification || |
||||||
|
machineState == IncomingVerificationStateMachine.State.RejectingIncomingVerification, |
||||||
|
) |
||||||
|
} |
||||||
|
is IncomingVerificationStateMachine.State.ChallengeReceived -> |
||||||
|
Step.Verifying( |
||||||
|
data = machineState.data, |
||||||
|
isWaiting = false, |
||||||
|
) |
||||||
|
IncomingVerificationStateMachine.State.Completed -> Step.Completed |
||||||
|
IncomingVerificationStateMachine.State.Canceling, |
||||||
|
IncomingVerificationStateMachine.State.Failure -> Step.Failure |
||||||
|
is IncomingVerificationStateMachine.State.AcceptingChallenge -> |
||||||
|
Step.Verifying( |
||||||
|
data = machineState.data, |
||||||
|
isWaiting = true, |
||||||
|
) |
||||||
|
is IncomingVerificationStateMachine.State.RejectingChallenge -> |
||||||
|
Step.Verifying( |
||||||
|
data = machineState.data, |
||||||
|
isWaiting = true, |
||||||
|
) |
||||||
|
IncomingVerificationStateMachine.State.Canceled -> Step.Canceled |
||||||
|
} |
||||||
|
|
||||||
|
private fun CoroutineScope.observeVerificationService() { |
||||||
|
sessionVerificationService.verificationFlowState |
||||||
|
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") } |
||||||
|
.onEach { verificationAttemptState -> |
||||||
|
when (verificationAttemptState) { |
||||||
|
VerificationFlowState.Initial, |
||||||
|
VerificationFlowState.DidAcceptVerificationRequest, |
||||||
|
VerificationFlowState.DidStartSasVerification -> Unit |
||||||
|
is VerificationFlowState.DidReceiveVerificationData -> { |
||||||
|
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data)) |
||||||
|
} |
||||||
|
VerificationFlowState.DidFinish -> { |
||||||
|
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidAcceptChallenge) |
||||||
|
} |
||||||
|
VerificationFlowState.DidCancel -> { |
||||||
|
// Can happen when: |
||||||
|
// - the remote party cancel the verification (before it is started) |
||||||
|
// - another session has accepted the incoming verification request |
||||||
|
// - the user reject the challenge from this application (I think this is an error). In this case, the state |
||||||
|
// machine will ignore this event and change state to Failure. |
||||||
|
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidCancel) |
||||||
|
} |
||||||
|
VerificationFlowState.DidFail -> { |
||||||
|
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidFail) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
.launchIn(this) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,38 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable |
||||||
|
import androidx.compose.runtime.Stable |
||||||
|
import io.element.android.libraries.matrix.api.core.DeviceId |
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationData |
||||||
|
|
||||||
|
@Immutable |
||||||
|
data class IncomingVerificationState( |
||||||
|
val step: Step, |
||||||
|
val eventSink: (IncomingVerificationViewEvents) -> Unit, |
||||||
|
) { |
||||||
|
@Stable |
||||||
|
sealed interface Step { |
||||||
|
data class Initial( |
||||||
|
val deviceDisplayName: String, |
||||||
|
val deviceId: DeviceId, |
||||||
|
val formattedSignInTime: String, |
||||||
|
val isWaiting: Boolean, |
||||||
|
) : Step |
||||||
|
|
||||||
|
data class Verifying( |
||||||
|
val data: SessionVerificationData, |
||||||
|
val isWaiting: Boolean, |
||||||
|
) : Step |
||||||
|
|
||||||
|
data object Canceled : Step |
||||||
|
data object Completed : Step |
||||||
|
data object Failure : Step |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,158 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class) |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
import com.freeletics.flowredux.dsl.FlowReduxStateMachine |
||||||
|
import io.element.android.features.verifysession.impl.util.andLogStateChange |
||||||
|
import io.element.android.features.verifysession.impl.util.logReceivedEvents |
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationData |
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService |
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi |
||||||
|
import javax.inject.Inject |
||||||
|
import com.freeletics.flowredux.dsl.State as MachineState |
||||||
|
|
||||||
|
class IncomingVerificationStateMachine @Inject constructor( |
||||||
|
private val sessionVerificationService: SessionVerificationService, |
||||||
|
) : FlowReduxStateMachine<IncomingVerificationStateMachine.State, IncomingVerificationStateMachine.Event>( |
||||||
|
initialState = State.Initial(isCancelled = false) |
||||||
|
) { |
||||||
|
init { |
||||||
|
spec { |
||||||
|
inState<State.Initial> { |
||||||
|
on { _: Event.AcceptIncomingRequest, state -> |
||||||
|
state.override { State.AcceptingIncomingVerification.andLogStateChange() } |
||||||
|
} |
||||||
|
} |
||||||
|
inState<State.AcceptingIncomingVerification> { |
||||||
|
onEnterEffect { |
||||||
|
sessionVerificationService.acceptVerificationRequest() |
||||||
|
} |
||||||
|
on { event: Event.DidReceiveChallenge, state -> |
||||||
|
state.override { State.ChallengeReceived(event.data).andLogStateChange() } |
||||||
|
} |
||||||
|
} |
||||||
|
inState<State.ChallengeReceived> { |
||||||
|
on { _: Event.AcceptChallenge, state -> |
||||||
|
state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() } |
||||||
|
} |
||||||
|
on { _: Event.DeclineChallenge, state -> |
||||||
|
state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() } |
||||||
|
} |
||||||
|
} |
||||||
|
inState<State.AcceptingChallenge> { |
||||||
|
onEnterEffect { _ -> |
||||||
|
sessionVerificationService.approveVerification() |
||||||
|
} |
||||||
|
on { _: Event.DidAcceptChallenge, state -> |
||||||
|
state.override { State.Completed.andLogStateChange() } |
||||||
|
} |
||||||
|
} |
||||||
|
inState<State.RejectingChallenge> { |
||||||
|
onEnterEffect { _ -> |
||||||
|
sessionVerificationService.declineVerification() |
||||||
|
} |
||||||
|
} |
||||||
|
inState<State.Canceling> { |
||||||
|
onEnterEffect { |
||||||
|
sessionVerificationService.cancelVerification() |
||||||
|
} |
||||||
|
} |
||||||
|
inState { |
||||||
|
logReceivedEvents() |
||||||
|
on { _: Event.Cancel, state: MachineState<State> -> |
||||||
|
when (state.snapshot) { |
||||||
|
State.Completed, State.Canceled -> state.noChange() |
||||||
|
else -> { |
||||||
|
sessionVerificationService.cancelVerification() |
||||||
|
state.override { State.Canceled.andLogStateChange() } |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
on { _: Event.DidCancel, state: MachineState<State> -> |
||||||
|
when (state.snapshot) { |
||||||
|
is State.RejectingChallenge -> { |
||||||
|
state.override { State.Failure.andLogStateChange() } |
||||||
|
} |
||||||
|
is State.Initial -> state.mutate { State.Initial(isCancelled = true).andLogStateChange() } |
||||||
|
State.AcceptingIncomingVerification, |
||||||
|
State.RejectingIncomingVerification, |
||||||
|
is State.ChallengeReceived, |
||||||
|
is State.AcceptingChallenge, |
||||||
|
State.Canceling -> state.override { State.Canceled.andLogStateChange() } |
||||||
|
State.Canceled, |
||||||
|
State.Completed, |
||||||
|
State.Failure -> state.noChange() |
||||||
|
} |
||||||
|
} |
||||||
|
on { _: Event.DidFail, state: MachineState<State> -> |
||||||
|
state.override { State.Failure.andLogStateChange() } |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
sealed interface State { |
||||||
|
/** The initial state, before verification started. */ |
||||||
|
data class Initial(val isCancelled: Boolean) : State |
||||||
|
|
||||||
|
/** User is accepting the incoming verification. */ |
||||||
|
data object AcceptingIncomingVerification : State |
||||||
|
|
||||||
|
/** User is rejecting the incoming verification. */ |
||||||
|
data object RejectingIncomingVerification : State |
||||||
|
|
||||||
|
/** Verification accepted and emojis received. */ |
||||||
|
data class ChallengeReceived(val data: SessionVerificationData) : State |
||||||
|
|
||||||
|
/** Accepting the verification challenge. */ |
||||||
|
data class AcceptingChallenge(val data: SessionVerificationData) : State |
||||||
|
|
||||||
|
/** Rejecting the verification challenge. */ |
||||||
|
data class RejectingChallenge(val data: SessionVerificationData) : State |
||||||
|
|
||||||
|
/** The verification is being canceled. */ |
||||||
|
data object Canceling : State |
||||||
|
|
||||||
|
/** The verification has been canceled, remotely or locally. */ |
||||||
|
data object Canceled : State |
||||||
|
|
||||||
|
/** Verification successful. */ |
||||||
|
data object Completed : State |
||||||
|
|
||||||
|
/** Verification failure. */ |
||||||
|
data object Failure : State |
||||||
|
} |
||||||
|
|
||||||
|
sealed interface Event { |
||||||
|
/** User accepts the incoming request. */ |
||||||
|
data object AcceptIncomingRequest : Event |
||||||
|
|
||||||
|
/** Has received data. */ |
||||||
|
data class DidReceiveChallenge(val data: SessionVerificationData) : Event |
||||||
|
|
||||||
|
/** Emojis match. */ |
||||||
|
data object AcceptChallenge : Event |
||||||
|
|
||||||
|
/** Emojis do not match. */ |
||||||
|
data object DeclineChallenge : Event |
||||||
|
|
||||||
|
/** Remote accepted challenge. */ |
||||||
|
data object DidAcceptChallenge : Event |
||||||
|
|
||||||
|
/** Request cancellation. */ |
||||||
|
data object Cancel : Event |
||||||
|
|
||||||
|
/** Verification cancelled. */ |
||||||
|
data object DidCancel : Event |
||||||
|
|
||||||
|
/** Request failed. */ |
||||||
|
data object DidFail : Event |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,46 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||||
|
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step |
||||||
|
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData |
||||||
|
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData |
||||||
|
import io.element.android.libraries.matrix.api.core.DeviceId |
||||||
|
|
||||||
|
open class IncomingVerificationStateProvider : PreviewParameterProvider<IncomingVerificationState> { |
||||||
|
override val values: Sequence<IncomingVerificationState> |
||||||
|
get() = sequenceOf( |
||||||
|
anIncomingVerificationState(), |
||||||
|
anIncomingVerificationState(step = aStepInitial(isWaiting = true)), |
||||||
|
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)), |
||||||
|
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)), |
||||||
|
anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)), |
||||||
|
anIncomingVerificationState(step = Step.Completed), |
||||||
|
anIncomingVerificationState(step = Step.Failure), |
||||||
|
anIncomingVerificationState(step = Step.Canceled), |
||||||
|
// Add other state here |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
internal fun aStepInitial( |
||||||
|
isWaiting: Boolean = false, |
||||||
|
) = Step.Initial( |
||||||
|
deviceDisplayName = "Element X Android", |
||||||
|
deviceId = DeviceId("ILAKNDNASDLK"), |
||||||
|
formattedSignInTime = "12:34", |
||||||
|
isWaiting = isWaiting, |
||||||
|
) |
||||||
|
|
||||||
|
internal fun anIncomingVerificationState( |
||||||
|
step: Step = aStepInitial(), |
||||||
|
eventSink: (IncomingVerificationViewEvents) -> Unit = {}, |
||||||
|
) = IncomingVerificationState( |
||||||
|
step = step, |
||||||
|
eventSink = eventSink, |
||||||
|
) |
@ -0,0 +1,235 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler |
||||||
|
import androidx.compose.foundation.layout.Arrangement |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.Spacer |
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth |
||||||
|
import androidx.compose.foundation.layout.height |
||||||
|
import androidx.compose.foundation.layout.padding |
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.res.stringResource |
||||||
|
import androidx.compose.ui.text.style.TextAlign |
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import io.element.android.compound.theme.ElementTheme |
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons |
||||||
|
import io.element.android.features.verifysession.impl.R |
||||||
|
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step |
||||||
|
import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView |
||||||
|
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu |
||||||
|
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying |
||||||
|
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage |
||||||
|
import io.element.android.libraries.designsystem.components.BigIcon |
||||||
|
import io.element.android.libraries.designsystem.components.PageTitle |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Button |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text |
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton |
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar |
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationData |
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings |
||||||
|
|
||||||
|
/** |
||||||
|
* [Figma](https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=819-7324). |
||||||
|
*/ |
||||||
|
@OptIn(ExperimentalMaterial3Api::class) |
||||||
|
@Composable |
||||||
|
fun IncomingVerificationView( |
||||||
|
state: IncomingVerificationState, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
val step = state.step |
||||||
|
|
||||||
|
BackHandler { |
||||||
|
state.eventSink(IncomingVerificationViewEvents.GoBack) |
||||||
|
} |
||||||
|
HeaderFooterPage( |
||||||
|
modifier = modifier, |
||||||
|
topBar = { |
||||||
|
TopAppBar( |
||||||
|
title = {}, |
||||||
|
) |
||||||
|
}, |
||||||
|
header = { |
||||||
|
HeaderContent(step = step) |
||||||
|
}, |
||||||
|
footer = { |
||||||
|
IncomingVerificationBottomMenu( |
||||||
|
state = state, |
||||||
|
) |
||||||
|
} |
||||||
|
) { |
||||||
|
Content( |
||||||
|
step = step, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun HeaderContent(step: Step) { |
||||||
|
val iconStyle = when (step) { |
||||||
|
Step.Canceled, |
||||||
|
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid()) |
||||||
|
is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction()) |
||||||
|
Step.Completed -> BigIcon.Style.SuccessSolid |
||||||
|
Step.Failure -> BigIcon.Style.AlertSolid |
||||||
|
} |
||||||
|
val titleTextId = when (step) { |
||||||
|
Step.Canceled -> CommonStrings.common_verification_cancelled |
||||||
|
is Step.Initial -> R.string.screen_session_verification_request_title |
||||||
|
is Step.Verifying -> when (step.data) { |
||||||
|
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title |
||||||
|
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title |
||||||
|
} |
||||||
|
Step.Completed -> R.string.screen_session_verification_request_success_title |
||||||
|
Step.Failure -> R.string.screen_session_verification_request_failure_title |
||||||
|
} |
||||||
|
val subtitleTextId = when (step) { |
||||||
|
Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle |
||||||
|
is Step.Initial -> R.string.screen_session_verification_request_subtitle |
||||||
|
is Step.Verifying -> when (step.data) { |
||||||
|
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle |
||||||
|
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle |
||||||
|
} |
||||||
|
Step.Completed -> R.string.screen_session_verification_request_success_subtitle |
||||||
|
Step.Failure -> R.string.screen_session_verification_request_failure_subtitle |
||||||
|
} |
||||||
|
PageTitle( |
||||||
|
iconStyle = iconStyle, |
||||||
|
title = stringResource(id = titleTextId), |
||||||
|
subtitle = stringResource(id = subtitleTextId) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun Content( |
||||||
|
step: Step, |
||||||
|
) { |
||||||
|
when (step) { |
||||||
|
is Step.Initial -> ContentInitial(step) |
||||||
|
is Step.Verifying -> VerificationContentVerifying(step.data) |
||||||
|
else -> Unit |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun ContentInitial( |
||||||
|
initialIncoming: Step.Initial, |
||||||
|
) { |
||||||
|
Column( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp), |
||||||
|
) { |
||||||
|
SessionDetailsView( |
||||||
|
deviceName = initialIncoming.deviceDisplayName, |
||||||
|
deviceId = initialIncoming.deviceId, |
||||||
|
signInFormattedTimestamp = initialIncoming.formattedSignInTime, |
||||||
|
) |
||||||
|
Text( |
||||||
|
modifier = Modifier |
||||||
|
.align(Alignment.CenterHorizontally) |
||||||
|
.padding(bottom = 16.dp), |
||||||
|
text = stringResource(R.string.screen_session_verification_request_footer), |
||||||
|
style = ElementTheme.typography.fontBodyMdMedium, |
||||||
|
textAlign = TextAlign.Center, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun IncomingVerificationBottomMenu( |
||||||
|
state: IncomingVerificationState, |
||||||
|
) { |
||||||
|
val step = state.step |
||||||
|
val eventSink = state.eventSink |
||||||
|
|
||||||
|
when (step) { |
||||||
|
is Step.Initial -> { |
||||||
|
if (step.isWaiting) { |
||||||
|
VerificationBottomMenu { |
||||||
|
Button( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
text = stringResource(R.string.screen_identity_waiting_on_other_device), |
||||||
|
onClick = {}, |
||||||
|
enabled = false, |
||||||
|
showProgress = true, |
||||||
|
) |
||||||
|
// Placeholder so the 1st button keeps its vertical position |
||||||
|
Spacer(modifier = Modifier.height(40.dp)) |
||||||
|
} |
||||||
|
} else { |
||||||
|
VerificationBottomMenu { |
||||||
|
Button( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
text = stringResource(CommonStrings.action_start), |
||||||
|
onClick = { eventSink(IncomingVerificationViewEvents.StartVerification) }, |
||||||
|
) |
||||||
|
TextButton( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
text = stringResource(CommonStrings.action_ignore), |
||||||
|
onClick = { eventSink(IncomingVerificationViewEvents.IgnoreVerification) }, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
is Step.Verifying -> { |
||||||
|
if (step.isWaiting) { |
||||||
|
VerificationBottomMenu { |
||||||
|
Button( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
text = stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing), |
||||||
|
onClick = {}, |
||||||
|
enabled = false, |
||||||
|
showProgress = true, |
||||||
|
) |
||||||
|
// Placeholder so the 1st button keeps its vertical position |
||||||
|
Spacer(modifier = Modifier.height(40.dp)) |
||||||
|
} |
||||||
|
} else { |
||||||
|
VerificationBottomMenu { |
||||||
|
Button( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
text = stringResource(R.string.screen_session_verification_they_match), |
||||||
|
onClick = { eventSink(IncomingVerificationViewEvents.ConfirmVerification) }, |
||||||
|
) |
||||||
|
TextButton( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
text = stringResource(R.string.screen_session_verification_they_dont_match), |
||||||
|
onClick = { eventSink(IncomingVerificationViewEvents.DeclineVerification) }, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
Step.Canceled, |
||||||
|
is Step.Completed, |
||||||
|
is Step.Failure -> { |
||||||
|
VerificationBottomMenu { |
||||||
|
Button( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
text = stringResource(CommonStrings.action_done), |
||||||
|
onClick = { eventSink(IncomingVerificationViewEvents.GoBack) }, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@PreviewsDayNight |
||||||
|
@Composable |
||||||
|
internal fun IncomingVerificationViewPreview(@PreviewParameter(IncomingVerificationStateProvider::class) state: IncomingVerificationState) = ElementPreview { |
||||||
|
IncomingVerificationView( |
||||||
|
state = state, |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,16 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
sealed interface IncomingVerificationViewEvents { |
||||||
|
data object GoBack : IncomingVerificationViewEvents |
||||||
|
data object StartVerification : IncomingVerificationViewEvents |
||||||
|
data object IgnoreVerification : IncomingVerificationViewEvents |
||||||
|
data object ConfirmVerification : IncomingVerificationViewEvents |
||||||
|
data object DeclineVerification : IncomingVerificationViewEvents |
||||||
|
} |
@ -0,0 +1,111 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming.ui |
||||||
|
|
||||||
|
import androidx.compose.foundation.border |
||||||
|
import androidx.compose.foundation.layout.Arrangement |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.Row |
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth |
||||||
|
import androidx.compose.foundation.layout.padding |
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.res.stringResource |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import io.element.android.compound.theme.ElementTheme |
||||||
|
import io.element.android.features.verifysession.impl.R |
||||||
|
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom |
||||||
|
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize |
||||||
|
import io.element.android.libraries.designsystem.icons.CompoundDrawables |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text |
||||||
|
import io.element.android.libraries.matrix.api.core.DeviceId |
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun SessionDetailsView( |
||||||
|
deviceName: String, |
||||||
|
deviceId: DeviceId, |
||||||
|
signInFormattedTimestamp: String, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
Column( |
||||||
|
modifier = modifier |
||||||
|
.fillMaxWidth() |
||||||
|
.border( |
||||||
|
width = 1.dp, |
||||||
|
color = ElementTheme.colors.borderDisabled, |
||||||
|
shape = RoundedCornerShape(8.dp) |
||||||
|
) |
||||||
|
.padding(24.dp), |
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp), |
||||||
|
) { |
||||||
|
Row( |
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp), |
||||||
|
verticalAlignment = Alignment.CenterVertically, |
||||||
|
) { |
||||||
|
RoundedIconAtom( |
||||||
|
modifier = Modifier, |
||||||
|
size = RoundedIconAtomSize.Big, |
||||||
|
resourceId = CompoundDrawables.ic_compound_devices |
||||||
|
) |
||||||
|
Text( |
||||||
|
text = deviceName, |
||||||
|
style = ElementTheme.typography.fontBodyMdMedium, |
||||||
|
color = ElementTheme.colors.textPrimary, |
||||||
|
) |
||||||
|
} |
||||||
|
Row( |
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp), |
||||||
|
) { |
||||||
|
TextWithLabelMolecule( |
||||||
|
label = stringResource(R.string.screen_session_verification_request_details_timestamp), |
||||||
|
text = signInFormattedTimestamp, |
||||||
|
modifier = Modifier.weight(2f), |
||||||
|
) |
||||||
|
TextWithLabelMolecule( |
||||||
|
label = stringResource(CommonStrings.common_device_id), |
||||||
|
text = deviceId.value, |
||||||
|
modifier = Modifier.weight(5f), |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun TextWithLabelMolecule( |
||||||
|
label: String, |
||||||
|
text: String, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
Column(modifier = modifier) { |
||||||
|
Text( |
||||||
|
text = label, |
||||||
|
style = ElementTheme.typography.fontBodySmRegular, |
||||||
|
color = ElementTheme.colors.textSecondary, |
||||||
|
) |
||||||
|
Text( |
||||||
|
text = text, |
||||||
|
style = ElementTheme.typography.fontBodyMdRegular, |
||||||
|
color = ElementTheme.colors.textPrimary, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@PreviewsDayNight |
||||||
|
@Composable |
||||||
|
internal fun SessionDetailsViewPreview() = ElementPreview { |
||||||
|
SessionDetailsView( |
||||||
|
deviceName = "Element X Android", |
||||||
|
deviceId = DeviceId("ILAKNDNASDLK"), |
||||||
|
signInFormattedTimestamp = "12:34", |
||||||
|
) |
||||||
|
} |
@ -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, |
||||||
|
) |
@ -0,0 +1,33 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.ui |
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationData |
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationEmoji |
||||||
|
|
||||||
|
internal fun aEmojisSessionVerificationData( |
||||||
|
emojiList: List<VerificationEmoji> = aVerificationEmojiList(), |
||||||
|
): SessionVerificationData { |
||||||
|
return SessionVerificationData.Emojis(emojiList) |
||||||
|
} |
||||||
|
|
||||||
|
internal fun aDecimalsSessionVerificationData( |
||||||
|
decimals: List<Int> = listOf(123, 456, 789), |
||||||
|
): SessionVerificationData { |
||||||
|
return SessionVerificationData.Decimals(decimals) |
||||||
|
} |
||||||
|
|
||||||
|
private fun aVerificationEmojiList() = listOf( |
||||||
|
VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"), |
||||||
|
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"), |
||||||
|
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"), |
||||||
|
VerificationEmoji(number = 42, emoji = "📕", description = "Book"), |
||||||
|
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"), |
||||||
|
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"), |
||||||
|
VerificationEmoji(number = 63, emoji = "📌", description = "Pin"), |
||||||
|
) |
@ -0,0 +1,27 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.ui |
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope |
||||||
|
import androidx.compose.foundation.layout.padding |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule |
||||||
|
|
||||||
|
@Composable |
||||||
|
internal fun VerificationBottomMenu( |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
buttons: @Composable ColumnScope.() -> Unit, |
||||||
|
) { |
||||||
|
ButtonColumnMolecule( |
||||||
|
modifier = modifier.padding(bottom = 16.dp) |
||||||
|
) { |
||||||
|
buttons() |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,94 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.ui |
||||||
|
|
||||||
|
import androidx.compose.foundation.Image |
||||||
|
import androidx.compose.foundation.layout.Arrangement |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.Row |
||||||
|
import androidx.compose.foundation.layout.Spacer |
||||||
|
import androidx.compose.foundation.layout.fillMaxSize |
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth |
||||||
|
import androidx.compose.foundation.layout.height |
||||||
|
import androidx.compose.foundation.layout.size |
||||||
|
import androidx.compose.foundation.layout.widthIn |
||||||
|
import androidx.compose.material3.MaterialTheme |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.res.painterResource |
||||||
|
import androidx.compose.ui.res.stringResource |
||||||
|
import androidx.compose.ui.text.style.TextAlign |
||||||
|
import androidx.compose.ui.text.style.TextOverflow |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import io.element.android.compound.theme.ElementTheme |
||||||
|
import io.element.android.features.verifysession.impl.emoji.toEmojiResource |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text |
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationData |
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationEmoji |
||||||
|
|
||||||
|
@Composable |
||||||
|
internal fun VerificationContentVerifying( |
||||||
|
data: SessionVerificationData, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
Box( |
||||||
|
modifier = modifier.fillMaxSize(), |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
when (data) { |
||||||
|
is SessionVerificationData.Decimals -> { |
||||||
|
val text = data.decimals.joinToString(separator = " - ") { it.toString() } |
||||||
|
Text( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
text = text, |
||||||
|
style = ElementTheme.typography.fontHeadingLgBold, |
||||||
|
color = MaterialTheme.colorScheme.primary, |
||||||
|
textAlign = TextAlign.Center, |
||||||
|
) |
||||||
|
} |
||||||
|
is SessionVerificationData.Emojis -> { |
||||||
|
// We want each row to have up to 4 emojis |
||||||
|
val rows = data.emojis.chunked(4) |
||||||
|
Column( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
verticalArrangement = Arrangement.spacedBy(40.dp), |
||||||
|
) { |
||||||
|
rows.forEach { emojis -> |
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { |
||||||
|
for (emoji in emojis) { |
||||||
|
EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp)) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifier) { |
||||||
|
val emojiResource = emoji.number.toEmojiResource() |
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { |
||||||
|
Image( |
||||||
|
modifier = Modifier.size(48.dp), |
||||||
|
painter = painterResource(id = emojiResource.drawableRes), |
||||||
|
contentDescription = null, |
||||||
|
) |
||||||
|
Spacer(modifier = Modifier.height(16.dp)) |
||||||
|
Text( |
||||||
|
text = stringResource(id = emojiResource.nameRes), |
||||||
|
style = ElementTheme.typography.fontBodyMdRegular, |
||||||
|
color = MaterialTheme.colorScheme.secondary, |
||||||
|
maxLines = 1, |
||||||
|
overflow = TextOverflow.Ellipsis, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.util |
||||||
|
|
||||||
|
import com.freeletics.flowredux.dsl.InStateBuilderBlock |
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi |
||||||
|
import timber.log.Timber |
||||||
|
import com.freeletics.flowredux.dsl.State as MachineState |
||||||
|
|
||||||
|
internal fun <T : Any> T.andLogStateChange() = also { |
||||||
|
Timber.w("Verification: state machine state moved to [${this::class.simpleName}]") |
||||||
|
} |
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class) |
||||||
|
inline fun <State : Any, reified Event : Any> InStateBuilderBlock<State, State, Event>.logReceivedEvents() { |
||||||
|
on { event: Event, state: MachineState<State> -> |
||||||
|
Timber.w("Verification in state [${state.snapshot::class.simpleName}] receiving event [${event::class.simpleName}]") |
||||||
|
state.noChange() |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,292 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat |
||||||
|
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData |
||||||
|
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter |
||||||
|
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE |
||||||
|
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter |
||||||
|
import io.element.android.libraries.matrix.api.core.FlowId |
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails |
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService |
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationFlowState |
||||||
|
import io.element.android.libraries.matrix.test.A_DEVICE_ID |
||||||
|
import io.element.android.libraries.matrix.test.A_TIMESTAMP |
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID |
||||||
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService |
||||||
|
import io.element.android.tests.testutils.WarmUpRule |
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError |
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder |
||||||
|
import io.element.android.tests.testutils.lambda.value |
||||||
|
import io.element.android.tests.testutils.test |
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi |
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle |
||||||
|
import kotlinx.coroutines.test.runTest |
||||||
|
import org.junit.Rule |
||||||
|
import org.junit.Test |
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi |
||||||
|
class IncomingVerificationPresenterTest { |
||||||
|
@get:Rule |
||||||
|
val warmUpRule = WarmUpRule() |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `present - nominal case - incoming verification successful`() = runTest { |
||||||
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> } |
||||||
|
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { } |
||||||
|
val approveVerificationLambda = lambdaRecorder<Unit> { } |
||||||
|
val resetLambda = lambdaRecorder<Boolean, Unit> { } |
||||||
|
val fakeSessionVerificationService = FakeSessionVerificationService( |
||||||
|
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, |
||||||
|
acceptVerificationRequestLambda = acceptVerificationRequestLambda, |
||||||
|
approveVerificationLambda = approveVerificationLambda, |
||||||
|
resetLambda = resetLambda, |
||||||
|
) |
||||||
|
createPresenter( |
||||||
|
service = fakeSessionVerificationService, |
||||||
|
).test { |
||||||
|
val initialState = awaitItem() |
||||||
|
assertThat(initialState.step).isEqualTo( |
||||||
|
IncomingVerificationState.Step.Initial( |
||||||
|
deviceDisplayName = "a device name", |
||||||
|
deviceId = A_DEVICE_ID, |
||||||
|
formattedSignInTime = A_FORMATTED_DATE, |
||||||
|
isWaiting = false, |
||||||
|
) |
||||||
|
) |
||||||
|
resetLambda.assertions().isCalledOnce().with(value(false)) |
||||||
|
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails)) |
||||||
|
acceptVerificationRequestLambda.assertions().isNeverCalled() |
||||||
|
// User accept the incoming verification |
||||||
|
initialState.eventSink(IncomingVerificationViewEvents.StartVerification) |
||||||
|
skipItems(1) |
||||||
|
val initialWaitingState = awaitItem() |
||||||
|
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue() |
||||||
|
advanceUntilIdle() |
||||||
|
acceptVerificationRequestLambda.assertions().isCalledOnce() |
||||||
|
// Remote sent the data |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification) |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState( |
||||||
|
VerificationFlowState.DidReceiveVerificationData( |
||||||
|
data = aEmojisSessionVerificationData() |
||||||
|
) |
||||||
|
) |
||||||
|
val emojiState = awaitItem() |
||||||
|
assertThat(emojiState.step).isEqualTo( |
||||||
|
IncomingVerificationState.Step.Verifying( |
||||||
|
data = aEmojisSessionVerificationData(), |
||||||
|
isWaiting = false |
||||||
|
) |
||||||
|
) |
||||||
|
// User claims that the emoji matches |
||||||
|
emojiState.eventSink(IncomingVerificationViewEvents.ConfirmVerification) |
||||||
|
val emojiWaitingItem = awaitItem() |
||||||
|
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue() |
||||||
|
approveVerificationLambda.assertions().isCalledOnce() |
||||||
|
// Remote confirm that the emojis match |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState( |
||||||
|
VerificationFlowState.DidFinish |
||||||
|
) |
||||||
|
val finalItem = awaitItem() |
||||||
|
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Completed) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `present - emoji not matching case - incoming verification failure`() = runTest { |
||||||
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> } |
||||||
|
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { } |
||||||
|
val declineVerificationLambda = lambdaRecorder<Unit> { } |
||||||
|
val resetLambda = lambdaRecorder<Boolean, Unit> { } |
||||||
|
val fakeSessionVerificationService = FakeSessionVerificationService( |
||||||
|
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, |
||||||
|
acceptVerificationRequestLambda = acceptVerificationRequestLambda, |
||||||
|
declineVerificationLambda = declineVerificationLambda, |
||||||
|
resetLambda = resetLambda, |
||||||
|
) |
||||||
|
createPresenter( |
||||||
|
service = fakeSessionVerificationService, |
||||||
|
).test { |
||||||
|
val initialState = awaitItem() |
||||||
|
assertThat(initialState.step).isEqualTo( |
||||||
|
IncomingVerificationState.Step.Initial( |
||||||
|
deviceDisplayName = "a device name", |
||||||
|
deviceId = A_DEVICE_ID, |
||||||
|
formattedSignInTime = A_FORMATTED_DATE, |
||||||
|
isWaiting = false, |
||||||
|
) |
||||||
|
) |
||||||
|
resetLambda.assertions().isCalledOnce().with(value(false)) |
||||||
|
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails)) |
||||||
|
acceptVerificationRequestLambda.assertions().isNeverCalled() |
||||||
|
// User accept the incoming verification |
||||||
|
initialState.eventSink(IncomingVerificationViewEvents.StartVerification) |
||||||
|
skipItems(1) |
||||||
|
val initialWaitingState = awaitItem() |
||||||
|
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue() |
||||||
|
advanceUntilIdle() |
||||||
|
acceptVerificationRequestLambda.assertions().isCalledOnce() |
||||||
|
// Remote sent the data |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification) |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState( |
||||||
|
VerificationFlowState.DidReceiveVerificationData( |
||||||
|
data = aEmojisSessionVerificationData() |
||||||
|
) |
||||||
|
) |
||||||
|
val emojiState = awaitItem() |
||||||
|
// User claims that the emojis do not match |
||||||
|
emojiState.eventSink(IncomingVerificationViewEvents.DeclineVerification) |
||||||
|
val emojiWaitingItem = awaitItem() |
||||||
|
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue() |
||||||
|
declineVerificationLambda.assertions().isCalledOnce() |
||||||
|
// Remote confirm that there is a failure |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState( |
||||||
|
VerificationFlowState.DidFail |
||||||
|
) |
||||||
|
val finalItem = awaitItem() |
||||||
|
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `present - incoming verification is remotely canceled`() = runTest { |
||||||
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> } |
||||||
|
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { } |
||||||
|
val declineVerificationLambda = lambdaRecorder<Unit> { } |
||||||
|
val resetLambda = lambdaRecorder<Boolean, Unit> { } |
||||||
|
val onFinishLambda = lambdaRecorder<Unit> { } |
||||||
|
val fakeSessionVerificationService = FakeSessionVerificationService( |
||||||
|
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, |
||||||
|
acceptVerificationRequestLambda = acceptVerificationRequestLambda, |
||||||
|
declineVerificationLambda = declineVerificationLambda, |
||||||
|
resetLambda = resetLambda, |
||||||
|
) |
||||||
|
createPresenter( |
||||||
|
service = fakeSessionVerificationService, |
||||||
|
navigator = IncomingVerificationNavigator(onFinishLambda), |
||||||
|
).test { |
||||||
|
val initialState = awaitItem() |
||||||
|
assertThat(initialState.step).isEqualTo( |
||||||
|
IncomingVerificationState.Step.Initial( |
||||||
|
deviceDisplayName = "a device name", |
||||||
|
deviceId = A_DEVICE_ID, |
||||||
|
formattedSignInTime = A_FORMATTED_DATE, |
||||||
|
isWaiting = false, |
||||||
|
) |
||||||
|
) |
||||||
|
// Remote cancel the verification request |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidCancel) |
||||||
|
// The screen is dismissed |
||||||
|
skipItems(2) |
||||||
|
onFinishLambda.assertions().isCalledOnce() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `present - user goes back when comparing emoji - incoming verification failure`() = runTest { |
||||||
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> } |
||||||
|
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { } |
||||||
|
val declineVerificationLambda = lambdaRecorder<Unit> { } |
||||||
|
val resetLambda = lambdaRecorder<Boolean, Unit> { } |
||||||
|
val fakeSessionVerificationService = FakeSessionVerificationService( |
||||||
|
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, |
||||||
|
acceptVerificationRequestLambda = acceptVerificationRequestLambda, |
||||||
|
declineVerificationLambda = declineVerificationLambda, |
||||||
|
resetLambda = resetLambda, |
||||||
|
) |
||||||
|
createPresenter( |
||||||
|
service = fakeSessionVerificationService, |
||||||
|
).test { |
||||||
|
val initialState = awaitItem() |
||||||
|
assertThat(initialState.step).isEqualTo( |
||||||
|
IncomingVerificationState.Step.Initial( |
||||||
|
deviceDisplayName = "a device name", |
||||||
|
deviceId = A_DEVICE_ID, |
||||||
|
formattedSignInTime = A_FORMATTED_DATE, |
||||||
|
isWaiting = false, |
||||||
|
) |
||||||
|
) |
||||||
|
resetLambda.assertions().isCalledOnce().with(value(false)) |
||||||
|
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails)) |
||||||
|
acceptVerificationRequestLambda.assertions().isNeverCalled() |
||||||
|
// User accept the incoming verification |
||||||
|
initialState.eventSink(IncomingVerificationViewEvents.StartVerification) |
||||||
|
skipItems(1) |
||||||
|
val initialWaitingState = awaitItem() |
||||||
|
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue() |
||||||
|
advanceUntilIdle() |
||||||
|
acceptVerificationRequestLambda.assertions().isCalledOnce() |
||||||
|
// Remote sent the data |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification) |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState( |
||||||
|
VerificationFlowState.DidReceiveVerificationData( |
||||||
|
data = aEmojisSessionVerificationData() |
||||||
|
) |
||||||
|
) |
||||||
|
val emojiState = awaitItem() |
||||||
|
// User goes back |
||||||
|
emojiState.eventSink(IncomingVerificationViewEvents.GoBack) |
||||||
|
val emojiWaitingItem = awaitItem() |
||||||
|
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue() |
||||||
|
declineVerificationLambda.assertions().isCalledOnce() |
||||||
|
// Remote confirm that there is a failure |
||||||
|
fakeSessionVerificationService.emitVerificationFlowState( |
||||||
|
VerificationFlowState.DidFail |
||||||
|
) |
||||||
|
val finalItem = awaitItem() |
||||||
|
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `present - user ignores incoming request`() = runTest { |
||||||
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> } |
||||||
|
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { } |
||||||
|
val resetLambda = lambdaRecorder<Boolean, Unit> { } |
||||||
|
val fakeSessionVerificationService = FakeSessionVerificationService( |
||||||
|
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, |
||||||
|
acceptVerificationRequestLambda = acceptVerificationRequestLambda, |
||||||
|
resetLambda = resetLambda, |
||||||
|
) |
||||||
|
val navigatorLambda = lambdaRecorder<Unit> { } |
||||||
|
createPresenter( |
||||||
|
service = fakeSessionVerificationService, |
||||||
|
navigator = IncomingVerificationNavigator(navigatorLambda), |
||||||
|
).test { |
||||||
|
val initialState = awaitItem() |
||||||
|
initialState.eventSink(IncomingVerificationViewEvents.IgnoreVerification) |
||||||
|
skipItems(1) |
||||||
|
navigatorLambda.assertions().isCalledOnce() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private val aSessionVerificationRequestDetails = SessionVerificationRequestDetails( |
||||||
|
senderId = A_USER_ID, |
||||||
|
flowId = FlowId("flowId"), |
||||||
|
deviceId = A_DEVICE_ID, |
||||||
|
displayName = "a device name", |
||||||
|
firstSeenTimestamp = A_TIMESTAMP, |
||||||
|
) |
||||||
|
|
||||||
|
private fun createPresenter( |
||||||
|
sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails, |
||||||
|
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() }, |
||||||
|
service: SessionVerificationService = FakeSessionVerificationService(), |
||||||
|
dateFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE), |
||||||
|
) = IncomingVerificationPresenter( |
||||||
|
sessionVerificationRequestDetails = sessionVerificationRequestDetails, |
||||||
|
navigator = navigator, |
||||||
|
sessionVerificationService = service, |
||||||
|
stateMachine = IncomingVerificationStateMachine(service), |
||||||
|
dateFormatter = dateFormatter, |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,217 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.incoming |
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity |
||||||
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule |
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule |
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4 |
||||||
|
import io.element.android.features.verifysession.impl.R |
||||||
|
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData |
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings |
||||||
|
import io.element.android.tests.testutils.EventsRecorder |
||||||
|
import io.element.android.tests.testutils.clickOn |
||||||
|
import io.element.android.tests.testutils.pressBackKey |
||||||
|
import org.junit.Rule |
||||||
|
import org.junit.Test |
||||||
|
import org.junit.rules.TestRule |
||||||
|
import org.junit.runner.RunWith |
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class) |
||||||
|
class IncomingVerificationViewTest { |
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() |
||||||
|
|
||||||
|
// region step Initial |
||||||
|
@Test |
||||||
|
fun `back key pressed - ignore the verification`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = aStepInitial(), |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.pressBackKey() |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `ignore incoming verification emits the expected event`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = aStepInitial(), |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.clickOn(CommonStrings.action_ignore) |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.IgnoreVerification) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `start incoming verification emits the expected event`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = aStepInitial(), |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.clickOn(CommonStrings.action_start) |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.StartVerification) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `back key pressed - when awaiting response cancels the verification`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = aStepInitial( |
||||||
|
isWaiting = true, |
||||||
|
), |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.pressBackKey() |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) |
||||||
|
} |
||||||
|
// endregion step Initial |
||||||
|
|
||||||
|
// region step Verifying |
||||||
|
@Test |
||||||
|
fun `back key pressed - when ready to verify cancels the verification`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = IncomingVerificationState.Step.Verifying( |
||||||
|
data = aEmojisSessionVerificationData(), |
||||||
|
isWaiting = false, |
||||||
|
), |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.pressBackKey() |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `back key pressed - when verifying and loading emits the expected event`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = IncomingVerificationState.Step.Verifying( |
||||||
|
data = aEmojisSessionVerificationData(), |
||||||
|
isWaiting = true, |
||||||
|
), |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.pressBackKey() |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `clicking on they do not match emits the expected event`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = IncomingVerificationState.Step.Verifying( |
||||||
|
data = aEmojisSessionVerificationData(), |
||||||
|
isWaiting = false, |
||||||
|
), |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.clickOn(R.string.screen_session_verification_they_dont_match) |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.DeclineVerification) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `clicking on they match emits the expected event`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = IncomingVerificationState.Step.Verifying( |
||||||
|
data = aEmojisSessionVerificationData(), |
||||||
|
isWaiting = false, |
||||||
|
), |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.clickOn(R.string.screen_session_verification_they_match) |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.ConfirmVerification) |
||||||
|
} |
||||||
|
// endregion |
||||||
|
|
||||||
|
// region step Failure |
||||||
|
@Test |
||||||
|
fun `back key pressed - when failure resets the flow`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = IncomingVerificationState.Step.Failure, |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.pressBackKey() |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `click on done - when failure resets the flow`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = IncomingVerificationState.Step.Failure, |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.clickOn(CommonStrings.action_done) |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) |
||||||
|
} |
||||||
|
|
||||||
|
// endregion |
||||||
|
|
||||||
|
// region step Completed |
||||||
|
@Test |
||||||
|
fun `back key pressed - on Completed step emits the expected event`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = IncomingVerificationState.Step.Completed, |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.pressBackKey() |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `when flow is completed and the user clicks on the done button, the expected event is emitted`() { |
||||||
|
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>() |
||||||
|
rule.setIncomingVerificationView( |
||||||
|
anIncomingVerificationState( |
||||||
|
step = IncomingVerificationState.Step.Completed, |
||||||
|
eventSink = eventsRecorder |
||||||
|
), |
||||||
|
) |
||||||
|
rule.clickOn(CommonStrings.action_done) |
||||||
|
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) |
||||||
|
} |
||||||
|
// endregion |
||||||
|
|
||||||
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setIncomingVerificationView( |
||||||
|
state: IncomingVerificationState, |
||||||
|
) { |
||||||
|
setContent { |
||||||
|
IncomingVerificationView( |
||||||
|
state = state, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.core |
||||||
|
|
||||||
|
import java.io.Serializable |
||||||
|
|
||||||
|
@JvmInline |
||||||
|
value class FlowId(val value: String) : Serializable { |
||||||
|
override fun toString(): String = value |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.verification |
||||||
|
|
||||||
|
import android.os.Parcelable |
||||||
|
import io.element.android.libraries.matrix.api.core.DeviceId |
||||||
|
import io.element.android.libraries.matrix.api.core.FlowId |
||||||
|
import io.element.android.libraries.matrix.api.core.UserId |
||||||
|
import kotlinx.parcelize.Parcelize |
||||||
|
|
||||||
|
@Parcelize |
||||||
|
data class SessionVerificationRequestDetails( |
||||||
|
val senderId: UserId, |
||||||
|
val flowId: FlowId, |
||||||
|
val deviceId: DeviceId, |
||||||
|
val displayName: String?, |
||||||
|
val firstSeenTimestamp: Long, |
||||||
|
) : Parcelable |
@ -0,0 +1,23 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2024 New Vector Ltd. |
||||||
|
* |
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only |
||||||
|
* Please see LICENSE in the repository root for full details. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.verification |
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.DeviceId |
||||||
|
import io.element.android.libraries.matrix.api.core.FlowId |
||||||
|
import io.element.android.libraries.matrix.api.core.UserId |
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails |
||||||
|
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails |
||||||
|
|
||||||
|
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails( |
||||||
|
senderId = UserId(senderId), |
||||||
|
flowId = FlowId(flowId), |
||||||
|
deviceId = DeviceId(deviceId), |
||||||
|
displayName = displayName, |
||||||
|
firstSeenTimestamp = firstSeenTimestamp.toLong(), |
||||||
|
) |
||||||
|
|
Loading…
Reference in new issue