Move session verification to FTUE flow, make it mandatory (#2594)

* Move session verification to the FTUE
* Allow session verification flow to be restarted
* Use `EncryptionService` to display session verification faster
* Remove session verification item from settings
* Remove session verification banner from room list
* Remove 'verification needed' variant from the `TimelineEncryptedHistoryBanner`
* Improve verification flow UI and UX
* Remove 'verification successful' snackbar message
* Only register push provider after the session has been verified
* Hide room list while the session hasn't been verified
* Prevent deep links from changing the navigation if the session isn't verified
* Update screenshots
* Renamed `FtueState` to `FtueService`, created an actual `FtueState`.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2024-04-03 16:53:17 +02:00 committed by GitHub
parent 06ba5eaafc
commit 8b335a9125
198 changed files with 822 additions and 761 deletions

View file

@ -53,7 +53,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
state = state,
modifier = modifier,
onEnterRecoveryKey = ::onEnterRecoveryKey,
goBack = ::onDone,
onFinished = ::onDone,
)
}
}

View file

@ -68,10 +68,10 @@ class VerifySelfSessionPresenter @Inject constructor(
when (event) {
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
VerifySelfSessionViewEvents.Restart -> stateAndDispatch.dispatchAction(StateMachineEvent.Restart)
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
VerifySelfSessionViewEvents.CancelAndClose -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
}
}
return VerifySelfSessionState(
@ -118,7 +118,7 @@ class VerifySelfSessionPresenter @Inject constructor(
private fun CoroutineScope.observeVerificationService() {
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
when (verificationAttemptState) {
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Restart)
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
VerificationFlowState.AcceptedVerificationRequest -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
}

View file

@ -20,24 +20,35 @@
package io.element.android.features.verifysession.impl
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.encryption.EncryptionService
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 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(
private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>(
initialState = State.Initial
) {
init {
spec {
inState<State.Initial> {
on { _: Event.RequestVerification, state: MachineState<State.Initial> ->
on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification }
}
on { _: Event.StartSasVerification, state: MachineState<State.Initial> ->
on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification }
}
}
@ -45,12 +56,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
onEnterEffect {
sessionVerificationService.requestVerification()
}
on { _: Event.DidAcceptVerificationRequest, state: MachineState<State.RequestingVerification> ->
on { _: Event.DidAcceptVerificationRequest, state ->
state.override { State.VerificationRequestAccepted }
}
on { _: Event.DidFail, state: MachineState<State.RequestingVerification> ->
state.override { State.Initial }
}
}
inState<State.StartingSasVerification> {
onEnterEffect {
@ -58,25 +66,28 @@ class VerifySelfSessionStateMachine @Inject constructor(
}
}
inState<State.VerificationRequestAccepted> {
on { _: Event.StartSasVerification, state: MachineState<State.VerificationRequestAccepted> ->
on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification }
}
}
inState<State.Canceled> {
on { _: Event.Restart, state: MachineState<State.Canceled> ->
on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification }
}
on { _: Event.Reset, state ->
state.override { State.Initial }
}
}
inState<State.SasVerificationStarted> {
on { event: Event.DidReceiveChallenge, state: MachineState<State.SasVerificationStarted> ->
on { event: Event.DidReceiveChallenge, state ->
state.override { State.Verifying.ChallengeReceived(event.data) }
}
}
inState<State.Verifying.ChallengeReceived> {
on { _: Event.AcceptChallenge, state: MachineState<State.Verifying.ChallengeReceived> ->
on { _: Event.AcceptChallenge, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = true) }
}
on { _: Event.DeclineChallenge, state: MachineState<State.Verifying.ChallengeReceived> ->
on { _: Event.DeclineChallenge, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = false) }
}
}
@ -88,11 +99,21 @@ class VerifySelfSessionStateMachine @Inject constructor(
sessionVerificationService.declineVerification()
}
}
on { _: Event.DidAcceptChallenge, state: MachineState<State.Verifying.Replying> ->
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) {
tryOrNull {
encryptionService.recoveryStateStateFlow.filter { it == RecoveryState.ENABLED }
.timeout(10.seconds)
.first()
}
}
state.override { State.Completed }
}
}
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()
}
@ -102,21 +123,24 @@ class VerifySelfSessionStateMachine @Inject constructor(
state.override { State.SasVerificationStarted }
}
on { _: Event.Cancel, state: MachineState<State> ->
if (state.snapshot in sequenceOf(
State.Initial,
State.Completed,
State.Canceled
)) {
state.noChange()
} else {
state.override { State.Canceling }
when (state.snapshot) {
State.Initial, State.Completed, State.Canceled -> state.noChange()
// For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
// `Canceling` state to `Canceled` automatically anymore
else -> {
sessionVerificationService.cancelVerification()
state.override { State.Canceled }
}
}
}
on { _: Event.DidCancel, state: MachineState<State> ->
state.override { State.Canceled }
}
on { _: Event.DidFail, state: MachineState<State> ->
state.override { State.Canceled }
when (state.snapshot) {
is State.RequestingVerification -> state.override { State.Initial }
else -> state.override { State.Canceled }
}
}
}
}
@ -190,7 +214,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
/** Request failed. */
data object DidFail : Event
/** Restart the verification flow. */
data object Restart : Event
/** Reset the verification flow to the initial state. */
data object Reset : Event
}
}

