Benoit Marty
13 hours ago
committed by
GitHub
85 changed files with 2272 additions and 462 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 = { |
||||||
|
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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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,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 @@ |
|||||||
|
/* |
||||||
|
* 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,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 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:f18cc4f6c04a59e1a5d51a091efe66bfa7525e2fe229302174fe40fefa00cc28 |
||||||
|
size 14026 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:d82fea268b5a9271497a3b6518a88c757e6ba6f62f4a8990491351509383858a |
||||||
|
size 13974 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:f781e977784d8bfb1bd84947519f31e28106cdc036c6dedc406be92fdbbc0c54 |
||||||
|
size 40077 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:dbf6f78ad928bcc9878e546345a98d334ae92c9b81d4d8404892a16d19b446c3 |
||||||
|
size 41534 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:dfc69dc6d93a62e23df2f817ad5a167b1e94d7fb0d408d6ec0051d666b6cf175 |
||||||
|
size 44869 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:81c559a9661b3fdccc8deb444b897f9a45b33de0d4d58367ff021f92202a17b6 |
||||||
|
size 21883 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:d3340a2d29e6c1d86f5ec5664179cae9501fcc95381311174d7d6b45b15af326 |
||||||
|
size 24123 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:bee1454db757b0897ae2113d3a11ed9adef0eb6bc52f3775edac56ed8533a88f |
||||||
|
size 24076 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:68b70ae5220e244acdfa3f1a2e36089c1994e8f05eeb6c344b4734858540e55c |
||||||
|
size 38942 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:b76212b5942484621b7a58044a958203d838807d50687e8f4e2f9c8bdb6ad37c |
||||||
|
size 40232 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:1033af0fc84e2819509fc798c17e8f0b07d74a08da99d0e059b7ff19db2ce56a |
||||||
|
size 43674 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:8565e90948aa18f04e1b868467a007d68fe3d18d534289d5c4d59d4b38915585 |
||||||
|
size 21190 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:c6c7e8cdf40bdf018931635565ac649fed518140a047a71cf70cf63e9edf54d3 |
||||||
|
size 23932 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:59a23ec5e3086349d3382f006cf1bcb4121183c83ea8c749aa95e3160561aef1 |
||||||
|
size 23524 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:c238637a0a3107cfcd98230ac52fad7779d8c260b0b97bf30d231cd5493a944a |
||||||
|
size 46453 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:a25f85925a38d02a65af4fb342949e9545d3e5e19012885f2c3aee4caa8b8f7d |
||||||
|
size 31535 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:fc4cbb2bea7749bda9f6c5647ff178717711bad5b1ee25621155bccb1e5f2328 |
||||||
|
size 45528 |
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:9ff1487c8c63a476a570d271f7a0b00c8990e1f90d49a2cae5056a9cc7e51e0e |
||||||
|
size 30765 |
Loading…
Reference in new issue