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

@ -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,
)
}
}
}