View file

@ -30,9 +30,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@ -42,15 +39,16 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.verifysession.impl.emoji.toEmojiResource
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
@ -62,36 +60,37 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver
fun VerifySelfSessionView(
state: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
goBack: () -> Unit,
onFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
fun goBackAndCancelIfNeeded() {
state.eventSink(VerifySelfSessionViewEvents.CancelAndClose)
goBack()
}
if (state.verificationFlowStep is FlowStep.Completed) {
goBack()
fun resetFlow() {
state.eventSink(VerifySelfSessionViewEvents.Reset)
}
BackHandler {
goBackAndCancelIfNeeded()
when (state.verificationFlowStep) {
is FlowStep.Canceled -> resetFlow()
is FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
is FlowStep.Verifying -> {
if (!state.verificationFlowStep.state.isLoading()) {
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
}
}
else -> Unit
}
}
val verificationFlowStep = state.verificationFlowStep
val buttonsVisible by remember(verificationFlowStep) {
derivedStateOf { verificationFlowStep != FlowStep.AwaitingOtherDeviceResponse && verificationFlowStep != FlowStep.Completed }
}
HeaderFooterPage(
modifier = modifier,
header = {
HeaderContent(verificationFlowStep = verificationFlowStep)
},
footer = {
if (buttonsVisible) {
BottomMenu(
screenState = state,
goBack = ::goBackAndCancelIfNeeded,
onEnterRecoveryKey = onEnterRecoveryKey
)
}
BottomMenu(
screenState = state,
goBack = ::resetFlow,
onEnterRecoveryKey = onEnterRecoveryKey,
onFinished = onFinished,
)
}
) {
Content(flowState = verificationFlowStep)
@ -100,40 +99,38 @@ fun VerifySelfSessionView(
@Composable
private fun HeaderContent(verificationFlowStep: FlowStep) {
val iconResourceId = when (verificationFlowStep) {
is FlowStep.Initial -> R.drawable.ic_verification_devices
FlowStep.Canceled -> R.drawable.ic_verification_warning
FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting
FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji
val iconStyle = when (verificationFlowStep) {
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
FlowStep.Canceled -> BigIcon.Style.AlertSolid
FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
FlowStep.Completed -> BigIcon.Style.SuccessSolid
}
val titleTextId = when (verificationFlowStep) {
is FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_title
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
FlowStep.Canceled -> CommonStrings.common_verification_cancelled
FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_title
FlowStep.Ready,
FlowStep.Completed -> R.string.screen_session_verification_compare_emojis_title
FlowStep.Ready -> R.string.screen_session_verification_compare_emojis_title
FlowStep.Completed -> R.string.screen_identity_confirmed_title
is FlowStep.Verifying -> when (verificationFlowStep.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
}
}
val subtitleTextId = when (verificationFlowStep) {
is FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_subtitle
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle
FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_subtitle
FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle
FlowStep.Completed -> R.string.screen_session_verification_compare_emojis_subtitle
FlowStep.Completed -> R.string.screen_identity_confirmation_subtitle
is FlowStep.Verifying -> when (verificationFlowStep.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
}
}
IconTitleSubtitleMolecule(
PageTitle(
modifier = Modifier.padding(top = 60.dp),
iconResourceId = iconResourceId,
iconStyle = iconStyle,
title = stringResource(id = titleTextId),
subTitle = stringResource(id = subtitleTextId)
subtitle = stringResource(id = subtitleTextId)
)
}
@ -141,20 +138,12 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
private fun Content(flowState: FlowStep) {
Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) {
when (flowState) {
is FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting()
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
is FlowStep.Verifying -> ContentVerifying(flowState)
}
}
}
@Composable
private fun ContentWaiting() {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
}
@Composable
private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) {
when (verificationFlowStep.data) {
@ -212,76 +201,102 @@ private fun BottomMenu(
screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
goBack: () -> Unit,
onFinished: () -> Unit,
) {
val verificationViewState = screenState.verificationFlowStep
val eventSink = screenState.eventSink
val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is AsyncData.Loading<Unit>
val positiveButtonTitle = when (verificationViewState) {
is FlowStep.Initial -> R.string.screen_session_verification_positive_button_initial
FlowStep.Canceled -> R.string.screen_session_verification_positive_button_canceled
when (verificationViewState) {
is FlowStep.Initial -> {
BottomMenu(
positiveButtonTitle = stringResource(R.string.screen_identity_use_another_device),
onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
negativeButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
onNegativeButtonClicked = onEnterRecoveryKey,
)
}
is FlowStep.Canceled -> {
BottomMenu(
positiveButtonTitle = stringResource(R.string.screen_session_verification_positive_button_canceled),
onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
negativeButtonTitle = stringResource(CommonStrings.action_cancel),
onNegativeButtonClicked = goBack,
)
}
is FlowStep.Ready -> {
BottomMenu(
positiveButtonTitle = stringResource(CommonStrings.action_start),
onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) },
negativeButtonTitle = stringResource(CommonStrings.action_cancel),
onNegativeButtonClicked = goBack,
)
}
is FlowStep.AwaitingOtherDeviceResponse -> {
BottomMenu(
positiveButtonTitle = stringResource(R.string.screen_identity_waiting_on_other_device),
onPositiveButtonClicked = {},
isLoading = true,
)
}
is FlowStep.Verifying -> {
if (isVerifying) {
R.string.screen_session_verification_positive_button_verifying_ongoing
val positiveButtonTitle = if (isVerifying) {
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
} else {
R.string.screen_session_verification_they_match
stringResource(R.string.screen_session_verification_they_match)
}
BottomMenu(
positiveButtonTitle = positiveButtonTitle,
onPositiveButtonClicked = {
if (!isVerifying) {
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
}
},
negativeButtonTitle = stringResource(R.string.screen_session_verification_they_dont_match),
onNegativeButtonClicked = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
isLoading = isVerifying,
)
}
FlowStep.Ready -> CommonStrings.action_start
else -> null
}
val negativeButtonTitle = when (verificationViewState) {
is FlowStep.Initial -> CommonStrings.action_cancel
FlowStep.Canceled -> CommonStrings.action_cancel
is FlowStep.Verifying -> R.string.screen_session_verification_they_dont_match
else -> null
}
val negativeButtonEnabled = !isVerifying
val positiveButtonEvent = when (verificationViewState) {
is FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification
FlowStep.Ready -> VerifySelfSessionViewEvents.StartSasVerification
is FlowStep.Verifying -> if (!isVerifying) VerifySelfSessionViewEvents.ConfirmVerification else null
FlowStep.Canceled -> VerifySelfSessionViewEvents.Restart
else -> null
}
val negativeButtonCallback: () -> Unit = when (verificationViewState) {
is FlowStep.Verifying -> {
{ eventSink(VerifySelfSessionViewEvents.DeclineVerification) }
is FlowStep.Completed -> {
BottomMenu(
positiveButtonTitle = stringResource(CommonStrings.action_continue),
onPositiveButtonClicked = onFinished,
)
}
else -> goBack
}
}
@Composable
private fun BottomMenu(
positiveButtonTitle: String?,
onPositiveButtonClicked: () -> Unit,
modifier: Modifier = Modifier,
negativeButtonTitle: String? = null,
negativeButtonEnabled: Boolean = negativeButtonTitle != null,
onNegativeButtonClicked: () -> Unit = {},
isLoading: Boolean = false,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 20.dp)
modifier = modifier.padding(bottom = 16.dp)
) {
if (positiveButtonTitle != null) {
Button(
text = stringResource(positiveButtonTitle),
showProgress = isVerifying,
text = positiveButtonTitle,
showProgress = isLoading,
modifier = Modifier.fillMaxWidth(),
onClick = { positiveButtonEvent?.let { eventSink(it) } }
onClick = onPositiveButtonClicked,
)
}
if (negativeButtonTitle != null) {
TextButton(
text = stringResource(negativeButtonTitle),
text = negativeButtonTitle,
modifier = Modifier.fillMaxWidth(),
onClick = negativeButtonCallback,
onClick = onNegativeButtonClicked,
enabled = negativeButtonEnabled,
)
}
if (verificationViewState is FlowStep.Initial && verificationViewState.canEnterRecoveryKey) {
Text(
text = stringResource(id = CommonStrings.common_or),
color = ElementTheme.colors.textSecondary,
)
TextButton(
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
modifier = Modifier.fillMaxWidth(),
onClick = onEnterRecoveryKey,
)
} else {
Spacer(modifier = Modifier.height(48.dp))
}
}
}
@ -292,6 +307,6 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
VerifySelfSessionView(
state = state,
onEnterRecoveryKey = {},
goBack = {},
onFinished = {},
)
}

View file

@ -19,8 +19,8 @@ package io.element.android.features.verifysession.impl
sealed interface VerifySelfSessionViewEvents {
data object RequestVerification : VerifySelfSessionViewEvents
data object StartSasVerification : VerifySelfSessionViewEvents
data object Restart : VerifySelfSessionViewEvents
data object ConfirmVerification : VerifySelfSessionViewEvents
data object DeclineVerification : VerifySelfSessionViewEvents
data object CancelAndClose : VerifySelfSessionViewEvents
data object Cancel : VerifySelfSessionViewEvents
data object Reset : VerifySelfSessionViewEvents
}

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M8.3,35.5V11Q8.3,9.8 9.2,8.9Q10.1,8 11.3,8H40.8Q41.45,8 41.875,8.425Q42.3,8.85 42.3,9.5Q42.3,10.15 41.875,10.575Q41.45,11 40.8,11H11.3Q11.3,11 11.3,11Q11.3,11 11.3,11V35.5H20.75Q21.7,35.5 22.35,36.15Q23,36.8 23,37.75Q23,38.7 22.35,39.35Q21.7,40 20.75,40H6.25Q5.3,40 4.65,39.35Q4,38.7 4,37.75Q4,36.8 4.65,36.15Q5.3,35.5 6.25,35.5ZM27.95,40Q27.15,40 26.575,39.4Q26,38.8 26,37.8V15.95Q26,15.15 26.575,14.575Q27.15,14 27.95,14H41.55Q42.55,14 43.275,14.575Q44,15.15 44,15.95V37.8Q44,38.8 43.275,39.4Q42.55,40 41.55,40ZM29,35.5H41V17H29Z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M31.3,21.35Q32.45,21.35 33.225,20.575Q34,19.8 34,18.65Q34,17.5 33.225,16.725Q32.45,15.95 31.3,15.95Q30.15,15.95 29.375,16.725Q28.6,17.5 28.6,18.65Q28.6,19.8 29.375,20.575Q30.15,21.35 31.3,21.35ZM16.7,21.35Q17.85,21.35 18.625,20.575Q19.4,19.8 19.4,18.65Q19.4,17.5 18.625,16.725Q17.85,15.95 16.7,15.95Q15.55,15.95 14.775,16.725Q14,17.5 14,18.65Q14,19.8 14.775,20.575Q15.55,21.35 16.7,21.35ZM24,34.95Q26.85,34.95 29.375,33.6Q31.9,32.25 33.35,29.85Q33.75,29.25 33.425,28.8Q33.1,28.35 32.4,28.35H15.6Q14.9,28.35 14.6,28.8Q14.3,29.25 14.7,29.85Q16.15,32.25 18.65,33.6Q21.15,34.95 24,34.95ZM24,44Q19.9,44 16.25,42.425Q12.6,40.85 9.875,38.125Q7.15,35.4 5.575,31.75Q4,28.1 4,23.95Q4,19.85 5.575,16.2Q7.15,12.55 9.875,9.85Q12.6,7.15 16.25,5.575Q19.9,4 24.05,4Q28.15,4 31.8,5.575Q35.45,7.15 38.15,9.85Q40.85,12.55 42.425,16.2Q44,19.85 44,24Q44,28.1 42.425,31.75Q40.85,35.4 38.15,38.125Q35.45,40.85 31.8,42.425Q28.15,44 24,44ZM24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24ZM24,41Q31.1,41 36.05,36.025Q41,31.05 41,24Q41,16.9 36.05,11.95Q31.1,7 24,7Q16.95,7 11.975,11.95Q7,16.9 7,24Q7,31.05 11.975,36.025Q16.95,41 24,41Z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M15.8,41H32.2V34.65Q32.2,31.15 29.825,28.625Q27.45,26.1 24,26.1Q20.55,26.1 18.175,28.625Q15.8,31.15 15.8,34.65ZM38.5,44H9.5Q8.85,44 8.425,43.575Q8,43.15 8,42.5Q8,41.85 8.425,41.425Q8.85,41 9.5,41H12.8V34.65Q12.8,31.15 14.625,28.225Q16.45,25.3 19.7,24Q16.45,22.7 14.625,19.75Q12.8,16.8 12.8,13.3V7H9.5Q8.85,7 8.425,6.575Q8,6.15 8,5.5Q8,4.85 8.425,4.425Q8.85,4 9.5,4H38.5Q39.15,4 39.575,4.425Q40,4.85 40,5.5Q40,6.15 39.575,6.575Q39.15,7 38.5,7H35.2V13.3Q35.2,16.8 33.35,19.75Q31.5,22.7 28.3,24Q31.55,25.3 33.375,28.225Q35.2,31.15 35.2,34.65V41H38.5Q39.15,41 39.575,41.425Q40,41.85 40,42.5Q40,43.15 39.575,43.575Q39.15,44 38.5,44Z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M24,24.6Q24.65,24.6 25.075,24.175Q25.5,23.75 25.5,23.1V15.75Q25.5,15.1 25.075,14.675Q24.65,14.25 24,14.25Q23.35,14.25 22.925,14.675Q22.5,15.1 22.5,15.75V23.1Q22.5,23.75 22.925,24.175Q23.35,24.6 24,24.6ZM24,31.3Q24.7,31.3 25.2,30.8Q25.7,30.3 25.7,29.6Q25.7,28.9 25.2,28.4Q24.7,27.9 24,27.9Q23.3,27.9 22.8,28.4Q22.3,28.9 22.3,29.6Q22.3,30.3 22.8,30.8Q23.3,31.3 24,31.3ZM24,43.85Q23.8,43.85 23.625,43.825Q23.45,43.8 23.3,43.75Q16.6,41.75 12.3,35.525Q8,29.3 8,21.85V12.05Q8,11.1 8.55,10.325Q9.1,9.55 9.95,9.2L22.95,4.35Q23.5,4.15 24,4.15Q24.5,4.15 25.05,4.35L38.05,9.2Q38.9,9.55 39.45,10.325Q40,11.1 40,12.05V21.85Q40,29.3 35.7,35.525Q31.4,41.75 24.7,43.75Q24.7,43.75 24,43.85ZM24,40.85Q29.75,38.95 33.375,33.675Q37,28.4 37,21.85V12.05Q37,12.05 37,12.05Q37,12.05 37,12.05L24,7.15Q24,7.15 24,7.15Q24,7.15 24,7.15L11,12.05Q11,12.05 11,12.05Q11,12.05 11,12.05V21.85Q11,28.4 14.625,33.675Q18.25,38.95 24,40.85ZM24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Z"/>
</vector>

View file

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_subtitle">"Verify this device to set up secure messaging."</string>
<string name="screen_identity_confirmation_title">"Confirm that it\'s you"</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_session_verification_cancelled_subtitle">"Something doesnt seem right. Either the request timed out or the request was denied."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Confirm that the emojis below match those shown on your other session."</string>
<string name="screen_session_verification_compare_emojis_title">"Compare emojis"</string>

View file

@ -106,13 +106,13 @@ class VerifySelfSessionPresenterTests {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.CancelAndClose)
eventSink(VerifySelfSessionViewEvents.Cancel)
expectNoEvents()
}
}
@Test
fun `present - A fail in the flow cancels it`() = runTest {
fun `present - A failure when verifying cancels it`() = runTest {
val service = FakeSessionVerificationService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
@ -128,6 +128,21 @@ class VerifySelfSessionPresenterTests {
}
}
@Test
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest {
val service = FakeSessionVerificationService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
service.shouldFail = true
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
service.shouldFail = false
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
}
}
@Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
val service = FakeSessionVerificationService()
@ -136,8 +151,7 @@ class VerifySelfSessionPresenterTests {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.CancelAndClose)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
state.eventSink(VerifySelfSessionViewEvents.Cancel)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
}
}
@ -165,13 +179,30 @@ class VerifySelfSessionPresenterTests {
val state = requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.Canceled)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Restart)
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Went back to requesting verification
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - Go back after cancelation returns to initial state`() = runTest {
val service = FakeSessionVerificationService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.Canceled)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset)
// Went back to initial state
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - When verification is approved, the flow completes if there is no error`() = runTest {
val emojis = listOf(
@ -247,7 +278,7 @@ class VerifySelfSessionPresenterTests {
return VerifySelfSessionPresenter(
sessionVerificationService = service,
encryptionService = encryptionService,
stateMachine = VerifySelfSessionStateMachine(service),
stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
)
}
}

View file

@ -36,45 +36,98 @@ class VerifySelfSessionViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on cancel calls the expected callback and emits the expected Event`() {
fun `back key pressed - when canceled resets the flow`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
ensureCalledOnce { callback ->
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
goBack = callback,
)
}
rule.clickOn(CommonStrings.action_cancel)
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.CancelAndClose)
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Reset)
}
@Test
fun `clicking on back key calls the expected callback and emits the expected Event`() {
fun `back key pressed - when awaiting response cancels the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
ensureCalledOnce { callback ->
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
goBack = callback,
)
}
rule.pressBackKey()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.CancelAndClose)
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
}
@Test
fun `when flow is completed, the expected callback is invoked`() {
fun `back key pressed - when ready to verify cancels the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
}
@Test
fun `back key pressed - when verifying and not loading declines the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
}
@Test
fun `back key pressed - when verifying and loading does nothing`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Loading(),
),
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.pressBackKey()
eventsRecorder.assertEmpty()
}
@Test
fun `when flow is completed and the user clicks on the continue button, the expected callback is invoked`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setContent {
@ -84,9 +137,10 @@ class VerifySelfSessionViewTest {
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
goBack = callback,
onFinished = callback,
)
}
rule.clickOn(CommonStrings.action_continue)
}
}
@ -102,7 +156,7 @@ class VerifySelfSessionViewTest {
eventSink = eventsRecorder
),
onEnterRecoveryKey = callback,
goBack = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.clickOn(R.string.screen_session_verification_enter_recovery_key)
@ -122,7 +176,7 @@ class VerifySelfSessionViewTest {
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
goBack = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.clickOn(R.string.screen_session_verification_they_match)
@ -142,7 +196,7 @@ class VerifySelfSessionViewTest {
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
goBack = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.clickOn(R.string.screen_session_verification_they_dont_match)