mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Add signal login password manager confirmation during account creation.
This commit is contained in:
committed by
Cody Henthorne
parent
1f29540bbf
commit
7b01d881ad
@@ -127,6 +127,8 @@ import org.signal.registration.screens.signallogincredentials.SignalLoginManualS
|
||||
import org.signal.registration.screens.signallogindetails.SignalLoginViewDetailsScreenActions
|
||||
import org.signal.registration.screens.signallogindetails.SignalLoginViewDetailsViewModel
|
||||
import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreen
|
||||
import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreenActions
|
||||
import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreenEvents
|
||||
import org.signal.registration.screens.signallogininfo.SignalLoginInfoViewModel
|
||||
import org.signal.registration.screens.signalloginmanualsave.SignalLoginViewDetailsForManualSaveScreen
|
||||
import org.signal.registration.screens.signalloginmanualsave.SignalLoginViewDetailsForManualSaveScreenActions
|
||||
@@ -746,6 +748,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
// -- Signal Login Info Screen
|
||||
entry<RegistrationRoute.SignalLoginInfo> {
|
||||
val context = LocalContext.current
|
||||
val credentialManagerScope = rememberCoroutineScope()
|
||||
val viewModel: SignalLoginInfoViewModel = viewModel {
|
||||
SignalLoginInfoViewModel(
|
||||
repository = registrationRepository,
|
||||
@@ -756,6 +759,28 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
}
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
CollectActions(viewModel.actions) { action ->
|
||||
when (action) {
|
||||
is SignalLoginInfoScreenActions.SaveToPasswordManager -> {
|
||||
credentialManagerScope.launch {
|
||||
val result = SignalCredentialManager.saveCredential(
|
||||
activityContext = context,
|
||||
username = action.accountId,
|
||||
password = action.recoveryKey
|
||||
)
|
||||
viewModel.onEvent(SignalLoginInfoScreenEvents.SaveToPasswordManagerCompleted(result))
|
||||
}
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenActions.ReadBackFromPasswordManager -> {
|
||||
credentialManagerScope.launch {
|
||||
val credential = SignalCredentialManager.getCredential(activityContext = context, id = action.accountId)
|
||||
viewModel.onEvent(SignalLoginInfoScreenEvents.SavedCredentialRetrieved(credential))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SignalLoginInfoScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogininfo
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.dismissWithAnimation
|
||||
import org.signal.registration.R
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Sheet shown once the password manager has taken the user's Signal Login, warning them that they're about to be asked
|
||||
* to fill it back in so we can check it really got stored.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConfirmLoginSavedToPasswordManagerBottomSheet(
|
||||
maxButtonWidth: Dp,
|
||||
onConfirm: () -> Unit,
|
||||
onSeeLoginInfoAgain: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BottomSheets.BottomSheet(
|
||||
onDismissRequest = { sheetState.dismissWithAnimation(scope, onComplete = onDismiss) },
|
||||
sheetState = sheetState
|
||||
) {
|
||||
ConfirmLoginSavedToPasswordManagerBottomSheetContent(
|
||||
maxButtonWidth = maxButtonWidth,
|
||||
onConfirmClick = { sheetState.dismissWithAnimation(scope, onComplete = onConfirm) },
|
||||
onSeeLoginInfoAgainClick = { sheetState.dismissWithAnimation(scope, onComplete = onSeeLoginInfoAgain) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmLoginSavedToPasswordManagerBottomSheetContent(
|
||||
maxButtonWidth: Dp,
|
||||
onConfirmClick: () -> Unit,
|
||||
onSeeLoginInfoAgainClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 24.dp, bottom = 16.dp)
|
||||
.testTag(TestTags.CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_SHEET)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_signal_login_lock_checkmark),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.ConfirmLoginSavedToPasswordManagerBottomSheet__confirm_your_login_info_is_saved),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.ConfirmLoginSavedToPasswordManagerBottomSheet__confirm_that_your_login_info_was_saved),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onConfirmClick,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxButtonWidth)
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_CONFIRM_BUTTON)
|
||||
) {
|
||||
Text(stringResource(R.string.ConfirmLoginSavedToPasswordManagerBottomSheet__confirm_login_info))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onSeeLoginInfoAgainClick,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxButtonWidth)
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_SEE_LOGIN_INFO_AGAIN_BUTTON)
|
||||
) {
|
||||
Text(stringResource(R.string.ConfirmLoginSavedToPasswordManagerBottomSheet__see_login_info_again))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ConfirmLoginSavedToPasswordManagerBottomSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
ConfirmLoginSavedToPasswordManagerBottomSheetContent(
|
||||
maxButtonWidth = 320.dp,
|
||||
onConfirmClick = {},
|
||||
onSeeLoginInfoAgainClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+34
-1
@@ -76,18 +76,51 @@ fun SignalLoginInfoScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (state.dialogs.saveNotConfirmed) {
|
||||
SaveNotConfirmedDialog(onEvent)
|
||||
}
|
||||
|
||||
val params = RegistrationScaffold.rememberLayoutParams()
|
||||
|
||||
if (state.showConfirmSavedSheet) {
|
||||
ConfirmLoginSavedToPasswordManagerBottomSheet(
|
||||
maxButtonWidth = params.maxButtonWidth,
|
||||
onConfirm = { onEvent(SignalLoginInfoScreenEvents.ConfirmSavedContinueClicked) },
|
||||
onSeeLoginInfoAgain = { onEvent(SignalLoginInfoScreenEvents.SeeLoginInfoAgainClicked) },
|
||||
onDismiss = { onEvent(SignalLoginInfoScreenEvents.ConfirmSavedSheetDismissed) }
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(TestTags.SIGNAL_LOGIN_INFO_SCREEN)
|
||||
) {
|
||||
when (val params = RegistrationScaffold.rememberLayoutParams()) {
|
||||
when (params) {
|
||||
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(params, state, onEvent)
|
||||
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(params, state, onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offered when the login we just handed to the password manager isn't there when we go looking for it, so the user can
|
||||
* either try the save again or record it themselves.
|
||||
*/
|
||||
@Composable
|
||||
private fun SaveNotConfirmedDialog(onEvent: (SignalLoginInfoScreenEvents) -> Unit) {
|
||||
Dialogs.AdvancedAlertDialog(
|
||||
title = stringResource(R.string.SignalLoginInfoScreen__error_confirming_login_info),
|
||||
body = stringResource(R.string.SignalLoginInfoScreen__your_signal_login_could_not_be_confirmed),
|
||||
positive = stringResource(R.string.SignalLoginInfoScreen__save_to_password_manager),
|
||||
onPositive = { onEvent(SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked) },
|
||||
neutral = stringResource(R.string.SignalLoginInfoScreen__save_manually),
|
||||
onNeutral = { onEvent(SignalLoginInfoScreenEvents.SaveManuallyClicked) },
|
||||
negative = stringResource(android.R.string.cancel),
|
||||
onNegative = { onEvent(SignalLoginInfoScreenEvents.SaveNotConfirmedDialogDismissed) }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun OnePaneLayout(
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogininfo
|
||||
|
||||
import org.signal.core.util.censor
|
||||
|
||||
/**
|
||||
* Things [SignalLoginInfoViewModel] needs an activity to do for it, since the credential manager can only be driven
|
||||
* from one.
|
||||
*/
|
||||
sealed interface SignalLoginInfoScreenActions {
|
||||
/** Hand both halves of the Signal Login to the system password manager to store. */
|
||||
data class SaveToPasswordManager(val accountId: String, val recoveryKey: String) : SignalLoginInfoScreenActions {
|
||||
override fun toString(): String = "SaveToPasswordManager(accountId=${accountId.censor()}, recoveryKey=${recoveryKey.censor()})"
|
||||
}
|
||||
|
||||
/** Read the Signal Login back out of the password manager to prove it really landed there. */
|
||||
data class ReadBackFromPasswordManager(val accountId: String) : SignalLoginInfoScreenActions {
|
||||
override fun toString(): String = "ReadBackFromPasswordManager(accountId=${accountId.censor()})"
|
||||
}
|
||||
}
|
||||
+20
@@ -5,6 +5,8 @@
|
||||
|
||||
package org.signal.registration.screens.signallogininfo
|
||||
|
||||
import org.signal.passwordmanager.CredentialManagerResult
|
||||
import org.signal.passwordmanager.UsernamePasswordCredential
|
||||
import org.signal.registration.RegistrationFlowState
|
||||
|
||||
sealed class SignalLoginInfoScreenEvents {
|
||||
@@ -17,12 +19,30 @@ sealed class SignalLoginInfoScreenEvents {
|
||||
/** The user chose to store the credentials with the system password manager. */
|
||||
data object SaveToPasswordManagerClicked : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The password manager finished with the save, one way or another. */
|
||||
data class SaveToPasswordManagerCompleted(val result: CredentialManagerResult) : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user is ready to have the login they just saved checked. */
|
||||
data object ConfirmSavedContinueClicked : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user wants to look at their login again rather than confirm it right now. */
|
||||
data object SeeLoginInfoAgainClicked : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user dismissed the confirm-you-saved-it sheet without answering it. */
|
||||
data object ConfirmSavedSheetDismissed : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The password manager handed back what it had stored, or null if nothing came back. */
|
||||
data class SavedCredentialRetrieved(val credential: UsernamePasswordCredential?) : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user chose to record the credentials themselves rather than using a password manager. */
|
||||
data object SaveManuallyClicked : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user dismissed the failed-save dialog. */
|
||||
data object SaveFailedDialogDismissed : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user dismissed the dialog saying the saved login couldn't be confirmed. */
|
||||
data object SaveNotConfirmedDialogDismissed : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user dismissed the unknown error dialog. */
|
||||
data object UnknownErrorDialogDismissed : SignalLoginInfoScreenEvents()
|
||||
}
|
||||
|
||||
+14
-1
@@ -20,13 +20,26 @@ data class SignalLoginInfoState(
|
||||
val aep: AccountEntropyPool? = null,
|
||||
val isPasswordManagerAvailable: Boolean = false,
|
||||
val showSpinner: Boolean = false,
|
||||
/** Whether the sheet that double-checks the login really made it into the password manager is up. */
|
||||
val showConfirmSavedSheet: Boolean = false,
|
||||
/** Whether an interrupted save has already been retried once, so a second interruption is treated as a failure. */
|
||||
val didRetrySave: Boolean = false,
|
||||
val dialogs: Dialogs = Dialogs()
|
||||
) {
|
||||
/** The account ID in the form it is shown in and stored in a password manager under. */
|
||||
val passwordManagerAccountId: String?
|
||||
get() = aci?.toString()?.uppercase()
|
||||
|
||||
/** The recovery key in the form it is shown in and stored in a password manager as. */
|
||||
val passwordManagerRecoveryKey: String?
|
||||
get() = aep?.displayValue
|
||||
|
||||
override fun toString(): String = "SignalLoginInfoState(aci=${aci?.logString()}, aep=${aep?.value?.censor()}, " +
|
||||
"isPasswordManagerAvailable=$isPasswordManagerAvailable, showSpinner=$showSpinner, dialogs=$dialogs)"
|
||||
"isPasswordManagerAvailable=$isPasswordManagerAvailable, showSpinner=$showSpinner, showConfirmSavedSheet=$showConfirmSavedSheet, didRetrySave=$didRetrySave, dialogs=$dialogs)"
|
||||
|
||||
data class Dialogs(
|
||||
val saveFailed: Boolean = false,
|
||||
val saveNotConfirmed: Boolean = false,
|
||||
val unknownError: Boolean = false
|
||||
)
|
||||
}
|
||||
|
||||
+156
-2
@@ -7,17 +7,25 @@ package org.signal.registration.screens.signallogininfo
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.passwordmanager.CredentialManagerError
|
||||
import org.signal.passwordmanager.CredentialManagerResult
|
||||
import org.signal.passwordmanager.UsernamePasswordCredential
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationFlowState
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.RegistrationRoute
|
||||
import org.signal.registration.screens.shared.AccountIdFormat
|
||||
import org.signal.registration.screens.util.navigateTo
|
||||
|
||||
/**
|
||||
@@ -37,6 +45,9 @@ class SignalLoginInfoViewModel(
|
||||
private val _state = MutableStateFlow(SignalLoginInfoState(isPasswordManagerAvailable = isPasswordManagerAvailable))
|
||||
val state: StateFlow<SignalLoginInfoState> = _state.asStateFlow()
|
||||
|
||||
private val _actions = Channel<SignalLoginInfoScreenActions>(Channel.BUFFERED)
|
||||
val actions: Flow<SignalLoginInfoScreenActions> = _actions.receiveAsFlow()
|
||||
|
||||
init {
|
||||
_state
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
@@ -68,8 +79,28 @@ class SignalLoginInfoViewModel(
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked -> {
|
||||
// TODO [phonenumberless] Store the credentials via the credential manager before advancing.
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.AddUsername)
|
||||
applySaveToPasswordManagerClicked(state, isRetry = false, stateEmitter)
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.SaveToPasswordManagerCompleted -> {
|
||||
applySaveToPasswordManagerCompleted(state, event.result, stateEmitter)
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.ConfirmSavedContinueClicked -> {
|
||||
applyConfirmSavedContinueClicked(state, stateEmitter)
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.SeeLoginInfoAgainClicked -> {
|
||||
stateEmitter(state.copy(showConfirmSavedSheet = false))
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.SignalLoginViewDetails)
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.ConfirmSavedSheetDismissed -> {
|
||||
stateEmitter(state.copy(showConfirmSavedSheet = false))
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.SavedCredentialRetrieved -> {
|
||||
applySavedCredentialRetrieved(state, event.credential, parentEventEmitter, stateEmitter)
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.SaveManuallyClicked -> {
|
||||
@@ -80,9 +111,132 @@ class SignalLoginInfoViewModel(
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(saveFailed = false)))
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.SaveNotConfirmedDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(saveNotConfirmed = false)))
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.UnknownErrorDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(unknownError = false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the login off to the password manager. Every dialog is cleared on the way out, since this is also how the
|
||||
* user retries after a save that didn't take.
|
||||
*/
|
||||
private fun applySaveToPasswordManagerClicked(
|
||||
state: SignalLoginInfoState,
|
||||
isRetry: Boolean,
|
||||
stateEmitter: (SignalLoginInfoState) -> Unit
|
||||
) {
|
||||
val accountId = state.passwordManagerAccountId
|
||||
val recoveryKey = state.passwordManagerRecoveryKey
|
||||
|
||||
if (accountId == null || recoveryKey == null) {
|
||||
Log.w(TAG, "[SaveToPasswordManager] There is no login in the flow state to save.")
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(unknownError = true)))
|
||||
return
|
||||
}
|
||||
|
||||
stateEmitter(state.copy(showSpinner = true, didRetrySave = isRetry, dialogs = SignalLoginInfoState.Dialogs()))
|
||||
_actions.trySend(SignalLoginInfoScreenActions.SaveToPasswordManager(accountId = accountId, recoveryKey = recoveryKey))
|
||||
}
|
||||
|
||||
private fun applySaveToPasswordManagerCompleted(
|
||||
state: SignalLoginInfoState,
|
||||
result: CredentialManagerResult,
|
||||
stateEmitter: (SignalLoginInfoState) -> Unit
|
||||
) {
|
||||
when (result) {
|
||||
is CredentialManagerResult.Success -> {
|
||||
Log.i(TAG, "[SaveCompleted] The password manager took the login. Asking the user to confirm it's really in there.")
|
||||
stateEmitter(state.copy(showSpinner = false, showConfirmSavedSheet = true))
|
||||
}
|
||||
|
||||
is CredentialManagerResult.UserCanceled -> {
|
||||
Log.i(TAG, "[SaveCompleted] The user backed out of the password manager.")
|
||||
stateEmitter(state.copy(showSpinner = false))
|
||||
}
|
||||
|
||||
is CredentialManagerResult.Interrupted -> {
|
||||
if (state.didRetrySave) {
|
||||
Log.w(TAG, "[SaveCompleted] Interrupted again after a retry. Telling the user to save it themselves.", result.exception)
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(saveFailed = true)))
|
||||
} else {
|
||||
Log.i(TAG, "[SaveCompleted] Interrupted. Trying once more.", result.exception)
|
||||
applySaveToPasswordManagerClicked(state, isRetry = true, stateEmitter)
|
||||
}
|
||||
}
|
||||
|
||||
is CredentialManagerError.MissingCredentialManager -> {
|
||||
Log.w(TAG, "[SaveCompleted] No password manager is configured.", result.exception)
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(saveFailed = true)))
|
||||
}
|
||||
|
||||
is CredentialManagerError.SavePromptDisabled -> {
|
||||
Log.w(TAG, "[SaveCompleted] The user has turned off the password manager's save prompt.", result.exception)
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(saveFailed = true)))
|
||||
}
|
||||
|
||||
is CredentialManagerError.Unexpected -> {
|
||||
Log.w(TAG, "[SaveCompleted] Unexpected error saving the login to the password manager.", result.exception)
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(unknownError = true)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyConfirmSavedContinueClicked(
|
||||
state: SignalLoginInfoState,
|
||||
stateEmitter: (SignalLoginInfoState) -> Unit
|
||||
) {
|
||||
val accountId = state.passwordManagerAccountId
|
||||
|
||||
if (accountId == null) {
|
||||
Log.w(TAG, "[ConfirmSaved] There is no login in the flow state to read back.")
|
||||
stateEmitter(state.copy(showConfirmSavedSheet = false, showSpinner = false, dialogs = state.dialogs.copy(unknownError = true)))
|
||||
return
|
||||
}
|
||||
|
||||
stateEmitter(state.copy(showConfirmSavedSheet = false, showSpinner = true))
|
||||
_actions.trySend(SignalLoginInfoScreenActions.ReadBackFromPasswordManager(accountId = accountId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks what the password manager handed back against the login the user was given. A mismatch means the save
|
||||
* didn't take, so the user is offered the save again rather than being sent on with nothing recorded.
|
||||
*/
|
||||
private fun applySavedCredentialRetrieved(
|
||||
state: SignalLoginInfoState,
|
||||
credential: UsernamePasswordCredential?,
|
||||
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
stateEmitter: (SignalLoginInfoState) -> Unit
|
||||
) {
|
||||
val expectedAci = state.aci
|
||||
val expectedAep = state.aep
|
||||
|
||||
if (expectedAci == null || expectedAep == null) {
|
||||
Log.w(TAG, "[CredentialRetrieved] There is no login in the flow state to compare against.")
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(unknownError = true)))
|
||||
return
|
||||
}
|
||||
|
||||
if (credential == null) {
|
||||
Log.w(TAG, "[CredentialRetrieved] The password manager had nothing to hand back.")
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(saveNotConfirmed = true)))
|
||||
return
|
||||
}
|
||||
|
||||
val storedAci = AccountIdFormat.toAciOrNull(AccountIdFormat.normalize(credential.username))
|
||||
val storedAep = AccountEntropyPool.parseOrNull(credential.password)
|
||||
|
||||
if (storedAci == expectedAci && storedAep?.value == expectedAep.value) {
|
||||
Log.i(TAG, "[CredentialRetrieved] The password manager has the login the user was given.")
|
||||
stateEmitter(state.copy(showSpinner = false))
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.AddUsername)
|
||||
} else {
|
||||
Log.w(TAG, "[CredentialRetrieved] What the password manager has doesn't match the login the user was given.")
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(saveNotConfirmed = true)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ object TestTags {
|
||||
const val SIGNAL_LOGIN_INFO_CREDENTIAL_CARD = "signal_login_info_credential_card"
|
||||
const val SIGNAL_LOGIN_INFO_SAVE_TO_PASSWORD_MANAGER_BUTTON = "signal_login_info_save_to_password_manager_button"
|
||||
const val SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON = "signal_login_info_save_manually_button"
|
||||
const val CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_SHEET = "confirm_login_saved_to_password_manager_sheet"
|
||||
const val CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_CONFIRM_BUTTON = "confirm_login_saved_to_password_manager_confirm_button"
|
||||
const val CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_SEE_LOGIN_INFO_AGAIN_BUTTON = "confirm_login_saved_to_password_manager_see_login_info_again_button"
|
||||
|
||||
// Save Signal Login Screen
|
||||
const val SIGNAL_LOGIN_MANUAL_SAVE_SCREEN = "signal_login_manual_save_screen"
|
||||
|
||||
@@ -669,6 +669,20 @@
|
||||
<string name="SignalLoginInfoScreen__save_manually">Save manually</string>
|
||||
<!-- Error shown when the Signal Login could not be stored in the password manager. -->
|
||||
<string name="SignalLoginInfoScreen__your_signal_login_could_not_be_saved">Your Signal Login could not be saved. Please save it manually instead.</string>
|
||||
<!-- Title of the dialog shown when the Signal Login could not be found in the password manager after saving it. -->
|
||||
<string name="SignalLoginInfoScreen__error_confirming_login_info">Error confirming login info</string>
|
||||
<!-- Description telling the user that the Signal Login we saved could not be read back out of their password manager. -->
|
||||
<string name="SignalLoginInfoScreen__your_signal_login_could_not_be_confirmed">Your Signal Login could not be confirmed. Make sure you\'ve saved it in your password manager, or save it manually.</string>
|
||||
|
||||
<!-- Sheet that double-checks the user\'s Signal Login really made it into their password manager -->
|
||||
<!-- Title of the sheet asking the user to confirm their Signal Login was saved to their password manager. -->
|
||||
<string name="ConfirmLoginSavedToPasswordManagerBottomSheet__confirm_your_login_info_is_saved">Confirm your login info is saved</string>
|
||||
<!-- Description telling the user they are about to be asked to fill in the Signal Login they just saved. -->
|
||||
<string name="ConfirmLoginSavedToPasswordManagerBottomSheet__confirm_that_your_login_info_was_saved">Confirm that your Signal Login was saved correctly. You will be prompted to fill in your saved password in the next step.</string>
|
||||
<!-- Action button that starts checking the password manager for the Signal Login. -->
|
||||
<string name="ConfirmLoginSavedToPasswordManagerBottomSheet__confirm_login_info">Confirm login info</string>
|
||||
<!-- Action button that sends the user back to their Signal Login. -->
|
||||
<string name="ConfirmLoginSavedToPasswordManagerBottomSheet__see_login_info_again">See login info again</string>
|
||||
|
||||
<!-- Save Signal Login screen -->
|
||||
<!-- Title of the screen that spells out the Signal Login so the user can record it themselves. -->
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogininfo
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.ui.CoreUiDependenciesRule
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.registration.test.TestTags
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class SignalLoginInfoScreenTest {
|
||||
|
||||
companion object {
|
||||
private val ACI_VALUE = ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91"))
|
||||
private val AEP = AccountEntropyPool("uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t")
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
|
||||
|
||||
private val events = mutableListOf<SignalLoginInfoScreenEvents>()
|
||||
|
||||
@Test
|
||||
fun `when save to password manager is clicked, SaveToPasswordManagerClicked is emitted`() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_TO_PASSWORD_MANAGER_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when save manually is clicked, SaveManuallyClicked is emitted`() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(SignalLoginInfoScreenEvents.SaveManuallyClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when confirm on the confirm sheet is clicked, ConfirmSavedContinueClicked is emitted`() {
|
||||
setContent(showConfirmSavedSheet = true)
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_CONFIRM_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(SignalLoginInfoScreenEvents.ConfirmSavedContinueClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when see login info again on the confirm sheet is clicked, SeeLoginInfoAgainClicked is emitted`() {
|
||||
setContent(showConfirmSavedSheet = true)
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_SEE_LOGIN_INFO_AGAIN_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(SignalLoginInfoScreenEvents.SeeLoginInfoAgainClicked)
|
||||
}
|
||||
|
||||
private fun setContent(showConfirmSavedSheet: Boolean = false) {
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
SignalLoginInfoScreen(
|
||||
state = SignalLoginInfoState(
|
||||
aci = ACI_VALUE,
|
||||
aep = AEP,
|
||||
isPasswordManagerAvailable = true,
|
||||
showConfirmSavedSheet = showConfirmSavedSheet
|
||||
),
|
||||
onEvent = { events += it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
-3
@@ -7,11 +7,14 @@ package org.signal.registration.screens.signallogininfo
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -21,6 +24,9 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.passwordmanager.CredentialManagerError
|
||||
import org.signal.passwordmanager.CredentialManagerResult
|
||||
import org.signal.passwordmanager.UsernamePasswordCredential
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationFlowState
|
||||
import org.signal.registration.RegistrationRepository
|
||||
@@ -30,10 +36,16 @@ import java.util.UUID
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SignalLoginInfoViewModelTest {
|
||||
|
||||
companion object {
|
||||
private val ACI_VALUE = ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91"))
|
||||
private const val ACCOUNT_ID = "A6B28482-2E32-83D0-7F23-91360A4C2B91"
|
||||
}
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private lateinit var mockRepository: RegistrationRepository
|
||||
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
private lateinit var aep: AccountEntropyPool
|
||||
private lateinit var viewModel: SignalLoginInfoViewModel
|
||||
|
||||
@Before
|
||||
@@ -41,9 +53,10 @@ class SignalLoginInfoViewModelTest {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockRepository = mockk(relaxed = true)
|
||||
parentEventEmitter = {}
|
||||
aep = AccountEntropyPool.generate()
|
||||
viewModel = SignalLoginInfoViewModel(
|
||||
repository = mockRepository,
|
||||
parentState = MutableStateFlow(RegistrationFlowState()),
|
||||
parentState = MutableStateFlow(RegistrationFlowState(aci = ACI_VALUE, accountEntropyPool = aep)),
|
||||
parentEventEmitter = parentEventEmitter,
|
||||
isPasswordManagerAvailable = true
|
||||
)
|
||||
@@ -80,14 +93,175 @@ class SignalLoginInfoViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SaveToPasswordManagerClicked advances to the add username screen`() = runTest(testDispatcher) {
|
||||
fun `SaveToPasswordManagerClicked hands the login to the password manager`() = runTest(testDispatcher) {
|
||||
val actions = mutableListOf<SignalLoginInfoScreenActions>()
|
||||
backgroundScope.launch { viewModel.actions.toList(actions) }
|
||||
|
||||
viewModel.onEvent(SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked)
|
||||
|
||||
assertThat(actions).containsExactly(SignalLoginInfoScreenActions.SaveToPasswordManager(accountId = ACCOUNT_ID, recoveryKey = aep.displayValue))
|
||||
assertThat(viewModel.state.value.showSpinner).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SaveToPasswordManagerClicked without a login in the flow state shows the unknown error dialog`() = runTest(testDispatcher) {
|
||||
var emittedState: SignalLoginInfoState? = null
|
||||
|
||||
viewModel.applyEvent(SignalLoginInfoState(), SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked, {}) { emittedState = it }
|
||||
|
||||
assertThat(emittedState?.dialogs?.unknownError).isEqualTo(true)
|
||||
assertThat(emittedState?.showSpinner).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful save raises the confirm-you-saved-it sheet`() = runTest(testDispatcher) {
|
||||
var emittedState: SignalLoginInfoState? = null
|
||||
|
||||
viewModel.applyEvent(
|
||||
SignalLoginInfoState(showSpinner = true),
|
||||
SignalLoginInfoScreenEvents.SaveToPasswordManagerCompleted(CredentialManagerResult.Success),
|
||||
{}
|
||||
) { emittedState = it }
|
||||
|
||||
assertThat(emittedState?.showConfirmSavedSheet).isEqualTo(true)
|
||||
assertThat(emittedState?.showSpinner).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a canceled save leaves the user where they are`() = runTest(testDispatcher) {
|
||||
var emittedState: SignalLoginInfoState? = null
|
||||
|
||||
viewModel.applyEvent(
|
||||
SignalLoginInfoState(showSpinner = true),
|
||||
SignalLoginInfoScreenEvents.SaveToPasswordManagerCompleted(CredentialManagerResult.UserCanceled),
|
||||
{}
|
||||
) { emittedState = it }
|
||||
|
||||
assertThat(emittedState?.showConfirmSavedSheet).isEqualTo(false)
|
||||
assertThat(emittedState?.showSpinner).isEqualTo(false)
|
||||
assertThat(emittedState?.dialogs).isEqualTo(SignalLoginInfoState.Dialogs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an interrupted save is retried once and then given up on`() = runTest(testDispatcher) {
|
||||
val actions = mutableListOf<SignalLoginInfoScreenActions>()
|
||||
backgroundScope.launch { viewModel.actions.toList(actions) }
|
||||
|
||||
viewModel.onEvent(SignalLoginInfoScreenEvents.SaveToPasswordManagerCompleted(CredentialManagerResult.Interrupted(RuntimeException())))
|
||||
|
||||
assertThat(actions).containsExactly(SignalLoginInfoScreenActions.SaveToPasswordManager(accountId = ACCOUNT_ID, recoveryKey = aep.displayValue))
|
||||
|
||||
viewModel.onEvent(SignalLoginInfoScreenEvents.SaveToPasswordManagerCompleted(CredentialManagerResult.Interrupted(RuntimeException())))
|
||||
|
||||
assertThat(actions).containsExactly(SignalLoginInfoScreenActions.SaveToPasswordManager(accountId = ACCOUNT_ID, recoveryKey = aep.displayValue))
|
||||
assertThat(viewModel.state.value.dialogs.saveFailed).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a save the password manager refused shows the failed-save dialog`() = runTest(testDispatcher) {
|
||||
var emittedState: SignalLoginInfoState? = null
|
||||
|
||||
viewModel.applyEvent(
|
||||
SignalLoginInfoState(),
|
||||
SignalLoginInfoScreenEvents.SaveToPasswordManagerCompleted(CredentialManagerError.SavePromptDisabled(RuntimeException())),
|
||||
{}
|
||||
) { emittedState = it }
|
||||
|
||||
assertThat(emittedState?.dialogs?.saveFailed).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a save that failed unexpectedly shows the unknown error dialog`() = runTest(testDispatcher) {
|
||||
var emittedState: SignalLoginInfoState? = null
|
||||
|
||||
viewModel.applyEvent(
|
||||
SignalLoginInfoState(),
|
||||
SignalLoginInfoScreenEvents.SaveToPasswordManagerCompleted(CredentialManagerError.Unexpected(RuntimeException())),
|
||||
{}
|
||||
) { emittedState = it }
|
||||
|
||||
assertThat(emittedState?.dialogs?.unknownError).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConfirmSavedContinueClicked reads the login back out of the password manager`() = runTest(testDispatcher) {
|
||||
val actions = mutableListOf<SignalLoginInfoScreenActions>()
|
||||
backgroundScope.launch { viewModel.actions.toList(actions) }
|
||||
|
||||
viewModel.onEvent(SignalLoginInfoScreenEvents.ConfirmSavedContinueClicked)
|
||||
|
||||
assertThat(actions).containsExactly(SignalLoginInfoScreenActions.ReadBackFromPasswordManager(accountId = ACCOUNT_ID))
|
||||
assertThat(viewModel.state.value.showConfirmSavedSheet).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a credential that matches the login advances to the add username screen`() = runTest(testDispatcher) {
|
||||
val parentEvents = mutableListOf<RegistrationFlowEvent>()
|
||||
|
||||
viewModel.applyEvent(SignalLoginInfoState(), SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked, { parentEvents.add(it) }) {}
|
||||
viewModel.applyEvent(
|
||||
SignalLoginInfoState(aci = ACI_VALUE, aep = aep, showSpinner = true),
|
||||
SignalLoginInfoScreenEvents.SavedCredentialRetrieved(UsernamePasswordCredential(username = ACCOUNT_ID, password = aep.displayValue)),
|
||||
{ parentEvents.add(it) }
|
||||
) {}
|
||||
|
||||
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.AddUsername))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a credential that does not match the login shows the not-confirmed dialog`() = runTest(testDispatcher) {
|
||||
val parentEvents = mutableListOf<RegistrationFlowEvent>()
|
||||
var emittedState: SignalLoginInfoState? = null
|
||||
|
||||
viewModel.applyEvent(
|
||||
SignalLoginInfoState(aci = ACI_VALUE, aep = aep, showSpinner = true),
|
||||
SignalLoginInfoScreenEvents.SavedCredentialRetrieved(UsernamePasswordCredential(username = ACCOUNT_ID, password = AccountEntropyPool.generate().displayValue)),
|
||||
{ parentEvents.add(it) }
|
||||
) { emittedState = it }
|
||||
|
||||
assertThat(emittedState?.dialogs?.saveNotConfirmed).isEqualTo(true)
|
||||
assertThat(emittedState?.showSpinner).isEqualTo(false)
|
||||
assertThat(parentEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no credential at all shows the not-confirmed dialog`() = runTest(testDispatcher) {
|
||||
val parentEvents = mutableListOf<RegistrationFlowEvent>()
|
||||
var emittedState: SignalLoginInfoState? = null
|
||||
|
||||
viewModel.applyEvent(
|
||||
SignalLoginInfoState(aci = ACI_VALUE, aep = aep, showSpinner = true),
|
||||
SignalLoginInfoScreenEvents.SavedCredentialRetrieved(null),
|
||||
{ parentEvents.add(it) }
|
||||
) { emittedState = it }
|
||||
|
||||
assertThat(emittedState?.dialogs?.saveNotConfirmed).isEqualTo(true)
|
||||
assertThat(parentEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SeeLoginInfoAgainClicked drops the sheet and shows the login again`() = runTest(testDispatcher) {
|
||||
val parentEvents = mutableListOf<RegistrationFlowEvent>()
|
||||
var emittedState: SignalLoginInfoState? = null
|
||||
|
||||
viewModel.applyEvent(
|
||||
SignalLoginInfoState(showConfirmSavedSheet = true),
|
||||
SignalLoginInfoScreenEvents.SeeLoginInfoAgainClicked,
|
||||
{ parentEvents.add(it) }
|
||||
) { emittedState = it }
|
||||
|
||||
assertThat(emittedState?.showConfirmSavedSheet).isEqualTo(false)
|
||||
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.SignalLoginViewDetails))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConfirmSavedSheetDismissed drops the sheet`() = runTest(testDispatcher) {
|
||||
var emittedState: SignalLoginInfoState? = null
|
||||
|
||||
viewModel.applyEvent(SignalLoginInfoState(showConfirmSavedSheet = true), SignalLoginInfoScreenEvents.ConfirmSavedSheetDismissed, {}) { emittedState = it }
|
||||
|
||||
assertThat(emittedState?.showConfirmSavedSheet).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SaveManuallyClicked advances to the save your login screen`() = runTest(testDispatcher) {
|
||||
val parentEvents = mutableListOf<RegistrationFlowEvent>()
|
||||
@@ -96,4 +270,17 @@ class SignalLoginInfoViewModelTest {
|
||||
|
||||
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.SignalLoginViewDetailsForManualSave))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SaveNotConfirmedDialogDismissed clears the not-confirmed dialog`() = runTest(testDispatcher) {
|
||||
var emittedState: SignalLoginInfoState? = null
|
||||
|
||||
viewModel.applyEvent(
|
||||
SignalLoginInfoState(dialogs = SignalLoginInfoState.Dialogs(saveNotConfirmed = true)),
|
||||
SignalLoginInfoScreenEvents.SaveNotConfirmedDialogDismissed,
|
||||
{}
|
||||
) { emittedState = it }
|
||||
|
||||
assertThat(emittedState?.dialogs?.saveNotConfirmed).isEqualTo(false)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user