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 2ce1b17dae
commit f73c0e42a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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.toRoomIdOrAlias
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.VerificationRequest
import io.element.android.services.appnavstate.api.AppNavigationStateService
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.onEach
import kotlinx.coroutines.launch
@ -84,6 +87,7 @@ import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.Optional
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(SessionScope::class)
class LoggedInFlowNode @AssistedInject constructor(
@ -127,8 +131,18 @@ class LoggedInFlowNode @AssistedInject constructor(
)
private val verificationListener = object : SessionVerificationServiceListener {
override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
// 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
@Parcelize
data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View file

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

View file

@ -16,7 +16,6 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
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.plugin.Plugin
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.SessionScope
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.filter
import kotlinx.coroutines.flow.launchIn
@ -59,7 +56,6 @@ class FtueFlowNode @AssistedInject constructor(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap,
backPressHandler = NoOpBackstackHandlerStrategy(),
),
buildContext = buildContext,
plugins = plugins,
@ -104,7 +100,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext)
}
NavTarget.SessionVerification -> {
is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() {
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 androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
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.navmodel.backstack.BackStack
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 dagger.assisted.Assisted
import dagger.assisted.AssistedInject
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.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackView
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.matrix.api.verification.VerificationRequest
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ -37,7 +44,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
private val secureBackupEntryPoint: SecureBackupEntryPoint,
) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root(showDeviceVerifiedScreen = false),
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
@ -45,7 +52,10 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data class Root(val showDeviceVerifiedScreen: Boolean) : NavTarget
data object Root : NavTarget
@Parcelize
data object UseAnotherDevice : NavTarget
@Parcelize
data object EnterRecoveryKey : NavTarget
@ -62,7 +72,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
override fun onDone() {
lifecycleScope.launch {
// 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 {
return when (navTarget) {
is NavTarget.Root -> {
verifySessionEntryPoint.nodeBuilder(this, buildContext)
.params(VerifySessionEntryPoint.Params(navTarget.showDeviceVerifiedScreen))
.callback(object : VerifySessionEntryPoint.Callback {
override fun onEnterRecoveryKey() {
backstack.push(NavTarget.EnterRecoveryKey)
}
val callback = object : ChooseSelfVerificationModeNode.Callback {
override fun onUseAnotherDevice() {
backstack.push(NavTarget.UseAnotherDevice)
}
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() {
plugins<Callback>().forEach { it.onDone() }
}
override fun onResetKey() {
backstack.push(NavTarget.ResetIdentity)
override fun onBack() {
backstack.pop()
}
override fun onLearnMoreAboutEncryption() {
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
}
})
.build()
@ -106,8 +140,12 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
}
}
private val learnMoreUrl = mutableStateOf<String?>(null)
@Composable
override fun View(modifier: Modifier) {
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"?>
<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_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_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>

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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import io.element.android.features.logout.impl.R
import io.element.android.libraries.architecture.AsyncAction

View file

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

View file

@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -21,6 +22,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
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.ElementCallEntryPoint
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.securityandprivacy.SecurityAndPrivacyFlowNode
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.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.operation.hide
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.matrix.api.core.EventId
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.permalink.PermalinkData
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.MediaViewerEntryPoint
import io.element.android.services.analytics.api.AnalyticsService
@ -65,6 +70,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -118,6 +124,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data object SecurityAndPrivacy : NavTarget
@Parcelize
data class VerifyUser(val userId: UserId) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -168,6 +177,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
backstack.push(NavTarget.SecurityAndPrivacy)
}
override fun openDmUserProfile(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
override fun onJoinCall() {
val inputs = CallType.RoomCall(
sessionId = room.sessionId,
@ -224,6 +237,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun onStartCall(dmRoomId: RoomId) {
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)
createNode<RoomMemberDetailsNode>(buildContext, plugins)
@ -301,11 +318,37 @@ class RoomDetailsFlowNode @AssistedInject constructor(
NavTarget.SecurityAndPrivacy -> {
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
override fun View(modifier: 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.libraries.androidutils.system.startSharePlainTextIntent
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.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
@ -50,6 +51,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openPinnedMessagesList()
fun openKnockRequestsList()
fun openSecurityAndPrivacy()
fun openDmUserProfile(userId: UserId)
fun onJoinCall()
}
@ -126,6 +128,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openSecurityAndPrivacy() }
}
private fun onProfileClick(userId: UserId) {
callbacks.forEach { it.openDmUserProfile(userId) }
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
@ -158,7 +164,8 @@ class RoomDetailsNode @AssistedInject constructor(
onJoinCallClick = ::onJoinCall,
onPinnedMessagesClick = ::openPinnedMessages,
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.matrix.api.core.RoomAlias
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.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.getBestName
@ -101,6 +102,7 @@ fun RoomDetailsView(
onPinnedMessagesClick: () -> Unit,
onKnockRequestsClick: () -> Unit,
onSecurityAndPrivacyClick: () -> Unit,
onProfileClick: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@ -179,11 +181,16 @@ fun RoomDetailsView(
state.eventSink(RoomDetailsEvent.SetFavorite(it))
}
)
if (state.canShowSecurityAndPrivacy) {
SecurityAndPrivacyItem(
onClick = onSecurityAndPrivacyClick
)
}
state.roomMemberDetailsState?.let { dmMemberDetails ->
ProfileItem(onClick = { onProfileClick(dmMemberDetails.userId) })
}
}
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
private fun MembersItem(
memberCount: Long,
@ -655,5 +673,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
onPinnedMessagesClick = {},
onKnockRequestsClick = {},
onSecurityAndPrivacyClick = {},
onProfileClick = {},
)
}

View file

@ -79,6 +79,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
onOpenDm = ::onStartDM,
onStartCall = ::onStartCall,
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.test.ext.junit.runners.AndroidJUnit4
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.test.A_USER_ID
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
@ -294,6 +297,21 @@ class RoomDetailsViewTest {
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(
@ -314,6 +332,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
onKnockRequestsClick: () -> Unit = EnsureNeverCalled(),
onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(),
onProfileClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
RoomDetailsView(
@ -332,6 +351,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
onPinnedMessagesClick = onPinnedMessagesClick,
onKnockRequestsClick = onKnockRequestsClick,
onSecurityAndPrivacyClick = onSecurityAndPrivacyClick,
onProfileClick = onProfileClick,
)
}
}

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.features.call.api)
implementation(projects.features.verifysession.api)
api(projects.features.userprofile.api)
api(projects.features.userprofile.shared)
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.impl.root.UserProfileNode
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.BaseFlowNode
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.matrix.api.core.EventId
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.verification.VerificationRequest
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import kotlinx.parcelize.Parcelize
@ -43,6 +46,7 @@ class UserProfileFlowNode @AssistedInject constructor(
private val elementCallEntryPoint: ElementCallEntryPoint,
private val sessionIdHolder: CurrentSessionIdHolder,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@ -57,6 +61,9 @@ class UserProfileFlowNode @AssistedInject constructor(
@Parcelize
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 {
@ -74,6 +81,10 @@ class UserProfileFlowNode @AssistedInject constructor(
override fun onStartCall(dmRoomId: RoomId) {
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)
createNode<UserProfileNode>(buildContext, listOf(callback, params))
@ -96,6 +107,15 @@ class UserProfileFlowNode @AssistedInject constructor(
.callback(callback)
.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,
onStartCall = callback::onStartCall,
openAvatarPreview = callback::openAvatarPreview,
onVerifyClick = callback::onVerifyUser,
)
}
}

View file

@ -73,7 +73,6 @@ class UserProfilePresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope()
val isCurrentUser = remember { client.isMe(userId) }
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isBlocked: 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) }
.launchIn(this)
}
LaunchedEffect(Unit) {
userProfile = client.getProfile(userId).getOrNull()
}
val userProfile by produceState<MatrixUser?>(null) { value = client.getProfile(userId).getOrNull() }
LaunchedEffect(Unit) {
suspend {
client.encryptionService().isUserVerified(userId).getOrThrow()

View file

@ -24,6 +24,7 @@ class UserProfileNodeHelper(
fun openAvatarPreview(username: String, avatarUrl: String)
fun onStartDM(roomId: RoomId)
fun onStartCall(dmRoomId: RoomId)
fun onVerifyUser(userId: UserId)
}
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.TopAppBar
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.ui.strings.CommonStrings
@ -50,6 +51,7 @@ fun UserProfileView(
onStartCall: (RoomId) -> Unit,
goBack: () -> Unit,
openAvatarPreview: (username: String, url: String) -> Unit,
onVerifyClick: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@ -82,7 +84,7 @@ fun UserProfileView(
)
Spacer(modifier = Modifier.height(26.dp))
if (!state.isCurrentUser) {
VerifyUserSection(state)
VerifyUserSection(state, onVerifyClick = { onVerifyClick(state.userId) })
BlockUserSection(state)
BlockUserDialogs(state)
}
@ -116,14 +118,15 @@ fun UserProfileView(
}
@Composable
private fun VerifyUserSection(state: UserProfileState) {
private fun VerifyUserSection(
state: UserProfileState,
onVerifyClick: () -> Unit,
) {
if (state.isVerified.dataOrNull() == false) {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_verify_identity)) },
supportingContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_subtitle)) },
headlineContent = { Text(stringResource(CommonStrings.common_verify_user)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
enabled = false,
onClick = { },
onClick = onVerifyClick,
)
}
}
@ -139,6 +142,7 @@ internal fun UserProfileViewPreview(
goBack = {},
onOpenDm = {},
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.libraries.architecture.AsyncData
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.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.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
@ -193,6 +195,17 @@ class UserProfileViewTest {
rule.clickOn(CommonStrings.action_cancel)
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(
@ -202,6 +215,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserP
onShareUser: () -> Unit = EnsureNeverCalled(),
onDmStarted: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onStartCall: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onVerifyClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
goBack: () -> Unit = EnsureNeverCalled(),
openAvatarPreview: (String, String) -> Unit = EnsureNeverCalledWithTwoParams(),
) {
@ -213,6 +227,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserP
onStartCall = onStartCall,
goBack = goBack,
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 io.element.android.libraries.architecture.FeatureEntryPoint
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 {
data class Params(
val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
val verificationRequest: VerificationRequest.Incoming,
) : NodeInputs
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 io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.verification.VerificationRequest
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
@ -25,8 +29,8 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
}
interface Callback : Plugin {
fun onEnterRecoveryKey()
fun onResetKey()
fun onLearnMoreAboutEncryption()
fun onBack()
fun onDone()
}
}

View file

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

View file

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

View file

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

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.aEmojisSessionVerificationData
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> {
override val values: Sequence<IncomingVerificationState>
get() = sequenceOf(
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),
verificationRequest = anIncomingUserVerificationRequest()
),
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.Completed),
anIncomingVerificationState(step = Step.Completed, verificationRequest = anIncomingUserVerificationRequest()),
anIncomingVerificationState(step = Step.Failure),
anIncomingVerificationState(step = Step.Canceled),
// Add other state here
@ -37,10 +53,38 @@ internal fun aStepInitial(
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(
step: Step = aStepInitial(),
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest(),
eventSink: (IncomingVerificationViewEvents) -> Unit = {},
) = IncomingVerificationState(
step = step,
request = verificationRequest,
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.ui.VerificationBottomMenu
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.components.BigIcon
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.PreviewsDayNight
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.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.ui.strings.CommonStrings
/**
@ -59,10 +61,17 @@ fun IncomingVerificationView(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
when {
step is Step.Initial && !step.isWaiting -> Unit
step is Step.Completed -> Unit
else -> BackButton(onClick = { state.eventSink(IncomingVerificationViewEvents.GoBack) })
}
}
)
},
header = {
IncomingVerificationHeader(step = step)
IncomingVerificationHeader(step = step, request = state.request)
},
footer = {
IncomingVerificationBottomMenu(
@ -72,37 +81,58 @@ fun IncomingVerificationView(
) {
IncomingVerificationContent(
step = step,
request = state.request,
)
}
}
@Composable
private fun IncomingVerificationHeader(step: Step) {
private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.Incoming) {
val iconStyle = when (step) {
Step.Canceled -> BigIcon.Style.AlertSolid
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
is Step.Initial -> if (step.isWaiting) {
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.Failure -> BigIcon.Style.AlertSolid
}
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.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
}
Step.Completed -> R.string.screen_session_verification_request_success_title
Step.Completed -> CommonStrings.common_verification_complete
Step.Failure -> R.string.screen_session_verification_request_failure_title
}
val subtitleTextId = when (step) {
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 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
}
PageTitle(
@ -115,9 +145,10 @@ private fun IncomingVerificationHeader(step: Step) {
@Composable
private fun IncomingVerificationContent(
step: Step,
request: VerificationRequest.Incoming,
) {
when (step) {
is Step.Initial -> ContentInitial(step)
is Step.Initial -> ContentInitial(step, request)
is Step.Verifying -> VerificationContentVerifying(step.data)
else -> Unit
}
@ -126,24 +157,40 @@ private fun IncomingVerificationContent(
@Composable
private fun ContentInitial(
initialIncoming: Step.Initial,
request: VerificationRequest.Incoming,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
SessionDetailsView(
deviceName = initialIncoming.deviceDisplayName,
deviceId = initialIncoming.deviceId,
signInFormattedTimestamp = initialIncoming.formattedSignInTime,
)
Text(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 16.dp),
text = stringResource(R.string.screen_session_verification_request_footer),
style = ElementTheme.typography.fontBodyMdMedium,
textAlign = TextAlign.Center,
)
when (request) {
is VerificationRequest.Incoming.OtherSession -> {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
SessionDetailsView(
deviceName = initialIncoming.deviceDisplayName,
deviceId = initialIncoming.deviceId,
signInFormattedTimestamp = initialIncoming.formattedSignInTime,
)
Text(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 16.dp),
text = stringResource(R.string.screen_session_verification_request_footer),
style = ElementTheme.typography.fontBodyMdMedium,
textAlign = TextAlign.Center,
)
}
}
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) {
is Step.Initial -> {
if (step.isWaiting) {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_waiting_on_other_device),
onClick = {},
enabled = false,
showProgress = true,
)
InvisibleButton()
}
// Show nothing
} else {
VerificationBottomMenu {
Button(
@ -184,16 +222,7 @@ private fun IncomingVerificationBottomMenu(
}
is Step.Verifying -> {
if (step.isWaiting) {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing),
onClick = {},
enabled = false,
showProgress = true,
)
InvisibleButton()
}
// Show nothing
} else {
VerificationBottomMenu {
Button(

View file

@ -7,8 +7,6 @@
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.ui.Modifier
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.AssistedInject
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.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ -33,28 +28,22 @@ class VerifySelfSessionNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) {
private val callback = plugins<VerifySessionEntryPoint.Callback>().first()
private val presenter = presenterFactory.create(
showDeviceVerifiedScreen = inputs<VerifySessionEntryPoint.Params>().showDeviceVerifiedScreen,
)
private val inputs = inputs<VerifySessionEntryPoint.Params>()
private fun onLearnMoreClick(activity: Activity, dark: Boolean) {
activity.openUrlInChromeCustomTab(null, dark, LearnMoreConfig.ENCRYPTION_URL)
}
private val presenter = presenterFactory.create(
showDeviceVerifiedScreen = inputs.showDeviceVerifiedScreen,
verificationRequest = inputs.verificationRequest,
)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
VerifySelfSessionView(
state = state,
modifier = modifier,
onLearnMoreClick = {
onLearnMoreClick(activity, isDark)
},
onEnterRecoveryKey = callback::onEnterRecoveryKey,
onResetKey = callback::onResetKey,
onLearnMoreClick = callback::onLearnMoreAboutEncryption,
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.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.freeletics.flowredux.compose.rememberStateAndDispatch
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
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.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.RecoveryState
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.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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState
class VerifySelfSessionPresenter @AssistedInject constructor(
@Assisted private val showDeviceVerifiedScreen: Boolean,
@Assisted private val verificationRequest: VerificationRequest.Outgoing,
private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
private val stateMachine: VerifySelfSessionStateMachine,
private val buildMeta: BuildMeta,
private val sessionPreferencesStore: SessionPreferencesStore,
private val logoutUseCase: LogoutUseCase,
) : Presenter<VerifySelfSessionState> {
@AssistedFactory
interface Factory {
fun create(showDeviceVerifiedScreen: Boolean): VerifySelfSessionPresenter
fun create(
verificationRequest: VerificationRequest.Outgoing,
showDeviceVerifiedScreen: Boolean,
): VerifySelfSessionPresenter
}
private val stateMachine = VerifySelfSessionStateMachine(
sessionVerificationService = sessionVerificationService,
encryptionService = encryptionService,
)
@Composable
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 skipVerification by sessionPreferencesStore.isSessionVerificationSkipped().collectAsState(initial = false)
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
val signOutAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val step by remember {
derivedStateOf {
if (skipVerification) {
VerifySelfSessionState.Step.Skipped
} else {
when (sessionVerifiedStatus) {
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
SessionVerifiedStatus.NotVerified -> {
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
VerifySelfSessionState.Step.Completed
} else {
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
VerifySelfSessionState.Step.Skipped
when (verificationRequest) {
is VerificationRequest.Outgoing.CurrentSession -> {
when (sessionVerifiedStatus) {
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
SessionVerifiedStatus.NotVerified -> {
stateAndDispatch.state.value.toVerificationStep()
}
SessionVerifiedStatus.Verified -> {
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
// The user has verified the session, we need to show the success screen
VerifySelfSessionState.Step.Completed
} else {
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
VerifySelfSessionState.Step.Exit
}
}
}
}
is VerificationRequest.Outgoing.User -> stateAndDispatch.state.value.toVerificationStep()
}
}
}
// Start this after observing state machine
LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state
sessionVerificationService.reset(cancelAnyPendingVerificationAttempt = true)
observeVerificationService()
}
fun handleEvents(event: VerifySelfSessionViewEvents) {
Timber.d("Verification user action: ${event::class.simpleName}")
when (event) {
VerifySelfSessionViewEvents.UseAnotherDevice -> stateAndDispatch.dispatchAction(StateMachineEvent.UseAnotherDevice)
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
// Just relay the event to the state machine
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification(verificationRequest))
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
VerifySelfSessionViewEvents.SignOut -> coroutineScope.signOut(signOutAction)
VerifySelfSessionViewEvents.SkipVerification -> coroutineScope.launch {
sessionPreferencesStore.setSkipSessionVerification(true)
}
}
}
return VerifySelfSessionState(
step = step,
signOutAction = signOutAction.value,
displaySkipButton = buildMeta.isDebuggable,
request = verificationRequest,
eventSink = ::handleEvents,
)
}
private fun StateMachineState?.toVerificationStep(
canEnterRecoveryKey: Boolean
): VerifySelfSessionState.Step =
private fun StateMachineState?.toVerificationStep(): VerifySelfSessionState.Step =
when (val machineState = this) {
StateMachineState.Initial, null -> {
VerifySelfSessionState.Step.Initial(
canEnterRecoveryKey = canEnterRecoveryKey,
isLastDevice = encryptionService.isLastDevice.value
)
VerifySelfSessionState.Step.Initial
}
VerifySelfSessionStateMachine.State.UseAnotherDevice -> {
VerifySelfSessionState.Step.UseAnotherDevice
}
StateMachineState.RequestingVerification,
StateMachineState.StartingSasVerification,
StateMachineState.SasVerificationStarted,
StateMachineState.Canceling -> {
is StateMachineState.RequestingVerification,
is StateMachineState.StartingSasVerification,
StateMachineState.SasVerificationStarted -> {
VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
}
@ -149,7 +125,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
VerifySelfSessionState.Step.Ready
}
StateMachineState.Canceled -> {
is StateMachineState.Canceled -> {
VerifySelfSessionState.Step.Canceled
}
@ -164,6 +140,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
StateMachineState.Completed -> {
VerifySelfSessionState.Step.Completed
}
StateMachineState.Exit -> {
VerifySelfSessionState.Step.Exit
}
}
private fun CoroutineScope.observeVerificationService() {
@ -171,33 +151,27 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
.onEach { verificationAttemptState ->
when (verificationAttemptState) {
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
VerificationFlowState.Initial -> stateMachine.dispatch(StateMachineEvent.Reset)
VerificationFlowState.DidAcceptVerificationRequest -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
stateMachine.dispatch(StateMachineEvent.DidAcceptVerificationRequest)
}
VerificationFlowState.DidStartSasVerification -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
stateMachine.dispatch(StateMachineEvent.DidStartSasVerification)
}
is VerificationFlowState.DidReceiveVerificationData -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
stateMachine.dispatch(StateMachineEvent.DidReceiveChallenge(verificationAttemptState.data))
}
VerificationFlowState.DidFinish -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
stateMachine.dispatch(StateMachineEvent.DidAcceptChallenge)
}
VerificationFlowState.DidCancel -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
stateMachine.dispatch(StateMachineEvent.DidCancel)
}
VerificationFlowState.DidFail -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
stateMachine.dispatch(StateMachineEvent.DidFail)
}
}
}
.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.Stable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
@Immutable
data class VerifySelfSessionState(
val step: Step,
val signOutAction: AsyncAction<Unit>,
val displaySkipButton: Boolean,
val request: VerificationRequest.Outgoing,
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
) {
@Stable
sealed interface Step {
data object Loading : Step
// FIXME canEnterRecoveryKey value is never read.
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
data object UseAnotherDevice : Step
data object Initial : Step
data object Canceled : Step
data object AwaitingOtherDeviceResponse : Step
data object Ready : Step
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step
data object Completed : Step
data object Skipped : Step
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.verification.SessionVerificationData
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.FlowPreview
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.timeout
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import com.freeletics.flowredux.dsl.State as MachineState
@OptIn(FlowPreview::class)
class VerifySelfSessionStateMachine @Inject constructor(
class VerifySelfSessionStateMachine(
private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>(
initialState = State.Initial
initialState = State.Initial,
) {
init {
spec {
inState<State.Initial> {
on { _: Event.UseAnotherDevice, state ->
state.override { State.UseAnotherDevice.andLogStateChange() }
}
}
inState<State.UseAnotherDevice> {
on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification.andLogStateChange() }
on<Event.RequestVerification> { event, state ->
state.override { State.RequestingVerification(event.verificationRequest).andLogStateChange() }
}
}
inState<State.RequestingVerification> {
onEnterEffect {
sessionVerificationService.requestVerification()
onEnterEffect { event ->
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() }
}
}
@ -61,25 +59,26 @@ class VerifySelfSessionStateMachine @Inject constructor(
}
}
inState<State.VerificationRequestAccepted> {
on { _: Event.StartSasVerification, state ->
on<Event.StartSasVerification> { _, state ->
state.override { State.StartingSasVerification.andLogStateChange() }
}
}
inState<State.Canceled> {
on { _: Event.Reset, state ->
on<Event.Reset> { _, state ->
sessionVerificationService.reset(cancelAnyPendingVerificationAttempt = false)
state.override { State.Initial.andLogStateChange() }
}
}
inState<State.SasVerificationStarted> {
on { event: Event.DidReceiveChallenge, state ->
on<Event.DidReceiveChallenge> { event, state ->
state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() }
}
}
inState<State.Verifying.ChallengeReceived> {
on { _: Event.AcceptChallenge, state ->
on<Event.AcceptChallenge> { _, state ->
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() }
}
}
@ -91,7 +90,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
sessionVerificationService.declineVerification()
}
}
on { _: Event.DidAcceptChallenge, state ->
on<Event.DidAcceptChallenge> { _, state ->
// If a key backup exists, wait until it's restored or a timeout happens
val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse()
if (hasBackup) {
@ -104,21 +103,14 @@ class VerifySelfSessionStateMachine @Inject constructor(
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 {
logReceivedEvents()
on { _: Event.DidStartSasVerification, state: MachineState<State> ->
on<Event.DidStartSasVerification> { _, state: MachineState<State> ->
state.override { State.SasVerificationStarted.andLogStateChange() }
}
on { _: Event.Cancel, state: MachineState<State> ->
on<Event.Cancel> { event, state: MachineState<State> ->
when (state.snapshot) {
State.Initial, State.Completed, State.Canceled -> state.noChange()
State.UseAnotherDevice -> state.override { State.Initial.andLogStateChange() }
State.Initial, State.Completed, is State.Canceled -> state.override { State.Exit }
// For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
// `Canceling` state to `Canceled` automatically anymore
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() }
}
on { _: Event.DidFail, state: MachineState<State> ->
when (state.snapshot) {
is State.RequestingVerification -> state.override { State.Initial.andLogStateChange() }
else -> state.override { State.Canceled.andLogStateChange() }
}
on<Event.DidFail> { event, state: MachineState<State> ->
state.override { State.Canceled.andLogStateChange() }
}
}
}
}
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
/** Let the user know that they need to get ready on their other session. */
data object UseAnotherDevice : State
/** Waiting for verification acceptance. */
data object RequestingVerification : State
data class RequestingVerification(val verificationRequest: VerificationRequest.Outgoing) : State
/** Verification request accepted. Waiting for start. */
data object VerificationRequestAccepted : State
@ -167,22 +153,18 @@ class VerifySelfSessionStateMachine @Inject constructor(
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. */
data object Canceled : State
/** Verification successful. */
data object Completed : State
data object Exit : State
}
sealed interface Event {
/** User wants to use another session. */
data object UseAnotherDevice : Event
/** Request verification. */
data object RequestVerification : Event
data class RequestVerification(val verificationRequest: VerificationRequest.Outgoing) : Event
/** The current verification request has been accepted. */
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.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.VerificationRequest
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState>
get() = sequenceOf(
aVerifySelfSessionState(displaySkipButton = true),
aVerifySelfSessionState(
step = Step.AwaitingOtherDeviceResponse
step = Step.Initial,
request = anOutgoingSessionVerificationRequest(),
),
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(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
@ -37,40 +55,32 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
step = Step.Initial(canEnterRecoveryKey = true)
),
aVerifySelfSessionState(
step = Step.Initial(canEnterRecoveryKey = true, isLastDevice = true)
step = Step.Completed,
request = anOutgoingSessionVerificationRequest(),
),
aVerifySelfSessionState(
step = Step.Completed,
displaySkipButton = true,
),
aVerifySelfSessionState(
signOutAction = AsyncAction.Loading,
displaySkipButton = true,
request = anOutgoingUserVerificationRequest(),
),
aVerifySelfSessionState(
step = Step.Loading
),
aVerifySelfSessionState(
step = Step.Skipped
),
aVerifySelfSessionState(
step = Step.UseAnotherDevice
step = Step.Exit
),
// Add other state here
)
}
internal fun anOutgoingUserVerificationRequest() = VerificationRequest.Outgoing.User(userId = UserId("@alice:example.com"))
internal fun anOutgoingSessionVerificationRequest() = VerificationRequest.Outgoing.CurrentSession
internal fun aVerifySelfSessionState(
step: Step = Step.Initial(canEnterRecoveryKey = false),
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
displaySkipButton: Boolean = false,
step: Step = Step.Initial,
request: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(),
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState(
step = step,
displaySkipButton = displaySkipButton,
request = request,
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.material3.ExperimentalMaterial3Api
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.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
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.ui.VerificationBottomMenu
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
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.PreviewsDayNight
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.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.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@ -55,18 +50,16 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun VerifySelfSessionView(
state: VerifySelfSessionState,
onLearnMoreClick: () -> Unit,
onEnterRecoveryKey: () -> Unit,
onResetKey: () -> Unit,
onFinish: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val step = state.step
fun cancelOrResetFlow() {
when (step) {
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
is Step.AwaitingOtherDeviceResponse,
Step.UseAnotherDevice,
Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
Step.Initial, Step.Completed -> onBack()
Step.Ready, is Step.AwaitingOtherDeviceResponse -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
is Step.Verifying -> {
if (!step.state.isLoading()) {
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 {
cancelOrResetFlow()
}
if (step is Step.Loading ||
step is Step.Skipped) {
if (step is Step.Loading) {
// Just display a loader in this case, to avoid UI glitch.
Box(
modifier = Modifier.fillMaxSize(),
@ -101,94 +87,94 @@ fun VerifySelfSessionView(
topBar = {
TopAppBar(
title = {},
actions = {
if (step !is Step.Completed &&
state.displaySkipButton &&
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) }
)
}
navigationIcon = if (step != Step.Completed) {
{ BackButton(onClick = ::cancelOrResetFlow) }
} else {
{}
}
)
},
header = {
VerifySelfSessionHeader(step = step)
VerifySelfSessionHeader(step = step, request = state.request)
},
footer = {
VerifySelfSessionBottomMenu(
screenState = state,
onCancelClick = ::cancelOrResetFlow,
onEnterRecoveryKey = onEnterRecoveryKey,
onContinueClick = onFinish,
onResetKey = onResetKey,
)
}
) {
VerifySelfSessionContent(
flowState = step,
request = state.request,
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
private fun VerifySelfSessionHeader(step: Step) {
private fun VerifySelfSessionHeader(step: Step, request: VerificationRequest.Outgoing) {
val iconStyle = when (step) {
Step.Loading -> error("Should not happen")
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
Step.UseAnotherDevice -> BigIcon.Style.Default(CompoundIcons.Devices())
Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.Devices())
Step.Initial -> when (request) {
is VerificationRequest.Outgoing.CurrentSession -> 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.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
Step.Ready -> BigIcon.Style.Default(CompoundIcons.Reaction())
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) {
Step.Loading -> error("Should not happen")
is Step.Initial -> R.string.screen_identity_confirmation_title
Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_title
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_title
Step.Initial -> when (request) {
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_use_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.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 SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
}
is Step.Skipped -> return
is Step.Exit -> return
}
val subtitleTextId = when (step) {
Step.Loading -> error("Should not happen")
is Step.Initial -> R.string.screen_identity_confirmation_subtitle
Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_subtitle
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_subtitle
Step.Initial -> when (request) {
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_use_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.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 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(
@ -201,11 +187,15 @@ private fun VerifySelfSessionHeader(step: Step) {
@Composable
private fun VerifySelfSessionContent(
flowState: Step,
request: VerificationRequest.Outgoing,
onLearnMoreClick: () -> Unit,
) {
when (flowState) {
is Step.Initial -> {
ContentInitial(onLearnMoreClick)
when (request) {
is VerificationRequest.Outgoing.CurrentSession -> Unit
is VerificationRequest.Outgoing.User -> ContentInitial(onLearnMoreClick)
}
}
is Step.Verifying -> {
VerificationContentVerifying(flowState.data)
@ -235,8 +225,6 @@ private fun ContentInitial(
@Composable
private fun VerifySelfSessionBottomMenu(
screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
onResetKey: () -> Unit,
onCancelClick: () -> Unit,
onContinueClick: () -> Unit,
) {
@ -248,27 +236,6 @@ private fun VerifySelfSessionBottomMenu(
when (verificationViewState) {
Step.Loading -> error("Should not happen")
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 {
Button(
modifier = Modifier.fillMaxWidth(),
@ -302,39 +269,20 @@ private fun VerifySelfSessionBottomMenu(
)
}
}
is Step.AwaitingOtherDeviceResponse -> {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_waiting_on_other_device),
onClick = {},
showProgress = true,
enabled = false,
)
InvisibleButton()
}
}
is Step.AwaitingOtherDeviceResponse -> Unit
is Step.Verifying -> {
val positiveButtonTitle = if (isVerifying) {
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
if (isVerifying) {
// Show nothing
} else {
stringResource(R.string.screen_session_verification_they_match)
}
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = positiveButtonTitle,
showProgress = isVerifying,
enabled = !isVerifying,
onClick = {
if (!isVerifying) {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_match),
onClick = {
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
}
},
)
if (isVerifying) {
InvisibleButton()
} else {
},
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_dont_match),
@ -353,7 +301,7 @@ private fun VerifySelfSessionBottomMenu(
InvisibleButton()
}
}
is Step.Skipped -> return
is Step.Exit -> return
}
}
@ -363,8 +311,7 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
VerifySelfSessionView(
state = state,
onLearnMoreClick = {},
onEnterRecoveryKey = {},
onResetKey = {},
onFinish = {},
onBack = {},
)
}

View file

@ -8,13 +8,10 @@
package io.element.android.features.verifysession.impl.outgoing
sealed interface VerifySelfSessionViewEvents {
data object UseAnotherDevice : VerifySelfSessionViewEvents
data object RequestVerification : VerifySelfSessionViewEvents
data object StartSasVerification : VerifySelfSessionViewEvents
data object ConfirmVerification : VerifySelfSessionViewEvents
data object DeclineVerification : VerifySelfSessionViewEvents
data object Cancel : 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.SessionVerificationService
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_TIMESTAMP
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.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -37,7 +39,7 @@ class IncomingVerificationPresenterTest {
@Test
fun `present - nominal case - incoming verification successful`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val approveVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
@ -60,7 +62,7 @@ class IncomingVerificationPresenterTest {
)
)
resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
@ -100,7 +102,7 @@ class IncomingVerificationPresenterTest {
@Test
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 declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
@ -123,7 +125,7 @@ class IncomingVerificationPresenterTest {
)
)
resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
@ -157,7 +159,7 @@ class IncomingVerificationPresenterTest {
@Test
fun `present - incoming verification is remotely canceled`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
@ -191,7 +193,7 @@ class IncomingVerificationPresenterTest {
@Test
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 declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
@ -214,7 +216,7 @@ class IncomingVerificationPresenterTest {
)
)
resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
@ -248,7 +250,7 @@ class IncomingVerificationPresenterTest {
@Test
fun `present - user ignores incoming request`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
@ -268,24 +270,30 @@ class IncomingVerificationPresenterTest {
}
}
private val aSessionVerificationRequestDetails = SessionVerificationRequestDetails(
senderId = A_USER_ID,
flowId = FlowId("flowId"),
deviceId = A_DEVICE_ID,
displayName = "a device name",
firstSeenTimestamp = A_TIMESTAMP,
private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.OtherSession(
details = SessionVerificationRequestDetails(
senderProfile = SessionVerificationRequestDetails.SenderProfile(
userId = A_USER_ID,
displayName = "a device name",
avatarUrl = null,
),
flowId = FlowId("flowId"),
deviceId = A_DEVICE_ID,
firstSeenTimestamp = A_TIMESTAMP,
)
)
private fun createPresenter(
sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails,
private fun TestScope.createPresenter(
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest,
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
service: SessionVerificationService = FakeSessionVerificationService(),
dateFormatter: DateFormatter = FakeDateFormatter(),
) = IncomingVerificationPresenter(
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
verificationRequest = verificationRequest,
navigator = navigator,
sessionVerificationService = service,
stateMachine = IncomingVerificationStateMachine(service),
dateFormatter = dateFormatter,
sessionCoroutineScope = backgroundScope,
)
}

View file

@ -9,27 +9,21 @@ package io.element.android.features.verifysession.impl.outgoing
import app.cash.turbine.ReceiveTurbine
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.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.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
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.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@ -48,85 +42,70 @@ class VerifySelfSessionPresenterTest {
)
presenter.test {
awaitItem().run {
assertThat(step).isEqualTo(Step.Initial(false))
assertThat(displaySkipButton).isTrue()
assertThat(step).isEqualTo(Step.Initial)
}
}
}
@Test
fun `present - hides skip verification button on non-debuggable builds`() = runTest {
val buildMeta = aBuildMeta(isDebuggable = false)
val presenter = createVerifySelfSessionPresenter(
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 {
fun `present - Handles requestVerification for session verification`() = runTest {
val requestSessionVerificationRecorder = lambdaRecorder<Unit> {}
val startVerificationRecorder = lambdaRecorder<Unit> {}
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
requestSessionVerificationLambda = requestSessionVerificationRecorder,
startVerificationLambda = startVerificationRecorder,
)
val presenter = createVerifySelfSessionPresenter(
service = service,
verificationRequest = anOutgoingSessionVerificationRequest(),
)
val presenter = createVerifySelfSessionPresenter(service)
presenter.test {
requestVerificationAndAwaitVerifyingState(service)
requestSessionVerificationRecorder.assertions().isCalledOnce()
startVerificationRecorder.assertions().isCalledOnce()
}
}
@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(
service = unverifiedSessionService(),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(Step.Initial(false))
assertThat(initialState.step).isEqualTo(Step.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.Cancel)
expectNoEvents()
assertThat(awaitItem().step).isEqualTo(Step.Exit)
}
}
@Test
fun `present - A failure when verifying cancels it`() = runTest {
val service = unverifiedSessionService(
requestVerificationLambda = { },
requestSessionVerificationLambda = { },
startVerificationLambda = { },
approveVerificationLambda = { },
)
@ -143,24 +122,23 @@ class VerifySelfSessionPresenterTest {
}
@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(
requestVerificationLambda = { },
requestSessionVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
presenter.test {
awaitItem().eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
service.emitVerificationFlowState(VerificationFlowState.DidFail)
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
}
}
@Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
val service = unverifiedSessionService(
requestVerificationLambda = { },
requestSessionVerificationLambda = { },
startVerificationLambda = { },
cancelVerificationLambda = { },
)
@ -175,7 +153,7 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
val service = unverifiedSessionService(
requestVerificationLambda = { },
requestSessionVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
@ -189,7 +167,7 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - Go back after cancellation returns to initial state`() = runTest {
val service = unverifiedSessionService(
requestVerificationLambda = { },
requestSessionVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
@ -199,7 +177,7 @@ class VerifySelfSessionPresenterTest {
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset)
// Went back to initial state
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
assertThat(awaitItem().step).isEqualTo(Step.Initial)
cancelAndIgnoreRemainingEvents()
}
}
@ -210,7 +188,7 @@ class VerifySelfSessionPresenterTest {
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
)
val service = unverifiedSessionService(
requestVerificationLambda = { },
requestSessionVerificationLambda = { },
startVerificationLambda = { },
approveVerificationLambda = { },
)
@ -235,7 +213,7 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - When verification is declined, the flow is canceled`() = runTest {
val service = unverifiedSessionService(
requestVerificationLambda = { },
requestSessionVerificationLambda = { },
startVerificationLambda = { },
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
fun `present - When verification is done using recovery key, the flow is completed`() = runTest {
val service = FakeSessionVerificationService(
@ -301,32 +265,7 @@ class VerifySelfSessionPresenterTest {
)
presenter.test {
skipItems(1)
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
}
}
@Test
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
val service = FakeSessionVerificationService(
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))
assertThat(awaitItem().step).isEqualTo(Step.Exit)
}
}
@ -335,10 +274,7 @@ class VerifySelfSessionPresenterTest {
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
): VerifySelfSessionState {
var state = awaitItem()
assertThat(state.step).isEqualTo(Step.Initial(false))
state.eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
state = awaitItem()
assertThat(state.step).isEqualTo(Step.UseAnotherDevice)
assertThat(state.step).isEqualTo(Step.Initial)
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response:
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
@ -360,17 +296,19 @@ class VerifySelfSessionPresenterTest {
}
private suspend fun unverifiedSessionService(
requestVerificationLambda: () -> Unit = { lambdaError() },
requestSessionVerificationLambda: () -> Unit = { lambdaError() },
requestUserVerificationLambda: (UserId) -> Unit = { lambdaError() },
cancelVerificationLambda: () -> Unit = { lambdaError() },
approveVerificationLambda: () -> Unit = { lambdaError() },
declineVerificationLambda: () -> Unit = { lambdaError() },
startVerificationLambda: () -> Unit = { lambdaError() },
resetLambda: (Boolean) -> Unit = { },
acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() },
acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
): FakeSessionVerificationService {
return FakeSessionVerificationService(
requestVerificationLambda = requestVerificationLambda,
requestCurrentSessionVerificationLambda = requestSessionVerificationLambda,
requestUserVerificationLambda = requestUserVerificationLambda,
cancelVerificationLambda = cancelVerificationLambda,
approveVerificationLambda = approveVerificationLambda,
declineVerificationLambda = declineVerificationLambda,
@ -385,20 +323,15 @@ class VerifySelfSessionPresenterTest {
private fun createVerifySelfSessionPresenter(
service: SessionVerificationService,
verificationRequest: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(),
encryptionService: EncryptionService = FakeEncryptionService(),
buildMeta: BuildMeta = aBuildMeta(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
logoutUseCase: LogoutUseCase = FakeLogoutUseCase(),
showDeviceVerifiedScreen: Boolean = false,
): VerifySelfSessionPresenter {
return VerifySelfSessionPresenter(
showDeviceVerifiedScreen = showDeviceVerifiedScreen,
verificationRequest = verificationRequest,
sessionVerificationService = service,
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.ui.strings.CommonStrings
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.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
@ -25,7 +24,6 @@ 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 VerifySelfSessionViewTest {
@ -103,16 +101,16 @@ class VerifySelfSessionViewTest {
}
@Test
fun `back key pressed - on Completed step does nothing`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Completed,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertEmpty()
fun `back key pressed - on Completed exits the flow`() {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
onBack = callback,
state = aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Completed,
),
)
rule.pressBackKey()
}
}
@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
fun `clicking on they match emits the expected event`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
@ -194,48 +160,18 @@ class VerifySelfSessionViewTest {
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(
state: VerifySelfSessionState,
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
onFinished: () -> Unit = EnsureNeverCalled(),
onResetKey: () -> Unit = EnsureNeverCalled(),
onBack: () -> Unit = EnsureNeverCalled(),
) {
setContent {
VerifySelfSessionView(
state = state,
onLearnMoreClick = onLearnMoreClick,
onEnterRecoveryKey = onEnterRecoveryKey,
onFinish = onFinished,
onResetKey = onResetKey,
onBack = onBack,
)
}
}

View file

@ -32,6 +32,7 @@ android {
implementation(libs.coil.compose)
implementation(libs.vanniktech.blurhash)
implementation(projects.features.enterprise.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
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.libraries.designsystem.preview.ElementPreview
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.ui.strings.CommonStrings
@ -78,6 +79,11 @@ object BigIcon {
* A success style with a tinted background.
*/
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.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
}
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
Style.Loading -> ElementTheme.colors.bgSubtleSecondary
}
Box(
modifier = modifier
@ -134,12 +116,50 @@ object BigIcon {
.background(backgroundColor),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(32.dp),
tint = iconTint,
imageVector = icon,
contentDescription = contentDescription
)
if (style is Style.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(27.dp),
color = ElementTheme.colors.iconSecondary,
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.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true),
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),
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
data class SessionVerificationRequestDetails(
val senderId: UserId,
val senderProfile: SenderProfile,
val flowId: FlowId,
val deviceId: DeviceId,
val displayName: String?,
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
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@ -31,7 +32,12 @@ interface SessionVerificationService {
/**
* 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.
@ -67,7 +73,7 @@ interface SessionVerificationService {
* Set this particular request as the currently active one and register for
* events pertaining it.
*/
suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails)
suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming)
/**
* Accept the previously acknowledged verification request.
@ -76,7 +82,7 @@ interface SessionVerificationService {
}
interface SessionVerificationServiceListener {
fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails)
fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming)
}
/** 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
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.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
@ -50,6 +51,8 @@ class RustSessionVerificationService(
isSyncServiceReady: Flow<Boolean>,
private val sessionCoroutineScope: CoroutineScope,
) : SessionVerificationService, SessionVerificationControllerDelegate {
private var currentVerificationRequest: VerificationRequest? = null
private val encryptionService: Encryption = client.encryption()
private lateinit var verificationController: SessionVerificationController
@ -88,10 +91,8 @@ class RustSessionVerificationService(
verificationStatus == SessionVerifiedStatus.NotVerified
}
private var isOwnVerification = true
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
listener?.onIncomingSessionRequest(details.map())
listener?.onIncomingSessionRequest(details.toVerificationRequest(UserId(client.userId())))
}
private var listener: SessionVerificationServiceListener? = null
@ -111,9 +112,16 @@ class RustSessionVerificationService(
this.listener = listener
}
override suspend fun requestVerification() = tryOrFail {
override suspend fun requestCurrentSessionVerification() = tryOrFail {
initVerificationControllerIfNeeded()
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 {
@ -130,16 +138,16 @@ class RustSessionVerificationService(
verificationController.startSasVerification()
}
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail {
isOwnVerification = false
override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) = tryOrFail {
initVerificationControllerIfNeeded()
verificationController.acknowledgeVerificationRequest(
senderId = details.senderId.value,
flowId = details.flowId.value,
senderId = verificationRequest.details.senderProfile.userId.value,
flowId = verificationRequest.details.flowId.value,
)
}
override suspend fun acceptVerificationRequest() = tryOrFail {
Timber.d("Accepting incoming verification request")
verificationController.acceptVerificationRequest()
}
@ -183,7 +191,7 @@ class RustSessionVerificationService(
}
}
.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
tryOrNull {
withTimeout(10.seconds) {
@ -215,7 +223,7 @@ class RustSessionVerificationService(
// end-region
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
isOwnVerification = true
currentVerificationRequest = null
if (isReady.value && cancelAnyPendingVerificationAttempt) {
// Cancel any pending verification attempt
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.UserId
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.UserProfile as RustUserProfile
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
senderId = UserId(senderProfile.userId),
senderProfile = senderProfile.map(),
flowId = FlowId(flowId),
deviceId = DeviceId(deviceId),
displayName = senderProfile.displayName,
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
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.SessionVerificationServiceListener
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.VerificationRequest
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
@ -20,13 +21,14 @@ import kotlinx.coroutines.flow.StateFlow
class FakeSessionVerificationService(
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 approveVerificationLambda: () -> Unit = { lambdaError() },
private val declineVerificationLambda: () -> Unit = { lambdaError() },
private val startVerificationLambda: () -> Unit = { lambdaError() },
private val resetLambda: (Boolean) -> Unit = { lambdaError() },
private val acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
private val acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() },
private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
) : SessionVerificationService {
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
@ -37,8 +39,12 @@ class FakeSessionVerificationService(
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
override val needsSessionVerification: Flow<Boolean> = _needsSessionVerification
override suspend fun requestVerification() {
requestVerificationLambda()
override suspend fun requestCurrentSessionVerification() {
requestCurrentSessionVerificationLambda()
}
override suspend fun requestUserVerification(userId: UserId) {
requestUserVerificationLambda(userId)
}
override suspend fun cancelVerification() {
@ -68,8 +74,8 @@ class FakeSessionVerificationService(
this.listener = listener
}
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) {
acknowledgeVerificationRequestLambda(details)
override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
acknowledgeVerificationRequestLambda(verificationRequest)
}
override suspend fun acceptVerificationRequest() = simulateLongTask {

View file

@ -11,4 +11,8 @@ plugins {
android {
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")
}
}
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
oid sha256:7479bd11493b82e7027599a938979fd50fb75c31cf7c505cf8669db9387fb124
size 38805
oid sha256:f1a69af9c184578a355345037ec34016c4b4ab26a2719bfaffafa1027365a678
size 38453

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a409716052c9c12c963668098943b8f286e41d6a10f5639d11b7b464cb9d0c3d
size 28909
oid sha256:201c3f2385059321f7500ca8d559bb973a51aeb56fd417e943be496ae55d0869
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
oid sha256:2473606524b856b4afd81e4a32b29c7456788746078387fff280f3c8eb577396
size 41622
oid sha256:1da0edef645c5abc391f0425e3a1f8719c1f061f66517b131a81cdd59d2bd04a
size 40105

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8371da870ec795fbced08edd199c6e6c53b1557b648686fe89c278def41e425b
size 24134
oid sha256:baa21d8011741ac2bca82ac26fa289d016a3ddcc16d408f92812adc662e27869
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