Add screenlock and screenshot guards to SignalLoginViewDetails.

This commit is contained in:
Greyson Parrelli
2026-09-09 16:44:25 -04:00
committed by Cody Henthorne
parent b87e35fcef
commit e61dd283c7
7 changed files with 55 additions and 16 deletions
@@ -73,10 +73,16 @@ class AccountSettingsFragment : ComposeFragment() {
val removalBiometrics = rememberBiometricsAuthentication(
promptTitle = stringResource(AppSettingsR.string.AccountSettingsFragment__unlock_to_remove_two_factor_method),
educationSheetMessage = stringResource(AppSettingsR.string.AccountSettingsFragment__to_remove_this_method_confirm_its_you),
onAuthenticationFailed = { viewModel.onEvent(AccountSettingsEvent.MethodRemovalAuthenticationFailed) }
onAuthenticationFailed = { viewModel.onEvent(AccountSettingsEvent.AuthenticationFailed) }
)
CollectActions(viewModel.actions) { action -> handleAction(action, removalBiometrics) }
val signalLoginBiometrics = rememberBiometricsAuthentication(
promptTitle = stringResource(AppSettingsR.string.AccountSettingsFragment__unlock_to_view_signal_login),
educationSheetMessage = stringResource(AppSettingsR.string.AccountSettingsFragment__to_view_your_signal_login_confirm_its_you),
onAuthenticationFailed = { viewModel.onEvent(AccountSettingsEvent.AuthenticationFailed) }
)
CollectActions(viewModel.actions) { action -> handleAction(action, removalBiometrics, signalLoginBiometrics) }
AccountSettingsScreen(
state = state,
@@ -84,12 +90,17 @@ class AccountSettingsFragment : ComposeFragment() {
)
}
private fun handleAction(action: AccountSettingsAction, removalBiometrics: BiometricsAuthentication) {
private fun handleAction(action: AccountSettingsAction, removalBiometrics: BiometricsAuthentication, signalLoginBiometrics: BiometricsAuthentication) {
when (action) {
AccountSettingsAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
AccountSettingsAction.LaunchCreatePinFlow -> pinFlowLauncher.launch(CreateSvrPinActivity.getIntentForPinCreate(requireContext()))
AccountSettingsAction.LaunchChangePinFlow -> pinFlowLauncher.launch(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()))
AccountSettingsAction.ShowPinCreatedConfirmation -> Snackbar.make(requireView(), R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show()
AccountSettingsAction.AuthenticateToViewSignalLoginDetails -> {
signalLoginBiometrics.withBiometricsAuthentication {
viewModel.onEvent(AccountSettingsEvent.SignalLoginDetailsAuthenticated)
}
}
AccountSettingsAction.NavigateToSignalLoginDetails -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_signalLoginViewDetailsFragment)
AccountSettingsAction.NavigateToTotpSetup -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorSetupFragment)
is AccountSettingsAction.NavigateToRenameTotpApp -> {
@@ -103,7 +114,7 @@ class AccountSettingsFragment : ComposeFragment() {
viewModel.onEvent(AccountSettingsEvent.MethodRemovalAuthenticated(action.method))
}
}
AccountSettingsAction.ShowRemovalAuthenticationFailed -> toast(AppSettingsR.string.AccountSettingsFragment__authentication_required)
AccountSettingsAction.ShowAuthenticationFailed -> toast(AppSettingsR.string.AccountSettingsFragment__authentication_required)
AccountSettingsAction.ShowTotpAppRemoved -> toast(AppSettingsR.string.AccountSettingsFragment__authenticator_app_removed)
AccountSettingsAction.ShowTotpAppRemovalFailed -> toast(AppSettingsR.string.AccountSettingsFragment__couldnt_remove_authenticator_app)
// TODO Open the two-factor authentication support article once one exists.
@@ -83,6 +83,9 @@ class AccountSettingsViewModel(
applyRegistrationLockConfirmed()
}
AccountSettingsEvent.AccountAndRecoveryClicked -> {
_actions.send(AccountSettingsAction.AuthenticateToViewSignalLoginDetails)
}
AccountSettingsEvent.SignalLoginDetailsAuthenticated -> {
_actions.send(AccountSettingsAction.NavigateToSignalLoginDetails)
}
AccountSettingsEvent.AddTotpAppClicked -> {
@@ -100,8 +103,8 @@ class AccountSettingsViewModel(
is AccountSettingsEvent.MethodRemovalAuthenticated -> {
applyMethodRemovalAuthenticated(event.method)
}
AccountSettingsEvent.MethodRemovalAuthenticationFailed -> {
_actions.send(AccountSettingsAction.ShowRemovalAuthenticationFailed)
AccountSettingsEvent.AuthenticationFailed -> {
_actions.send(AccountSettingsAction.ShowAuthenticationFailed)
}
AccountSettingsEvent.RemoveTotpAppConfirmed -> {
applyRemoveTotpAppConfirmed()
@@ -21,6 +21,7 @@ import org.signal.core.util.Util
import org.signal.passwordmanager.SignalCredentialManager
import org.signal.signallogin.pdf.SignalLoginPdfRenderer
import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreen
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
/**
* Shows the account and recovery keys that make up the user's Signal Login, the same way registration does.
@@ -49,6 +50,8 @@ class SignalLoginViewDetailsFragment : ComposeFragment() {
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
TemporaryScreenshotSecurity.bind()
CollectActions(viewModel.actions) { action -> handleAction(action) }
SignalLoginViewDetailsScreen(
@@ -444,7 +444,7 @@ class AccountSettingsViewModelTest {
}
@Test
fun `MethodRemovalAuthenticationFailed says so and removes nothing`() = runTest(testDispatcher) {
fun `AuthenticationFailed says so and removes nothing`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP)
@@ -452,9 +452,9 @@ class AccountSettingsViewModelTest {
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(TOTP_APP))
viewModel.onEvent(AccountSettingsEvent.MethodRemovalAuthenticationFailed)
viewModel.onEvent(AccountSettingsEvent.AuthenticationFailed)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.ShowRemovalAuthenticationFailed)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.ShowAuthenticationFailed)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
coVerify(exactly = 0) { repository.removeTotpApp(any()) }
}
@@ -546,7 +546,7 @@ class AccountSettingsViewModelTest {
}
@Test
fun `AccountAndRecoveryClicked opens the Signal Login details screen`() = runTest(testDispatcher) {
fun `AccountAndRecoveryClicked asks for the screen lock first`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
val viewModel = createViewModel()
@@ -554,6 +554,18 @@ class AccountSettingsViewModelTest {
viewModel.onEvent(AccountSettingsEvent.AccountAndRecoveryClicked)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.AuthenticateToViewSignalLoginDetails)
}
@Test
fun `SignalLoginDetailsAuthenticated opens the Signal Login details screen`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.SignalLoginDetailsAuthenticated)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToSignalLoginDetails)
}
@@ -27,6 +27,9 @@ sealed interface AccountSettingsAction {
/** Tell the user their PIN was created. */
data object ShowPinCreatedConfirmation : AccountSettingsAction
/** Ask the user to get past their screen lock before we show them the account and recovery keys. */
data object AuthenticateToViewSignalLoginDetails : AccountSettingsAction
/** Open the screen that shows the account and recovery keys that make up the Signal Login. */
data object NavigateToSignalLoginDetails : AccountSettingsAction
@@ -39,8 +42,8 @@ sealed interface AccountSettingsAction {
/** Ask the user to get past their screen lock before we remove [method] from the account. */
data class AuthenticateToRemoveMethod(val method: TwoFactorMethod) : AccountSettingsAction
/** Tell the user we couldn't confirm it was them, so nothing was removed. */
data object ShowRemovalAuthenticationFailed : AccountSettingsAction
/** Tell the user we couldn't confirm it was them, so whatever they asked for didn't happen. */
data object ShowAuthenticationFailed : AccountSettingsAction
/** Tell the user their authenticator app was removed. */
data object ShowTotpAppRemoved : AccountSettingsAction
@@ -16,9 +16,12 @@ sealed interface AccountSettingsEvent {
/** The user tapped the navigation (back) icon. */
data object NavigateBackClicked : AccountSettingsEvent
/** The user tapped the Signal Login card, which shows the account and recovery keys. */
/** The user tapped the Signal Login card, which asks for the screen lock before showing the account and recovery keys. */
data object AccountAndRecoveryClicked : AccountSettingsEvent
/** The user got past their screen lock, so we can show them the account and recovery keys. */
data object SignalLoginDetailsAuthenticated : AccountSettingsEvent
/** The user tapped the row that either creates or changes their PIN. */
data object ModifyPinClicked : AccountSettingsEvent
@@ -60,8 +63,8 @@ sealed interface AccountSettingsEvent {
/** The user got past their screen lock, so we can go on asking them to confirm removing [method]. */
data class MethodRemovalAuthenticated(val method: TwoFactorMethod) : AccountSettingsEvent
/** The screen lock turned the user away, so [RemoveMethodClicked] goes no further. */
data object MethodRemovalAuthenticationFailed : AccountSettingsEvent
/** The screen lock turned the user away, so whatever asked for it goes no further. */
data object AuthenticationFailed : AccountSettingsEvent
/** The user confirmed removing the authenticator app named by the open dialog, which removes it. */
data object RemoveTotpAppConfirmed : AccountSettingsEvent
@@ -76,7 +76,11 @@
<string name="AccountSettingsFragment__unlock_to_remove_two_factor_method">Unlock to remove two-factor method</string>
<!-- Title of the sheet explaining that the device screen lock is needed before a two-factor authentication method can be removed -->
<string name="AccountSettingsFragment__to_remove_this_method_confirm_its_you">To remove this method, confirm it\'s you</string>
<!-- Toast shown when the device screen lock wasn\'t confirmed, so nothing was removed -->
<!-- Title of the device screen lock prompt shown before the Signal Login details can be viewed -->
<string name="AccountSettingsFragment__unlock_to_view_signal_login">Unlock to view Signal Login</string>
<!-- Title of the sheet explaining that the device screen lock is needed before the Signal Login details can be viewed -->
<string name="AccountSettingsFragment__to_view_your_signal_login_confirm_its_you">To view your Signal Login, confirm it\'s you</string>
<!-- Toast shown when the device screen lock wasn\'t confirmed, so nothing happened -->
<string name="AccountSettingsFragment__authentication_required">Authentication required</string>
<!-- Title of the dialog shown when the account already has as many authenticator apps as it\'s allowed -->
<string name="AccountSettingsFragment__cant_add_authenticator_app">Can\'t add authenticator app</string>