diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7084e34969..2800a9a40f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -556,6 +556,7 @@ dependencies { } implementation(libs.google.play.services.maps) implementation(libs.google.play.services.auth) + implementation(libs.google.signin) implementation(libs.bundles.media3) implementation(libs.conscrypt.android) implementation(libs.signal.aesgcmprovider) 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 7f0ae1de69..68e092d909 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 @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt index 974bdda363..990ecaca51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyCredentialManagerHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyCredentialManagerHandler.kt index 255afeee3a..908b2bc465 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyCredentialManagerHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyCredentialManagerHandler.kt @@ -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()?.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() -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt index f30896b37c..e07f2aeb1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmSvrPinFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmSvrPinFragment.kt index 53a9b5ce8e..de2646694a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmSvrPinFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmSvrPinFragment.kt @@ -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() { @@ -25,6 +29,8 @@ internal class ConfirmSvrPinFragment : BaseSvrPinFragment 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 { confirm.cancelSpinning() RegistrationUtil.maybeMarkRegistrationComplete() @@ -118,4 +127,14 @@ internal class ConfirmSvrPinFragment : BaseSvrPinFragment() { 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt new file mode 100644 index 0000000000..602278fdf5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt @@ -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()?.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() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e74646e668..4f074e61bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4572,6 +4572,8 @@ PIN created. Re-enter your PIN Creating PIN… + + Signal PIN Introducing PINs @@ -8362,6 +8364,8 @@ Save to password manager? Only store your backup key in a password manager that you trust is secure. Signal does not make a recommendation on which password manager is right for you. + + Signal Backups Saved to your password manager diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db9abfc2e3..dc90bb78a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -129,6 +129,7 @@ google-libphonenumber = "com.googlecode.libphonenumber:libphonenumber:8.13.50" google-play-services-maps = "com.google.android.gms:play-services-maps:19.0.0" google-play-services-auth = "com.google.android.gms:play-services-auth:21.3.0" google-play-services-wallet = "com.google.android.gms:play-services-wallet:19.4.0" +google-signin = "com.google.android.libraries.identity.googleid:googleid:1.1.1" google-zxing-android-integration = "com.google.zxing:android-integration:3.3.0" google-zxing-core = "com.google.zxing:core:3.4.1" google-ez-vcard = "com.googlecode.ez-vcard:ez-vcard:0.9.11" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index ce66c17376..356e1c337d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -4068,6 +4068,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + +