diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index 9984872015..9189d10571 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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.navigationEntries( // -- Signal Login Info Screen entry { val context = LocalContext.current + val credentialManagerScope = rememberCoroutineScope() val viewModel: SignalLoginInfoViewModel = viewModel { SignalLoginInfoViewModel( repository = registrationRepository, @@ -756,6 +759,28 @@ private fun EntryProviderScope.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) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/ConfirmLoginSavedToPasswordManagerBottomSheet.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/ConfirmLoginSavedToPasswordManagerBottomSheet.kt new file mode 100644 index 0000000000..65fcbffa88 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/ConfirmLoginSavedToPasswordManagerBottomSheet.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt index 106a4c1bb4..472cef26db 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt @@ -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( diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenActions.kt new file mode 100644 index 0000000000..41d6b503e7 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenActions.kt @@ -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()})" + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenEvents.kt index f76b51d647..570605814a 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenEvents.kt @@ -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() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt index 438f3edbba..c42ba57820 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt @@ -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 ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt index ef2133c467..e178f9b4fc 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt @@ -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 = _state.asStateFlow() + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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))) + } + } } diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index 52f1c76b27..ee3d52346a 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -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" diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 7381b7f349..5e07cf3260 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -669,6 +669,20 @@ Save manually Your Signal Login could not be saved. Please save it manually instead. + + Error confirming login info + + Your Signal Login could not be confirmed. Make sure you\'ve saved it in your password manager, or save it manually. + + + + Confirm your login info is saved + + Confirm that your Signal Login was saved correctly. You will be prompted to fill in your saved password in the next step. + + Confirm login info + + See login info again diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenTest.kt new file mode 100644 index 0000000000..c84a0e8156 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenTest.kt @@ -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() + + @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 } + ) + } + } + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModelTest.kt index 53d1b1f739..673476dee5 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModelTest.kt @@ -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() + 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() + 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() + 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() - 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() + 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() + 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() + 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() @@ -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) + } }