Benoit Marty
9 hours ago
committed by
GitHub
85 changed files with 2272 additions and 462 deletions
@ -0,0 +1,33 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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, |
||||
) |
||||
} |
@ -0,0 +1,16 @@
@@ -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,92 @@
@@ -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", |
||||
) |
||||
} |
@ -0,0 +1,73 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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,34 @@
@@ -0,0 +1,34 @@
|
||||
/* |
||||
* 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.designsystem.atomic.molecules |
||||
|
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import io.element.android.compound.theme.ElementTheme |
||||
import io.element.android.libraries.designsystem.theme.components.Text |
||||
|
||||
@Composable |
||||
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, |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,15 @@
@@ -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 @@
@@ -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,22 @@
@@ -0,0 +1,22 @@
|
||||
/* |
||||
* 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(), |
||||
) |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:f18cc4f6c04a59e1a5d51a091efe66bfa7525e2fe229302174fe40fefa00cc28 |
||||
size 14026 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:d82fea268b5a9271497a3b6518a88c757e6ba6f62f4a8990491351509383858a |
||||
size 13974 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:f781e977784d8bfb1bd84947519f31e28106cdc036c6dedc406be92fdbbc0c54 |
||||
size 40077 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:dbf6f78ad928bcc9878e546345a98d334ae92c9b81d4d8404892a16d19b446c3 |
||||
size 41534 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:dfc69dc6d93a62e23df2f817ad5a167b1e94d7fb0d408d6ec0051d666b6cf175 |
||||
size 44869 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:81c559a9661b3fdccc8deb444b897f9a45b33de0d4d58367ff021f92202a17b6 |
||||
size 21883 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:d3340a2d29e6c1d86f5ec5664179cae9501fcc95381311174d7d6b45b15af326 |
||||
size 24123 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:bee1454db757b0897ae2113d3a11ed9adef0eb6bc52f3775edac56ed8533a88f |
||||
size 24076 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:68b70ae5220e244acdfa3f1a2e36089c1994e8f05eeb6c344b4734858540e55c |
||||
size 38942 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:b76212b5942484621b7a58044a958203d838807d50687e8f4e2f9c8bdb6ad37c |
||||
size 40232 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:1033af0fc84e2819509fc798c17e8f0b07d74a08da99d0e059b7ff19db2ce56a |
||||
size 43674 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:8565e90948aa18f04e1b868467a007d68fe3d18d534289d5c4d59d4b38915585 |
||||
size 21190 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:c6c7e8cdf40bdf018931635565ac649fed518140a047a71cf70cf63e9edf54d3 |
||||
size 23932 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:59a23ec5e3086349d3382f006cf1bcb4121183c83ea8c749aa95e3160561aef1 |
||||
size 23524 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:c238637a0a3107cfcd98230ac52fad7779d8c260b0b97bf30d231cd5493a944a |
||||
size 46453 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:a25f85925a38d02a65af4fb342949e9545d3e5e19012885f2c3aee4caa8b8f7d |
||||
size 31535 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:fc4cbb2bea7749bda9f6c5647ff178717711bad5b1ee25621155bccb1e5f2328 |
||||
size 45528 |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:9ff1487c8c63a476a570d271f7a0b00c8990e1f90d49a2cae5056a9cc7e51e0e |
||||
size 30765 |
Loading…
Reference in new issue