Merge pull request #3733 from element-hq/feature/bma/incomingVerification
Incoming session verification
This commit is contained in:
commit
666cedd66e
85 changed files with 2275 additions and 465 deletions
|
|
@ -25,8 +25,10 @@ import com.bumble.appyx.core.node.Node
|
|||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
|
|
@ -50,6 +52,7 @@ import io.element.android.features.roomlist.api.RoomListEntryPoint
|
|||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||
import io.element.android.features.share.api.ShareEntryPoint
|
||||
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
||||
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
|
|
@ -66,6 +69,8 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
||||
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -99,6 +104,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
private val matrixClient: MatrixClient,
|
||||
private val sendingQueue: SendQueues,
|
||||
private val logoutEntryPoint: LogoutEntryPoint,
|
||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||
|
|
@ -123,6 +129,12 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
matrixClient.roomMembershipObserver(),
|
||||
)
|
||||
|
||||
private val verificationListener = object : SessionVerificationServiceListener {
|
||||
override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
|
||||
backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
|
|
@ -131,6 +143,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
// TODO We do not support Space yet, so directly navigate to main space
|
||||
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
||||
loggedInFlowProcessor.observeEvents(coroutineScope)
|
||||
matrixClient.sessionVerificationService().setListener(verificationListener)
|
||||
|
||||
ftueService.state
|
||||
.onEach { ftueState ->
|
||||
|
|
@ -152,6 +165,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
appNavigationStateService.onLeavingSpace(id)
|
||||
appNavigationStateService.onLeavingSession(id)
|
||||
loggedInFlowProcessor.stopObserving()
|
||||
matrixClient.sessionVerificationService().setListener(null)
|
||||
}
|
||||
)
|
||||
observeSyncStateAndNetworkStatus()
|
||||
|
|
@ -232,6 +246,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
@ -432,6 +449,16 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
is NavTarget.IncomingVerificationRequest -> {
|
||||
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(IncomingVerificationEntryPoint.Params(navTarget.data))
|
||||
.callback(object : IncomingVerificationEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class DefaultFtueServiceTest {
|
|||
@Test
|
||||
fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Unknown)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Unknown)
|
||||
}
|
||||
val service = createDefaultFtueService(
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
|
|
@ -46,7 +46,7 @@ class DefaultFtueServiceTest {
|
|||
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
|
||||
|
||||
// Verification state is known, we should display the flow if any check is false
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
assertThat(awaitItem()).isEqualTo(FtueState.Incomplete)
|
||||
}
|
||||
}
|
||||
|
|
@ -64,7 +64,7 @@ class DefaultFtueServiceTest {
|
|||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
analyticsService.setDidAskUserConsent()
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
|
@ -76,7 +76,7 @@ class DefaultFtueServiceTest {
|
|||
@Test
|
||||
fun `traverse flow`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
}
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||
|
|
@ -91,7 +91,7 @@ class DefaultFtueServiceTest {
|
|||
|
||||
// Session verification
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
|
||||
// Notifications opt in
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
|
|
@ -132,7 +132,7 @@ class DefaultFtueServiceTest {
|
|||
)
|
||||
|
||||
// Skip first 3 steps
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ class DefaultFtueServiceTest {
|
|||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ class RoomListPresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showAvatarIndicator).isTrue()
|
||||
sessionVerificationService.givenNeedsSessionVerification(false)
|
||||
sessionVerificationService.emitNeedsSessionVerification(false)
|
||||
encryptionService.emitBackupState(BackupState.ENABLED)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.showAvatarIndicator).isFalse()
|
||||
|
|
@ -231,7 +231,7 @@ class RoomListPresenterTest {
|
|||
roomListService = roomListService,
|
||||
encryptionService = encryptionService,
|
||||
sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
emitNeedsSessionVerification(false)
|
||||
},
|
||||
syncService = FakeSyncService(MutableStateFlow(SyncState.Running)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ android {
|
|||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ dependencies {
|
|||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.dateformatter.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
|
|
@ -43,6 +44,7 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.features.logout.test)
|
||||
testImplementation(projects.libraries.dateformatter.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -39,8 +39,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState
|
||||
import timber.log.Timber
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.Event as StateMachineEvent
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState
|
||||
|
||||
class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
@Assisted private val showDeviceVerifiedScreen: Boolean,
|
||||
|
|
@ -61,7 +62,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
val coroutineScope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
// Force reset, just in case the service was left in a broken state
|
||||
sessionVerificationService.reset()
|
||||
sessionVerificationService.reset(true)
|
||||
}
|
||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
|
||||
|
|
@ -70,13 +71,13 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
val signOutAction = remember {
|
||||
mutableStateOf<AsyncAction<String?>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val verificationFlowStep by remember {
|
||||
val step by remember {
|
||||
derivedStateOf {
|
||||
if (skipVerification) {
|
||||
VerifySelfSessionState.VerificationStep.Skipped
|
||||
VerifySelfSessionState.Step.Skipped
|
||||
} else {
|
||||
when (sessionVerifiedStatus) {
|
||||
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.VerificationStep.Loading
|
||||
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
|
||||
SessionVerifiedStatus.NotVerified -> {
|
||||
stateAndDispatch.state.value.toVerificationStep(
|
||||
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
|
||||
|
|
@ -85,10 +86,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
SessionVerifiedStatus.Verified -> {
|
||||
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
|
||||
// The user has verified the session, we need to show the success screen
|
||||
VerifySelfSessionState.VerificationStep.Completed
|
||||
VerifySelfSessionState.Step.Completed
|
||||
} else {
|
||||
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
|
||||
VerifySelfSessionState.VerificationStep.Skipped
|
||||
VerifySelfSessionState.Step.Skipped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -101,6 +102,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
}
|
||||
|
||||
fun handleEvents(event: VerifySelfSessionViewEvents) {
|
||||
Timber.d("Verification user action: ${event::class.simpleName}")
|
||||
when (event) {
|
||||
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
|
||||
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
|
||||
|
|
@ -115,7 +117,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
return VerifySelfSessionState(
|
||||
verificationFlowStep = verificationFlowStep,
|
||||
step = step,
|
||||
signOutAction = signOutAction.value,
|
||||
displaySkipButton = buildMeta.isDebuggable,
|
||||
eventSink = ::handleEvents,
|
||||
|
|
@ -124,10 +126,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
|
||||
private fun StateMachineState?.toVerificationStep(
|
||||
canEnterRecoveryKey: Boolean
|
||||
): VerifySelfSessionState.VerificationStep =
|
||||
): VerifySelfSessionState.Step =
|
||||
when (val machineState = this) {
|
||||
StateMachineState.Initial, null -> {
|
||||
VerifySelfSessionState.VerificationStep.Initial(
|
||||
VerifySelfSessionState.Step.Initial(
|
||||
canEnterRecoveryKey = canEnterRecoveryKey,
|
||||
isLastDevice = encryptionService.isLastDevice.value
|
||||
)
|
||||
|
|
@ -136,15 +138,15 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
StateMachineState.StartingSasVerification,
|
||||
StateMachineState.SasVerificationStarted,
|
||||
StateMachineState.Canceling -> {
|
||||
VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse
|
||||
VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
|
||||
}
|
||||
|
||||
StateMachineState.VerificationRequestAccepted -> {
|
||||
VerifySelfSessionState.VerificationStep.Ready
|
||||
VerifySelfSessionState.Step.Ready
|
||||
}
|
||||
|
||||
StateMachineState.Canceled -> {
|
||||
VerifySelfSessionState.VerificationStep.Canceled
|
||||
VerifySelfSessionState.Step.Canceled
|
||||
}
|
||||
|
||||
is StateMachineState.Verifying -> {
|
||||
|
|
@ -152,38 +154,41 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
is StateMachineState.Verifying.Replying -> AsyncData.Loading()
|
||||
else -> AsyncData.Uninitialized
|
||||
}
|
||||
VerifySelfSessionState.VerificationStep.Verifying(machineState.data, async)
|
||||
VerifySelfSessionState.Step.Verifying(machineState.data, async)
|
||||
}
|
||||
|
||||
StateMachineState.Completed -> {
|
||||
VerifySelfSessionState.VerificationStep.Completed
|
||||
VerifySelfSessionState.Step.Completed
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.observeVerificationService() {
|
||||
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
|
||||
when (verificationAttemptState) {
|
||||
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
|
||||
VerificationFlowState.AcceptedVerificationRequest -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
|
||||
}
|
||||
VerificationFlowState.StartedSasVerification -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
|
||||
}
|
||||
is VerificationFlowState.ReceivedVerificationData -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
|
||||
}
|
||||
VerificationFlowState.Finished -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
|
||||
}
|
||||
VerificationFlowState.Canceled -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
|
||||
}
|
||||
VerificationFlowState.Failed -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
|
||||
sessionVerificationService.verificationFlowState
|
||||
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
|
||||
.onEach { verificationAttemptState ->
|
||||
when (verificationAttemptState) {
|
||||
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
|
||||
VerificationFlowState.DidAcceptVerificationRequest -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
|
||||
}
|
||||
VerificationFlowState.DidStartSasVerification -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
|
||||
}
|
||||
is VerificationFlowState.DidReceiveVerificationData -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
|
||||
}
|
||||
VerificationFlowState.DidFinish -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
|
||||
}
|
||||
VerificationFlowState.DidCancel -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
|
||||
}
|
||||
VerificationFlowState.DidFail -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.launchIn(this)
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<String?>>) = launch {
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
|
|
@ -15,22 +15,22 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
|
|||
|
||||
@Immutable
|
||||
data class VerifySelfSessionState(
|
||||
val verificationFlowStep: VerificationStep,
|
||||
val step: Step,
|
||||
val signOutAction: AsyncAction<String?>,
|
||||
val displaySkipButton: Boolean,
|
||||
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
|
||||
) {
|
||||
@Stable
|
||||
sealed interface VerificationStep {
|
||||
data object Loading : VerificationStep
|
||||
sealed interface Step {
|
||||
data object Loading : Step
|
||||
|
||||
// FIXME canEnterRecoveryKey value is never read.
|
||||
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : VerificationStep
|
||||
data object Canceled : VerificationStep
|
||||
data object AwaitingOtherDeviceResponse : VerificationStep
|
||||
data object Ready : VerificationStep
|
||||
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : VerificationStep
|
||||
data object Completed : VerificationStep
|
||||
data object Skipped : VerificationStep
|
||||
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
|
||||
data object Canceled : Step
|
||||
data object AwaitingOtherDeviceResponse : Step
|
||||
data object Ready : Step
|
||||
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step
|
||||
data object Completed : Step
|
||||
data object Skipped : Step
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,11 @@
|
|||
@file:Suppress("WildcardImport")
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
|
||||
import io.element.android.features.verifysession.impl.util.andLogStateChange
|
||||
import io.element.android.features.verifysession.impl.util.logReceivedEvents
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
|
|
@ -37,10 +39,10 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
spec {
|
||||
inState<State.Initial> {
|
||||
on { _: Event.RequestVerification, state ->
|
||||
state.override { State.RequestingVerification }
|
||||
state.override { State.RequestingVerification.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.StartSasVerification, state ->
|
||||
state.override { State.StartingSasVerification }
|
||||
state.override { State.StartingSasVerification.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.RequestingVerification> {
|
||||
|
|
@ -48,7 +50,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
sessionVerificationService.requestVerification()
|
||||
}
|
||||
on { _: Event.DidAcceptVerificationRequest, state ->
|
||||
state.override { State.VerificationRequestAccepted }
|
||||
state.override { State.VerificationRequestAccepted.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.StartingSasVerification> {
|
||||
|
|
@ -58,28 +60,28 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
}
|
||||
inState<State.VerificationRequestAccepted> {
|
||||
on { _: Event.StartSasVerification, state ->
|
||||
state.override { State.StartingSasVerification }
|
||||
state.override { State.StartingSasVerification.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Canceled> {
|
||||
on { _: Event.RequestVerification, state ->
|
||||
state.override { State.RequestingVerification }
|
||||
state.override { State.RequestingVerification.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.Reset, state ->
|
||||
state.override { State.Initial }
|
||||
state.override { State.Initial.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.SasVerificationStarted> {
|
||||
on { event: Event.DidReceiveChallenge, state ->
|
||||
state.override { State.Verifying.ChallengeReceived(event.data) }
|
||||
state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Verifying.ChallengeReceived> {
|
||||
on { _: Event.AcceptChallenge, state ->
|
||||
state.override { State.Verifying.Replying(state.snapshot.data, accept = true) }
|
||||
state.override { State.Verifying.Replying(state.snapshot.data, accept = true).andLogStateChange() }
|
||||
}
|
||||
on { _: Event.DeclineChallenge, state ->
|
||||
state.override { State.Verifying.Replying(state.snapshot.data, accept = false) }
|
||||
state.override { State.Verifying.Replying(state.snapshot.data, accept = false).andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Verifying.Replying> {
|
||||
|
|
@ -100,7 +102,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
.first()
|
||||
}
|
||||
}
|
||||
state.override { State.Completed }
|
||||
state.override { State.Completed.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Canceling> {
|
||||
|
|
@ -110,8 +112,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
}
|
||||
}
|
||||
inState {
|
||||
logReceivedEvents()
|
||||
on { _: Event.DidStartSasVerification, state: MachineState<State> ->
|
||||
state.override { State.SasVerificationStarted }
|
||||
state.override { State.SasVerificationStarted.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.Cancel, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
|
|
@ -120,17 +123,17 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
// `Canceling` state to `Canceled` automatically anymore
|
||||
else -> {
|
||||
sessionVerificationService.cancelVerification()
|
||||
state.override { State.Canceled }
|
||||
state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
on { _: Event.DidCancel, state: MachineState<State> ->
|
||||
state.override { State.Canceled }
|
||||
state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.DidFail, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
is State.RequestingVerification -> state.override { State.Initial }
|
||||
else -> state.override { State.Canceled }
|
||||
is State.RequestingVerification -> state.override { State.Initial.andLogStateChange() }
|
||||
else -> state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -5,25 +5,19 @@
|
|||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -31,18 +25,17 @@ import androidx.compose.runtime.rememberUpdatedState
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.verifysession.impl.emoji.toEmojiResource
|
||||
import io.element.android.features.verifysession.impl.R
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
|
||||
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
|
||||
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.PageTitle
|
||||
|
|
@ -56,9 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -71,12 +62,13 @@ fun VerifySelfSessionView(
|
|||
onSuccessLogout: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val step = state.step
|
||||
fun cancelOrResetFlow() {
|
||||
when (state.verificationFlowStep) {
|
||||
is FlowStep.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
is FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
is FlowStep.Verifying -> {
|
||||
if (!state.verificationFlowStep.state.isLoading()) {
|
||||
when (step) {
|
||||
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
is Step.AwaitingOtherDeviceResponse, Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
is Step.Verifying -> {
|
||||
if (!step.state.isLoading()) {
|
||||
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
|
||||
}
|
||||
}
|
||||
|
|
@ -85,18 +77,17 @@ fun VerifySelfSessionView(
|
|||
}
|
||||
|
||||
val latestOnFinish by rememberUpdatedState(newValue = onFinish)
|
||||
LaunchedEffect(state.verificationFlowStep, latestOnFinish) {
|
||||
if (state.verificationFlowStep is FlowStep.Skipped) {
|
||||
LaunchedEffect(step, latestOnFinish) {
|
||||
if (step is Step.Skipped) {
|
||||
latestOnFinish()
|
||||
}
|
||||
}
|
||||
BackHandler {
|
||||
cancelOrResetFlow()
|
||||
}
|
||||
val verificationFlowStep = state.verificationFlowStep
|
||||
|
||||
if (state.verificationFlowStep is FlowStep.Loading ||
|
||||
state.verificationFlowStep is FlowStep.Skipped) {
|
||||
if (step is Step.Loading ||
|
||||
step is Step.Skipped) {
|
||||
// Just display a loader in this case, to avoid UI glitch.
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -111,7 +102,7 @@ fun VerifySelfSessionView(
|
|||
TopAppBar(
|
||||
title = {},
|
||||
actions = {
|
||||
if (state.verificationFlowStep !is FlowStep.Completed &&
|
||||
if (step !is Step.Completed &&
|
||||
state.displaySkipButton &&
|
||||
LocalInspectionMode.current.not()) {
|
||||
TextButton(
|
||||
|
|
@ -119,7 +110,7 @@ fun VerifySelfSessionView(
|
|||
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
|
||||
)
|
||||
}
|
||||
if (state.verificationFlowStep is FlowStep.Initial) {
|
||||
if (step is Step.Initial) {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_signout),
|
||||
onClick = { state.eventSink(VerifySelfSessionViewEvents.SignOut) }
|
||||
|
|
@ -129,10 +120,10 @@ fun VerifySelfSessionView(
|
|||
)
|
||||
},
|
||||
header = {
|
||||
HeaderContent(verificationFlowStep = verificationFlowStep)
|
||||
VerifySelfSessionHeader(step = step)
|
||||
},
|
||||
footer = {
|
||||
BottomMenu(
|
||||
VerifySelfSessionBottomMenu(
|
||||
screenState = state,
|
||||
onCancelClick = ::cancelOrResetFlow,
|
||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
||||
|
|
@ -141,8 +132,8 @@ fun VerifySelfSessionView(
|
|||
)
|
||||
}
|
||||
) {
|
||||
Content(
|
||||
flowState = verificationFlowStep,
|
||||
VerifySelfSessionContent(
|
||||
flowState = step,
|
||||
onLearnMoreClick = onLearnMoreClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -165,38 +156,38 @@ fun VerifySelfSessionView(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderContent(verificationFlowStep: FlowStep) {
|
||||
val iconStyle = when (verificationFlowStep) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||
FlowStep.Canceled -> BigIcon.Style.AlertSolid
|
||||
FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
FlowStep.Completed -> BigIcon.Style.SuccessSolid
|
||||
is FlowStep.Skipped -> return
|
||||
private fun VerifySelfSessionHeader(step: Step) {
|
||||
val iconStyle = when (step) {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial, Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||
Step.Canceled -> BigIcon.Style.AlertSolid
|
||||
Step.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
Step.Completed -> BigIcon.Style.SuccessSolid
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
val titleTextId = when (verificationFlowStep) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
|
||||
FlowStep.Canceled -> CommonStrings.common_verification_cancelled
|
||||
FlowStep.Ready -> R.string.screen_session_verification_compare_emojis_title
|
||||
FlowStep.Completed -> R.string.screen_identity_confirmed_title
|
||||
is FlowStep.Verifying -> when (verificationFlowStep.data) {
|
||||
val titleTextId = when (step) {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
|
||||
Step.Canceled -> CommonStrings.common_verification_cancelled
|
||||
Step.Ready -> R.string.screen_session_verification_compare_emojis_title
|
||||
Step.Completed -> R.string.screen_identity_confirmed_title
|
||||
is Step.Verifying -> when (step.data) {
|
||||
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
val subtitleTextId = when (verificationFlowStep) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
|
||||
FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle
|
||||
FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle
|
||||
FlowStep.Completed -> R.string.screen_identity_confirmed_subtitle
|
||||
is FlowStep.Verifying -> when (verificationFlowStep.data) {
|
||||
val subtitleTextId = when (step) {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
|
||||
Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle
|
||||
Step.Ready -> R.string.screen_session_verification_ready_subtitle
|
||||
Step.Completed -> R.string.screen_identity_confirmed_subtitle
|
||||
is Step.Verifying -> when (step.data) {
|
||||
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
|
||||
PageTitle(
|
||||
|
|
@ -207,16 +198,16 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(
|
||||
flowState: FlowStep,
|
||||
private fun VerifySelfSessionContent(
|
||||
flowState: Step,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
) {
|
||||
when (flowState) {
|
||||
is VerifySelfSessionState.VerificationStep.Initial -> {
|
||||
is Step.Initial -> {
|
||||
ContentInitial(onLearnMoreClick)
|
||||
}
|
||||
is FlowStep.Verifying -> {
|
||||
ContentVerifying(flowState)
|
||||
is Step.Verifying -> {
|
||||
VerificationContentVerifying(flowState.data)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
|
@ -241,79 +232,22 @@ private fun ContentInitial(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (verificationFlowStep.data) {
|
||||
is SessionVerificationData.Decimals -> {
|
||||
val text = verificationFlowStep.data.decimals.joinToString(separator = " - ") { it.toString() }
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontHeadingLgBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
is SessionVerificationData.Emojis -> {
|
||||
// We want each row to have up to 4 emojis
|
||||
val rows = verificationFlowStep.data.emojis.chunked(4)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(40.dp),
|
||||
) {
|
||||
rows.forEach { emojis ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
for (emoji in emojis) {
|
||||
EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifier) {
|
||||
val emojiResource = emoji.number.toEmojiResource()
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
|
||||
Image(
|
||||
modifier = Modifier.size(48.dp),
|
||||
painter = painterResource(id = emojiResource.drawableRes),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = emojiResource.nameRes),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomMenu(
|
||||
private fun VerifySelfSessionBottomMenu(
|
||||
screenState: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit,
|
||||
onResetKey: () -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onContinueClick: () -> Unit,
|
||||
) {
|
||||
val verificationViewState = screenState.verificationFlowStep
|
||||
val verificationViewState = screenState.step
|
||||
val eventSink = screenState.eventSink
|
||||
|
||||
val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is AsyncData.Loading<Unit>
|
||||
val isVerifying = (verificationViewState as? Step.Verifying)?.state is AsyncData.Loading<Unit>
|
||||
|
||||
when (verificationViewState) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial -> {
|
||||
BottomMenu {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial -> {
|
||||
VerificationBottomMenu {
|
||||
if (verificationViewState.isLastDevice) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -340,8 +274,8 @@ private fun BottomMenu(
|
|||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Canceled -> {
|
||||
BottomMenu {
|
||||
is Step.Canceled -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_positive_button_canceled),
|
||||
|
|
@ -354,8 +288,8 @@ private fun BottomMenu(
|
|||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Ready -> {
|
||||
BottomMenu {
|
||||
is Step.Ready -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_start),
|
||||
|
|
@ -368,8 +302,8 @@ private fun BottomMenu(
|
|||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.AwaitingOtherDeviceResponse -> {
|
||||
BottomMenu {
|
||||
is Step.AwaitingOtherDeviceResponse -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_waiting_on_other_device),
|
||||
|
|
@ -380,13 +314,13 @@ private fun BottomMenu(
|
|||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
is FlowStep.Verifying -> {
|
||||
is Step.Verifying -> {
|
||||
val positiveButtonTitle = if (isVerifying) {
|
||||
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
|
||||
} else {
|
||||
stringResource(R.string.screen_session_verification_they_match)
|
||||
}
|
||||
BottomMenu {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = positiveButtonTitle,
|
||||
|
|
@ -404,8 +338,8 @@ private fun BottomMenu(
|
|||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Completed -> {
|
||||
BottomMenu {
|
||||
is Step.Completed -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
|
|
@ -415,19 +349,7 @@ private fun BottomMenu(
|
|||
Spacer(modifier = Modifier.height(48.dp))
|
||||
}
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomMenu(
|
||||
modifier: Modifier = Modifier,
|
||||
buttons: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
ButtonColumnMolecule(
|
||||
modifier = modifier.padding(bottom = 16.dp)
|
||||
) {
|
||||
buttons()
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
sealed interface VerifySelfSessionViewEvents {
|
||||
data object RequestVerification : VerifySelfSessionViewEvents
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
|
|
@ -14,12 +14,13 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.logout.api.LogoutUseCase
|
||||
import io.element.android.features.logout.test.FakeLogoutUseCase
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
|
|
@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
|||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -43,12 +45,14 @@ class VerifySelfSessionPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - Initial state is received`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter()
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().run {
|
||||
assertThat(verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(step).isEqualTo(Step.Initial(false))
|
||||
assertThat(displaySkipButton).isTrue()
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +61,10 @@ class VerifySelfSessionPresenterTest {
|
|||
@Test
|
||||
fun `present - hides skip verification button on non-debuggable builds`() = runTest {
|
||||
val buildMeta = aBuildMeta(isDebuggable = false)
|
||||
val presenter = createVerifySelfSessionPresenter(buildMeta = buildMeta)
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
buildMeta = buildMeta,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -67,7 +74,11 @@ class VerifySelfSessionPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - Initial state is received, can use recovery key`() = runTest {
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(
|
||||
resetLambda = resetLambda
|
||||
),
|
||||
encryptionService = FakeEncryptionService().apply {
|
||||
emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||
}
|
||||
|
|
@ -75,13 +86,15 @@ class VerifySelfSessionPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(true))
|
||||
resetLambda.assertions().isCalledOnce().with(value(true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Initial state is received, can use recovery key and is last device`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
encryptionService = FakeEncryptionService().apply {
|
||||
emitIsLastDevice(true)
|
||||
emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||
|
|
@ -90,13 +103,16 @@ class VerifySelfSessionPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Handles requestVerification`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -107,32 +123,36 @@ class VerifySelfSessionPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - Handles startSasVerification`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
val eventSink = initialState.eventSink
|
||||
eventSink(VerifySelfSessionViewEvents.StartSasVerification)
|
||||
assertThat(initialState.step).isEqualTo(Step.Initial(false))
|
||||
initialState.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
|
||||
// Await for other device response:
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
|
||||
// ChallengeReceived:
|
||||
service.triggerReceiveVerificationData(SessionVerificationData.Emojis(emptyList()))
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
|
||||
val verifyingState = awaitItem()
|
||||
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
|
||||
assertThat(verifyingState.step).isInstanceOf(Step.Verifying::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Cancelation on initial state does nothing`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter()
|
||||
fun `present - Cancellation on initial state does nothing`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(initialState.step).isEqualTo(Step.Initial(false))
|
||||
val eventSink = initialState.eventSink
|
||||
eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
expectNoEvents()
|
||||
|
|
@ -141,92 +161,110 @@ class VerifySelfSessionPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - A failure when verifying cancels it`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
approveVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
service.shouldFail = true
|
||||
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
// Cancelling
|
||||
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
|
||||
assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidFail)
|
||||
// Cancelled
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
service.shouldFail = true
|
||||
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
service.shouldFail = false
|
||||
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidFail)
|
||||
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
cancelVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
requestVerificationAndAwaitVerifyingState(service)
|
||||
service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(SessionVerificationData.Emojis(emptyList())))
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Restart after cancelation returns to requesting verification`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
fun `present - Restart after cancellation returns to requesting verification`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
service.givenVerificationFlowState(VerificationFlowState.Canceled)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
// Went back to requesting verification
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Go back after cancelation returns to initial state`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
fun `present - Go back after cancellation returns to initial state`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
service.givenVerificationFlowState(VerificationFlowState.Canceled)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
// Went back to initial state
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
|
@ -236,7 +274,11 @@ class VerifySelfSessionPresenterTest {
|
|||
val emojis = listOf(
|
||||
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
|
||||
)
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
approveVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -246,54 +288,65 @@ class VerifySelfSessionPresenterTest {
|
|||
SessionVerificationData.Emojis(emojis)
|
||||
)
|
||||
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(
|
||||
VerificationStep.Verifying(
|
||||
assertThat(awaitItem().step).isEqualTo(
|
||||
Step.Verifying(
|
||||
SessionVerificationData.Emojis(emojis),
|
||||
AsyncData.Loading(),
|
||||
)
|
||||
)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verification is declined, the flow is canceled`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
declineVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(
|
||||
VerificationStep.Verifying(
|
||||
assertThat(awaitItem().step).isEqualTo(
|
||||
Step.Verifying(
|
||||
SessionVerificationData.Emojis(emptyList()),
|
||||
AsyncData.Loading(),
|
||||
)
|
||||
)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Skip event skips the flow`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verification is done using recovery key, the flow is completed`() = runTest {
|
||||
val service = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
givenVerificationFlowState(VerificationFlowState.Finished)
|
||||
val service = FakeSessionVerificationService(
|
||||
resetLambda = { },
|
||||
).apply {
|
||||
emitNeedsSessionVerification(false)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
}
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = service,
|
||||
|
|
@ -302,16 +355,18 @@ class VerifySelfSessionPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verification is not needed, the flow is skipped`() = runTest {
|
||||
val service = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
givenVerificationFlowState(VerificationFlowState.Finished)
|
||||
val service = FakeSessionVerificationService(
|
||||
resetLambda = { },
|
||||
).apply {
|
||||
emitNeedsSessionVerification(false)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
}
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = service,
|
||||
|
|
@ -321,16 +376,18 @@ class VerifySelfSessionPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
|
||||
val service = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
givenVerificationFlowState(VerificationFlowState.Finished)
|
||||
val service = FakeSessionVerificationService(
|
||||
resetLambda = { },
|
||||
).apply {
|
||||
emitNeedsSessionVerification(false)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
}
|
||||
val signOutLambda = lambdaRecorder<Boolean, String?> { "aUrl" }
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
|
|
@ -356,33 +413,53 @@ class VerifySelfSessionPresenterTest {
|
|||
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
|
||||
): VerifySelfSessionState {
|
||||
var state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(state.step).isEqualTo(Step.Initial(false))
|
||||
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
// Await for other device response:
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
// Await for the state to be Ready
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Ready)
|
||||
assertThat(state.step).isEqualTo(Step.Ready)
|
||||
state.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
|
||||
// Await for other device response (again):
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
fakeService.triggerReceiveVerificationData(sessionVerificationData)
|
||||
assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
// Finally, ChallengeReceived:
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(sessionVerificationData))
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
|
||||
assertThat(state.step).isInstanceOf(Step.Verifying::class.java)
|
||||
return state
|
||||
}
|
||||
|
||||
private fun unverifiedSessionService(): FakeSessionVerificationService {
|
||||
return FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
private suspend fun unverifiedSessionService(
|
||||
requestVerificationLambda: () -> Unit = { lambdaError() },
|
||||
cancelVerificationLambda: () -> Unit = { lambdaError() },
|
||||
approveVerificationLambda: () -> Unit = { lambdaError() },
|
||||
declineVerificationLambda: () -> Unit = { lambdaError() },
|
||||
startVerificationLambda: () -> Unit = { lambdaError() },
|
||||
resetLambda: (Boolean) -> Unit = { },
|
||||
acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
|
||||
acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
|
||||
): FakeSessionVerificationService {
|
||||
return FakeSessionVerificationService(
|
||||
requestVerificationLambda = requestVerificationLambda,
|
||||
cancelVerificationLambda = cancelVerificationLambda,
|
||||
approveVerificationLambda = approveVerificationLambda,
|
||||
declineVerificationLambda = declineVerificationLambda,
|
||||
startVerificationLambda = startVerificationLambda,
|
||||
resetLambda = resetLambda,
|
||||
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
|
||||
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
|
||||
).apply {
|
||||
emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createVerifySelfSessionPresenter(
|
||||
service: SessionVerificationService = unverifiedSessionService(),
|
||||
service: SessionVerificationService,
|
||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
|
|
@ -5,12 +5,14 @@
|
|||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl
|
||||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.verifysession.impl.R
|
||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -36,7 +38,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
|
||||
step = VerifySelfSessionState.Step.Canceled,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
|
|
@ -49,7 +51,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
|
||||
step = VerifySelfSessionState.Step.AwaitingOtherDeviceResponse,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
|
|
@ -62,7 +64,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
|
||||
step = VerifySelfSessionState.Step.Ready,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
|
|
@ -75,7 +77,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
|
|
@ -91,7 +93,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Loading(),
|
||||
),
|
||||
|
|
@ -107,7 +109,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
|
||||
step = VerifySelfSessionState.Step.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
|
|
@ -121,7 +123,7 @@ class VerifySelfSessionViewTest {
|
|||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
|
||||
step = VerifySelfSessionState.Step.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onFinished = callback,
|
||||
|
|
@ -137,7 +139,7 @@ class VerifySelfSessionViewTest {
|
|||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
|
||||
step = VerifySelfSessionState.Step.Initial(true),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = callback,
|
||||
|
|
@ -153,7 +155,7 @@ class VerifySelfSessionViewTest {
|
|||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
|
||||
step = VerifySelfSessionState.Step.Initial(true),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onLearnMoreClick = callback,
|
||||
|
|
@ -167,7 +169,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
|
|
@ -183,7 +185,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
|
|
@ -199,7 +201,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = true),
|
||||
step = VerifySelfSessionState.Step.Initial(canEnterRecoveryKey = true),
|
||||
displaySkipButton = true,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
|
|
@ -213,7 +215,7 @@ class VerifySelfSessionViewTest {
|
|||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Skipped,
|
||||
step = VerifySelfSessionState.Step.Skipped,
|
||||
displaySkipButton = true,
|
||||
eventSink = EnsureNeverCalledWithParam(),
|
||||
),
|
||||
|
|
@ -11,8 +11,9 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
|
|||
|
||||
const val A_FORMATTED_DATE = "formatted_date"
|
||||
|
||||
class FakeLastMessageTimestampFormatter : LastMessageTimestampFormatter {
|
||||
private var format = ""
|
||||
class FakeLastMessageTimestampFormatter(
|
||||
var format: String = "",
|
||||
) : LastMessageTimestampFormatter {
|
||||
fun givenFormat(format: String) {
|
||||
this.format = format
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -56,7 +56,27 @@ interface SessionVerificationService {
|
|||
/**
|
||||
* Returns the verification service state to the initial step.
|
||||
*/
|
||||
suspend fun reset()
|
||||
suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean)
|
||||
|
||||
/**
|
||||
* Register a listener to be notified of incoming session verification requests.
|
||||
*/
|
||||
fun setListener(listener: SessionVerificationServiceListener?)
|
||||
|
||||
/**
|
||||
* Set this particular request as the currently active one and register for
|
||||
* events pertaining it.
|
||||
*/
|
||||
suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails)
|
||||
|
||||
/**
|
||||
* Accept the previously acknowledged verification request.
|
||||
*/
|
||||
suspend fun acceptVerificationRequest()
|
||||
}
|
||||
|
||||
interface SessionVerificationServiceListener {
|
||||
fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails)
|
||||
}
|
||||
|
||||
/** Verification status of the current session. */
|
||||
|
|
@ -82,20 +102,20 @@ sealed interface VerificationFlowState {
|
|||
data object Initial : VerificationFlowState
|
||||
|
||||
/** Session verification request was accepted by another device. */
|
||||
data object AcceptedVerificationRequest : VerificationFlowState
|
||||
data object DidAcceptVerificationRequest : VerificationFlowState
|
||||
|
||||
/** Short Authentication String (SAS) verification started between the 2 devices. */
|
||||
data object StartedSasVerification : VerificationFlowState
|
||||
data object DidStartSasVerification : VerificationFlowState
|
||||
|
||||
/** Verification data for the SAS verification received. */
|
||||
data class ReceivedVerificationData(val data: SessionVerificationData) : VerificationFlowState
|
||||
data class DidReceiveVerificationData(val data: SessionVerificationData) : VerificationFlowState
|
||||
|
||||
/** Verification completed successfully. */
|
||||
data object Finished : VerificationFlowState
|
||||
data object DidFinish : VerificationFlowState
|
||||
|
||||
/** Verification was cancelled by either device. */
|
||||
data object Canceled : VerificationFlowState
|
||||
data object DidCancel : VerificationFlowState
|
||||
|
||||
/** Verification failed with an error. */
|
||||
data object Failed : VerificationFlowState
|
||||
data object DidFail : VerificationFlowState
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ package io.element.android.libraries.matrix.impl.verification
|
|||
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -28,6 +31,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.Encryption
|
||||
|
|
@ -35,13 +39,13 @@ import org.matrix.rustcomponents.sdk.RecoveryState
|
|||
import org.matrix.rustcomponents.sdk.RecoveryStateListener
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationController
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails
|
||||
import org.matrix.rustcomponents.sdk.VerificationState
|
||||
import org.matrix.rustcomponents.sdk.VerificationStateListener
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
|
||||
|
||||
class RustSessionVerificationService(
|
||||
private val client: Client,
|
||||
|
|
@ -101,6 +105,16 @@ class RustSessionVerificationService(
|
|||
.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
|
||||
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
|
||||
listener?.onIncomingSessionRequest(details.map())
|
||||
}
|
||||
|
||||
private var listener: SessionVerificationServiceListener? = null
|
||||
|
||||
override fun setListener(listener: SessionVerificationServiceListener?) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
override suspend fun requestVerification() = tryOrFail {
|
||||
initVerificationControllerIfNeeded()
|
||||
verificationController.requestVerification()
|
||||
|
|
@ -120,9 +134,24 @@ class RustSessionVerificationService(
|
|||
verificationController.startSasVerification()
|
||||
}
|
||||
|
||||
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail {
|
||||
verificationController.acknowledgeVerificationRequest(
|
||||
senderId = details.senderId.value,
|
||||
flowId = details.flowId.value,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun acceptVerificationRequest() = tryOrFail {
|
||||
verificationController.acceptVerificationRequest()
|
||||
}
|
||||
|
||||
private suspend fun tryOrFail(block: suspend () -> Unit) {
|
||||
runCatching {
|
||||
block()
|
||||
// Ensure the block cannot be cancelled, else if the Rust SDK emit a new state during the API execution,
|
||||
// the state machine may cancel the api call.
|
||||
withContext(NonCancellable) {
|
||||
block()
|
||||
}
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to verify session")
|
||||
didFail()
|
||||
|
|
@ -133,16 +162,16 @@ class RustSessionVerificationService(
|
|||
|
||||
// When verification attempt is accepted by the other device
|
||||
override fun didAcceptVerificationRequest() {
|
||||
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
|
||||
_verificationFlowState.value = VerificationFlowState.DidAcceptVerificationRequest
|
||||
}
|
||||
|
||||
override fun didCancel() {
|
||||
_verificationFlowState.value = VerificationFlowState.Canceled
|
||||
_verificationFlowState.value = VerificationFlowState.DidCancel
|
||||
}
|
||||
|
||||
override fun didFail() {
|
||||
Timber.e("Session verification failed with an unknown error")
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
_verificationFlowState.value = VerificationFlowState.DidFail
|
||||
}
|
||||
|
||||
override fun didFinish() {
|
||||
|
|
@ -158,7 +187,7 @@ class RustSessionVerificationService(
|
|||
}
|
||||
.onSuccess {
|
||||
// Order here is important, first set the flow state as finished, then update the verification status
|
||||
_verificationFlowState.value = VerificationFlowState.Finished
|
||||
_verificationFlowState.value = VerificationFlowState.DidFinish
|
||||
updateVerificationStatus()
|
||||
}
|
||||
.onFailure {
|
||||
|
|
@ -169,22 +198,18 @@ class RustSessionVerificationService(
|
|||
}
|
||||
|
||||
override fun didReceiveVerificationData(data: RustSessionVerificationData) {
|
||||
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(data.map())
|
||||
_verificationFlowState.value = VerificationFlowState.DidReceiveVerificationData(data.map())
|
||||
}
|
||||
|
||||
// When the actual SAS verification starts
|
||||
override fun didStartSasVerification() {
|
||||
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
|
||||
}
|
||||
|
||||
override fun didReceiveVerificationRequest(details: SessionVerificationRequestDetails) {
|
||||
// TODO
|
||||
_verificationFlowState.value = VerificationFlowState.DidStartSasVerification
|
||||
}
|
||||
|
||||
// end-region
|
||||
|
||||
override suspend fun reset() {
|
||||
if (isReady.value) {
|
||||
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
|
||||
if (isReady.value && cancelAnyPendingVerificationAttempt) {
|
||||
// Cancel any pending verification attempt
|
||||
tryOrNull { verificationController.cancelVerification() }
|
||||
}
|
||||
|
|
@ -213,7 +238,7 @@ class RustSessionVerificationService(
|
|||
}
|
||||
|
||||
private suspend fun updateVerificationStatus() {
|
||||
if (verificationFlowState.value == VerificationFlowState.Finished) {
|
||||
if (verificationFlowState.value == VerificationFlowState.DidFinish) {
|
||||
// Calling `encryptionService.verificationState()` performs a network call and it will deadlock if there is no network
|
||||
// So we need to check that *only* if we know there is network connection, which is the case when the verification flow just finished
|
||||
Timber.d("Updating verification status: flow just finished")
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -7,79 +7,84 @@
|
|||
|
||||
package io.element.android.libraries.matrix.test.verification
|
||||
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class FakeSessionVerificationService(
|
||||
initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown,
|
||||
private val requestVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val cancelVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val approveVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val declineVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val startVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val resetLambda: (Boolean) -> Unit = { lambdaError() },
|
||||
private val acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
|
||||
private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
|
||||
) : SessionVerificationService {
|
||||
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
|
||||
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
|
||||
private var _needsSessionVerification = MutableStateFlow(true)
|
||||
var shouldFail = false
|
||||
|
||||
override val verificationFlowState: StateFlow<VerificationFlowState> = _verificationFlowState
|
||||
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
|
||||
override val needsSessionVerification: Flow<Boolean> = _needsSessionVerification
|
||||
|
||||
override suspend fun requestVerification() {
|
||||
if (!shouldFail) {
|
||||
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
|
||||
} else {
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
}
|
||||
requestVerificationLambda()
|
||||
}
|
||||
|
||||
override suspend fun cancelVerification() {
|
||||
_verificationFlowState.value = VerificationFlowState.Canceled
|
||||
cancelVerificationLambda()
|
||||
}
|
||||
|
||||
override suspend fun approveVerification() {
|
||||
if (!shouldFail) {
|
||||
_verificationFlowState.value = VerificationFlowState.Finished
|
||||
} else {
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
}
|
||||
approveVerificationLambda()
|
||||
}
|
||||
|
||||
override suspend fun declineVerification() {
|
||||
if (!shouldFail) {
|
||||
_verificationFlowState.value = VerificationFlowState.Canceled
|
||||
} else {
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerReceiveVerificationData(sessionVerificationData: SessionVerificationData) {
|
||||
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(sessionVerificationData)
|
||||
declineVerificationLambda()
|
||||
}
|
||||
|
||||
override suspend fun startVerification() {
|
||||
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
|
||||
startVerificationLambda()
|
||||
}
|
||||
|
||||
fun givenVerifiedStatus(status: SessionVerifiedStatus) {
|
||||
_sessionVerifiedStatus.value = status
|
||||
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
|
||||
resetLambda(cancelAnyPendingVerificationAttempt)
|
||||
}
|
||||
|
||||
var listener: SessionVerificationServiceListener? = null
|
||||
private set
|
||||
|
||||
override fun setListener(listener: SessionVerificationServiceListener?) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) {
|
||||
acknowledgeVerificationRequestLambda(details)
|
||||
}
|
||||
|
||||
override suspend fun acceptVerificationRequest() = simulateLongTask {
|
||||
acceptVerificationRequestLambda()
|
||||
}
|
||||
|
||||
suspend fun emitVerificationFlowState(state: VerificationFlowState) {
|
||||
_verificationFlowState.emit(state)
|
||||
}
|
||||
|
||||
suspend fun emitVerifiedStatus(status: SessionVerifiedStatus) {
|
||||
_sessionVerifiedStatus.emit(status)
|
||||
}
|
||||
|
||||
fun givenVerificationFlowState(state: VerificationFlowState) {
|
||||
_verificationFlowState.value = state
|
||||
}
|
||||
|
||||
fun givenNeedsSessionVerification(needsVerification: Boolean) {
|
||||
_needsSessionVerification.value = needsVerification
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
_verificationFlowState.value = VerificationFlowState.Initial
|
||||
suspend fun emitNeedsSessionVerification(needsVerification: Boolean) {
|
||||
_needsSessionVerification.emit(needsVerification)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Add a link
Reference in a new issue