Implement user verification (#4294)

* Add support for starting verification of a user

* Add support for replying to incoming user verification requests

* Add reset recovery key button and previews to `ChooseSelfVerificationModeView`

* Add 'Profile' item in room details screen

* Update screenshots

* Remove `showDeviceVerifiedScreen` parameter from `NavTarget.UseAnotherDevice`

* Allow exiting the FTUE flow, which will close the app. The previous state will be restored when the app is reopened.

* When outgoing verification fails, move to the `Canceled` state. Then, when resetting the state machine state also reset the verification service.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-03-10 11:20:17 +01:00 committed by GitHub
parent 6f54de58f3
commit b91933625c
145 changed files with 1662 additions and 830 deletions

View file

@ -73,10 +73,13 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId 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.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -84,6 +87,7 @@ import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class LoggedInFlowNode @AssistedInject constructor( class LoggedInFlowNode @AssistedInject constructor(
@ -127,8 +131,18 @@ class LoggedInFlowNode @AssistedInject constructor(
) )
private val verificationListener = object : SessionVerificationServiceListener { private val verificationListener = object : SessionVerificationServiceListener {
override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) { override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails)) // Without this launch the rendering and actual state of this Appyx node's children gets out of sync, resulting in a crash.
// This might be because this method is called back from Rust in a background thread.
MainScope().launch {
// Wait until the app is in foreground to display the incoming verification request
appNavigationStateService.appNavigationState.first { it.isInForeground }
// Wait for the UI to be ready
delay(500.milliseconds)
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
}
} }
} }
@ -218,7 +232,7 @@ class LoggedInFlowNode @AssistedInject constructor(
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
@Parcelize @Parcelize
data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View file

@ -1,4 +1,5 @@
import extension.setupAnvil import extension.setupAnvil
import org.gradle.kotlin.dsl.test
/* /*
* Copyright 2023, 2024 New Vector Ltd. * Copyright 2023, 2024 New Vector Ltd.
@ -14,6 +15,12 @@ plugins {
android { android {
namespace = "io.element.android.features.ftue.impl" namespace = "io.element.android.features.ftue.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
setupAnvil() setupAnvil()
@ -30,6 +37,7 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.logout.api)
implementation(projects.features.securebackup.api) implementation(projects.features.securebackup.api)
implementation(projects.features.verifysession.api) implementation(projects.features.verifysession.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
@ -37,12 +45,16 @@ dependencies {
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop) implementation(projects.libraries.permissions.noop)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(projects.appconfig)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.analytics.noop) testImplementation(projects.services.analytics.noop)

View file

@ -16,7 +16,6 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
@ -38,8 +37,6 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -59,7 +56,6 @@ class FtueFlowNode @AssistedInject constructor(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Placeholder, initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
backPressHandler = NoOpBackstackHandlerStrategy(),
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
@ -104,7 +100,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.Placeholder -> { NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext) createNode<PlaceholderNode>(buildContext)
} }
NavTarget.SessionVerification -> { is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback { val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() { override fun onDone() {
moveToNextStepIfNeeded() moveToNextStepIfNeeded()
@ -175,11 +171,3 @@ class FtueFlowNode @AssistedInject constructor(
} }
} }
} }
private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {
override val canHandleBackPressFlow: StateFlow<Boolean> = MutableStateFlow(true)
override fun onBackPressed() {
// No-op
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.ftue.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModePresenter
import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@Module
interface FtueModule {
@Binds
fun bindChooseSelfVerificationMethodPresenter(presenter: ChooseSelfVerificationModePresenter): Presenter<ChooseSelfVerificationModeState>
}

View file

@ -9,6 +9,7 @@ package io.element.android.features.ftue.impl.sessionverification
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@ -17,15 +18,21 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.designsystem.utils.OpenUrlInTabView
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -37,7 +44,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
private val secureBackupEntryPoint: SecureBackupEntryPoint, private val secureBackupEntryPoint: SecureBackupEntryPoint,
) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>( ) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root(showDeviceVerifiedScreen = false), initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -45,7 +52,10 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
) { ) {
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data class Root(val showDeviceVerifiedScreen: Boolean) : NavTarget data object Root : NavTarget
@Parcelize
data object UseAnotherDevice : NavTarget
@Parcelize @Parcelize
data object EnterRecoveryKey : NavTarget data object EnterRecoveryKey : NavTarget
@ -62,7 +72,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
override fun onDone() { override fun onDone() {
lifecycleScope.launch { lifecycleScope.launch {
// Move to the completed state view in the verification flow // Move to the completed state view in the verification flow
backstack.newRoot(NavTarget.Root(showDeviceVerifiedScreen = true)) backstack.newRoot(NavTarget.UseAnotherDevice)
} }
} }
} }
@ -70,19 +80,43 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
is NavTarget.Root -> { is NavTarget.Root -> {
verifySessionEntryPoint.nodeBuilder(this, buildContext) val callback = object : ChooseSelfVerificationModeNode.Callback {
.params(VerifySessionEntryPoint.Params(navTarget.showDeviceVerifiedScreen)) override fun onUseAnotherDevice() {
.callback(object : VerifySessionEntryPoint.Callback { backstack.push(NavTarget.UseAnotherDevice)
override fun onEnterRecoveryKey() { }
backstack.push(NavTarget.EnterRecoveryKey)
}
override fun onUseRecoveryKey() {
backstack.push(NavTarget.EnterRecoveryKey)
}
override fun onResetKey() {
backstack.push(NavTarget.ResetIdentity)
}
override fun onLearnMoreAboutEncryption() {
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
}
}
createNode<ChooseSelfVerificationModeNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.UseAnotherDevice -> {
verifySessionEntryPoint.nodeBuilder(this, buildContext)
.params(VerifySessionEntryPoint.Params(
showDeviceVerifiedScreen = true,
verificationRequest = VerificationRequest.Outgoing.CurrentSession,
))
.callback(object : VerifySessionEntryPoint.Callback {
override fun onDone() { override fun onDone() {
plugins<Callback>().forEach { it.onDone() } plugins<Callback>().forEach { it.onDone() }
} }
override fun onResetKey() { override fun onBack() {
backstack.push(NavTarget.ResetIdentity) backstack.pop()
}
override fun onLearnMoreAboutEncryption() {
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
} }
}) })
.build() .build()
@ -106,8 +140,12 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
} }
} }
private val learnMoreUrl = mutableStateOf<String?>(null)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
BackstackView() BackstackView()
OpenUrlInTabView(learnMoreUrl)
} }
} }

View file

@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.ftue.impl.sessionverification.choosemode
sealed interface ChooseSelfVerificationModeEvent {
data object SignOut : ChooseSelfVerificationModeEvent
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.ftue.impl.sessionverification.choosemode
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.logout.api.direct.DirectLogoutView
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class ChooseSelfVerificationModeNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: Presenter<ChooseSelfVerificationModeState>,
private val directLogoutView: DirectLogoutView,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onUseAnotherDevice()
fun onUseRecoveryKey()
fun onResetKey()
fun onLearnMoreAboutEncryption()
}
private val callback = plugins<Callback>().first()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ChooseSelfVerificationModeView(
state = state,
onUseAnotherDevice = callback::onUseAnotherDevice,
onUseRecoveryKey = callback::onUseRecoveryKey,
onResetKey = callback::onResetKey,
onLearnMore = callback::onLearnMoreAboutEncryption,
modifier = modifier,
)
directLogoutView.Render(state = state.directLogoutState)
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.ftue.impl.sessionverification.choosemode
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import javax.inject.Inject
class ChooseSelfVerificationModePresenter @Inject constructor(
private val encryptionService: EncryptionService,
private val directLogoutPresenter: Presenter<DirectLogoutState>,
) : Presenter<ChooseSelfVerificationModeState> {
@Composable
override fun present(): ChooseSelfVerificationModeState {
val isLastDevice by encryptionService.isLastDevice.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val canEnterRecoveryKey by remember { derivedStateOf { recoveryState == RecoveryState.INCOMPLETE } }
val directLogoutState = directLogoutPresenter.present()
fun eventHandler(event: ChooseSelfVerificationModeEvent) {
when (event) {
ChooseSelfVerificationModeEvent.SignOut -> directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false))
}
}
return ChooseSelfVerificationModeState(
isLastDevice = isLastDevice,
canEnterRecoveryKey = canEnterRecoveryKey,
directLogoutState = directLogoutState,
eventSink = ::eventHandler,
)
}
}

View file

@ -0,0 +1,17 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.ftue.impl.sessionverification.choosemode
import io.element.android.features.logout.api.direct.DirectLogoutState
data class ChooseSelfVerificationModeState(
val isLastDevice: Boolean,
val canEnterRecoveryKey: Boolean,
val directLogoutState: DirectLogoutState,
val eventSink: (ChooseSelfVerificationModeEvent) -> Unit,
)

View file

@ -0,0 +1,31 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.ftue.impl.sessionverification.choosemode
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.aDirectLogoutState
class ChooseSelfVerificationModeStateProvider :
PreviewParameterProvider<ChooseSelfVerificationModeState> {
override val values = sequenceOf(
aChooseSelfVerificationModeState(isLastDevice = true, canEnterRecoveryKey = true),
aChooseSelfVerificationModeState(isLastDevice = true, canEnterRecoveryKey = false),
aChooseSelfVerificationModeState(isLastDevice = false, canEnterRecoveryKey = true),
aChooseSelfVerificationModeState(isLastDevice = false, canEnterRecoveryKey = false),
)
}
fun aChooseSelfVerificationModeState(
isLastDevice: Boolean = false,
canEnterRecoveryKey: Boolean = true,
) = ChooseSelfVerificationModeState(
isLastDevice = isLastDevice,
canEnterRecoveryKey = canEnterRecoveryKey,
directLogoutState = aDirectLogoutState(),
eventSink = {},
)

View file

@ -0,0 +1,127 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.ftue.impl.sessionverification.choosemode
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.ftue.impl.R
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
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.OutlinedButton
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.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChooseSelfVerificationModeView(
state: ChooseSelfVerificationModeState,
onUseAnotherDevice: () -> Unit,
onUseRecoveryKey: () -> Unit,
onResetKey: () -> Unit,
onLearnMore: () -> Unit,
modifier: Modifier = Modifier
) {
val activity = LocalActivity.current
BackHandler {
activity?.finish()
}
HeaderFooterPage(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
actions = {
TextButton(
text = stringResource(CommonStrings.action_signout),
onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) }
)
}
)
},
header = {
PageTitle(
iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()),
title = stringResource(id = R.string.screen_identity_confirmation_title),
subtitle = stringResource(id = R.string.screen_identity_confirmation_subtitle)
)
},
footer = {
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 16.dp)
) {
if (state.isLastDevice.not()) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_use_another_device),
onClick = onUseAnotherDevice,
)
}
if (state.canEnterRecoveryKey) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
onClick = onUseRecoveryKey,
)
}
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
onClick = onResetKey,
)
}
}
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Text(
modifier = Modifier
.clickable(onClick = onLearnMore)
.padding(vertical = 4.dp, horizontal = 16.dp),
text = stringResource(CommonStrings.action_learn_more),
style = ElementTheme.typography.fontBodyLgMedium
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ChooseSelfVerificationModeViewPreview(
@PreviewParameter(ChooseSelfVerificationModeStateProvider::class) state: ChooseSelfVerificationModeState
) = ElementPreview {
ChooseSelfVerificationModeView(
state = state,
onUseAnotherDevice = {},
onUseRecoveryKey = {},
onResetKey = {},
onLearnMore = {},
)
}

View file

@ -1,7 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Can\'t confirm?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new recovery key"</string>
<string name="screen_identity_confirmation_subtitle">"Verify this device to set up secure messaging."</string>
<string name="screen_identity_confirmation_title">"Confirm your identity"</string>
<string name="screen_identity_confirmation_use_another_device">"Use another device"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Use recovery key"</string>
<string name="screen_identity_confirmed_subtitle">"Now you can read or send messages securely, and anyone you chat with can also trust this device."</string>
<string name="screen_identity_confirmed_title">"Device verified"</string>
<string name="screen_identity_use_another_device">"Use another device"</string>
<string name="screen_identity_waiting_on_other_device">"Waiting on other device…"</string>
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string> <string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string> <string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
<string name="screen_session_verification_enter_recovery_key">"Enter recovery key"</string>
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string> <string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms isnt available yet."</string> <string name="screen_welcome_bullet_2">"Message history for encrypted rooms isnt available yet."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string> <string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>

View file

@ -0,0 +1,64 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.ftue.impl.sessionverification.choosemode
import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
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.test.runTest
import org.junit.Test
class ChooseSessionVerificationModePresenterTest {
@Test
fun `initial state - is relayed from EncryptionService`() = runTest {
val encryptionService = FakeEncryptionService().apply {
// Is last device
emitIsLastDevice(true)
// Can enter recovery key
emitRecoveryState(RecoveryState.INCOMPLETE)
}
val presenter = createPresenter(encryptionService = encryptionService)
presenter.test {
awaitItem().run {
assertThat(isLastDevice).isTrue()
assertThat(canEnterRecoveryKey).isTrue()
assertThat(directLogoutState.logoutAction.isUninitialized()).isTrue()
}
}
}
@Test
fun `sing out action triggers a direct logout`() = runTest {
val logoutEventRecorder = lambdaRecorder<DirectLogoutEvents, Unit> {}
val logoutPresenter = Presenter<DirectLogoutState> {
aDirectLogoutState(eventSink = logoutEventRecorder)
}
val presenter = createPresenter(directLogoutPresenter = logoutPresenter)
presenter.test {
val initial = awaitItem()
initial.eventSink(ChooseSelfVerificationModeEvent.SignOut)
logoutEventRecorder.assertions().isCalledOnce().with(value(DirectLogoutEvents.Logout(ignoreSdkError = false)))
}
}
private fun createPresenter(
encryptionService: FakeEncryptionService = FakeEncryptionService(),
directLogoutPresenter: Presenter<DirectLogoutState> = Presenter<DirectLogoutState> { aDirectLogoutState() }
) = ChooseSelfVerificationModePresenter(
encryptionService = encryptionService,
directLogoutPresenter = directLogoutPresenter,
)
}

View file

@ -0,0 +1,94 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.ftue.impl.sessionverification.choosemode
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.ftue.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class ChooseSessionVerificationModeViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on learn more invokes the expected callback`() {
ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(),
onLearnMoreClick = callback,
)
rule.clickOn(CommonStrings.action_learn_more)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on use another device calls the callback`() {
ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(isLastDevice = false),
onUseAnotherDevice = callback,
)
rule.clickOn(R.string.screen_identity_use_another_device)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on enter recovery key calls the callback`() {
ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(canEnterRecoveryKey = true),
onEnterRecoveryKey = callback,
)
rule.clickOn(R.string.screen_session_verification_enter_recovery_key)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on cannot confirm calls the reset keys callback`() {
ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(),
onResetKey = callback,
)
rule.clickOn(R.string.screen_identity_confirmation_cannot_confirm)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChooseSelfVerificationModeView(
state: ChooseSelfVerificationModeState,
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onUseAnotherDevice: () -> Unit = EnsureNeverCalled(),
onResetKey: () -> Unit = EnsureNeverCalled(),
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ChooseSelfVerificationModeView(
state = state,
onLearnMore = onLearnMoreClick,
onUseAnotherDevice = onUseAnotherDevice,
onResetKey = onResetKey,
onUseRecoveryKey = onEnterRecoveryKey,
)
}
}
}

View file

@ -8,7 +8,6 @@
package io.element.android.features.logout.impl.ui package io.element.android.features.logout.impl.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.logout.impl.R import io.element.android.features.logout.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction

View file

@ -24,6 +24,7 @@ android {
setupAnvil() setupAnvil()
dependencies { dependencies {
implementation(projects.appconfig)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
@ -51,6 +52,7 @@ dependencies {
implementation(projects.features.messages.api) implementation(projects.features.messages.api)
implementation(projects.features.roomcall.api) implementation(projects.features.roomcall.api)
implementation(projects.features.knockrequests.api) implementation(projects.features.knockrequests.api)
implementation(projects.features.verifysession.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View file

@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
@ -21,6 +22,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
@ -35,11 +37,13 @@ import io.element.android.features.roomdetails.impl.notificationsettings.RoomNot
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyFlowNode import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyFlowNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackWithOverlayBox import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.operation.hide import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.designsystem.utils.OpenUrlInTabView
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -47,6 +51,7 @@ 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.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -65,6 +70,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint, private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint, private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint, private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>( ) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(), initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -118,6 +124,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object SecurityAndPrivacy : NavTarget data object SecurityAndPrivacy : NavTarget
@Parcelize
data class VerifyUser(val userId: UserId) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -168,6 +177,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
backstack.push(NavTarget.SecurityAndPrivacy) backstack.push(NavTarget.SecurityAndPrivacy)
} }
override fun openDmUserProfile(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
override fun onJoinCall() { override fun onJoinCall() {
val inputs = CallType.RoomCall( val inputs = CallType.RoomCall(
sessionId = room.sessionId, sessionId = room.sessionId,
@ -224,6 +237,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun onStartCall(dmRoomId: RoomId) { override fun onStartCall(dmRoomId: RoomId) {
elementCallEntryPoint.startCall(CallType.RoomCall(roomId = dmRoomId, sessionId = room.sessionId)) elementCallEntryPoint.startCall(CallType.RoomCall(roomId = dmRoomId, sessionId = room.sessionId))
} }
override fun onVerifyUser(userId: UserId) {
backstack.push(NavTarget.VerifyUser(userId))
}
} }
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback) val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)
createNode<RoomMemberDetailsNode>(buildContext, plugins) createNode<RoomMemberDetailsNode>(buildContext, plugins)
@ -301,11 +318,37 @@ class RoomDetailsFlowNode @AssistedInject constructor(
NavTarget.SecurityAndPrivacy -> { NavTarget.SecurityAndPrivacy -> {
createNode<SecurityAndPrivacyFlowNode>(buildContext) createNode<SecurityAndPrivacyFlowNode>(buildContext)
} }
is NavTarget.VerifyUser -> {
val params = VerifySessionEntryPoint.Params(
showDeviceVerifiedScreen = true,
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId,)
)
verifySessionEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.callback(object : VerifySessionEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
override fun onBack() {
backstack.pop()
}
override fun onLearnMoreAboutEncryption() {
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
}
})
.build()
}
} }
} }
private val learnMoreUrl = mutableStateOf<String?>(null)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
BackstackWithOverlayBox(modifier) BackstackWithOverlayBox(modifier)
OpenUrlInTabView(learnMoreUrl)
} }
} }

View file

@ -23,6 +23,7 @@ import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -50,6 +51,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openPinnedMessagesList() fun openPinnedMessagesList()
fun openKnockRequestsList() fun openKnockRequestsList()
fun openSecurityAndPrivacy() fun openSecurityAndPrivacy()
fun openDmUserProfile(userId: UserId)
fun onJoinCall() fun onJoinCall()
} }
@ -126,6 +128,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openSecurityAndPrivacy() } callbacks.forEach { it.openSecurityAndPrivacy() }
} }
private fun onProfileClick(userId: UserId) {
callbacks.forEach { it.openDmUserProfile(userId) }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val context = LocalContext.current val context = LocalContext.current
@ -158,7 +164,8 @@ class RoomDetailsNode @AssistedInject constructor(
onJoinCallClick = ::onJoinCall, onJoinCallClick = ::onJoinCall,
onPinnedMessagesClick = ::openPinnedMessages, onPinnedMessagesClick = ::openPinnedMessages,
onKnockRequestsClick = ::openKnockRequestsLists, onKnockRequestsClick = ::openKnockRequestsLists,
onSecurityAndPrivacyClick = ::openSecurityAndPrivacy onSecurityAndPrivacyClick = ::openSecurityAndPrivacy,
onProfileClick = ::onProfileClick,
) )
} }
} }

View file

@ -70,6 +70,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.getBestName import io.element.android.libraries.matrix.api.room.getBestName
@ -101,6 +102,7 @@ fun RoomDetailsView(
onPinnedMessagesClick: () -> Unit, onPinnedMessagesClick: () -> Unit,
onKnockRequestsClick: () -> Unit, onKnockRequestsClick: () -> Unit,
onSecurityAndPrivacyClick: () -> Unit, onSecurityAndPrivacyClick: () -> Unit,
onProfileClick: (UserId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -179,11 +181,16 @@ fun RoomDetailsView(
state.eventSink(RoomDetailsEvent.SetFavorite(it)) state.eventSink(RoomDetailsEvent.SetFavorite(it))
} }
) )
if (state.canShowSecurityAndPrivacy) { if (state.canShowSecurityAndPrivacy) {
SecurityAndPrivacyItem( SecurityAndPrivacyItem(
onClick = onSecurityAndPrivacyClick onClick = onSecurityAndPrivacyClick
) )
} }
state.roomMemberDetailsState?.let { dmMemberDetails ->
ProfileItem(onClick = { onProfileClick(dmMemberDetails.userId) })
}
} }
if (state.roomType is RoomDetailsType.Room) { if (state.roomType is RoomDetailsType.Room) {
@ -546,6 +553,17 @@ private fun FavoriteItem(
) )
} }
@Composable
private fun ProfileItem(
onClick: () -> Unit,
) {
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
headlineContent = { Text(stringResource(id = R.string.screen_room_details_profile_row_title)) },
onClick = onClick,
)
}
@Composable @Composable
private fun MembersItem( private fun MembersItem(
memberCount: Long, memberCount: Long,
@ -655,5 +673,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
onPinnedMessagesClick = {}, onPinnedMessagesClick = {},
onKnockRequestsClick = {}, onKnockRequestsClick = {},
onSecurityAndPrivacyClick = {}, onSecurityAndPrivacyClick = {},
onProfileClick = {},
) )
} }

View file

@ -79,6 +79,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
onOpenDm = ::onStartDM, onOpenDm = ::onStartDM,
onStartCall = ::onStartCall, onStartCall = ::onStartCall,
openAvatarPreview = callback::openAvatarPreview, openAvatarPreview = callback::openAvatarPreview,
onVerifyClick = callback::onVerifyUser,
) )
} }
} }

View file

@ -15,7 +15,10 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
@ -294,6 +297,21 @@ class RoomDetailsViewTest {
rule.clickOn(R.string.screen_room_details_requests_to_join_title) rule.clickOn(R.string.screen_room_details_requests_to_join_title)
} }
} }
@Config(qualifiers = "h1024dp")
@Test
fun `click on profile invokes the expected callback`() {
ensureCalledOnceWithParam(A_USER_ID) { callback ->
rule.setRoomDetailView(
state = aRoomDetailsState(
eventSink = EventsRecorder(expectEvents = false),
roomMemberDetailsState = aUserProfileState(userId = A_USER_ID),
),
onProfileClick = callback,
)
rule.clickOn(R.string.screen_room_details_profile_row_title)
}
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailView(
@ -314,6 +332,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
onKnockRequestsClick: () -> Unit = EnsureNeverCalled(), onKnockRequestsClick: () -> Unit = EnsureNeverCalled(),
onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(), onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(),
onProfileClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
) { ) {
setContent { setContent {
RoomDetailsView( RoomDetailsView(
@ -332,6 +351,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
onPinnedMessagesClick = onPinnedMessagesClick, onPinnedMessagesClick = onPinnedMessagesClick,
onKnockRequestsClick = onKnockRequestsClick, onKnockRequestsClick = onKnockRequestsClick,
onSecurityAndPrivacyClick = onSecurityAndPrivacyClick, onSecurityAndPrivacyClick = onSecurityAndPrivacyClick,
onProfileClick = onProfileClick,
) )
} }
} }

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediaviewer.api) implementation(projects.libraries.mediaviewer.api)
implementation(projects.features.call.api) implementation(projects.features.call.api)
implementation(projects.features.verifysession.api)
api(projects.features.userprofile.api) api(projects.features.userprofile.api)
api(projects.features.userprofile.shared) api(projects.features.userprofile.shared)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View file

@ -25,6 +25,7 @@ import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.userprofile.impl.root.UserProfileNode import io.element.android.features.userprofile.impl.root.UserProfileNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -32,7 +33,9 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -43,6 +46,7 @@ class UserProfileFlowNode @AssistedInject constructor(
private val elementCallEntryPoint: ElementCallEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint,
private val sessionIdHolder: CurrentSessionIdHolder, private val sessionIdHolder: CurrentSessionIdHolder,
private val mediaViewerEntryPoint: MediaViewerEntryPoint, private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
) : BaseFlowNode<UserProfileFlowNode.NavTarget>( ) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@ -57,6 +61,9 @@ class UserProfileFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget
@Parcelize
data class VerifyUser(val userId: UserId) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -74,6 +81,10 @@ class UserProfileFlowNode @AssistedInject constructor(
override fun onStartCall(dmRoomId: RoomId) { override fun onStartCall(dmRoomId: RoomId) {
elementCallEntryPoint.startCall(CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = dmRoomId)) elementCallEntryPoint.startCall(CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = dmRoomId))
} }
override fun onVerifyUser(userId: UserId) {
backstack.push(NavTarget.VerifyUser(userId))
}
} }
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId) val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)
createNode<UserProfileNode>(buildContext, listOf(callback, params)) createNode<UserProfileNode>(buildContext, listOf(callback, params))
@ -96,6 +107,15 @@ class UserProfileFlowNode @AssistedInject constructor(
.callback(callback) .callback(callback)
.build() .build()
} }
is NavTarget.VerifyUser -> {
val params = VerifySessionEntryPoint.Params(
showDeviceVerifiedScreen = false,
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId)
)
verifySessionEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.build()
}
} }
} }

View file

@ -75,6 +75,7 @@ class UserProfileNode @AssistedInject constructor(
onOpenDm = ::onStartDM, onOpenDm = ::onStartDM,
onStartCall = callback::onStartCall, onStartCall = callback::onStartCall,
openAvatarPreview = callback::openAvatarPreview, openAvatarPreview = callback::openAvatarPreview,
onVerifyClick = callback::onVerifyUser,
) )
} }
} }

View file

@ -73,7 +73,6 @@ class UserProfilePresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val isCurrentUser = remember { client.isMe(userId) } val isCurrentUser = remember { client.isMe(userId) }
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) } var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) } val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val isVerified: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) } val isVerified: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
@ -86,9 +85,8 @@ class UserProfilePresenter @AssistedInject constructor(
.onEach { isBlocked.value = AsyncData.Success(it) } .onEach { isBlocked.value = AsyncData.Success(it) }
.launchIn(this) .launchIn(this)
} }
LaunchedEffect(Unit) { val userProfile by produceState<MatrixUser?>(null) { value = client.getProfile(userId).getOrNull() }
userProfile = client.getProfile(userId).getOrNull()
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
suspend { suspend {
client.encryptionService().isUserVerified(userId).getOrThrow() client.encryptionService().isUserVerified(userId).getOrThrow()

View file

@ -24,6 +24,7 @@ class UserProfileNodeHelper(
fun openAvatarPreview(username: String, avatarUrl: String) fun openAvatarPreview(username: String, avatarUrl: String)
fun onStartDM(roomId: RoomId) fun onStartDM(roomId: RoomId)
fun onStartCall(dmRoomId: RoomId) fun onStartCall(dmRoomId: RoomId)
fun onVerifyUser(userId: UserId)
} }
fun onShareUser( fun onShareUser(

View file

@ -38,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -50,6 +51,7 @@ fun UserProfileView(
onStartCall: (RoomId) -> Unit, onStartCall: (RoomId) -> Unit,
goBack: () -> Unit, goBack: () -> Unit,
openAvatarPreview: (username: String, url: String) -> Unit, openAvatarPreview: (username: String, url: String) -> Unit,
onVerifyClick: (UserId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -82,7 +84,7 @@ fun UserProfileView(
) )
Spacer(modifier = Modifier.height(26.dp)) Spacer(modifier = Modifier.height(26.dp))
if (!state.isCurrentUser) { if (!state.isCurrentUser) {
VerifyUserSection(state) VerifyUserSection(state, onVerifyClick = { onVerifyClick(state.userId) })
BlockUserSection(state) BlockUserSection(state)
BlockUserDialogs(state) BlockUserDialogs(state)
} }
@ -116,14 +118,15 @@ fun UserProfileView(
} }
@Composable @Composable
private fun VerifyUserSection(state: UserProfileState) { private fun VerifyUserSection(
state: UserProfileState,
onVerifyClick: () -> Unit,
) {
if (state.isVerified.dataOrNull() == false) { if (state.isVerified.dataOrNull() == false) {
ListItem( ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_verify_identity)) }, headlineContent = { Text(stringResource(CommonStrings.common_verify_user)) },
supportingContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_subtitle)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
enabled = false, onClick = onVerifyClick,
onClick = { },
) )
} }
} }
@ -139,6 +142,7 @@ internal fun UserProfileViewPreview(
goBack = {}, goBack = {},
onOpenDm = {}, onOpenDm = {},
onStartCall = {}, onStartCall = {},
openAvatarPreview = { _, _ -> } openAvatarPreview = { _, _ -> },
onVerifyClick = {},
) )
} }

View file

@ -20,8 +20,10 @@ import io.element.android.features.userprofile.shared.UserProfileView
import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -193,6 +195,17 @@ class UserProfileViewTest {
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog) eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
} }
@Test
fun `on verify user clicked - the right callback is called`() = runTest {
ensureCalledOnceWithParam(A_USER_ID) { callback ->
rule.setUserProfileView(
state = aUserProfileState(userId = A_USER_ID, isVerified = AsyncData.Success(false)),
onVerifyClick = callback,
)
rule.clickOn(CommonStrings.common_verify_user)
}
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserProfileView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserProfileView(
@ -202,6 +215,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserP
onShareUser: () -> Unit = EnsureNeverCalled(), onShareUser: () -> Unit = EnsureNeverCalled(),
onDmStarted: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onDmStarted: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onStartCall: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onStartCall: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onVerifyClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
goBack: () -> Unit = EnsureNeverCalled(), goBack: () -> Unit = EnsureNeverCalled(),
openAvatarPreview: (String, String) -> Unit = EnsureNeverCalledWithTwoParams(), openAvatarPreview: (String, String) -> Unit = EnsureNeverCalledWithTwoParams(),
) { ) {
@ -213,6 +227,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserP
onStartCall = onStartCall, onStartCall = onStartCall,
goBack = goBack, goBack = goBack,
openAvatarPreview = openAvatarPreview, openAvatarPreview = openAvatarPreview,
onVerifyClick = onVerifyClick,
) )
} }
} }

View file

@ -12,11 +12,11 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.VerificationRequest
interface IncomingVerificationEntryPoint : FeatureEntryPoint { interface IncomingVerificationEntryPoint : FeatureEntryPoint {
data class Params( data class Params(
val sessionVerificationRequestDetails: SessionVerificationRequestDetails, val verificationRequest: VerificationRequest.Incoming,
) : NodeInputs ) : NodeInputs
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder

View file

@ -12,9 +12,13 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.verification.VerificationRequest
interface VerifySessionEntryPoint : FeatureEntryPoint { interface VerifySessionEntryPoint : FeatureEntryPoint {
data class Params(val showDeviceVerifiedScreen: Boolean) : NodeInputs data class Params(
val showDeviceVerifiedScreen: Boolean,
val verificationRequest: VerificationRequest.Outgoing,
) : NodeInputs
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
@ -25,8 +29,8 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
} }
interface Callback : Plugin { interface Callback : Plugin {
fun onEnterRecoveryKey() fun onLearnMoreAboutEncryption()
fun onResetKey() fun onBack()
fun onDone() fun onDone()
} }
} }

View file

@ -28,7 +28,7 @@ class IncomingVerificationNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins), ) : Node(buildContext, plugins = plugins),
IncomingVerificationNavigator { IncomingVerificationNavigator {
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
sessionVerificationRequestDetails = inputs<IncomingVerificationEntryPoint.Params>().sessionVerificationRequestDetails, verificationRequest = inputs<IncomingVerificationEntryPoint.Params>().verificationRequest,
navigator = this, navigator = this,
) )

View file

@ -10,10 +10,12 @@
package io.element.android.features.verifysession.impl.incoming package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.freeletics.flowredux.compose.rememberStateAndDispatch import com.freeletics.flowredux.compose.rememberStateAndDispatch
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@ -22,20 +24,25 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails 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.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber 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.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState
class IncomingVerificationPresenter @AssistedInject constructor( class IncomingVerificationPresenter @AssistedInject constructor(
@Assisted private val sessionVerificationRequestDetails: SessionVerificationRequestDetails, @Assisted private val verificationRequest: VerificationRequest.Incoming,
@Assisted private val navigator: IncomingVerificationNavigator, @Assisted private val navigator: IncomingVerificationNavigator,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val stateMachine: IncomingVerificationStateMachine, private val stateMachine: IncomingVerificationStateMachine,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
@ -43,49 +50,63 @@ class IncomingVerificationPresenter @AssistedInject constructor(
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
sessionVerificationRequestDetails: SessionVerificationRequestDetails, verificationRequest: VerificationRequest.Incoming,
navigator: IncomingVerificationNavigator, navigator: IncomingVerificationNavigator,
): IncomingVerificationPresenter ): IncomingVerificationPresenter
} }
@Composable @Composable
override fun present(): IncomingVerificationState { override fun present(): IncomingVerificationState {
LaunchedEffect(Unit) { val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
// 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 stateAndDispatch = stateMachine.rememberStateAndDispatch()
DisposableEffect(Unit) {
coroutineScope.launch {
// Force reset, just in case the service was left in a broken state
sessionVerificationService.reset(
cancelAnyPendingVerificationAttempt = false
)
// Start this after observing state machine
observeVerificationService()
// Acknowledge the request right now
sessionVerificationService.acknowledgeVerificationRequest(verificationRequest)
}
onDispose {
sessionCoroutineScope.launch {
val currentState = stateAndDispatch.state.value
sessionVerificationService.reset(
cancelAnyPendingVerificationAttempt = currentState?.isPending() == true,
)
}
}
}
val formattedSignInTime = remember { val formattedSignInTime = remember {
dateFormatter.format( dateFormatter.format(
timestamp = sessionVerificationRequestDetails.firstSeenTimestamp, timestamp = verificationRequest.details.firstSeenTimestamp,
mode = DateFormatterMode.TimeOrDate, mode = DateFormatterMode.TimeOrDate,
) )
} }
val step by remember { val step by remember {
derivedStateOf { derivedStateOf {
stateAndDispatch.state.value.toVerificationStep( stateAndDispatch.state.value.toVerificationStep(
sessionVerificationRequestDetails = sessionVerificationRequestDetails, sessionVerificationRequestDetails = verificationRequest.details,
formattedSignInTime = formattedSignInTime, formattedSignInTime = formattedSignInTime,
) )
} }
} }
LaunchedEffect(stateAndDispatch.state.value) { LaunchedEffect(stateAndDispatch.state.value) {
if ((stateAndDispatch.state.value as? IncomingVerificationStateMachine.State.Initial)?.isCancelled == true) { if ((stateAndDispatch.state.value as? StateMachineState.Initial)?.isCancelled == true) {
// The verification was canceled before it was started, maybe because another session accepted it // The verification was canceled before it was started, maybe because another session accepted it
navigator.onFinish() navigator.onFinish()
} }
} }
// Start this after observing state machine
LaunchedEffect(Unit) {
observeVerificationService()
}
fun handleEvents(event: IncomingVerificationViewEvents) { fun handleEvents(event: IncomingVerificationViewEvents) {
Timber.d("Verification user action: ${event::class.simpleName}") Timber.d("Verification user action: ${event::class.simpleName}")
when (event) { when (event) {
@ -119,6 +140,7 @@ class IncomingVerificationPresenter @AssistedInject constructor(
return IncomingVerificationState( return IncomingVerificationState(
step = step, step = step,
request = verificationRequest,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -129,36 +151,36 @@ class IncomingVerificationPresenter @AssistedInject constructor(
): Step = ): Step =
when (val machineState = this) { when (val machineState = this) {
is StateMachineState.Initial, is StateMachineState.Initial,
IncomingVerificationStateMachine.State.AcceptingIncomingVerification, StateMachineState.AcceptingIncomingVerification,
IncomingVerificationStateMachine.State.RejectingIncomingVerification, StateMachineState.RejectingIncomingVerification,
null -> { null -> {
Step.Initial( Step.Initial(
deviceDisplayName = sessionVerificationRequestDetails.displayName ?: sessionVerificationRequestDetails.deviceId.value, deviceDisplayName = sessionVerificationRequestDetails.senderProfile.displayName ?: sessionVerificationRequestDetails.deviceId.value,
deviceId = sessionVerificationRequestDetails.deviceId, deviceId = sessionVerificationRequestDetails.deviceId,
formattedSignInTime = formattedSignInTime, formattedSignInTime = formattedSignInTime,
isWaiting = machineState == IncomingVerificationStateMachine.State.AcceptingIncomingVerification || isWaiting = machineState == StateMachineState.AcceptingIncomingVerification ||
machineState == IncomingVerificationStateMachine.State.RejectingIncomingVerification, machineState == StateMachineState.RejectingIncomingVerification,
) )
} }
is IncomingVerificationStateMachine.State.ChallengeReceived -> is StateMachineState.ChallengeReceived ->
Step.Verifying( Step.Verifying(
data = machineState.data, data = machineState.data,
isWaiting = false, isWaiting = false,
) )
IncomingVerificationStateMachine.State.Completed -> Step.Completed StateMachineState.Completed -> Step.Completed
IncomingVerificationStateMachine.State.Canceling, StateMachineState.Canceling,
IncomingVerificationStateMachine.State.Failure -> Step.Failure StateMachineState.Failure -> Step.Failure
is IncomingVerificationStateMachine.State.AcceptingChallenge -> is StateMachineState.AcceptingChallenge ->
Step.Verifying( Step.Verifying(
data = machineState.data, data = machineState.data,
isWaiting = true, isWaiting = true,
) )
is IncomingVerificationStateMachine.State.RejectingChallenge -> is StateMachineState.RejectingChallenge ->
Step.Verifying( Step.Verifying(
data = machineState.data, data = machineState.data,
isWaiting = true, isWaiting = true,
) )
IncomingVerificationStateMachine.State.Canceled -> Step.Canceled StateMachineState.Canceled -> Step.Canceled
} }
private fun CoroutineScope.observeVerificationService() { private fun CoroutineScope.observeVerificationService() {
@ -170,10 +192,10 @@ class IncomingVerificationPresenter @AssistedInject constructor(
VerificationFlowState.DidAcceptVerificationRequest, VerificationFlowState.DidAcceptVerificationRequest,
VerificationFlowState.DidStartSasVerification -> Unit VerificationFlowState.DidStartSasVerification -> Unit
is VerificationFlowState.DidReceiveVerificationData -> { is VerificationFlowState.DidReceiveVerificationData -> {
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data)) stateMachine.dispatch(StateMachineEvent.DidReceiveChallenge(verificationAttemptState.data))
} }
VerificationFlowState.DidFinish -> { VerificationFlowState.DidFinish -> {
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidAcceptChallenge) stateMachine.dispatch(StateMachineEvent.DidAcceptChallenge)
} }
VerificationFlowState.DidCancel -> { VerificationFlowState.DidCancel -> {
// Can happen when: // Can happen when:
@ -181,10 +203,10 @@ class IncomingVerificationPresenter @AssistedInject constructor(
// - another session has accepted the incoming verification request // - 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 // - 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. // machine will ignore this event and change state to Failure.
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidCancel) stateMachine.dispatch(StateMachineEvent.DidCancel)
} }
VerificationFlowState.DidFail -> { VerificationFlowState.DidFail -> {
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidFail) stateMachine.dispatch(StateMachineEvent.DidFail)
} }
} }
} }

View file

@ -11,10 +11,12 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
@Immutable @Immutable
data class IncomingVerificationState( data class IncomingVerificationState(
val step: Step, val step: Step,
val request: VerificationRequest.Incoming,
val eventSink: (IncomingVerificationViewEvents) -> Unit, val eventSink: (IncomingVerificationViewEvents) -> Unit,
) { ) {
@Stable @Stable

View file

@ -26,7 +26,7 @@ class IncomingVerificationStateMachine @Inject constructor(
init { init {
spec { spec {
inState<State.Initial> { inState<State.Initial> {
on { _: Event.AcceptIncomingRequest, state -> on<Event.AcceptIncomingRequest> { _, state ->
state.override { State.AcceptingIncomingVerification.andLogStateChange() } state.override { State.AcceptingIncomingVerification.andLogStateChange() }
} }
} }
@ -39,23 +39,23 @@ class IncomingVerificationStateMachine @Inject constructor(
} }
} }
inState<State.ChallengeReceived> { inState<State.ChallengeReceived> {
on { _: Event.AcceptChallenge, state -> on<Event.AcceptChallenge> { _, state ->
state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() } state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() }
} }
on { _: Event.DeclineChallenge, state -> on<Event.DeclineChallenge> { _, state ->
state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() } state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() }
} }
} }
inState<State.AcceptingChallenge> { inState<State.AcceptingChallenge> {
onEnterEffect { _ -> onEnterEffect {
sessionVerificationService.approveVerification() sessionVerificationService.approveVerification()
} }
on { _: Event.DidAcceptChallenge, state -> on<Event.DidAcceptChallenge> { _, state ->
state.override { State.Completed.andLogStateChange() } state.override { State.Completed.andLogStateChange() }
} }
} }
inState<State.RejectingChallenge> { inState<State.RejectingChallenge> {
onEnterEffect { _ -> onEnterEffect {
sessionVerificationService.declineVerification() sessionVerificationService.declineVerification()
} }
} }
@ -66,7 +66,7 @@ class IncomingVerificationStateMachine @Inject constructor(
} }
inState { inState {
logReceivedEvents() logReceivedEvents()
on { _: Event.Cancel, state: MachineState<State> -> on<Event.Cancel> { _, state: MachineState<State> ->
when (state.snapshot) { when (state.snapshot) {
State.Completed, State.Canceled -> state.noChange() State.Completed, State.Canceled -> state.noChange()
else -> { else -> {
@ -75,7 +75,7 @@ class IncomingVerificationStateMachine @Inject constructor(
} }
} }
} }
on { _: Event.DidCancel, state: MachineState<State> -> on<Event.DidCancel> { _, state: MachineState<State> ->
when (state.snapshot) { when (state.snapshot) {
is State.RejectingChallenge -> { is State.RejectingChallenge -> {
state.override { State.Failure.andLogStateChange() } state.override { State.Failure.andLogStateChange() }
@ -91,7 +91,7 @@ class IncomingVerificationStateMachine @Inject constructor(
State.Failure -> state.noChange() State.Failure -> state.noChange()
} }
} }
on { _: Event.DidFail, state: MachineState<State> -> on<Event.DidFail> { _, state: MachineState<State> ->
state.override { State.Failure.andLogStateChange() } state.override { State.Failure.andLogStateChange() }
} }
} }
@ -128,6 +128,11 @@ class IncomingVerificationStateMachine @Inject constructor(
/** Verification failure. */ /** Verification failure. */
data object Failure : State data object Failure : State
fun isPending(): Boolean = when (this) {
AcceptingIncomingVerification, RejectingIncomingVerification, Failure, is ChallengeReceived, is AcceptingChallenge, is RejectingChallenge -> true
is Initial, Canceling, Canceled, Completed -> false
}
} }
sealed interface Event { sealed interface Event {

View file

@ -12,16 +12,32 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.matrix.api.core.DeviceId 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 io.element.android.libraries.matrix.api.verification.VerificationRequest
open class IncomingVerificationStateProvider : PreviewParameterProvider<IncomingVerificationState> { open class IncomingVerificationStateProvider : PreviewParameterProvider<IncomingVerificationState> {
override val values: Sequence<IncomingVerificationState> override val values: Sequence<IncomingVerificationState>
get() = sequenceOf( get() = sequenceOf(
anIncomingVerificationState(), anIncomingVerificationState(),
anIncomingVerificationState(step = aStepInitial(isWaiting = true)), anIncomingVerificationState(step = aStepInitial(isWaiting = false), verificationRequest = anIncomingSessionVerificationRequest()),
anIncomingVerificationState(step = aStepInitial(isWaiting = false), verificationRequest = anIncomingUserVerificationRequest()),
anIncomingVerificationState(step = aStepInitial(isWaiting = true), verificationRequest = anIncomingSessionVerificationRequest()),
anIncomingVerificationState(step = aStepInitial(isWaiting = true), verificationRequest = anIncomingUserVerificationRequest()),
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)), anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)),
anIncomingVerificationState(
step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false),
verificationRequest = anIncomingUserVerificationRequest()
),
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)), anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)),
anIncomingVerificationState(
step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true),
verificationRequest = anIncomingUserVerificationRequest()
),
anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)), anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)),
anIncomingVerificationState(step = Step.Completed), anIncomingVerificationState(step = Step.Completed),
anIncomingVerificationState(step = Step.Completed, verificationRequest = anIncomingUserVerificationRequest()),
anIncomingVerificationState(step = Step.Failure), anIncomingVerificationState(step = Step.Failure),
anIncomingVerificationState(step = Step.Canceled), anIncomingVerificationState(step = Step.Canceled),
// Add other state here // Add other state here
@ -37,10 +53,38 @@ internal fun aStepInitial(
isWaiting = isWaiting, isWaiting = isWaiting,
) )
internal fun anIncomingSessionVerificationRequest() = VerificationRequest.Incoming.OtherSession(
details = SessionVerificationRequestDetails(
senderProfile = SessionVerificationRequestDetails.SenderProfile(
userId = UserId("@alice:example.com"),
displayName = "Alice",
avatarUrl = null,
),
flowId = FlowId("1234"),
deviceId = DeviceId("ILAKNDNASDLK"),
firstSeenTimestamp = 0,
)
)
internal fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User(
details = SessionVerificationRequestDetails(
senderProfile = SessionVerificationRequestDetails.SenderProfile(
userId = UserId("@alice:example.com"),
displayName = "Alice",
avatarUrl = null,
),
flowId = FlowId("1234"),
deviceId = DeviceId("ILAKNDNASDLK"),
firstSeenTimestamp = 0,
)
)
internal fun anIncomingVerificationState( internal fun anIncomingVerificationState(
step: Step = aStepInitial(), step: Step = aStepInitial(),
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest(),
eventSink: (IncomingVerificationViewEvents) -> Unit = {}, eventSink: (IncomingVerificationViewEvents) -> Unit = {},
) = IncomingVerificationState( ) = IncomingVerificationState(
step = step, step = step,
request = verificationRequest,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -27,17 +27,19 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat
import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView 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.VerificationBottomMenu
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
import io.element.android.features.verifysession.impl.ui.VerificationUserProfileContent
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Button
import io.element.android.libraries.designsystem.theme.components.InvisibleButton
import io.element.android.libraries.designsystem.theme.components.Text 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.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
/** /**
@ -59,10 +61,17 @@ fun IncomingVerificationView(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = {
when {
step is Step.Initial && !step.isWaiting -> Unit
step is Step.Completed -> Unit
else -> BackButton(onClick = { state.eventSink(IncomingVerificationViewEvents.GoBack) })
}
}
) )
}, },
header = { header = {
IncomingVerificationHeader(step = step) IncomingVerificationHeader(step = step, request = state.request)
}, },
footer = { footer = {
IncomingVerificationBottomMenu( IncomingVerificationBottomMenu(
@ -72,37 +81,58 @@ fun IncomingVerificationView(
) { ) {
IncomingVerificationContent( IncomingVerificationContent(
step = step, step = step,
request = state.request,
) )
} }
} }
@Composable @Composable
private fun IncomingVerificationHeader(step: Step) { private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.Incoming) {
val iconStyle = when (step) { val iconStyle = when (step) {
Step.Canceled -> BigIcon.Style.AlertSolid Step.Canceled -> BigIcon.Style.AlertSolid
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid()) is Step.Initial -> if (step.isWaiting) {
is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction()) BigIcon.Style.Loading
} else {
when (request) {
is VerificationRequest.Incoming.OtherSession -> BigIcon.Style.Default(CompoundIcons.LockSolid())
is VerificationRequest.Incoming.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid())
}
}
is Step.Verifying -> if (step.isWaiting) {
BigIcon.Style.Loading
} else {
BigIcon.Style.Default(CompoundIcons.ReactionSolid())
}
Step.Completed -> BigIcon.Style.SuccessSolid Step.Completed -> BigIcon.Style.SuccessSolid
Step.Failure -> BigIcon.Style.AlertSolid Step.Failure -> BigIcon.Style.AlertSolid
} }
val titleTextId = when (step) { val titleTextId = when (step) {
Step.Canceled -> R.string.screen_session_verification_request_failure_title Step.Canceled -> CommonStrings.common_verification_failed
is Step.Initial -> R.string.screen_session_verification_request_title is Step.Initial -> R.string.screen_session_verification_request_title
is Step.Verifying -> when (step.data) { is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
} }
Step.Completed -> R.string.screen_session_verification_request_success_title Step.Completed -> CommonStrings.common_verification_complete
Step.Failure -> R.string.screen_session_verification_request_failure_title Step.Failure -> R.string.screen_session_verification_request_failure_title
} }
val subtitleTextId = when (step) { val subtitleTextId = when (step) {
Step.Canceled -> R.string.screen_session_verification_request_failure_subtitle Step.Canceled -> R.string.screen_session_verification_request_failure_subtitle
is Step.Initial -> R.string.screen_session_verification_request_subtitle is Step.Initial -> when (request) {
is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_request_subtitle
is VerificationRequest.Incoming.User -> R.string.screen_session_verification_user_responder_subtitle
}
is Step.Verifying -> when (step.data) { is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle is SessionVerificationData.Emojis -> when (request) {
is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_compare_emojis_subtitle
is VerificationRequest.Incoming.User -> R.string.screen_session_verification_compare_emojis_user_subtitle
}
}
Step.Completed -> when (request) {
is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_complete_subtitle
is VerificationRequest.Incoming.User -> R.string.screen_session_verification_complete_user_subtitle
} }
Step.Completed -> R.string.screen_session_verification_request_success_subtitle
Step.Failure -> R.string.screen_session_verification_request_failure_subtitle Step.Failure -> R.string.screen_session_verification_request_failure_subtitle
} }
PageTitle( PageTitle(
@ -115,9 +145,10 @@ private fun IncomingVerificationHeader(step: Step) {
@Composable @Composable
private fun IncomingVerificationContent( private fun IncomingVerificationContent(
step: Step, step: Step,
request: VerificationRequest.Incoming,
) { ) {
when (step) { when (step) {
is Step.Initial -> ContentInitial(step) is Step.Initial -> ContentInitial(step, request)
is Step.Verifying -> VerificationContentVerifying(step.data) is Step.Verifying -> VerificationContentVerifying(step.data)
else -> Unit else -> Unit
} }
@ -126,24 +157,40 @@ private fun IncomingVerificationContent(
@Composable @Composable
private fun ContentInitial( private fun ContentInitial(
initialIncoming: Step.Initial, initialIncoming: Step.Initial,
request: VerificationRequest.Incoming,
) { ) {
Column( when (request) {
modifier = Modifier.fillMaxWidth(), is VerificationRequest.Incoming.OtherSession -> {
verticalArrangement = Arrangement.spacedBy(24.dp), Column(
) { modifier = Modifier.fillMaxWidth(),
SessionDetailsView( verticalArrangement = Arrangement.spacedBy(24.dp),
deviceName = initialIncoming.deviceDisplayName, ) {
deviceId = initialIncoming.deviceId, SessionDetailsView(
signInFormattedTimestamp = initialIncoming.formattedSignInTime, deviceName = initialIncoming.deviceDisplayName,
) deviceId = initialIncoming.deviceId,
Text( signInFormattedTimestamp = initialIncoming.formattedSignInTime,
modifier = Modifier )
.align(Alignment.CenterHorizontally) Text(
.padding(bottom = 16.dp), modifier = Modifier
text = stringResource(R.string.screen_session_verification_request_footer), .align(Alignment.CenterHorizontally)
style = ElementTheme.typography.fontBodyMdMedium, .padding(bottom = 16.dp),
textAlign = TextAlign.Center, text = stringResource(R.string.screen_session_verification_request_footer),
) style = ElementTheme.typography.fontBodyMdMedium,
textAlign = TextAlign.Center,
)
}
}
is VerificationRequest.Incoming.User -> {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
) {
VerificationUserProfileContent(
userId = request.details.senderProfile.userId,
displayName = request.details.senderProfile.displayName,
avatarUrl = request.details.senderProfile.avatarUrl,
)
}
}
} }
} }
@ -157,16 +204,7 @@ private fun IncomingVerificationBottomMenu(
when (step) { when (step) {
is Step.Initial -> { is Step.Initial -> {
if (step.isWaiting) { if (step.isWaiting) {
VerificationBottomMenu { // Show nothing
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_waiting_on_other_device),
onClick = {},
enabled = false,
showProgress = true,
)
InvisibleButton()
}
} else { } else {
VerificationBottomMenu { VerificationBottomMenu {
Button( Button(
@ -184,16 +222,7 @@ private fun IncomingVerificationBottomMenu(
} }
is Step.Verifying -> { is Step.Verifying -> {
if (step.isWaiting) { if (step.isWaiting) {
VerificationBottomMenu { // Show nothing
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing),
onClick = {},
enabled = false,
showProgress = true,
)
InvisibleButton()
}
} else { } else {
VerificationBottomMenu { VerificationBottomMenu {
Button( Button(

View file

@ -7,8 +7,6 @@
package io.element.android.features.verifysession.impl.outgoing package io.element.android.features.verifysession.impl.outgoing
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@ -18,10 +16,7 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.verifysession.api.VerifySessionEntryPoint import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ -33,28 +28,22 @@ class VerifySelfSessionNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val callback = plugins<VerifySessionEntryPoint.Callback>().first() private val callback = plugins<VerifySessionEntryPoint.Callback>().first()
private val presenter = presenterFactory.create( private val inputs = inputs<VerifySessionEntryPoint.Params>()
showDeviceVerifiedScreen = inputs<VerifySessionEntryPoint.Params>().showDeviceVerifiedScreen,
)
private fun onLearnMoreClick(activity: Activity, dark: Boolean) { private val presenter = presenterFactory.create(
activity.openUrlInChromeCustomTab(null, dark, LearnMoreConfig.ENCRYPTION_URL) showDeviceVerifiedScreen = inputs.showDeviceVerifiedScreen,
} verificationRequest = inputs.verificationRequest,
)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
VerifySelfSessionView( VerifySelfSessionView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onLearnMoreClick = { onLearnMoreClick = callback::onLearnMoreAboutEncryption,
onLearnMoreClick(activity, isDark)
},
onEnterRecoveryKey = callback::onEnterRecoveryKey,
onResetKey = callback::onResetKey,
onFinish = callback::onDone, onFinish = callback::onDone,
onBack = callback::onBack,
) )
} }
} }

View file

@ -11,137 +11,113 @@ package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.freeletics.flowredux.compose.rememberStateAndDispatch import com.freeletics.flowredux.compose.rememberStateAndDispatch
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
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.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerificationService 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.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.matrix.api.verification.VerificationRequest
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber 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.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState
class VerifySelfSessionPresenter @AssistedInject constructor( class VerifySelfSessionPresenter @AssistedInject constructor(
@Assisted private val showDeviceVerifiedScreen: Boolean, @Assisted private val showDeviceVerifiedScreen: Boolean,
@Assisted private val verificationRequest: VerificationRequest.Outgoing,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val stateMachine: VerifySelfSessionStateMachine,
private val buildMeta: BuildMeta,
private val sessionPreferencesStore: SessionPreferencesStore,
private val logoutUseCase: LogoutUseCase,
) : Presenter<VerifySelfSessionState> { ) : Presenter<VerifySelfSessionState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(showDeviceVerifiedScreen: Boolean): VerifySelfSessionPresenter fun create(
verificationRequest: VerificationRequest.Outgoing,
showDeviceVerifiedScreen: Boolean,
): VerifySelfSessionPresenter
} }
private val stateMachine = VerifySelfSessionStateMachine(
sessionVerificationService = sessionVerificationService,
encryptionService = encryptionService,
)
@Composable @Composable
override fun present(): VerifySelfSessionState { override fun present(): VerifySelfSessionState {
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state
sessionVerificationService.reset(true)
}
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val stateAndDispatch = stateMachine.rememberStateAndDispatch() val stateAndDispatch = stateMachine.rememberStateAndDispatch()
val skipVerification by sessionPreferencesStore.isSessionVerificationSkipped().collectAsState(initial = false)
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState() val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
val signOutAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val step by remember { val step by remember {
derivedStateOf { derivedStateOf {
if (skipVerification) { when (verificationRequest) {
VerifySelfSessionState.Step.Skipped is VerificationRequest.Outgoing.CurrentSession -> {
} else { when (sessionVerifiedStatus) {
when (sessionVerifiedStatus) { SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading SessionVerifiedStatus.NotVerified -> {
SessionVerifiedStatus.NotVerified -> { stateAndDispatch.state.value.toVerificationStep()
stateAndDispatch.state.value.toVerificationStep( }
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE SessionVerifiedStatus.Verified -> {
) if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
} // The user has verified the session, we need to show the success screen
SessionVerifiedStatus.Verified -> { VerifySelfSessionState.Step.Completed
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) { } else {
// The user has verified the session, we need to show the success screen // Automatic verification, which can happen on freshly created account, in this case, skip the screen
VerifySelfSessionState.Step.Completed VerifySelfSessionState.Step.Exit
} else { }
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
VerifySelfSessionState.Step.Skipped
} }
} }
} }
is VerificationRequest.Outgoing.User -> stateAndDispatch.state.value.toVerificationStep()
} }
} }
} }
// Start this after observing state machine // Start this after observing state machine
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state
sessionVerificationService.reset(cancelAnyPendingVerificationAttempt = true)
observeVerificationService() observeVerificationService()
} }
fun handleEvents(event: VerifySelfSessionViewEvents) { fun handleEvents(event: VerifySelfSessionViewEvents) {
Timber.d("Verification user action: ${event::class.simpleName}") Timber.d("Verification user action: ${event::class.simpleName}")
when (event) { when (event) {
VerifySelfSessionViewEvents.UseAnotherDevice -> stateAndDispatch.dispatchAction(StateMachineEvent.UseAnotherDevice) // Just relay the event to the state machine
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification) VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification(verificationRequest))
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification) VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge) VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge) VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel) VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset) VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
VerifySelfSessionViewEvents.SignOut -> coroutineScope.signOut(signOutAction)
VerifySelfSessionViewEvents.SkipVerification -> coroutineScope.launch {
sessionPreferencesStore.setSkipSessionVerification(true)
}
} }
} }
return VerifySelfSessionState( return VerifySelfSessionState(
step = step, step = step,
signOutAction = signOutAction.value, request = verificationRequest,
displaySkipButton = buildMeta.isDebuggable,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
private fun StateMachineState?.toVerificationStep( private fun StateMachineState?.toVerificationStep(): VerifySelfSessionState.Step =
canEnterRecoveryKey: Boolean
): VerifySelfSessionState.Step =
when (val machineState = this) { when (val machineState = this) {
StateMachineState.Initial, null -> { StateMachineState.Initial, null -> {
VerifySelfSessionState.Step.Initial( VerifySelfSessionState.Step.Initial
canEnterRecoveryKey = canEnterRecoveryKey,
isLastDevice = encryptionService.isLastDevice.value
)
} }
VerifySelfSessionStateMachine.State.UseAnotherDevice -> { is StateMachineState.RequestingVerification,
VerifySelfSessionState.Step.UseAnotherDevice is StateMachineState.StartingSasVerification,
} StateMachineState.SasVerificationStarted -> {
StateMachineState.RequestingVerification,
StateMachineState.StartingSasVerification,
StateMachineState.SasVerificationStarted,
StateMachineState.Canceling -> {
VerifySelfSessionState.Step.AwaitingOtherDeviceResponse VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
} }
@ -149,7 +125,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
VerifySelfSessionState.Step.Ready VerifySelfSessionState.Step.Ready
} }
StateMachineState.Canceled -> { is StateMachineState.Canceled -> {
VerifySelfSessionState.Step.Canceled VerifySelfSessionState.Step.Canceled
} }
@ -164,6 +140,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
StateMachineState.Completed -> { StateMachineState.Completed -> {
VerifySelfSessionState.Step.Completed VerifySelfSessionState.Step.Completed
} }
StateMachineState.Exit -> {
VerifySelfSessionState.Step.Exit
}
} }
private fun CoroutineScope.observeVerificationService() { private fun CoroutineScope.observeVerificationService() {
@ -171,33 +151,27 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") } .onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
.onEach { verificationAttemptState -> .onEach { verificationAttemptState ->
when (verificationAttemptState) { when (verificationAttemptState) {
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset) VerificationFlowState.Initial -> stateMachine.dispatch(StateMachineEvent.Reset)
VerificationFlowState.DidAcceptVerificationRequest -> { VerificationFlowState.DidAcceptVerificationRequest -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest) stateMachine.dispatch(StateMachineEvent.DidAcceptVerificationRequest)
} }
VerificationFlowState.DidStartSasVerification -> { VerificationFlowState.DidStartSasVerification -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification) stateMachine.dispatch(StateMachineEvent.DidStartSasVerification)
} }
is VerificationFlowState.DidReceiveVerificationData -> { is VerificationFlowState.DidReceiveVerificationData -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data)) stateMachine.dispatch(StateMachineEvent.DidReceiveChallenge(verificationAttemptState.data))
} }
VerificationFlowState.DidFinish -> { VerificationFlowState.DidFinish -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge) stateMachine.dispatch(StateMachineEvent.DidAcceptChallenge)
} }
VerificationFlowState.DidCancel -> { VerificationFlowState.DidCancel -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel) stateMachine.dispatch(StateMachineEvent.DidCancel)
} }
VerificationFlowState.DidFail -> { VerificationFlowState.DidFail -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail) stateMachine.dispatch(StateMachineEvent.DidFail)
} }
} }
} }
.launchIn(this) .launchIn(this)
} }
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<Unit>>) = launch {
suspend {
logoutUseCase.logout(ignoreSdkError = true)
}.runCatchingUpdatingState(signOutAction)
}
} }

View file

@ -9,29 +9,25 @@ package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
@Immutable @Immutable
data class VerifySelfSessionState( data class VerifySelfSessionState(
val step: Step, val step: Step,
val signOutAction: AsyncAction<Unit>, val request: VerificationRequest.Outgoing,
val displaySkipButton: Boolean,
val eventSink: (VerifySelfSessionViewEvents) -> Unit, val eventSink: (VerifySelfSessionViewEvents) -> Unit,
) { ) {
@Stable @Stable
sealed interface Step { sealed interface Step {
data object Loading : Step data object Loading : Step
data object Initial : Step
// FIXME canEnterRecoveryKey value is never read.
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
data object UseAnotherDevice : Step
data object Canceled : Step data object Canceled : Step
data object AwaitingOtherDeviceResponse : Step data object AwaitingOtherDeviceResponse : Step
data object Ready : Step data object Ready : Step
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step
data object Completed : Step data object Completed : Step
data object Skipped : Step data object Exit : Step
} }
} }

View file

@ -19,39 +19,37 @@ 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.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.flow.timeout
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import com.freeletics.flowredux.dsl.State as MachineState import com.freeletics.flowredux.dsl.State as MachineState
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class VerifySelfSessionStateMachine @Inject constructor( class VerifySelfSessionStateMachine(
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>( ) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>(
initialState = State.Initial initialState = State.Initial,
) { ) {
init { init {
spec { spec {
inState<State.Initial> { inState<State.Initial> {
on { _: Event.UseAnotherDevice, state -> on<Event.RequestVerification> { event, state ->
state.override { State.UseAnotherDevice.andLogStateChange() } state.override { State.RequestingVerification(event.verificationRequest).andLogStateChange() }
}
}
inState<State.UseAnotherDevice> {
on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification.andLogStateChange() }
} }
} }
inState<State.RequestingVerification> { inState<State.RequestingVerification> {
onEnterEffect { onEnterEffect { event ->
sessionVerificationService.requestVerification() when (event.verificationRequest) {
is VerificationRequest.Outgoing.CurrentSession -> sessionVerificationService.requestCurrentSessionVerification()
is VerificationRequest.Outgoing.User -> sessionVerificationService.requestUserVerification(event.verificationRequest.userId)
}
} }
on { _: Event.DidAcceptVerificationRequest, state -> on<Event.DidAcceptVerificationRequest> { _, state ->
state.override { State.VerificationRequestAccepted.andLogStateChange() } state.override { State.VerificationRequestAccepted.andLogStateChange() }
} }
} }
@ -61,25 +59,26 @@ class VerifySelfSessionStateMachine @Inject constructor(
} }
} }
inState<State.VerificationRequestAccepted> { inState<State.VerificationRequestAccepted> {
on { _: Event.StartSasVerification, state -> on<Event.StartSasVerification> { _, state ->
state.override { State.StartingSasVerification.andLogStateChange() } state.override { State.StartingSasVerification.andLogStateChange() }
} }
} }
inState<State.Canceled> { inState<State.Canceled> {
on { _: Event.Reset, state -> on<Event.Reset> { _, state ->
sessionVerificationService.reset(cancelAnyPendingVerificationAttempt = false)
state.override { State.Initial.andLogStateChange() } state.override { State.Initial.andLogStateChange() }
} }
} }
inState<State.SasVerificationStarted> { inState<State.SasVerificationStarted> {
on { event: Event.DidReceiveChallenge, state -> on<Event.DidReceiveChallenge> { event, state ->
state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() } state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() }
} }
} }
inState<State.Verifying.ChallengeReceived> { inState<State.Verifying.ChallengeReceived> {
on { _: Event.AcceptChallenge, state -> on<Event.AcceptChallenge> { _, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = true).andLogStateChange() } state.override { State.Verifying.Replying(state.snapshot.data, accept = true).andLogStateChange() }
} }
on { _: Event.DeclineChallenge, state -> on<Event.DeclineChallenge> { _, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = false).andLogStateChange() } state.override { State.Verifying.Replying(state.snapshot.data, accept = false).andLogStateChange() }
} }
} }
@ -91,7 +90,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
sessionVerificationService.declineVerification() sessionVerificationService.declineVerification()
} }
} }
on { _: Event.DidAcceptChallenge, state -> on<Event.DidAcceptChallenge> { _, state ->
// If a key backup exists, wait until it's restored or a timeout happens // If a key backup exists, wait until it's restored or a timeout happens
val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse() val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse()
if (hasBackup) { if (hasBackup) {
@ -104,21 +103,14 @@ class VerifySelfSessionStateMachine @Inject constructor(
state.override { State.Completed.andLogStateChange() } state.override { State.Completed.andLogStateChange() }
} }
} }
inState<State.Canceling> {
// TODO The 'Canceling' -> 'Canceled' transitions doesn't seem to work anymore, check if something changed in the Rust SDK
onEnterEffect {
sessionVerificationService.cancelVerification()
}
}
inState { inState {
logReceivedEvents() logReceivedEvents()
on { _: Event.DidStartSasVerification, state: MachineState<State> -> on<Event.DidStartSasVerification> { _, state: MachineState<State> ->
state.override { State.SasVerificationStarted.andLogStateChange() } state.override { State.SasVerificationStarted.andLogStateChange() }
} }
on { _: Event.Cancel, state: MachineState<State> -> on<Event.Cancel> { event, state: MachineState<State> ->
when (state.snapshot) { when (state.snapshot) {
State.Initial, State.Completed, State.Canceled -> state.noChange() State.Initial, State.Completed, is State.Canceled -> state.override { State.Exit }
State.UseAnotherDevice -> state.override { State.Initial.andLogStateChange() }
// For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from // For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
// `Canceling` state to `Canceled` automatically anymore // `Canceling` state to `Canceled` automatically anymore
else -> { else -> {
@ -127,28 +119,22 @@ class VerifySelfSessionStateMachine @Inject constructor(
} }
} }
} }
on { _: Event.DidCancel, state: MachineState<State> -> on<Event.DidCancel> { event, state: MachineState<State> ->
state.override { State.Canceled.andLogStateChange() } state.override { State.Canceled.andLogStateChange() }
} }
on { _: Event.DidFail, state: MachineState<State> -> on<Event.DidFail> { event, state: MachineState<State> ->
when (state.snapshot) { state.override { State.Canceled.andLogStateChange() }
is State.RequestingVerification -> state.override { State.Initial.andLogStateChange() }
else -> state.override { State.Canceled.andLogStateChange() }
}
} }
} }
} }
} }
sealed interface State { sealed interface State {
/** The initial state, before verification started. */ /** Let the user know that they need to get ready on their other session. */
data object Initial : State data object Initial : State
/** Let the user know that they need to get ready on their other session. */
data object UseAnotherDevice : State
/** Waiting for verification acceptance. */ /** Waiting for verification acceptance. */
data object RequestingVerification : State data class RequestingVerification(val verificationRequest: VerificationRequest.Outgoing) : State
/** Verification request accepted. Waiting for start. */ /** Verification request accepted. Waiting for start. */
data object VerificationRequestAccepted : State data object VerificationRequestAccepted : State
@ -167,22 +153,18 @@ class VerifySelfSessionStateMachine @Inject constructor(
data class Replying(override val data: SessionVerificationData, val accept: Boolean) : Verifying(data) data class Replying(override val data: SessionVerificationData, val accept: Boolean) : Verifying(data)
} }
/** The verification is being canceled. */
data object Canceling : State
/** The verification has been canceled, remotely or locally. */ /** The verification has been canceled, remotely or locally. */
data object Canceled : State data object Canceled : State
/** Verification successful. */ /** Verification successful. */
data object Completed : State data object Completed : State
data object Exit : State
} }
sealed interface Event { sealed interface Event {
/** User wants to use another session. */
data object UseAnotherDevice : Event
/** Request verification. */ /** Request verification. */
data object RequestVerification : Event data class RequestVerification(val verificationRequest: VerificationRequest.Outgoing) : Event
/** The current verification request has been accepted. */ /** The current verification request has been accepted. */
data object DidAcceptVerificationRequest : Event data object DidAcceptVerificationRequest : Event

View file

@ -11,18 +11,36 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step 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.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData 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.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.VerificationRequest
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> { open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState> override val values: Sequence<VerifySelfSessionState>
get() = sequenceOf( get() = sequenceOf(
aVerifySelfSessionState(displaySkipButton = true),
aVerifySelfSessionState( aVerifySelfSessionState(
step = Step.AwaitingOtherDeviceResponse step = Step.Initial,
request = anOutgoingSessionVerificationRequest(),
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized) step = Step.Initial,
request = anOutgoingUserVerificationRequest(),
),
aVerifySelfSessionState(
step = Step.AwaitingOtherDeviceResponse,
request = anOutgoingSessionVerificationRequest(),
),
aVerifySelfSessionState(
step = Step.AwaitingOtherDeviceResponse,
request = anOutgoingUserVerificationRequest(),
),
aVerifySelfSessionState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized),
request = anOutgoingSessionVerificationRequest(),
),
aVerifySelfSessionState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized),
request = anOutgoingUserVerificationRequest(),
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading()) step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
@ -37,40 +55,32 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized) step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
step = Step.Initial(canEnterRecoveryKey = true) step = Step.Completed,
), request = anOutgoingSessionVerificationRequest(),
aVerifySelfSessionState(
step = Step.Initial(canEnterRecoveryKey = true, isLastDevice = true)
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
step = Step.Completed, step = Step.Completed,
displaySkipButton = true, request = anOutgoingUserVerificationRequest(),
),
aVerifySelfSessionState(
signOutAction = AsyncAction.Loading,
displaySkipButton = true,
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
step = Step.Loading step = Step.Loading
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
step = Step.Skipped step = Step.Exit
),
aVerifySelfSessionState(
step = Step.UseAnotherDevice
), ),
// Add other state here // Add other state here
) )
} }
internal fun anOutgoingUserVerificationRequest() = VerificationRequest.Outgoing.User(userId = UserId("@alice:example.com"))
internal fun anOutgoingSessionVerificationRequest() = VerificationRequest.Outgoing.CurrentSession
internal fun aVerifySelfSessionState( internal fun aVerifySelfSessionState(
step: Step = Step.Initial(canEnterRecoveryKey = false), step: Step = Step.Initial,
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized, request: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(),
displaySkipButton: Boolean = false,
eventSink: (VerifySelfSessionViewEvents) -> Unit = {}, eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState( ) = VerifySelfSessionState(
step = step, step = step,
displaySkipButton = displaySkipButton, request = request,
eventSink = eventSink, eventSink = eventSink,
signOutAction = signOutAction,
) )

View file

@ -17,12 +17,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -32,22 +28,21 @@ 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.outgoing.VerifySelfSessionState.Step
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying 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.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.InvisibleButton import io.element.android.libraries.designsystem.theme.components.InvisibleButton
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text 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.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -55,18 +50,16 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun VerifySelfSessionView( fun VerifySelfSessionView(
state: VerifySelfSessionState, state: VerifySelfSessionState,
onLearnMoreClick: () -> Unit, onLearnMoreClick: () -> Unit,
onEnterRecoveryKey: () -> Unit,
onResetKey: () -> Unit,
onFinish: () -> Unit, onFinish: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val step = state.step val step = state.step
fun cancelOrResetFlow() { fun cancelOrResetFlow() {
when (step) { when (step) {
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset) is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
is Step.AwaitingOtherDeviceResponse, Step.Initial, Step.Completed -> onBack()
Step.UseAnotherDevice, Step.Ready, is Step.AwaitingOtherDeviceResponse -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
is Step.Verifying -> { is Step.Verifying -> {
if (!step.state.isLoading()) { if (!step.state.isLoading()) {
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification) state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
@ -76,18 +69,11 @@ fun VerifySelfSessionView(
} }
} }
val latestOnFinish by rememberUpdatedState(newValue = onFinish)
LaunchedEffect(step, latestOnFinish) {
if (step is Step.Skipped) {
latestOnFinish()
}
}
BackHandler { BackHandler {
cancelOrResetFlow() cancelOrResetFlow()
} }
if (step is Step.Loading || if (step is Step.Loading) {
step is Step.Skipped) {
// Just display a loader in this case, to avoid UI glitch. // Just display a loader in this case, to avoid UI glitch.
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -101,94 +87,94 @@ fun VerifySelfSessionView(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = {}, title = {},
actions = { navigationIcon = if (step != Step.Completed) {
if (step !is Step.Completed && { BackButton(onClick = ::cancelOrResetFlow) }
state.displaySkipButton && } else {
LocalInspectionMode.current.not()) { {}
TextButton(
text = stringResource(CommonStrings.action_skip),
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
)
}
if (step is Step.Initial) {
TextButton(
text = stringResource(CommonStrings.action_signout),
onClick = { state.eventSink(VerifySelfSessionViewEvents.SignOut) }
)
}
} }
) )
}, },
header = { header = {
VerifySelfSessionHeader(step = step) VerifySelfSessionHeader(step = step, request = state.request)
}, },
footer = { footer = {
VerifySelfSessionBottomMenu( VerifySelfSessionBottomMenu(
screenState = state, screenState = state,
onCancelClick = ::cancelOrResetFlow, onCancelClick = ::cancelOrResetFlow,
onEnterRecoveryKey = onEnterRecoveryKey,
onContinueClick = onFinish, onContinueClick = onFinish,
onResetKey = onResetKey,
) )
} }
) { ) {
VerifySelfSessionContent( VerifySelfSessionContent(
flowState = step, flowState = step,
request = state.request,
onLearnMoreClick = onLearnMoreClick, onLearnMoreClick = onLearnMoreClick,
) )
} }
} }
when (state.signOutAction) {
AsyncAction.Loading -> {
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
}
is AsyncAction.Success,
is AsyncAction.Confirming,
is AsyncAction.Failure,
AsyncAction.Uninitialized -> Unit
}
} }
@Composable @Composable
private fun VerifySelfSessionHeader(step: Step) { private fun VerifySelfSessionHeader(step: Step, request: VerificationRequest.Outgoing) {
val iconStyle = when (step) { val iconStyle = when (step) {
Step.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid()) Step.Initial -> when (request) {
Step.UseAnotherDevice -> BigIcon.Style.Default(CompoundIcons.Devices()) is VerificationRequest.Outgoing.CurrentSession -> BigIcon.Style.Default(CompoundIcons.Devices())
Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.Devices()) is VerificationRequest.Outgoing.User -> BigIcon.Style.Default(CompoundIcons.LockSolid())
}
Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Loading
Step.Canceled -> BigIcon.Style.AlertSolid Step.Canceled -> BigIcon.Style.AlertSolid
Step.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction()) Step.Ready -> BigIcon.Style.Default(CompoundIcons.Reaction())
Step.Completed -> BigIcon.Style.SuccessSolid Step.Completed -> BigIcon.Style.SuccessSolid
is Step.Skipped -> return is Step.Verifying -> {
if (step.state is AsyncData.Loading<Unit>) {
BigIcon.Style.Loading
} else {
BigIcon.Style.Default(CompoundIcons.Reaction())
}
}
is Step.Exit -> return
} }
val titleTextId = when (step) { val titleTextId = when (step) {
Step.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is Step.Initial -> R.string.screen_identity_confirmation_title Step.Initial -> when (request) {
Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_title is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_use_another_device_title
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_title is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_user_initiator_title
}
Step.AwaitingOtherDeviceResponse -> when (request) {
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_waiting_another_device_title
is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_waiting_other_user_title
}
Step.Canceled -> CommonStrings.common_verification_failed Step.Canceled -> CommonStrings.common_verification_failed
Step.Ready -> R.string.screen_session_verification_compare_emojis_title Step.Ready -> R.string.screen_session_verification_compare_emojis_title
Step.Completed -> R.string.screen_identity_confirmed_title Step.Completed -> CommonStrings.common_verification_complete
is Step.Verifying -> when (step.data) { is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
} }
is Step.Skipped -> return is Step.Exit -> return
} }
val subtitleTextId = when (step) { val subtitleTextId = when (step) {
Step.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is Step.Initial -> R.string.screen_identity_confirmation_subtitle Step.Initial -> when (request) {
Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_subtitle is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_use_another_device_subtitle
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_subtitle is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_user_initiator_subtitle
}
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_subtitle
Step.Canceled -> R.string.screen_session_verification_failed_subtitle Step.Canceled -> R.string.screen_session_verification_failed_subtitle
Step.Ready -> R.string.screen_session_verification_ready_subtitle Step.Ready -> R.string.screen_session_verification_ready_subtitle
Step.Completed -> R.string.screen_identity_confirmed_subtitle Step.Completed -> when (request) {
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_identity_confirmed_subtitle
is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_complete_user_subtitle
}
is Step.Verifying -> when (step.data) { is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle is SessionVerificationData.Emojis -> when (request) {
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_compare_emojis_subtitle
is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_compare_emojis_user_subtitle
}
} }
is Step.Skipped -> return is Step.Exit -> return
} }
PageTitle( PageTitle(
@ -201,11 +187,15 @@ private fun VerifySelfSessionHeader(step: Step) {
@Composable @Composable
private fun VerifySelfSessionContent( private fun VerifySelfSessionContent(
flowState: Step, flowState: Step,
request: VerificationRequest.Outgoing,
onLearnMoreClick: () -> Unit, onLearnMoreClick: () -> Unit,
) { ) {
when (flowState) { when (flowState) {
is Step.Initial -> { is Step.Initial -> {
ContentInitial(onLearnMoreClick) when (request) {
is VerificationRequest.Outgoing.CurrentSession -> Unit
is VerificationRequest.Outgoing.User -> ContentInitial(onLearnMoreClick)
}
} }
is Step.Verifying -> { is Step.Verifying -> {
VerificationContentVerifying(flowState.data) VerificationContentVerifying(flowState.data)
@ -235,8 +225,6 @@ private fun ContentInitial(
@Composable @Composable
private fun VerifySelfSessionBottomMenu( private fun VerifySelfSessionBottomMenu(
screenState: VerifySelfSessionState, screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
onResetKey: () -> Unit,
onCancelClick: () -> Unit, onCancelClick: () -> Unit,
onContinueClick: () -> Unit, onContinueClick: () -> Unit,
) { ) {
@ -248,27 +236,6 @@ private fun VerifySelfSessionBottomMenu(
when (verificationViewState) { when (verificationViewState) {
Step.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is Step.Initial -> { is Step.Initial -> {
VerificationBottomMenu {
if (verificationViewState.isLastDevice.not()) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_use_another_device),
onClick = { eventSink(VerifySelfSessionViewEvents.UseAnotherDevice) },
)
}
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
onClick = onEnterRecoveryKey,
)
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
onClick = onResetKey,
)
}
}
is Step.UseAnotherDevice -> {
VerificationBottomMenu { VerificationBottomMenu {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -302,39 +269,20 @@ private fun VerifySelfSessionBottomMenu(
) )
} }
} }
is Step.AwaitingOtherDeviceResponse -> { is Step.AwaitingOtherDeviceResponse -> Unit
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_waiting_on_other_device),
onClick = {},
showProgress = true,
enabled = false,
)
InvisibleButton()
}
}
is Step.Verifying -> { is Step.Verifying -> {
val positiveButtonTitle = if (isVerifying) { if (isVerifying) {
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing) // Show nothing
} else { } else {
stringResource(R.string.screen_session_verification_they_match) VerificationBottomMenu {
} Button(
VerificationBottomMenu { modifier = Modifier.fillMaxWidth(),
Button( text = stringResource(R.string.screen_session_verification_they_match),
modifier = Modifier.fillMaxWidth(), onClick = {
text = positiveButtonTitle,
showProgress = isVerifying,
enabled = !isVerifying,
onClick = {
if (!isVerifying) {
eventSink(VerifySelfSessionViewEvents.ConfirmVerification) eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
} },
}, )
)
if (isVerifying) {
InvisibleButton()
} else {
TextButton( TextButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_dont_match), text = stringResource(R.string.screen_session_verification_they_dont_match),
@ -353,7 +301,7 @@ private fun VerifySelfSessionBottomMenu(
InvisibleButton() InvisibleButton()
} }
} }
is Step.Skipped -> return is Step.Exit -> return
} }
} }
@ -363,8 +311,7 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
VerifySelfSessionView( VerifySelfSessionView(
state = state, state = state,
onLearnMoreClick = {}, onLearnMoreClick = {},
onEnterRecoveryKey = {},
onResetKey = {},
onFinish = {}, onFinish = {},
onBack = {},
) )
} }

View file

@ -8,13 +8,10 @@
package io.element.android.features.verifysession.impl.outgoing package io.element.android.features.verifysession.impl.outgoing
sealed interface VerifySelfSessionViewEvents { sealed interface VerifySelfSessionViewEvents {
data object UseAnotherDevice : VerifySelfSessionViewEvents
data object RequestVerification : VerifySelfSessionViewEvents data object RequestVerification : VerifySelfSessionViewEvents
data object StartSasVerification : VerifySelfSessionViewEvents data object StartSasVerification : VerifySelfSessionViewEvents
data object ConfirmVerification : VerifySelfSessionViewEvents data object ConfirmVerification : VerifySelfSessionViewEvents
data object DeclineVerification : VerifySelfSessionViewEvents data object DeclineVerification : VerifySelfSessionViewEvents
data object Cancel : VerifySelfSessionViewEvents data object Cancel : VerifySelfSessionViewEvents
data object Reset : VerifySelfSessionViewEvents data object Reset : VerifySelfSessionViewEvents
data object SignOut : VerifySelfSessionViewEvents
data object SkipVerification : VerifySelfSessionViewEvents
} }

View file

@ -0,0 +1,73 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.UserId
@Composable
fun VerificationUserProfileContent(
userId: UserId,
displayName: String?,
avatarUrl: String?,
modifier: Modifier = Modifier,
) {
val avatarData = remember(userId, displayName, avatarUrl) {
AvatarData(id = userId.value, name = displayName, url = avatarUrl, size = AvatarSize.UserVerification)
}
Row(
modifier = modifier.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(avatarData)
Spacer(modifier = Modifier.padding(12.dp))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = displayName ?: userId.value, style = ElementTheme.typography.fontBodyLgMedium, color = ElementTheme.colors.textPrimary)
if (displayName != null) {
Text(text = userId.value, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textSecondary)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun VerificationUserProfileContentPreview() = ElementPreview {
VerificationUserProfileContent(
userId = UserId("@alice:example.com"),
displayName = "Alice",
avatarUrl = "https://example.com/avatar.png",
)
}

View file

@ -15,6 +15,7 @@ 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.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.test.A_DEVICE_ID 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_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
@ -25,6 +26,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -37,7 +39,7 @@ class IncomingVerificationPresenterTest {
@Test @Test
fun `present - nominal case - incoming verification successful`() = runTest { fun `present - nominal case - incoming verification successful`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> } val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { } val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val approveVerificationLambda = lambdaRecorder<Unit> { } val approveVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { } val resetLambda = lambdaRecorder<Boolean, Unit> { }
@ -60,7 +62,7 @@ class IncomingVerificationPresenterTest {
) )
) )
resetLambda.assertions().isCalledOnce().with(value(false)) resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails)) acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
acceptVerificationRequestLambda.assertions().isNeverCalled() acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification // User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification) initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
@ -100,7 +102,7 @@ class IncomingVerificationPresenterTest {
@Test @Test
fun `present - emoji not matching case - incoming verification failure`() = runTest { fun `present - emoji not matching case - incoming verification failure`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> } val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { } val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val declineVerificationLambda = lambdaRecorder<Unit> { } val declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { } val resetLambda = lambdaRecorder<Boolean, Unit> { }
@ -123,7 +125,7 @@ class IncomingVerificationPresenterTest {
) )
) )
resetLambda.assertions().isCalledOnce().with(value(false)) resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails)) acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
acceptVerificationRequestLambda.assertions().isNeverCalled() acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification // User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification) initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
@ -157,7 +159,7 @@ class IncomingVerificationPresenterTest {
@Test @Test
fun `present - incoming verification is remotely canceled`() = runTest { fun `present - incoming verification is remotely canceled`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> } val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { } val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val declineVerificationLambda = lambdaRecorder<Unit> { } val declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { } val resetLambda = lambdaRecorder<Boolean, Unit> { }
@ -191,7 +193,7 @@ class IncomingVerificationPresenterTest {
@Test @Test
fun `present - user goes back when comparing emoji - incoming verification failure`() = runTest { fun `present - user goes back when comparing emoji - incoming verification failure`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> } val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { } val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val declineVerificationLambda = lambdaRecorder<Unit> { } val declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { } val resetLambda = lambdaRecorder<Boolean, Unit> { }
@ -214,7 +216,7 @@ class IncomingVerificationPresenterTest {
) )
) )
resetLambda.assertions().isCalledOnce().with(value(false)) resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails)) acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
acceptVerificationRequestLambda.assertions().isNeverCalled() acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification // User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification) initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
@ -248,7 +250,7 @@ class IncomingVerificationPresenterTest {
@Test @Test
fun `present - user ignores incoming request`() = runTest { fun `present - user ignores incoming request`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> } val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { } val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { } val resetLambda = lambdaRecorder<Boolean, Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService( val fakeSessionVerificationService = FakeSessionVerificationService(
@ -268,24 +270,30 @@ class IncomingVerificationPresenterTest {
} }
} }
private val aSessionVerificationRequestDetails = SessionVerificationRequestDetails( private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.OtherSession(
senderId = A_USER_ID, details = SessionVerificationRequestDetails(
flowId = FlowId("flowId"), senderProfile = SessionVerificationRequestDetails.SenderProfile(
deviceId = A_DEVICE_ID, userId = A_USER_ID,
displayName = "a device name", displayName = "a device name",
firstSeenTimestamp = A_TIMESTAMP, avatarUrl = null,
),
flowId = FlowId("flowId"),
deviceId = A_DEVICE_ID,
firstSeenTimestamp = A_TIMESTAMP,
)
) )
private fun createPresenter( private fun TestScope.createPresenter(
sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails, verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest,
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() }, navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
service: SessionVerificationService = FakeSessionVerificationService(), service: SessionVerificationService = FakeSessionVerificationService(),
dateFormatter: DateFormatter = FakeDateFormatter(), dateFormatter: DateFormatter = FakeDateFormatter(),
) = IncomingVerificationPresenter( ) = IncomingVerificationPresenter(
sessionVerificationRequestDetails = sessionVerificationRequestDetails, verificationRequest = verificationRequest,
navigator = navigator, navigator = navigator,
sessionVerificationService = service, sessionVerificationService = service,
stateMachine = IncomingVerificationStateMachine(service), stateMachine = IncomingVerificationStateMachine(service),
dateFormatter = dateFormatter, dateFormatter = dateFormatter,
sessionCoroutineScope = backgroundScope,
) )
} }

View file

@ -9,27 +9,21 @@ package io.element.android.features.verifysession.impl.outgoing
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat 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.outgoing.VerifySelfSessionState.Step import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService 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.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.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus 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.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService 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.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -48,85 +42,70 @@ class VerifySelfSessionPresenterTest {
) )
presenter.test { presenter.test {
awaitItem().run { awaitItem().run {
assertThat(step).isEqualTo(Step.Initial(false)) assertThat(step).isEqualTo(Step.Initial)
assertThat(displaySkipButton).isTrue()
} }
} }
} }
@Test @Test
fun `present - hides skip verification button on non-debuggable builds`() = runTest { fun `present - Handles requestVerification for session verification`() = runTest {
val buildMeta = aBuildMeta(isDebuggable = false) val requestSessionVerificationRecorder = lambdaRecorder<Unit> {}
val presenter = createVerifySelfSessionPresenter( val startVerificationRecorder = lambdaRecorder<Unit> {}
service = unverifiedSessionService(),
buildMeta = buildMeta,
)
presenter.test {
assertThat(awaitItem().displaySkipButton).isFalse()
}
}
@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)
}
)
presenter.test {
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)
}
)
presenter.test {
assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true))
}
}
@Test
fun `present - Handles requestVerification`() = runTest {
val service = unverifiedSessionService( val service = unverifiedSessionService(
requestVerificationLambda = { }, requestSessionVerificationLambda = requestSessionVerificationRecorder,
startVerificationLambda = { }, startVerificationLambda = startVerificationRecorder,
)
val presenter = createVerifySelfSessionPresenter(
service = service,
verificationRequest = anOutgoingSessionVerificationRequest(),
) )
val presenter = createVerifySelfSessionPresenter(service)
presenter.test { presenter.test {
requestVerificationAndAwaitVerifyingState(service) requestVerificationAndAwaitVerifyingState(service)
requestSessionVerificationRecorder.assertions().isCalledOnce()
startVerificationRecorder.assertions().isCalledOnce()
} }
} }
@Test @Test
fun `present - Cancellation on initial state does nothing`() = runTest { fun `present - Handles requestVerification for user verification`() = runTest {
val requestUserVerificationRecorder = lambdaRecorder<UserId, Unit> {}
val startVerificationRecorder = lambdaRecorder<Unit> {}
val service = unverifiedSessionService(
requestUserVerificationLambda = requestUserVerificationRecorder,
startVerificationLambda = startVerificationRecorder,
)
val presenter = createVerifySelfSessionPresenter(
service = service,
verificationRequest = anOutgoingUserVerificationRequest(),
)
presenter.test {
requestVerificationAndAwaitVerifyingState(service)
requestUserVerificationRecorder.assertions().isCalledOnce()
startVerificationRecorder.assertions().isCalledOnce()
}
}
@Test
fun `present - Cancellation on initial state moves to Exit state`() = runTest {
val presenter = createVerifySelfSessionPresenter( val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(), service = unverifiedSessionService(),
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(Step.Initial(false)) assertThat(initialState.step).isEqualTo(Step.Initial)
val eventSink = initialState.eventSink val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.Cancel) eventSink(VerifySelfSessionViewEvents.Cancel)
expectNoEvents()
assertThat(awaitItem().step).isEqualTo(Step.Exit)
} }
} }
@Test @Test
fun `present - A failure when verifying cancels it`() = runTest { fun `present - A failure when verifying cancels it`() = runTest {
val service = unverifiedSessionService( val service = unverifiedSessionService(
requestVerificationLambda = { }, requestSessionVerificationLambda = { },
startVerificationLambda = { }, startVerificationLambda = { },
approveVerificationLambda = { }, approveVerificationLambda = { },
) )
@ -143,24 +122,23 @@ class VerifySelfSessionPresenterTest {
} }
@Test @Test
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest { fun `present - A fail when requesting verification resets the state to the canceled one`() = runTest {
val service = unverifiedSessionService( val service = unverifiedSessionService(
requestVerificationLambda = { }, requestSessionVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
presenter.test { presenter.test {
awaitItem().eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification) awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
service.emitVerificationFlowState(VerificationFlowState.DidFail) service.emitVerificationFlowState(VerificationFlowState.DidFail)
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java) assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
assertThat(awaitItem().step).isEqualTo(Step.Initial(false)) assertThat(awaitItem().step).isEqualTo(Step.Canceled)
} }
} }
@Test @Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest { fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
val service = unverifiedSessionService( val service = unverifiedSessionService(
requestVerificationLambda = { }, requestSessionVerificationLambda = { },
startVerificationLambda = { }, startVerificationLambda = { },
cancelVerificationLambda = { }, cancelVerificationLambda = { },
) )
@ -175,7 +153,7 @@ class VerifySelfSessionPresenterTest {
@Test @Test
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest { fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
val service = unverifiedSessionService( val service = unverifiedSessionService(
requestVerificationLambda = { }, requestSessionVerificationLambda = { },
startVerificationLambda = { }, startVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
@ -189,7 +167,7 @@ class VerifySelfSessionPresenterTest {
@Test @Test
fun `present - Go back after cancellation returns to initial state`() = runTest { fun `present - Go back after cancellation returns to initial state`() = runTest {
val service = unverifiedSessionService( val service = unverifiedSessionService(
requestVerificationLambda = { }, requestSessionVerificationLambda = { },
startVerificationLambda = { }, startVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
@ -199,7 +177,7 @@ class VerifySelfSessionPresenterTest {
assertThat(awaitItem().step).isEqualTo(Step.Canceled) assertThat(awaitItem().step).isEqualTo(Step.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset) state.eventSink(VerifySelfSessionViewEvents.Reset)
// Went back to initial state // Went back to initial state
assertThat(awaitItem().step).isEqualTo(Step.Initial(false)) assertThat(awaitItem().step).isEqualTo(Step.Initial)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -210,7 +188,7 @@ class VerifySelfSessionPresenterTest {
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley") VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
) )
val service = unverifiedSessionService( val service = unverifiedSessionService(
requestVerificationLambda = { }, requestSessionVerificationLambda = { },
startVerificationLambda = { }, startVerificationLambda = { },
approveVerificationLambda = { }, approveVerificationLambda = { },
) )
@ -235,7 +213,7 @@ class VerifySelfSessionPresenterTest {
@Test @Test
fun `present - When verification is declined, the flow is canceled`() = runTest { fun `present - When verification is declined, the flow is canceled`() = runTest {
val service = unverifiedSessionService( val service = unverifiedSessionService(
requestVerificationLambda = { }, requestSessionVerificationLambda = { },
startVerificationLambda = { }, startVerificationLambda = { },
declineVerificationLambda = { }, declineVerificationLambda = { },
) )
@ -254,20 +232,6 @@ class VerifySelfSessionPresenterTest {
} }
} }
@Test
fun `present - Skip event skips the flow`() = runTest {
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
}
}
@Test @Test
fun `present - When verification is done using recovery key, the flow is completed`() = runTest { fun `present - When verification is done using recovery key, the flow is completed`() = runTest {
val service = FakeSessionVerificationService( val service = FakeSessionVerificationService(
@ -301,32 +265,7 @@ class VerifySelfSessionPresenterTest {
) )
presenter.test { presenter.test {
skipItems(1) skipItems(1)
assertThat(awaitItem().step).isEqualTo(Step.Skipped) assertThat(awaitItem().step).isEqualTo(Step.Exit)
}
}
@Test
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
val service = FakeSessionVerificationService(
resetLambda = { },
).apply {
emitNeedsSessionVerification(false)
emitVerifiedStatus(SessionVerifiedStatus.Verified)
emitVerificationFlowState(VerificationFlowState.DidFinish)
}
val signOutLambda = lambdaRecorder<Boolean, Unit> {}
val presenter = createVerifySelfSessionPresenter(
service,
logoutUseCase = FakeLogoutUseCase(signOutLambda)
)
presenter.test {
skipItems(1)
val initialItem = awaitItem()
initialItem.eventSink(VerifySelfSessionViewEvents.SignOut)
assertThat(awaitItem().signOutAction.isLoading()).isTrue()
val finalItem = awaitItem()
assertThat(finalItem.signOutAction.isSuccess()).isTrue()
signOutLambda.assertions().isCalledOnce().with(value(true))
} }
} }
@ -335,10 +274,7 @@ class VerifySelfSessionPresenterTest {
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()), sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
): VerifySelfSessionState { ): VerifySelfSessionState {
var state = awaitItem() var state = awaitItem()
assertThat(state.step).isEqualTo(Step.Initial(false)) assertThat(state.step).isEqualTo(Step.Initial)
state.eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
state = awaitItem()
assertThat(state.step).isEqualTo(Step.UseAnotherDevice)
state.eventSink(VerifySelfSessionViewEvents.RequestVerification) state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response: // Await for other device response:
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
@ -360,17 +296,19 @@ class VerifySelfSessionPresenterTest {
} }
private suspend fun unverifiedSessionService( private suspend fun unverifiedSessionService(
requestVerificationLambda: () -> Unit = { lambdaError() }, requestSessionVerificationLambda: () -> Unit = { lambdaError() },
requestUserVerificationLambda: (UserId) -> Unit = { lambdaError() },
cancelVerificationLambda: () -> Unit = { lambdaError() }, cancelVerificationLambda: () -> Unit = { lambdaError() },
approveVerificationLambda: () -> Unit = { lambdaError() }, approveVerificationLambda: () -> Unit = { lambdaError() },
declineVerificationLambda: () -> Unit = { lambdaError() }, declineVerificationLambda: () -> Unit = { lambdaError() },
startVerificationLambda: () -> Unit = { lambdaError() }, startVerificationLambda: () -> Unit = { lambdaError() },
resetLambda: (Boolean) -> Unit = { }, resetLambda: (Boolean) -> Unit = { },
acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() }, acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() },
acceptVerificationRequestLambda: () -> Unit = { lambdaError() }, acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
): FakeSessionVerificationService { ): FakeSessionVerificationService {
return FakeSessionVerificationService( return FakeSessionVerificationService(
requestVerificationLambda = requestVerificationLambda, requestCurrentSessionVerificationLambda = requestSessionVerificationLambda,
requestUserVerificationLambda = requestUserVerificationLambda,
cancelVerificationLambda = cancelVerificationLambda, cancelVerificationLambda = cancelVerificationLambda,
approveVerificationLambda = approveVerificationLambda, approveVerificationLambda = approveVerificationLambda,
declineVerificationLambda = declineVerificationLambda, declineVerificationLambda = declineVerificationLambda,
@ -385,20 +323,15 @@ class VerifySelfSessionPresenterTest {
private fun createVerifySelfSessionPresenter( private fun createVerifySelfSessionPresenter(
service: SessionVerificationService, service: SessionVerificationService,
verificationRequest: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(),
encryptionService: EncryptionService = FakeEncryptionService(), encryptionService: EncryptionService = FakeEncryptionService(),
buildMeta: BuildMeta = aBuildMeta(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
logoutUseCase: LogoutUseCase = FakeLogoutUseCase(),
showDeviceVerifiedScreen: Boolean = false, showDeviceVerifiedScreen: Boolean = false,
): VerifySelfSessionPresenter { ): VerifySelfSessionPresenter {
return VerifySelfSessionPresenter( return VerifySelfSessionPresenter(
showDeviceVerifiedScreen = showDeviceVerifiedScreen, showDeviceVerifiedScreen = showDeviceVerifiedScreen,
verificationRequest = verificationRequest,
sessionVerificationService = service, sessionVerificationService = service,
encryptionService = encryptionService, encryptionService = encryptionService,
stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
buildMeta = buildMeta,
sessionPreferencesStore = sessionPreferencesStore,
logoutUseCase = logoutUseCase,
) )
} }
} }

View file

@ -16,7 +16,6 @@ import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificat
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
@ -25,7 +24,6 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class VerifySelfSessionViewTest { class VerifySelfSessionViewTest {
@ -103,16 +101,16 @@ class VerifySelfSessionViewTest {
} }
@Test @Test
fun `back key pressed - on Completed step does nothing`() { fun `back key pressed - on Completed exits the flow`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() ensureCalledOnce { callback ->
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( onBack = callback,
step = VerifySelfSessionState.Step.Completed, state = aVerifySelfSessionState(
eventSink = eventsRecorder step = VerifySelfSessionState.Step.Completed,
), ),
) )
rule.pressBackKey() rule.pressBackKey()
eventsRecorder.assertEmpty() }
} }
@Test @Test
@ -130,38 +128,6 @@ class VerifySelfSessionViewTest {
} }
} }
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on enter recovery key calls the expected callback`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Initial(true),
eventSink = eventsRecorder
),
onEnterRecoveryKey = callback,
)
rule.clickOn(R.string.screen_session_verification_enter_recovery_key)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on learn more invokes the expected callback`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Initial(true),
eventSink = eventsRecorder
),
onLearnMoreClick = callback,
)
rule.clickOn(CommonStrings.action_learn_more)
}
}
@Test @Test
fun `clicking on they match emits the expected event`() { fun `clicking on they match emits the expected event`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
@ -194,48 +160,18 @@ class VerifySelfSessionViewTest {
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification) eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
} }
@Test
fun `clicking on 'Skip' emits the expected event`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Initial(canEnterRecoveryKey = true),
displaySkipButton = true,
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_skip)
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.SkipVerification)
}
@Test
fun `on Skipped step - onFinished callback is called immediately`() {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Skipped,
displaySkipButton = true,
eventSink = EnsureNeverCalledWithParam(),
),
onFinished = callback,
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setVerifySelfSessionView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setVerifySelfSessionView(
state: VerifySelfSessionState, state: VerifySelfSessionState,
onLearnMoreClick: () -> Unit = EnsureNeverCalled(), onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
onFinished: () -> Unit = EnsureNeverCalled(), onFinished: () -> Unit = EnsureNeverCalled(),
onResetKey: () -> Unit = EnsureNeverCalled(), onBack: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
VerifySelfSessionView( VerifySelfSessionView(
state = state, state = state,
onLearnMoreClick = onLearnMoreClick, onLearnMoreClick = onLearnMoreClick,
onEnterRecoveryKey = onEnterRecoveryKey,
onFinish = onFinished, onFinish = onFinished,
onResetKey = onResetKey, onBack = onBack,
) )
} }
} }

View file

@ -32,6 +32,7 @@ android {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.vanniktech.blurhash) implementation(libs.vanniktech.blurhash)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)

View file

@ -33,6 +33,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -78,6 +79,11 @@ object BigIcon {
* A success style with a tinted background. * A success style with a tinted background.
*/ */
data object SuccessSolid : Style data object SuccessSolid : Style
/**
* A loading style with the default background color.
*/
data object Loading : Style
} }
/** /**
@ -101,31 +107,7 @@ object BigIcon {
Style.Success -> Color.Transparent Style.Success -> Color.Transparent
Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
} Style.Loading -> ElementTheme.colors.bgSubtleSecondary
val icon = when (style) {
is Style.Default -> style.vectorIcon
Style.Alert,
Style.AlertSolid -> CompoundIcons.ErrorSolid()
Style.Success,
Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
}
val contentDescription = when (style) {
is Style.Default -> style.contentDescription
Style.Alert,
Style.AlertSolid -> stringResource(CommonStrings.common_error)
Style.Success,
Style.SuccessSolid -> stringResource(CommonStrings.common_success)
}
val iconTint = when (style) {
is Style.Default -> if (style.useCriticalTint) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconSecondary
}
Style.Alert,
Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
Style.Success,
Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
} }
Box( Box(
modifier = modifier modifier = modifier
@ -134,12 +116,50 @@ object BigIcon {
.background(backgroundColor), .background(backgroundColor),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( if (style is Style.Loading) {
modifier = Modifier.size(32.dp), CircularProgressIndicator(
tint = iconTint, modifier = Modifier.size(27.dp),
imageVector = icon, color = ElementTheme.colors.iconSecondary,
contentDescription = contentDescription trackColor = Color.Transparent,
) strokeWidth = 3.dp,
)
} else {
val icon = when (style) {
is Style.Default -> style.vectorIcon
Style.Alert,
Style.AlertSolid -> CompoundIcons.ErrorSolid()
Style.Success,
Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
Style.Loading -> error("This should never be reached")
}
val contentDescription = when (style) {
is Style.Default -> style.contentDescription
Style.Alert,
Style.AlertSolid -> stringResource(CommonStrings.common_error)
Style.Success,
Style.SuccessSolid -> stringResource(CommonStrings.common_success)
Style.Loading -> error("This should never be reached")
}
val iconTint = when (style) {
is Style.Default -> if (style.useCriticalTint) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconSecondary
}
Style.Alert,
Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
Style.Success,
Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
Style.Loading -> error("This should never be reached")
}
Icon(
modifier = Modifier.size(32.dp),
tint = iconTint,
imageVector = icon,
contentDescription = contentDescription
)
}
} }
} }
} }
@ -173,6 +193,7 @@ internal class BigIconStyleProvider : PreviewParameterProvider<BigIcon.Style> {
BigIcon.Style.AlertSolid, BigIcon.Style.AlertSolid,
BigIcon.Style.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true), BigIcon.Style.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true),
BigIcon.Style.Success, BigIcon.Style.Success,
BigIcon.Style.SuccessSolid BigIcon.Style.SuccessSolid,
BigIcon.Style.Loading,
) )
} }

View file

@ -61,4 +61,6 @@ enum class AvatarSize(val dp: Dp) {
MediaSender(32.dp), MediaSender(32.dp),
DmCreationConfirmation(64.dp), DmCreationConfirmation(64.dp),
UserVerification(52.dp),
} }

View file

@ -0,0 +1,29 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.utils
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
@Suppress("MutableStateParam")
@Composable
fun OpenUrlInTabView(url: MutableState<String?>) {
val activity = requireNotNull(LocalActivity.current)
val darkTheme = ElementTheme.isLightTheme.not()
LaunchedEffect(url.value) {
url.value?.let {
activity.openUrlInChromeCustomTab(null, darkTheme, it)
url.value = null
}
}
}

View file

@ -15,9 +15,15 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class SessionVerificationRequestDetails( data class SessionVerificationRequestDetails(
val senderId: UserId, val senderProfile: SenderProfile,
val flowId: FlowId, val flowId: FlowId,
val deviceId: DeviceId, val deviceId: DeviceId,
val displayName: String?,
val firstSeenTimestamp: Long, val firstSeenTimestamp: Long,
) : Parcelable ) : Parcelable {
@Parcelize
data class SenderProfile(
val userId: UserId,
val displayName: String?,
val avatarUrl: String?,
) : Parcelable
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.verification package io.element.android.libraries.matrix.api.verification
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -31,7 +32,12 @@ interface SessionVerificationService {
/** /**
* Request verification of the current session. * Request verification of the current session.
*/ */
suspend fun requestVerification() suspend fun requestCurrentSessionVerification()
/**
* Request verification of the user with the given [userId].
*/
suspend fun requestUserVerification(userId: UserId)
/** /**
* Cancels the current verification attempt. * Cancels the current verification attempt.
@ -67,7 +73,7 @@ interface SessionVerificationService {
* Set this particular request as the currently active one and register for * Set this particular request as the currently active one and register for
* events pertaining it. * events pertaining it.
*/ */
suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming)
/** /**
* Accept the previously acknowledged verification request. * Accept the previously acknowledged verification request.
@ -76,7 +82,7 @@ interface SessionVerificationService {
} }
interface SessionVerificationServiceListener { interface SessionVerificationServiceListener {
fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming)
} }
/** Verification status of the current session. */ /** Verification status of the current session. */

