Add signal login password manager confirmation during account creation.

This commit is contained in:
Greyson Parrelli
2026-09-09 16:44:25 -04:00
committed by Cody Henthorne
parent 1f29540bbf
commit 7b01d881ad
11 changed files with 717 additions and 7 deletions
@@ -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) }
@@ -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 = {}
)
}
}
@@ -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(
@@ -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()})"
}
}
@@ -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()
}
@@ -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
)
}
@@ -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. -->
@@ -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 }
)
}
}
}
}
@@ -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)
}
}