From f577d1a86998136892632a1e697af66b2f1ff477 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 21 Sep 2026 16:09:58 -0400 Subject: [PATCH] Show disabled state if no password manager is available. --- .../MessageBackupsFlowFragment.kt | 9 ++- ...ssageBackupsSignalLoginDetailsViewModel.kt | 12 +++- .../SettingsSignalLoginDetailsFragment.kt | 11 +++- .../SettingsSignalLoginDetailsViewModel.kt | 12 +++- .../SignalLoginViewDetailsAction.kt | 3 + ...eBackupsSignalLoginDetailsViewModelTest.kt | 10 ++++ ...SettingsSignalLoginDetailsViewModelTest.kt | 11 ++++ .../registration/RegistrationNavigation.kt | 4 ++ .../signallogininfo/SignalLoginInfoScreen.kt | 58 ++++++++++++------- .../SignalLoginInfoScreenActions.kt | 3 + .../SignalLoginInfoViewModel.kt | 6 ++ .../src/main/res/values/strings.xml | 2 + .../SignalLoginViewDetailsScreenTest.kt | 17 +++++- .../SignalLoginInfoScreenTest.kt | 12 +++- .../SignalLoginInfoViewModelTest.kt | 14 ++++- .../SignalLoginViewDetailsScreen.kt | 41 +++++++++++-- .../SignalLoginViewDetailsState.kt | 6 +- .../src/main/res/values/strings.xml | 2 + 18 files changed, 195 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt index de9c98f15d..1f40feb639 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.core.os.bundleOf -import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -54,6 +53,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.PlayStoreUtil import org.thoughtcrime.securesms.util.viewModel +import org.signal.signallogin.R as SignalLoginR /** * Handles the selection, payment, and changing of a user's backup tier. @@ -83,7 +83,9 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega ) } - private val signalLoginDetailsViewModel: MessageBackupsSignalLoginDetailsViewModel by viewModels() + private val signalLoginDetailsViewModel: MessageBackupsSignalLoginDetailsViewModel by viewModel { + MessageBackupsSignalLoginDetailsViewModel(isPasswordManagerAvailable = SignalCredentialManager.isSupported(requireContext())) + } private val savePdfLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument(PDF_MIME_TYPE)) { uri: Uri? -> if (uri != null) { @@ -334,6 +336,9 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega ) } } + SignalLoginViewDetailsAction.ShowNoPasswordManagerAvailable -> { + Toast.makeText(requireContext(), SignalLoginR.string.SignalLoginViewDetailsScreen__no_password_manager_available, Toast.LENGTH_LONG).show() + } SignalLoginViewDetailsAction.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(requireContext())) is SignalLoginViewDetailsAction.CopyTextToClipboard -> Util.copyToClipboardSensitive(requireContext(), action.text) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModel.kt index bf22dfe2f5..aa87a11874 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModel.kt @@ -24,7 +24,8 @@ import org.thoughtcrime.securesms.components.settings.app.account.signallogin.Si * saved. Resets belong to account settings, so this only ever emits [SignalLoginViewDetailsAction.Shared]. */ class MessageBackupsSignalLoginDetailsViewModel( - repository: SignalLoginViewDetailsRepository = SignalLoginViewDetailsRepository() + repository: SignalLoginViewDetailsRepository = SignalLoginViewDetailsRepository(), + isPasswordManagerAvailable: Boolean = true ) : EventDrivenViewModel(TAG) { companion object { @@ -34,7 +35,8 @@ class MessageBackupsSignalLoginDetailsViewModel( private val _state = MutableStateFlow( SignalLoginViewDetailsState( accountKey = repository.getAci()?.toString()?.uppercase().orEmpty(), - recoveryKey = repository.getAccountEntropyPool()?.displayValue.orEmpty() + recoveryKey = repository.getAccountEntropyPool()?.displayValue.orEmpty(), + isPasswordManagerAvailable = isPasswordManagerAvailable ) ) private val _actions = Channel(Channel.BUFFERED) @@ -48,7 +50,11 @@ class MessageBackupsSignalLoginDetailsViewModel( _actions.send(SignalLoginViewDetailsAction.NavigateBack) } SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked -> { - _actions.send(SignalLoginViewDetailsAction.LaunchSaveToPasswordManager) + if (_state.value.isPasswordManagerAvailable) { + _actions.send(SignalLoginViewDetailsAction.LaunchSaveToPasswordManager) + } else { + _actions.send(SignalLoginViewDetailsAction.ShowNoPasswordManagerAvailable) + } } SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> { _actions.send(SignalLoginViewDetailsAction.LaunchSaveAsPdf) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsFragment.kt index ec16c760f0..3ac897a8ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsFragment.kt @@ -29,13 +29,19 @@ import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyDisplayFragment import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.viewModel +import org.signal.signallogin.R as SignalLoginR /** * Shows the account and recovery keys that make up the user's Signal Login, the same way registration does. */ class SettingsSignalLoginDetailsFragment : ComposeFragment() { - private val viewModel: SettingsSignalLoginDetailsViewModel by viewModel { SettingsSignalLoginDetailsViewModel(showResetRecoveryKeyButton = true) } + private val viewModel: SettingsSignalLoginDetailsViewModel by viewModel { + SettingsSignalLoginDetailsViewModel( + showResetRecoveryKeyButton = true, + isPasswordManagerAvailable = SignalCredentialManager.isSupported(requireContext()) + ) + } private val savePdfLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri: Uri? -> if (uri != null) { @@ -111,6 +117,9 @@ class SettingsSignalLoginDetailsFragment : ComposeFragment() { ) } } + SignalLoginViewDetailsAction.ShowNoPasswordManagerAvailable -> { + Toast.makeText(requireContext(), SignalLoginR.string.SignalLoginViewDetailsScreen__no_password_manager_available, Toast.LENGTH_LONG).show() + } SignalLoginViewDetailsAction.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(requireContext())) SignalLoginViewDetailsAction.LaunchRecoveryKeyReset -> { findNavController().safeNavigate( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModel.kt index 0f9598f958..d0bb23d6ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModel.kt @@ -24,10 +24,13 @@ import org.signal.signallogin.viewdetails.SignalLoginViewDetailsState * account settings. * * @param showResetRecoveryKeyButton True if the option to reset the recovery key should be offered in the UI. + * @param isPasswordManagerAvailable False if the device has no password manager, which leaves the save button showing + * but styled as disabled. */ class SettingsSignalLoginDetailsViewModel( private val repository: SignalLoginViewDetailsRepository = SignalLoginViewDetailsRepository(), - showResetRecoveryKeyButton: Boolean = false + showResetRecoveryKeyButton: Boolean = false, + isPasswordManagerAvailable: Boolean = true ) : EventDrivenViewModel(TAG) { companion object { @@ -38,6 +41,7 @@ class SettingsSignalLoginDetailsViewModel( SignalLoginViewDetailsState( accountKey = repository.getAci()?.toString()?.uppercase().orEmpty(), recoveryKey = repository.getAccountEntropyPool()?.displayValue.orEmpty(), + isPasswordManagerAvailable = isPasswordManagerAvailable, showResetRecoveryKeyButton = showResetRecoveryKeyButton, resetRecoveryKeyButtonLoading = showResetRecoveryKeyButton ) @@ -100,7 +104,11 @@ class SettingsSignalLoginDetailsViewModel( _actions.send(SignalLoginViewDetailsAction.NavigateBack) } SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked -> { - _actions.send(SignalLoginViewDetailsAction.LaunchSaveToPasswordManager) + if (_state.value.isPasswordManagerAvailable) { + _actions.send(SignalLoginViewDetailsAction.LaunchSaveToPasswordManager) + } else { + _actions.send(SignalLoginViewDetailsAction.ShowNoPasswordManagerAvailable) + } } SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> { _actions.send(SignalLoginViewDetailsAction.LaunchSaveAsPdf) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt index d3959d9c4b..a75c34cd93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt @@ -27,6 +27,9 @@ sealed interface SignalLoginViewDetailsAction { /** Launch the system credential manager UI so the user can store the login in their password manager. */ data object LaunchSaveToPasswordManager : Shared + /** Tell the user that there is no password manager on the device to save the login into. */ + data object ShowNoPasswordManagerAvailable : Shared + /** Launch the system document picker so the user can choose where to save the login PDF. */ data object LaunchSaveAsPdf : Shared diff --git a/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModelTest.kt index 02c35b3fa2..afe8e4f1f6 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModelTest.kt @@ -98,6 +98,16 @@ class MessageBackupsSignalLoginDetailsViewModelTest { assertThat(actions).containsExactly(SignalLoginViewDetailsAction.LaunchSaveToPasswordManager) } + @Test + fun `SaveToPasswordManagerClicked without a password manager tells the user there isn't one`() = runTest(testDispatcher) { + val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository, isPasswordManagerAvailable = false) + val actions = collectActions(viewModel) + + viewModel.onEvent(SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked) + + assertThat(actions).containsExactly(SignalLoginViewDetailsAction.ShowNoPasswordManagerAvailable) + } + @Test fun `SaveAsPdfClicked launches the save as PDF flow`() = runTest(testDispatcher) { val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository) diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModelTest.kt index abb4d90676..32fcbd7246 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModelTest.kt @@ -98,6 +98,17 @@ class SettingsSignalLoginDetailsViewModelTest { assertThat(actions).containsExactly(SignalLoginViewDetailsAction.LaunchSaveToPasswordManager) } + @Test + fun `SaveToPasswordManagerClicked without a password manager tells the user there isn't one`() = runTest(testDispatcher) { + val viewModel = SettingsSignalLoginDetailsViewModel(repository, isPasswordManagerAvailable = false) + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked)) + + assertThat(actions).containsExactly(SignalLoginViewDetailsAction.ShowNoPasswordManagerAvailable) + } + @Test fun `SaveAsPdfClicked launches the save as PDF flow`() = runTest(testDispatcher) { val viewModel = SettingsSignalLoginDetailsViewModel(repository) 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 4209224edc..ccc788423d 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -807,6 +807,10 @@ private fun EntryProviderScope.navigationEntries( viewModel.onEvent(SignalLoginInfoScreenEvents.SavedCredentialRetrieved(credential)) } } + + SignalLoginInfoScreenActions.ShowNoPasswordManagerAvailable -> { + Toast.makeText(context, R.string.SignalLoginInfoScreen__no_password_manager_available, Toast.LENGTH_LONG).show() + } } } 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 5aa89eba7b..a13fcb4b90 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -248,28 +249,23 @@ private fun Footer( .fillMaxWidth() .padding(params.footerPadding) ) { - if (state.isPasswordManagerAvailable) { - Buttons.LargeTonal( - onClick = { onEvent(SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked) }, - enabled = !state.showSpinner, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ), - modifier = Modifier - .widthIn(max = params.maxButtonWidth) - .fillMaxWidth() - .testTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_TO_PASSWORD_MANAGER_BUTTON) - ) { - if (state.showSpinner) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.onPrimaryContainer, - strokeWidth = 2.dp, - modifier = Modifier.size(20.dp) - ) - } else { - Text(stringResource(R.string.SignalLoginInfoScreen__save_to_password_manager)) - } + Buttons.LargeTonal( + onClick = { onEvent(SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked) }, + enabled = !state.showSpinner, + colors = primaryContainerButtonColors(enabledLook = state.isPasswordManagerAvailable), + modifier = Modifier + .widthIn(max = params.maxButtonWidth) + .fillMaxWidth() + .testTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_TO_PASSWORD_MANAGER_BUTTON) + ) { + if (state.showSpinner) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimaryContainer, + strokeWidth = 2.dp, + modifier = Modifier.size(20.dp) + ) + } else { + Text(stringResource(R.string.SignalLoginInfoScreen__save_to_password_manager)) } } @@ -291,6 +287,24 @@ private fun Footer( } } +/** + * Tonal button colors, optionally rendered with the disabled palette while the button stays clickable so that tapping + * it can explain why it won't work. + */ +@Composable +private fun primaryContainerButtonColors(enabledLook: Boolean): ButtonColors { + val colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + + return if (enabledLook) { + colors + } else { + colors.copy(containerColor = colors.disabledContainerColor, contentColor = colors.disabledContentColor) + } +} + @AllDevicePreviews @Composable private fun SignalLoginInfoScreenPreview() { 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 index 41d6b503e7..047a9c7ffe 100644 --- 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 @@ -21,4 +21,7 @@ sealed interface SignalLoginInfoScreenActions { data class ReadBackFromPasswordManager(val accountId: String) : SignalLoginInfoScreenActions { override fun toString(): String = "ReadBackFromPasswordManager(accountId=${accountId.censor()})" } + + /** Tell the user that there is no password manager on the device to save the login into. */ + data object ShowNoPasswordManagerAvailable : SignalLoginInfoScreenActions } 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 b5b8e6bbd4..962c1a6af2 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 @@ -131,6 +131,12 @@ class SignalLoginInfoViewModel( isRetry: Boolean, stateEmitter: (SignalLoginInfoState) -> Unit ) { + if (!state.isPasswordManagerAvailable) { + Log.w(TAG, "[SaveToPasswordManager] There is no password manager on this device.") + _actions.trySend(SignalLoginInfoScreenActions.ShowNoPasswordManagerAvailable) + return + } + val accountId = state.passwordManagerAccountId val recoveryKey = state.passwordManagerRecoveryKey diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index ec39d88516..acd07baf3c 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -683,6 +683,8 @@ Thanks for your purchase. Use your Signal Login to register and restore your account. If you forget your keys, you will not be able to recover your account. Save to password manager + + You don\'t have a password manager set up, or the password manager you\'re using isn\'t supported. Save manually diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt index 2f32017591..e77825616f 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt @@ -96,6 +96,15 @@ class SignalLoginViewDetailsScreenTest { composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_SAVE_AS_PDF_BUTTON).assertIsDisplayed() } + @Test + fun `when there is no password manager, the save to password manager button is still shown and clickable`() { + setContent(isPasswordManagerAvailable = false) + + composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_SAVE_TO_PASSWORD_MANAGER_BUTTON).assertIsDisplayed().performClick() + + assertThat(events).contains(SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked) + } + @Test fun `when the screen cannot reset the recovery key, the reset button is not shown`() { setContent() @@ -120,7 +129,12 @@ class SignalLoginViewDetailsScreenTest { composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_BUTTON).assertIsNotDisplayed() } - private fun setContent(showSaveToPasswordManagerButton: Boolean = true, showResetRecoveryKeyButton: Boolean = false, resetRecoveryKeyButtonLoading: Boolean = false) { + private fun setContent( + showSaveToPasswordManagerButton: Boolean = true, + isPasswordManagerAvailable: Boolean = true, + showResetRecoveryKeyButton: Boolean = false, + resetRecoveryKeyButtonLoading: Boolean = false + ) { composeTestRule.setContent { SignalTheme { SignalLoginViewDetailsScreen( @@ -128,6 +142,7 @@ class SignalLoginViewDetailsScreenTest { accountKey = ACCOUNT_KEY, recoveryKey = RECOVERY_KEY, showSaveToPasswordManagerButton = showSaveToPasswordManagerButton, + isPasswordManagerAvailable = isPasswordManagerAvailable, showResetRecoveryKeyButton = showResetRecoveryKeyButton, resetRecoveryKeyButtonLoading = resetRecoveryKeyButtonLoading ), 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 index 2fc3870e2e..f7fe2395ed 100644 --- 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 @@ -60,6 +60,15 @@ class SignalLoginInfoScreenTest { assertThat(events).contains(SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked) } + @Test + fun `when there is no password manager, the save to password manager button is still shown and clickable`() { + setContent(isPasswordManagerAvailable = false) + + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_TO_PASSWORD_MANAGER_BUTTON).assertIsDisplayed().performClick() + + assertThat(events).contains(SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked) + } + @Test fun `when save manually is clicked, SaveManuallyClicked is emitted`() { setContent() @@ -117,6 +126,7 @@ class SignalLoginInfoScreenTest { private fun setContent( showConfirmSavedSheet: Boolean = false, + isPasswordManagerAvailable: Boolean = true, dialogs: SignalLoginInfoState.Dialogs = SignalLoginInfoState.Dialogs() ) { composeTestRule.setContent { @@ -125,7 +135,7 @@ class SignalLoginInfoScreenTest { state = SignalLoginInfoState( aci = ACI_VALUE, aep = AEP, - isPasswordManagerAvailable = true, + isPasswordManagerAvailable = isPasswordManagerAvailable, showConfirmSavedSheet = showConfirmSavedSheet, dialogs = dialogs ), 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 cea783e1e0..f09934b726 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 @@ -107,12 +107,24 @@ class SignalLoginInfoViewModelTest { 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 } + viewModel.applyEvent(SignalLoginInfoState(isPasswordManagerAvailable = true), SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked, {}) { emittedState = it } assertThat(emittedState?.dialogs?.unknownError).isEqualTo(true) assertThat(emittedState?.showSpinner).isEqualTo(false) } + @Test + fun `SaveToPasswordManagerClicked without a password manager tells the user there isn't one`() = runTest(testDispatcher) { + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + var emittedState: SignalLoginInfoState? = null + + viewModel.applyEvent(SignalLoginInfoState(isPasswordManagerAvailable = false), SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked, {}) { emittedState = it } + + assertThat(actions).containsExactly(SignalLoginInfoScreenActions.ShowNoPasswordManagerAvailable) + assertThat(emittedState).isEqualTo(null) + } + @Test fun `a successful save raises the confirm-you-saved-it sheet`() = runTest(testDispatcher) { var emittedState: SignalLoginInfoState? = null diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt index 027e22a382..79cf6868b9 100644 --- a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt +++ b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -141,6 +142,7 @@ fun SignalLoginViewDetailsScreen( maxButtonWidth = layout.maxButtonWidth, isElevated = firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, showSaveToPasswordManagerButton = state.showSaveToPasswordManagerButton, + isPasswordManagerAvailable = state.isPasswordManagerAvailable, showResetRecoveryKeyButton = state.showResetRecoveryKeyButton, resetRecoveryKeyButtonLoading = state.resetRecoveryKeyButtonLoading, onEvent = onEvent @@ -296,6 +298,7 @@ private fun Footer( maxButtonWidth: Dp, isElevated: Boolean, showSaveToPasswordManagerButton: Boolean, + isPasswordManagerAvailable: Boolean, showResetRecoveryKeyButton: Boolean, resetRecoveryKeyButtonLoading: Boolean, onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit @@ -313,10 +316,7 @@ private fun Footer( if (showSaveToPasswordManagerButton) { Buttons.MediumTonal( onClick = { onEvent(SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked) }, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ), + colors = primaryContainerButtonColors(enabledLook = isPasswordManagerAvailable), modifier = Modifier .widthIn(max = maxButtonWidth) .fillMaxWidth() @@ -369,6 +369,24 @@ private fun Footer( } } +/** + * Tonal button colors, optionally rendered with the disabled palette while the button stays clickable so that tapping + * it can explain why it won't work. + */ +@Composable +private fun primaryContainerButtonColors(enabledLook: Boolean): ButtonColors { + val colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + + return if (enabledLook) { + colors + } else { + colors.copy(containerColor = colors.disabledContainerColor, contentColor = colors.disabledContentColor) + } +} + @BreakpointPreviews @Composable private fun SignalLoginViewDetailsScreenPreview() { @@ -383,6 +401,21 @@ private fun SignalLoginViewDetailsScreenPreview() { } } +@AllDevicePreviews +@Composable +private fun SignalLoginViewDetailsScreenNoPasswordManagerPreview() { + Previews.Preview { + SignalLoginViewDetailsScreen( + state = SignalLoginViewDetailsState( + accountKey = "A6B28482-2E32-83D0-7F23-91360A4C2B91", + recoveryKey = "UY38JH2778HJJHJ8LK19GA61S672JSJ=89R=23S6A578=9BAP92J2YH5T326VV7T", + isPasswordManagerAvailable = false + ), + onEvent = {} + ) + } +} + @AllDevicePreviews @Composable private fun SignalLoginViewDetailsScreenWithResetPreview() { diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt index cda1287d83..a73ee1a165 100644 --- a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt +++ b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt @@ -16,11 +16,15 @@ import org.signal.signallogin.RecoveryKeyGroups * know whether the user has any resets left, so a spinner stands in for the button. * * [showSaveToPasswordManagerButton] is false during registration, where the only save option we offer is the PDF. + * + * [isPasswordManagerAvailable] is false when the device has no password manager at all. The save button is still shown + * then, but styled as disabled, so the user can find out why it isn't an option rather than wonder where it went. */ data class SignalLoginViewDetailsState( val accountKey: String = "", val recoveryKey: String = "", val showSaveToPasswordManagerButton: Boolean = true, + val isPasswordManagerAvailable: Boolean = true, val showResetRecoveryKeyButton: Boolean = false, val resetRecoveryKeyButtonLoading: Boolean = false ) { @@ -28,5 +32,5 @@ data class SignalLoginViewDetailsState( val recoveryKeyGroups: RecoveryKeyGroups get() = RecoveryKeyGroups.from(recoveryKey) - override fun toString(): String = "SignalLoginViewDetailsState(accountKey=${accountKey.censor()}, recoveryKey=${recoveryKey.censor()}, showSaveToPasswordManagerButton=$showSaveToPasswordManagerButton, showResetRecoveryKeyButton=$showResetRecoveryKeyButton, resetRecoveryKeyButtonLoading=$resetRecoveryKeyButtonLoading)" + override fun toString(): String = "SignalLoginViewDetailsState(accountKey=${accountKey.censor()}, recoveryKey=${recoveryKey.censor()}, showSaveToPasswordManagerButton=$showSaveToPasswordManagerButton, isPasswordManagerAvailable=$isPasswordManagerAvailable, showResetRecoveryKeyButton=$showResetRecoveryKeyButton, resetRecoveryKeyButtonLoading=$resetRecoveryKeyButtonLoading)" } diff --git a/lib/signal-login/src/main/res/values/strings.xml b/lib/signal-login/src/main/res/values/strings.xml index eb2c364ac9..1bbe72119f 100644 --- a/lib/signal-login/src/main/res/values/strings.xml +++ b/lib/signal-login/src/main/res/values/strings.xml @@ -35,6 +35,8 @@ Reset recovery key Signal Login.pdf + + You don\'t have a password manager set up, or the password manager you\'re using isn\'t supported. Unable to save PDF. Please try again.