Prompt to save PIN to device password manager.

Fixes an issue where the system auto-fill could overwrite the Signal backup key saved to the device password manager with the Signal PIN. The PIN confirmation screen now explicitly uses `CredentialManager` to save the `Signal PIN` under a separate username from the `Signal Backups` key, allowing both credentials to be stored and auto-filled correctly.

- Add `com.google.android.libraries.identity.googleid` dependency so `CredentialManager` works on Android < 14.
- Prompt to save Signal PIN to credential manager after PIN is created/edited.
This commit is contained in:
jeffrey-signal
2025-07-08 11:53:01 -04:00
committed by Alex Hart
parent ef874c4091
commit 6d58e89c18
11 changed files with 181 additions and 125 deletions

View File

@@ -33,7 +33,6 @@ import org.signal.core.util.getSerializableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav
@@ -41,6 +40,7 @@ import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -140,7 +140,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD.name) {
val context = LocalContext.current
val passwordManagerSettingsIntent = BackupKeyCredentialManagerHandler.getCredentialManagerSettingsIntent(requireContext())
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,

View File

@@ -6,7 +6,7 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.content.Context
import android.os.Build
import androidx.annotation.UiContext
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -36,13 +36,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialInterruptedException
import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
@@ -50,17 +43,14 @@ import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.components.settings.app.backups.remote.CredentialManagerError
import org.thoughtcrime.securesms.components.settings.app.backups.remote.CredentialManagerResult
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.storage.CredentialManagerError
import org.thoughtcrime.securesms.util.storage.CredentialManagerResult
import org.signal.core.ui.R as CoreUiR
private const val TAG = "MessageBackupsKeyRecordScreen"
/**
* Screen displaying the backup key allowing the user to write it down
* or copy it.
@@ -167,7 +157,7 @@ fun MessageBackupsKeyRecordScreen(
}
}
if (BackupKeyCredentialManagerHandler.isCredentialManagerSupported) {
if (AndroidCredentialRepository.isCredentialManagerSupported) {
item {
Buttons.Small(
onClick = { onRequestSaveToPasswordManager() }
@@ -279,52 +269,14 @@ private fun BackupKeySaveErrorDialog(
}
private suspend fun saveKeyToCredentialManager(
context: Context,
@UiContext activityContext: Context,
backupKey: String
): CredentialManagerResult {
return try {
CredentialManager.create(context)
.createCredential(
context = context,
request = CreatePasswordRequest(
id = context.getString(R.string.RemoteBackupsSettingsFragment__signal_backups),
password = backupKey,
preferImmediatelyAvailableCredentials = false,
isAutoSelectAllowed = false
)
)
CredentialManagerResult.Success
} catch (e: Exception) {
when (e) {
is CreateCredentialCancellationException -> CredentialManagerResult.UserCanceled
is CreateCredentialInterruptedException -> CredentialManagerResult.Interrupted(e)
is CreateCredentialNoCreateOptionException, is CreateCredentialProviderConfigurationException -> CredentialManagerError.MissingCredentialManager(e)
is CreateCredentialUnknownException -> {
when {
Build.VERSION.SDK_INT <= 33 && e.message?.contains("[28431]") == true -> {
// This error only impacts Android 13 and earlier, when Google is the designated autofill provider. The error can be safely disregarded, since users
// will receive a save prompt from autofill and the password will be stored in Google Password Manager, which syncs with the Credential Manager API.
Log.d(TAG, "Disregarding CreateCredentialUnknownException and treating credential creation as success: \"${e.message}\".")
CredentialManagerResult.Success
}
e.message?.contains("[28434]") == true -> {
Log.w(TAG, "Detected MissingCredentialManager error based on CreateCredentialUnknownException message: \"${e.message}\"")
CredentialManagerError.MissingCredentialManager(e)
}
e.message?.contains("[28435]") == true -> {
Log.w(TAG, "CreateCredentialUnknownException: \"${e.message}\"")
CredentialManagerError.SavePromptDisabled(e)
}
else -> CredentialManagerError.Unexpected(e)
}
}
else -> CredentialManagerError.Unexpected(e)
}
}
return AndroidCredentialRepository.saveCredential(
activityContext = activityContext,
username = activityContext.getString(R.string.MessageBackupsKeyRecordScreen__backup_key_password_manager_id),
password = backupKey
)
}
@SignalPreview

View File

@@ -5,15 +5,10 @@
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.view.autofill.AutofillManager
import androidx.core.content.getSystemService
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logW
import org.thoughtcrime.securesms.util.storage.CredentialManagerError
import org.thoughtcrime.securesms.util.storage.CredentialManagerResult
/**
* Handles the process of storing a backup key to the device password manager.
@@ -21,37 +16,6 @@ import org.signal.core.util.logging.logW
interface BackupKeyCredentialManagerHandler {
companion object {
private val TAG = Log.tag(BackupKeyCredentialManagerHandler::class)
val isCredentialManagerSupported: Boolean = Build.VERSION.SDK_INT >= 19
/**
* Returns an [Intent] that can be used to launch the device's password manager settings.
*/
fun getCredentialManagerSettingsIntent(context: Context): Intent? {
if (Build.VERSION.SDK_INT >= 34) {
val intent = Intent(
Settings.ACTION_CREDENTIAL_PROVIDER,
Uri.fromParts("package", context.packageName, null)
)
if (intent.resolveActivity(context.packageManager) != null) {
return intent
}
}
if (Build.VERSION.SDK_INT >= 26) {
val isAutofillSupported = context.getSystemService<AutofillManager>()?.isAutofillSupported() == true
if (isAutofillSupported) {
val intent = Intent(
Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE,
Uri.fromParts("package", context.packageName, null)
)
return intent.takeIf { it.resolveActivity(context.packageManager) != null }
}
}
return null
}
}
/** Updates the [BackupKeySaveState]. Implementers must update their associated state to match [newState]. */
@@ -108,23 +72,3 @@ sealed interface BackupKeySaveState {
data object Success : BackupKeySaveState
data class Error(val errorType: CredentialManagerError) : BackupKeySaveState
}
sealed interface CredentialManagerResult {
data object Success : CredentialManagerResult
data object UserCanceled : CredentialManagerResult
/** The backup key save operation was interrupted and should be retried. */
data class Interrupted(val exception: Exception) : CredentialManagerResult
}
sealed class CredentialManagerError : CredentialManagerResult {
abstract val exception: Exception
/** No password manager is configured on the device. */
data class MissingCredentialManager(override val exception: Exception) : CredentialManagerError()
/** The user has added this app to the "never save" list in the smart lock for passwords settings. **/
data class SavePromptDisabled(override val exception: Exception) : CredentialManagerError()
data class Unexpected(override val exception: Exception) : CredentialManagerError()
}