View file

@ -0,0 +1,30 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files 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.UserId
import kotlinx.parcelize.Parcelize
sealed interface VerificationRequest : Parcelable {
sealed interface Outgoing : VerificationRequest {
@Parcelize
data object CurrentSession : Outgoing
@Parcelize
data class User(val userId: UserId) : Outgoing
}
sealed class Incoming(open val details: SessionVerificationRequestDetails) : VerificationRequest {
@Parcelize
data class OtherSession(override val details: SessionVerificationRequestDetails) : Incoming(details)
@Parcelize
data class User(override val details: SessionVerificationRequestDetails) : Incoming(details)
}
}

View file

@ -8,13 +8,14 @@
package io.element.android.libraries.matrix.impl.verification package io.element.android.libraries.matrix.impl.verification
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationData 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.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener 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.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
@ -50,6 +51,8 @@ class RustSessionVerificationService(
isSyncServiceReady: Flow<Boolean>, isSyncServiceReady: Flow<Boolean>,
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
) : SessionVerificationService, SessionVerificationControllerDelegate { ) : SessionVerificationService, SessionVerificationControllerDelegate {
private var currentVerificationRequest: VerificationRequest? = null
private val encryptionService: Encryption = client.encryption() private val encryptionService: Encryption = client.encryption()
private lateinit var verificationController: SessionVerificationController private lateinit var verificationController: SessionVerificationController
@ -88,10 +91,8 @@ class RustSessionVerificationService(
verificationStatus == SessionVerifiedStatus.NotVerified verificationStatus == SessionVerifiedStatus.NotVerified
} }
private var isOwnVerification = true
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) { override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
listener?.onIncomingSessionRequest(details.map()) listener?.onIncomingSessionRequest(details.toVerificationRequest(UserId(client.userId())))
} }
private var listener: SessionVerificationServiceListener? = null private var listener: SessionVerificationServiceListener? = null
@ -111,9 +112,16 @@ class RustSessionVerificationService(
this.listener = listener this.listener = listener
} }
override suspend fun requestVerification() = tryOrFail { override suspend fun requestCurrentSessionVerification() = tryOrFail {
initVerificationControllerIfNeeded() initVerificationControllerIfNeeded()
verificationController.requestDeviceVerification() verificationController.requestDeviceVerification()
currentVerificationRequest = VerificationRequest.Outgoing.CurrentSession
}
override suspend fun requestUserVerification(userId: UserId) = tryOrFail {
initVerificationControllerIfNeeded()
verificationController.requestUserVerification(userId.value)
currentVerificationRequest = VerificationRequest.Outgoing.User(userId)
} }
override suspend fun cancelVerification() = tryOrFail { override suspend fun cancelVerification() = tryOrFail {
@ -130,16 +138,16 @@ class RustSessionVerificationService(
verificationController.startSasVerification() verificationController.startSasVerification()
} }
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail { override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) = tryOrFail {
isOwnVerification = false
initVerificationControllerIfNeeded() initVerificationControllerIfNeeded()
verificationController.acknowledgeVerificationRequest( verificationController.acknowledgeVerificationRequest(
senderId = details.senderId.value, senderId = verificationRequest.details.senderProfile.userId.value,
flowId = details.flowId.value, flowId = verificationRequest.details.flowId.value,
) )
} }
override suspend fun acceptVerificationRequest() = tryOrFail { override suspend fun acceptVerificationRequest() = tryOrFail {
Timber.d("Accepting incoming verification request")
verificationController.acceptVerificationRequest() verificationController.acceptVerificationRequest()
} }
@ -183,7 +191,7 @@ class RustSessionVerificationService(
} }
} }
.onSuccess { .onSuccess {
if (isOwnVerification) { if (currentVerificationRequest is VerificationRequest.Outgoing.CurrentSession) {
// Try waiting for the final recovery state for better UX, but don't block the verification state on it // Try waiting for the final recovery state for better UX, but don't block the verification state on it
tryOrNull { tryOrNull {
withTimeout(10.seconds) { withTimeout(10.seconds) {
@ -215,7 +223,7 @@ class RustSessionVerificationService(
// end-region // end-region
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) { override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
isOwnVerification = true currentVerificationRequest = null
if (isReady.value && cancelAnyPendingVerificationAttempt) { if (isReady.value && cancelAnyPendingVerificationAttempt) {
// Cancel any pending verification attempt // Cancel any pending verification attempt
tryOrNull { verificationController.cancelVerification() } tryOrNull { verificationController.cancelVerification() }

View file

@ -11,12 +11,28 @@ 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.FlowId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
import org.matrix.rustcomponents.sdk.UserProfile as RustUserProfile
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails( fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
senderId = UserId(senderProfile.userId), senderProfile = senderProfile.map(),
flowId = FlowId(flowId), flowId = FlowId(flowId),
deviceId = DeviceId(deviceId), deviceId = DeviceId(deviceId),
displayName = senderProfile.displayName,
firstSeenTimestamp = firstSeenTimestamp.toLong(), firstSeenTimestamp = firstSeenTimestamp.toLong(),
) )
fun RustUserProfile.map() = SessionVerificationRequestDetails.SenderProfile(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl,
)
fun RustSessionVerificationRequestDetails.toVerificationRequest(currentUserId: UserId): VerificationRequest.Incoming {
val details = map()
return if (currentUserId == details.senderProfile.userId) {
VerificationRequest.Incoming.OtherSession(details)
} else {
VerificationRequest.Incoming.User(details)
}
}

View file

@ -7,11 +7,12 @@
package io.element.android.libraries.matrix.test.verification package io.element.android.libraries.matrix.test.verification
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationService 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.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -20,13 +21,14 @@ import kotlinx.coroutines.flow.StateFlow
class FakeSessionVerificationService( class FakeSessionVerificationService(
initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown, initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown,
private val requestVerificationLambda: () -> Unit = { lambdaError() }, private val requestCurrentSessionVerificationLambda: () -> Unit = { lambdaError() },
private val requestUserVerificationLambda: (UserId) -> Unit = { lambdaError() },
private val cancelVerificationLambda: () -> Unit = { lambdaError() }, private val cancelVerificationLambda: () -> Unit = { lambdaError() },
private val approveVerificationLambda: () -> Unit = { lambdaError() }, private val approveVerificationLambda: () -> Unit = { lambdaError() },
private val declineVerificationLambda: () -> Unit = { lambdaError() }, private val declineVerificationLambda: () -> Unit = { lambdaError() },
private val startVerificationLambda: () -> Unit = { lambdaError() }, private val startVerificationLambda: () -> Unit = { lambdaError() },
private val resetLambda: (Boolean) -> Unit = { lambdaError() }, private val resetLambda: (Boolean) -> Unit = { lambdaError() },
private val acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() }, private val acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() },
private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() }, private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
) : SessionVerificationService { ) : SessionVerificationService {
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus) private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
@ -37,8 +39,12 @@ class FakeSessionVerificationService(
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
override val needsSessionVerification: Flow<Boolean> = _needsSessionVerification override val needsSessionVerification: Flow<Boolean> = _needsSessionVerification
override suspend fun requestVerification() { override suspend fun requestCurrentSessionVerification() {
requestVerificationLambda() requestCurrentSessionVerificationLambda()
}
override suspend fun requestUserVerification(userId: UserId) {
requestUserVerificationLambda(userId)
} }
override suspend fun cancelVerification() { override suspend fun cancelVerification() {
@ -68,8 +74,8 @@ class FakeSessionVerificationService(
this.listener = listener this.listener = listener
} }
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) { override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
acknowledgeVerificationRequestLambda(details) acknowledgeVerificationRequestLambda(verificationRequest)
} }
override suspend fun acceptVerificationRequest() = simulateLongTask { override suspend fun acceptVerificationRequest() = simulateLongTask {

View file

@ -11,4 +11,8 @@ plugins {
android { android {
namespace = "io.element.android.libraries.ui.strings" namespace = "io.element.android.libraries.ui.strings"
lint {
disable += "Typos"
}
} }

View file

@ -38,3 +38,9 @@ class EnsureNeverCalledWithTwoParamsAndResult<T, U, R> : (T, U) -> R {
lambdaError("Should not be called and is called with $p1 and $p2") lambdaError("Should not be called and is called with $p1 and $p2")
} }
} }
class EnsureNeverCalledWithThreeParams<T, U, V> : (T, U, V) -> Unit {
override fun invoke(p1: T, p2: U, p3: V) {
lambdaError("Should not be called and is called with $p1, $p2 and $p3")
}
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:470cb7c851af9d91cc44d52d502b50764136b079b600ca58ae2512e7ff12e34b
size 29534

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49db4684bd8f16361848130650ce1ee0fbc49fff5452fab177e5920ab857c1a9
size 24663

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d1504a5137b90bf6c03ae8d3a90946ae1a9b528013f9c3898a0e91e4d073d159
size 34347

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bd973e0f9c5940e3f216f27af682fffd156535b6d759e4cec01c3979eb35f1c9
size 29624

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9dba1ef09211361216ee035c64ee50d62ab1df5191d365ef26f5a4f741477867
size 28635

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ba7502251d19d1fe54fa631b7c71afcdfc3c864cbab04d741a0c4a8d691b2e7f
size 24048

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:14770fdc3e68b250b5f7dad6bb6fb1bf387c66774287276ab4b505c144007b33
size 33299

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0466b1f78eda1575072f9be443fcb5741ff9952fd215b9aa9b2281de7109da70
size 28783

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7479bd11493b82e7027599a938979fd50fb75c31cf7c505cf8669db9387fb124 oid sha256:f1a69af9c184578a355345037ec34016c4b4ab26a2719bfaffafa1027365a678
size 38805 size 38453

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:56418af183e3287631cead30c1077d5734ff59f014c03e2ea0df8d91d38ade01 oid sha256:c41fdaa2341788494026277683590e0fe58fcc6b4c93919c77dcf29fd1e95c15
size 42255 size 41752

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:56c21416493a55bb9f105a9908f2b9e07441ec5f5ae3afb68f59fb8329cdb242 oid sha256:e99e06f6281be006fb12e0a56e7523c8144256562464474cb2aa918f6576d636
size 39527 size 39091

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:128bc1d07c659dfdb25a5cac2513ef3dfcad6a156fd3a38457eec40a735e74c9 oid sha256:d59deaee5d608c279670104c0f3bfcb5bbda90ac9779111441053ea1b9d40d4c
size 43265 size 42658

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f41090fceca5845f88dbbc6b983e6330ecd3b86d2056ba4584f1fca26305bb0a oid sha256:4e395d9ca4fc6b2eb1ea9a8fd6e2ad34e7397d9f64cf39e05eed50a1bfffabd7
size 28943 size 24186

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0516c0355081b7b2d745ec46fd160fd566be224ef7d350849c9990a8c6320563 oid sha256:87cb4255c8346796a226cdfc2afb67641a3b27c2c1a15629a07c3293fcad5131
size 27018 size 22078

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f20c32a85bf93f25c51c18b79adc0f8237173dc9fe0705570634adbcc94b1350 oid sha256:ba80787576dd893f90fcdc83021e47abbde4ae2bcaaca2c167178ba7c2be3886
size 36484 size 36481

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5cc7bdca7fe74fe0b16ab23eb4866b3af5245ed762c74a1edba087070cfe32b8 oid sha256:3a2278fd516dff3a5c86702f09d5621e8b3bb3de1df702b5036fa114975e4d85
size 30448 size 27844

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dfc7c5d99c0d1e5e0194a5961aac43efea94ae100fca0249f6b00e20b47cbb09 oid sha256:a465e6254ae595d62a335914414ad2f33b76736f529d1e188f3d0e0d57268f65
size 26565 size 23015

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e2f9d02116554413601c8b11fb75581f30ebb6ce5b8ade2a5a62a0aa4ce04705 oid sha256:ef88b209eb93fb6b4e5aa411c4cdcb9ff1aa49bd44f11c2271a4d758482b145a
size 29914 size 25181

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f86dd19d8056f16ae1a5337c49f35d420f2b83baafb29360c8c07c3347d5a93f oid sha256:6894ddb5662e7da82e5ee42fb0c9eb30afd87e15e415ba42be218ebab434d53a
size 28074 size 23531

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:46d3c77fff43081041fb952fe9c8ea733c5ec164ca9aca548924d3cbefb52369 oid sha256:0f393f620cc11b7aec7bf52f7efc5a5872ed07a14e7fbf2149b16171dbb2b03e
size 26131 size 21463

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7fdbd16b4e78f890f60668fccfbe7af6c6a4f5ba22af26e9f3735dafb13e9d8f oid sha256:ce136a49edadeaadc229ec485b27bf2d4ae20601e948bca46c632a4035b90130
size 33700 size 33697

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:79f2c2bb7772dd91b37351e4d1e658155f341d83cb6d496eded65b0b543fa2b1 oid sha256:580fc1fe77975e83cf83fe582c2f3f4aa7003cf302e7dadcd6bb97c04cbbf148
size 27898 size 25517

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4acb962472308c94998b3bc67d71a6a69b9bad0294461df374f7e74dcf703328 oid sha256:4b48c3e2c5e06a612473e252ba52eead6efd741b7ea21fdb778ec9ed5952a2a2
size 24379 size 21181

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a409716052c9c12c963668098943b8f286e41d6a10f5639d11b7b464cb9d0c3d oid sha256:201c3f2385059321f7500ca8d559bb973a51aeb56fd417e943be496ae55d0869
size 28909 size 24370

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b8634747fdeca8b754396dd32e02c36956017860118dbc33098aaa10fee358ec
size 28876

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66af8c7679f62fede7b09d6661f0b78d7264a2a415cdf5faf0928aa115adfc2b
size 24514

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7c89798843bd3bc2a02094e200ddfc4fcdad8e71554d64c3f0cf2bfc75e894dc
size 24514

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7c89798843bd3bc2a02094e200ddfc4fcdad8e71554d64c3f0cf2bfc75e894dc
size 24514

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2473606524b856b4afd81e4a32b29c7456788746078387fff280f3c8eb577396 oid sha256:1da0edef645c5abc391f0425e3a1f8719c1f061f66517b131a81cdd59d2bd04a
size 41622 size 40105

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6a8c359687b3ec5f038602b9022ef3967af12c6632a59c56bd10fecf7d92233c oid sha256:8ebb02ae3454f3cbb28e3aac886d088888251d25ba2c5e8fb7adde700f10383e
size 46457 size 36323

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b6576c0b77984df1930f0115e1ea90e6720f0fb5f500e186c591f0fd2eb771b6 oid sha256:682472baf2a1bc5f99cfadc3177615167f1f7531923ec34374f59e263b50d79f
size 44729 size 37049

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:90c8e131e639b6638a75468d41a3ff0107822e3450c6edbcc4d9eccdd180f6ae oid sha256:f8aa70b0d5eace900fc8c58674d2f3cdcf409335a33e8cf3eea5f60fb495d009
size 31537 size 32299

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3fc2ecdf5b8880b67e9ee9eacba9636819396236325f6ce28f0bf5755fa5ae5e oid sha256:c28294b67c1d8f448300b35a4ec6ebecd105334375af61b61612b82b5509d417
size 21892 size 46402

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8371da870ec795fbced08edd199c6e6c53b1557b648686fe89c278def41e425b oid sha256:becc11517522d030f4539c67d7b4728661135a43b05e9c902481b79539155912
size 24134 size 46738

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8371da870ec795fbced08edd199c6e6c53b1557b648686fe89c278def41e425b oid sha256:baa21d8011741ac2bca82ac26fa289d016a3ddcc16d408f92812adc662e27869
size 24134 size 40088

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c93ea8f1be90fc1bc43bc94c86eb7b7605872fbd96ac142ba1d633e20978d7a1
size 40427

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:acd3515c98dc74f265fbd4c8b82c60a65ae7c764994016ab39bf25062d70140f
size 31496

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:227c4049433b734d591eeea0cb5cca65ce27b3f4fe385eb224ec322148865f6b
size 28321

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a0393e0549647a0fe6ebef2372d9e6885db7225d137e44d4057e3fe0a15b1aa8
size 23895

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb51fe5a51969f768609a5e5eaf5baa95005b094bf1995c3b304f637bf58fbc7
size 24251

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb51fe5a51969f768609a5e5eaf5baa95005b094bf1995c3b304f637bf58fbc7
size 24251

Some files were not shown because too many files have changed in this diff Show more