View File

@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRec
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -29,7 +30,7 @@ class BackupKeyDisplayFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val passwordManagerSettingsIntent = BackupKeyCredentialManagerHandler.getCredentialManagerSettingsIntent(requireContext())
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
MessageBackupsKeyRecordScreen(
backupKey = SignalStore.account.accountEntropyPool.displayValue,

View File

@@ -8,6 +8,9 @@ import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.lock.v2.ConfirmSvrPinViewModel.SaveAnimation
@@ -15,6 +18,7 @@ import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
internal class ConfirmSvrPinFragment : BaseSvrPinFragment<ConfirmSvrPinViewModel>() {
@@ -25,6 +29,8 @@ internal class ConfirmSvrPinFragment : BaseSvrPinFragment<ConfirmSvrPinViewModel
} else {
initializeViewStatesForPinCreate()
}
ViewCompat.setImportantForAutofill(input, View.IMPORTANT_FOR_AUTOFILL_YES)
ViewCompat.setAutofillHints(input, HintConstants.AUTOFILL_HINT_NEW_PASSWORD)
}
@@ -64,6 +70,7 @@ internal class ConfirmSvrPinFragment : BaseSvrPinFragment<ConfirmSvrPinViewModel
label.setText(R.string.ConfirmKbsPinFragment__creating_pin)
input.isEnabled = false
}
ConfirmSvrPinViewModel.LabelState.RE_ENTER_PIN -> label.setText(R.string.ConfirmKbsPinFragment__re_enter_your_pin)
ConfirmSvrPinViewModel.LabelState.PIN_DOES_NOT_MATCH -> {
label.text = SpanUtil.color(
@@ -85,7 +92,9 @@ internal class ConfirmSvrPinFragment : BaseSvrPinFragment<ConfirmSvrPinViewModel
closeNavGraphBranch()
RegistrationUtil.maybeMarkRegistrationComplete()
StorageSyncHelper.scheduleSyncForDataChange()
showSavePinToPasswordManagerPrompt()
}
SaveAnimation.FAILURE -> {
confirm.cancelSpinning()
RegistrationUtil.maybeMarkRegistrationComplete()
@@ -118,4 +127,14 @@ internal class ConfirmSvrPinFragment : BaseSvrPinFragment<ConfirmSvrPinViewModel
private fun markMegaphoneSeenIfNecessary() {
AppDependencies.megaphoneRepository.markSeen(Megaphones.Event.PINS_FOR_ALL)
}
private fun showSavePinToPasswordManagerPrompt() {
CoroutineScope(Dispatchers.Main).launch {
AndroidCredentialRepository.saveCredential(
activityContext = requireActivity(),
username = getString(R.string.ConfirmKbsPinFragment__pin_password_manager_id),
password = input.text.toString()
)
}
}
}

View File

@@ -4,9 +4,7 @@ import android.view.animation.Animation
import android.view.animation.TranslateAnimation
import android.widget.EditText
import androidx.annotation.PluralsRes
import androidx.autofill.HintConstants
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation.findNavController
import org.thoughtcrime.securesms.R
@@ -19,16 +17,15 @@ class CreateSvrPinFragment : BaseSvrPinFragment<CreateSvrPinViewModel?>() {
override fun initializeViewStates() {
val args = CreateSvrPinFragmentArgs.fromBundle(requireArguments())
if (args.isPinChange) {
initializeViewStatesForPinChange(args.isForgotPin)
initializeViewStatesForPinChange()
} else {
initializeViewStatesForPinCreate()
}
label.text = getPinLengthRestrictionText(R.plurals.CreateKbsPinFragment__pin_must_be_at_least_digits)
confirm.isEnabled = false
ViewCompat.setAutofillHints(input, HintConstants.AUTOFILL_HINT_NEW_PASSWORD)
}
private fun initializeViewStatesForPinChange(isForgotPin: Boolean) {
private fun initializeViewStatesForPinChange() {
title.setText(R.string.CreateKbsPinFragment__create_a_new_pin)
description.setText(R.string.CreateKbsPinFragment__you_can_choose_a_new_pin_as_long_as_this_device_is_registered)
description.setLearnMoreVisible(true)

View File

@@ -0,0 +1,132 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.util.storage
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.view.autofill.AutofillManager
import androidx.core.content.getSystemService
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialInterruptedException
import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import org.signal.core.util.logging.Log
/**
* Responsible for storing and retrieving credentials using Android's Credential Manager.
*/
object AndroidCredentialRepository {
private val TAG = Log.tag(AndroidCredentialRepository::class)
private const val ERROR_CODE_GOOGLE_AUTOFILL_SUCCESS = "[28431]"
private const val ERROR_CODE_MISSING_CREDENTIAL_MANAGER = "[28434]"
private const val ERROR_CODE_SAVE_PROMPT_DISABLED = "[28435]"
val isCredentialManagerSupported: Boolean = Build.VERSION.SDK_INT >= 19
suspend fun saveCredential(
activityContext: Context,
username: String,
password: String
): CredentialManagerResult = try {
CredentialManager.create(activityContext)
.createCredential(
context = activityContext,
request = CreatePasswordRequest(
id = username,
password = password,
preferImmediatelyAvailableCredentials = false,
isAutoSelectAllowed = false
)
)
CredentialManagerResult.Success
} catch (e: Exception) {
when (e) {
is CreateCredentialCancellationException -> CredentialManagerResult.UserCanceled
is CreateCredentialInterruptedException -> CredentialManagerResult.Interrupted(e)
is CreateCredentialNoCreateOptionException, is CreateCredentialProviderConfigurationException -> CredentialManagerError.MissingCredentialManager(e)
is CreateCredentialUnknownException -> {
when {
Build.VERSION.SDK_INT <= 33 && e.message?.contains(ERROR_CODE_GOOGLE_AUTOFILL_SUCCESS) == true -> {
// This error only impacts Android 13 and earlier, when Google is the designated autofill provider. The error can be safely disregarded, since users
// will receive a save prompt from autofill and the password will be stored in Google Password Manager, which syncs with the Credential Manager API.
Log.d(TAG, "Disregarding CreateCredentialUnknownException and treating credential creation as success: \"${e.message}\".")
CredentialManagerResult.Success
}
e.message?.contains(ERROR_CODE_MISSING_CREDENTIAL_MANAGER) == true -> {
Log.w(TAG, "Detected MissingCredentialManager error based on CreateCredentialUnknownException message: \"${e.message}\"")
CredentialManagerError.MissingCredentialManager(e)
}
e.message?.contains(ERROR_CODE_SAVE_PROMPT_DISABLED) == true -> {
Log.w(TAG, "CreateCredentialUnknownException: \"${e.message}\"")
CredentialManagerError.SavePromptDisabled(e)
}
else -> CredentialManagerError.Unexpected(e)
}
}
else -> CredentialManagerError.Unexpected(e)
}
}
/**
* Returns an [Intent] that can be used to launch the device's password manager settings.
*/
fun getCredentialManagerSettingsIntent(context: Context): Intent? {
if (Build.VERSION.SDK_INT >= 34) {
val intent = Intent(
Settings.ACTION_CREDENTIAL_PROVIDER,
Uri.fromParts("package", context.packageName, null)
)
if (intent.resolveActivity(context.packageManager) != null) {
return intent
}
}
if (Build.VERSION.SDK_INT >= 26) {
val isAutofillSupported = context.getSystemService<AutofillManager>()?.isAutofillSupported() == true
if (isAutofillSupported) {
val intent = Intent(
Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE,
Uri.fromParts("package", context.packageName, null)
)
return intent.takeIf { it.resolveActivity(context.packageManager) != null }
}
}
return null
}
}
sealed interface CredentialManagerResult {
data object Success : CredentialManagerResult
data object UserCanceled : CredentialManagerResult
/** The backup key save operation was interrupted and should be retried. */
data class Interrupted(val exception: Exception) : CredentialManagerResult
}
sealed class CredentialManagerError : CredentialManagerResult {
abstract val exception: Exception
/** No password manager is configured on the device. */
data class MissingCredentialManager(override val exception: Exception) : CredentialManagerError()
/** The user has added this app to the "never save" list in the smart lock for passwords settings. **/
data class SavePromptDisabled(override val exception: Exception) : CredentialManagerError()
data class Unexpected(override val exception: Exception) : CredentialManagerError()
}