From 8bf47713eacd065fc510f69b25d6947d121d1a2f Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 27 Aug 2026 11:05:19 -0400 Subject: [PATCH] Move password manager stuff into a lib. --- app/build.gradle.kts | 3 +- .../v2/ui/subscription/EnterKeyScreen.kt | 8 +- .../MessageBackupsFlowFragment.kt | 8 +- .../MessageBackupsKeyRecordScreen.kt | 14 +-- .../upgrade/UpgradeToPaidTierBottomSheet.kt | 4 +- .../BackupKeyCredentialManagerHandler.kt | 4 +- .../ui/restore/EnterBackupKeyScreen.kt | 6 +- .../local/EnterLocalBackupKeyScreen.kt | 8 +- feature/registration/build.gradle.kts | 5 +- .../registration/RegistrationNavigation.kt | 10 +- .../screens/aepentry/EnterAepScreen.kt | 12 +- .../localbackuprestore/BackupKeyAutoFill.kt | 103 ------------------ .../util/RegistrationCredentialManager.kt | 56 ---------- gradle/libs.versions.toml | 1 + gradle/verification-metadata.xml | 23 ++++ lib/password-manager/build.gradle.kts | 24 ++++ .../src/main/AndroidManifest.xml | 2 + .../SignalCredentialManager.kt | 51 +++++---- .../compose/PasswordAutoFill.kt | 20 ++-- settings.gradle.kts | 1 + 20 files changed, 133 insertions(+), 230 deletions(-) delete mode 100644 feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/BackupKeyAutoFill.kt delete mode 100644 feature/registration/src/main/java/org/signal/registration/util/RegistrationCredentialManager.kt create mode 100644 lib/password-manager/build.gradle.kts create mode 100644 lib/password-manager/src/main/AndroidManifest.xml rename app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt => lib/password-manager/src/main/java/org/signal/passwordmanager/SignalCredentialManager.kt (73%) rename app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/BackupKeyAutoFill.kt => lib/password-manager/src/main/java/org/signal/passwordmanager/compose/PasswordAutoFill.kt (85%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cb026cc9fb..b8889a52af 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -702,6 +702,7 @@ dependencies { implementation(project(":lib:libsignal-service")) implementation(project(":lib:network")) implementation(project(":lib:paging")) + implementation(project(":lib:password-manager")) implementation(project(":lib:photoview")) implementation(project(":lib:qr")) implementation(project(":lib:sticky-header-grid")) @@ -808,8 +809,6 @@ dependencies { implementation(libs.rxjava3.rxandroid) implementation(libs.rxjava3.rxkotlin) implementation(libs.rxdogtag) - implementation(libs.androidx.credentials) - implementation(libs.androidx.credentials.compat) implementation(libs.kotlinx.serialization.json) implementation(project(":lib:billing")) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt index 3edfe969b4..e1f9543066 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt @@ -42,12 +42,12 @@ import androidx.compose.ui.unit.sp import org.signal.core.models.AccountEntropyPool import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.horizontalGutters +import org.signal.passwordmanager.compose.attachPasswordAutoFillHelper +import org.signal.passwordmanager.compose.passwordAutoFillHelper import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity import org.thoughtcrime.securesms.fonts.MonoTypeface import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation -import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper -import org.thoughtcrime.securesms.registration.ui.restore.backupKeyAutoFillHelper /** * Screen to enter backup key with an option to view the backup key again @@ -99,7 +99,7 @@ fun EnterKeyScreen( } var requestFocus: Boolean by remember { mutableStateOf(true) } - val autoFillHelper = backupKeyAutoFillHelper { updateEnteredBackupKey(it) } + val autoFillHelper = passwordAutoFillHelper { updateEnteredBackupKey(it) } TextField( value = enteredBackupKey, @@ -140,7 +140,7 @@ fun EnterKeyScreen( .testTag("message-backups-key-verify-screen-backup-key-input-field") .fillMaxWidth() .focusRequester(focusRequester) - .attachBackupKeyAutoFillHelper(autoFillHelper) + .attachPasswordAutoFillHelper(autoFillHelper) .onGloballyPositioned { if (requestFocus) { focusRequester.requestFocus() 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 80e6b85c9f..021798dd7f 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 @@ -34,6 +34,7 @@ import org.signal.core.ui.compose.Dialogs import org.signal.core.util.Util import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.getSerializableCompat +import org.signal.passwordmanager.SignalCredentialManager import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.v2.MessageBackupTier @@ -44,7 +45,6 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.PlayStoreUtil -import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository import org.thoughtcrime.securesms.util.viewModel /** @@ -69,7 +69,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega MessageBackupsFlowViewModel( initialTierSelection = requireArguments().getSerializableCompat(TIER, MessageBackupTier::class.java), googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()), - isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext()) + isCredentialManagerSupported = SignalCredentialManager.isSupported(requireContext()) ) } @@ -154,7 +154,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD.name) { val context = LocalContext.current - val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext()) + val passwordManagerSettingsIntent = SignalCredentialManager.getSettingsIntent(requireContext()) MessageBackupsKeyRecordScreen( backupKey = state.accountEntropyPool.displayValue, @@ -180,7 +180,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD_MANUALLY.name) { val context = LocalContext.current - val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext()) + val passwordManagerSettingsIntent = SignalCredentialManager.getSettingsIntent(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 3c7fd9e3d4..cfe20b9789 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 @@ -76,6 +76,9 @@ import org.signal.core.ui.compose.Snackbars import org.signal.core.ui.compose.horizontalGutters import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.Util +import org.signal.passwordmanager.CredentialManagerError +import org.signal.passwordmanager.CredentialManagerResult +import org.signal.passwordmanager.SignalCredentialManager import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.v2.ui.warning.ClipStage import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetContent @@ -86,9 +89,6 @@ import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupK import org.thoughtcrime.securesms.fonts.MonoTypeface import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.CommunicationActions -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 CLIPBOARD_TIMEOUT_SECONDS = 60 @@ -123,7 +123,7 @@ fun MessageBackupsKeyRecordScreen( ) { val context = LocalContext.current val passwordManagerSettingsIntent = remember { - AndroidCredentialRepository.getCredentialManagerSettingsIntent(context) + SignalCredentialManager.getSettingsIntent(context) } val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher @@ -414,7 +414,7 @@ fun MessageBackupsKeyRecordScreen( } } - if (!showAsPasskey && AndroidCredentialRepository.isCredentialManagerSupported(context)) { + if (!showAsPasskey && SignalCredentialManager.isSupported(context)) { item { Buttons.Small( onClick = { onRequestSaveToPasswordManager() } @@ -764,7 +764,7 @@ private suspend fun saveKeyToCredentialManager( @UiContext activityContext: Context, backupKey: String ): CredentialManagerResult { - return AndroidCredentialRepository.saveCredential( + return SignalCredentialManager.saveCredential( activityContext = activityContext, username = activityContext.getString(R.string.MessageBackupsKeyRecordScreen__backup_key_password_manager_id), password = backupKey @@ -775,7 +775,7 @@ private suspend fun getKeyFromCredentialManager( @UiContext activityContext: Context, id: String ): String? { - return AndroidCredentialRepository.getCredential(activityContext, id) + return SignalCredentialManager.getCredential(activityContext, id) } @DayNightPreviews diff --git a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt index 3af3a83abd..e5f972553f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.rx3.asFlowable import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment import org.signal.core.ui.compose.Dialogs import org.signal.core.util.concurrent.SignalDispatchers +import org.signal.passwordmanager.SignalCredentialManager import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowViewModel @@ -34,7 +35,6 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository import org.thoughtcrime.securesms.util.viewModel /** @@ -62,7 +62,7 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment() MessageBackupsFlowViewModel( initialTierSelection = MessageBackupTier.PAID, googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()), - isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext()), + isCredentialManagerSupported = SignalCredentialManager.isSupported(requireContext()), startScreen = MessageBackupsStage.TYPE_SELECTION ) } 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 29783c5156..00167d8a8b 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 @@ -7,8 +7,8 @@ package org.thoughtcrime.securesms.components.settings.app.backups.remote 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 +import org.signal.passwordmanager.CredentialManagerError +import org.signal.passwordmanager.CredentialManagerResult /** * Handles the process of storing a backup key to the device password manager. diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt index da3bd084a5..ec418eecc7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt @@ -55,6 +55,8 @@ import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.horizontalGutters +import org.signal.passwordmanager.compose.attachPasswordAutoFillHelper +import org.signal.passwordmanager.compose.passwordAutoFillHelper import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity @@ -137,7 +139,7 @@ fun EnterBackupKeyScreen( val visualTransform = remember(chunkLength) { BackupKeyVisualTransformation(chunkSize = chunkLength) } val keyboardController = LocalSoftwareKeyboardController.current - val autoFillHelper = backupKeyAutoFillHelper { onBackupKeyChanged(it) } + val autoFillHelper = passwordAutoFillHelper { onBackupKeyChanged(it) } TextField( value = enteredText, @@ -173,7 +175,7 @@ fun EnterBackupKeyScreen( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) - .attachBackupKeyAutoFillHelper(autoFillHelper) + .attachPasswordAutoFillHelper(autoFillHelper) .onGloballyPositioned { if (requestFocus) { focusRequester.requestFocus() diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/EnterLocalBackupKeyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/EnterLocalBackupKeyScreen.kt index 311f4c0773..d041f926e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/EnterLocalBackupKeyScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/EnterLocalBackupKeyScreen.kt @@ -35,6 +35,8 @@ import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.CircularProgressWrapper import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Previews +import org.signal.passwordmanager.compose.attachPasswordAutoFillHelper +import org.signal.passwordmanager.compose.passwordAutoFillHelper import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity import org.thoughtcrime.securesms.fonts.MonoTypeface @@ -42,8 +44,6 @@ import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResul import org.thoughtcrime.securesms.registration.ui.restore.AccountEntropyPoolVerification import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation import org.thoughtcrime.securesms.registration.ui.restore.RegistrationErrorDialogs -import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper -import org.thoughtcrime.securesms.registration.ui.restore.backupKeyAutoFillHelper import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen @Composable @@ -67,7 +67,7 @@ fun EnterLocalBackupKeyScreen( val focusRequester = remember { FocusRequester() } var requestFocus by remember { mutableStateOf(true) } - val autoFillHelper = backupKeyAutoFillHelper { onBackupKeyChanged(it) } + val autoFillHelper = passwordAutoFillHelper { onBackupKeyChanged(it) } RegistrationScreen( title = stringResource(R.string.EnterLocalBackupKeyScreen__enter_your_recovery_key), @@ -128,7 +128,7 @@ fun EnterLocalBackupKeyScreen( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) - .attachBackupKeyAutoFillHelper(autoFillHelper) + .attachPasswordAutoFillHelper(autoFillHelper) .onGloballyPositioned { if (requestFocus) { focusRequester.requestFocus() diff --git a/feature/registration/build.gradle.kts b/feature/registration/build.gradle.kts index f44caf5288..8da6d181c6 100644 --- a/feature/registration/build.gradle.kts +++ b/feature/registration/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(project(":core:models-jvm")) implementation(project(":core:serialization")) implementation(project(":lib:device-transfer")) + implementation(project(":lib:password-manager")) implementation(project(":lib:signal-login")) implementation(libs.libsignal.android) @@ -98,10 +99,6 @@ dependencies { implementation(libs.google.play.services.auth) implementation(libs.kotlinx.coroutines.play.services) - // Credential Manager (password manager retrieval) - implementation(libs.androidx.credentials) - implementation(libs.androidx.credentials.compat) - // Testing testImplementation(testFixtures(project(":core:ui"))) testImplementation(testLibs.junit.junit) 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 c5b7551517..97fe9cbeb0 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -47,6 +47,7 @@ import org.signal.core.util.LinkActions.OpenUrlError import org.signal.core.util.serialization.AccountEntropyPoolSerializer import org.signal.network.api.RegistrationApiV2.SessionMetadata import org.signal.network.api.RegistrationApiV2.SvrCredentials +import org.signal.passwordmanager.SignalCredentialManager import org.signal.registration.screens.accountlocked.AccountLockedScreen import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents import org.signal.registration.screens.accountlocked.AccountLockedState @@ -124,7 +125,6 @@ import org.signal.registration.screens.welcome.WelcomeScreen import org.signal.registration.screens.welcome.WelcomeScreenActions import org.signal.registration.screens.welcome.WelcomeScreenViewModel import org.signal.registration.util.AccountEntropyPoolParceler -import org.signal.registration.util.RegistrationCredentialManager import org.signal.registration.util.SessionMetadataParceler import org.signal.registration.util.SvrCredentialsParceler @@ -659,7 +659,7 @@ private fun EntryProviderScope.navigationEntries( factory = SignalLoginInfoViewModel.Factory( repository = registrationRepository, parentEventEmitter = registrationViewModel::onEvent, - isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context) + isPasswordManagerAvailable = SignalCredentialManager.isSupported(context) ) ) val state by viewModel.state.collectAsStateWithLifecycle() @@ -904,7 +904,7 @@ private fun EntryProviderScope.navigationEntries( parentEventEmitter = registrationViewModel::onEvent, resultBus = registrationViewModel.resultBus, resultKey = AEP_FOR_LOCAL_BACKUP_RESULT, - isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context) + isPasswordManagerAvailable = SignalCredentialManager.isSupported(context) ) ) val state by viewModel.state.collectAsStateWithLifecycle() @@ -922,7 +922,7 @@ private fun EntryProviderScope.navigationEntries( e164 = key.e164, repository = registrationRepository, parentEventEmitter = registrationViewModel::onEvent, - isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context) + isPasswordManagerAvailable = SignalCredentialManager.isSupported(context) ) ) val state by viewModel.state.collectAsStateWithLifecycle() @@ -939,7 +939,7 @@ private fun EntryProviderScope.navigationEntries( factory = EnterAepForRemoteBackupPostRegistrationViewModel.Factory( repository = registrationRepository, parentEventEmitter = registrationViewModel::onEvent, - isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context) + isPasswordManagerAvailable = SignalCredentialManager.isSupported(context) ) ) val state by viewModel.state.collectAsStateWithLifecycle() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt index 4d0902ed65..f17708454c 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt @@ -56,16 +56,16 @@ import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews +import org.signal.passwordmanager.SignalCredentialManager +import org.signal.passwordmanager.compose.attachPasswordAutoFillHelper +import org.signal.passwordmanager.compose.passwordAutoFillHelper import org.signal.registration.R import org.signal.registration.fonts.MonoTypeface import org.signal.registration.screens.OnePaneRegistrationScaffold import org.signal.registration.screens.RegistrationScaffold import org.signal.registration.screens.TwoPaneRegistrationScaffold import org.signal.registration.screens.attachDebugLogHelper -import org.signal.registration.screens.localbackuprestore.attachBackupKeyAutoFillHelper -import org.signal.registration.screens.localbackuprestore.backupKeyAutoFillHelper import org.signal.registration.test.TestTags -import org.signal.registration.util.RegistrationCredentialManager @Composable fun EnterAepScreen( @@ -264,7 +264,7 @@ private fun RecoveryKeyTextField(state: EnterAepState, onEvent: (EnterAepEvents) val focusRequester = remember { FocusRequester() } var requestFocus by remember { mutableStateOf(true) } val keyboardController = LocalSoftwareKeyboardController.current - val autoFillHelper = backupKeyAutoFillHelper { onEvent(EnterAepEvents.BackupKeyChanged(it)) } + val autoFillHelper = passwordAutoFillHelper { onEvent(EnterAepEvents.BackupKeyChanged(it)) } TextField( value = state.enteredText, @@ -311,7 +311,7 @@ private fun RecoveryKeyTextField(state: EnterAepState, onEvent: (EnterAepEvents) .fillMaxWidth() .testTag(TestTags.ENTER_AEP_INPUT) .focusRequester(focusRequester) - .attachBackupKeyAutoFillHelper(autoFillHelper) + .attachPasswordAutoFillHelper(autoFillHelper) .onGloballyPositioned { if (requestFocus) { focusRequester.requestFocus() @@ -330,7 +330,7 @@ private fun FillFromPasswordManagerButton(onEvent: (EnterAepEvents) -> Unit, mod modifier = modifier, onClick = { coroutineScope.launch { - val password = RegistrationCredentialManager.getPasswordCredential(context) + val password = SignalCredentialManager.getCredential(context) if (password != null) { onEvent(EnterAepEvents.BackupKeyChanged(password)) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/BackupKeyAutoFill.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/BackupKeyAutoFill.kt deleted file mode 100644 index a04fda9e3a..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/BackupKeyAutoFill.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.registration.screens.localbackuprestore - -import android.annotation.SuppressLint -import android.content.Context -import android.os.Build -import android.view.autofill.AutofillManager -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.Autofill -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalAutofill -import androidx.compose.ui.platform.LocalAutofillTree -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.content.ContextCompat -import kotlin.math.roundToInt -import android.graphics.Rect as ViewRect -import androidx.compose.ui.geometry.Rect as ComposeRect - -@OptIn(ExperimentalComposeUiApi::class) -@SuppressLint("NewApi") -@Composable -fun backupKeyAutoFillHelper( - onFill: (String) -> Unit -): BackupKeyAutoFillHelper { - val view = LocalView.current - val context = LocalContext.current - val autofill: Autofill? = LocalAutofill.current - - val node = remember { AutofillNode(autofillTypes = listOf(AutofillType.Password), onFill = onFill) } - LocalAutofillTree.current += node - - return remember { - object : BackupKeyAutoFillHelper(context) { - override fun request() { - if (node.boundingBox != null) { - autofill?.requestAutofillForNode(node) - } - } - - override fun cancel() { - autofill?.cancelAutofillForNode(node) - } - - override fun requestDirectly() { - val bounds = node.boundingBox?.let { ViewRect(it.left.roundToInt(), it.top.roundToInt(), it.right.roundToInt(), it.bottom.roundToInt()) } - if (bounds != null) { - autoFillManager?.requestAutofill(view, node.id, bounds) - } - } - - override fun updateNodeBounds(boundsInWindow: ComposeRect) { - node.boundingBox = boundsInWindow - } - } - } -} - -fun Modifier.attachBackupKeyAutoFillHelper(helper: BackupKeyAutoFillHelper): Modifier { - return this.then( - Modifier - .onFocusChanged { - if (it.isFocused) { - helper.request() - } else { - helper.cancel() - } - } - .onGloballyPositioned { - helper.updateNodeBounds(it.boundsInWindow()) - } - ) -} - -abstract class BackupKeyAutoFillHelper(context: Context) { - protected val autoFillManager: AutofillManager? = if (Build.VERSION.SDK_INT >= 26) { - ContextCompat.getSystemService(context, AutofillManager::class.java) - } else { - null - } - - fun onValueChanged(value: String) { - if (value.isEmpty()) { - requestDirectly() - } - } - - abstract fun request() - abstract fun cancel() - abstract fun requestDirectly() - abstract fun updateNodeBounds(boundsInWindow: ComposeRect) -} diff --git a/feature/registration/src/main/java/org/signal/registration/util/RegistrationCredentialManager.kt b/feature/registration/src/main/java/org/signal/registration/util/RegistrationCredentialManager.kt deleted file mode 100644 index 94924f44bb..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/util/RegistrationCredentialManager.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.registration.util - -import android.content.Context -import android.os.Build -import android.view.autofill.AutofillManager -import androidx.annotation.UiContext -import androidx.core.content.getSystemService -import androidx.credentials.CredentialManager -import androidx.credentials.GetCredentialRequest -import androidx.credentials.GetPasswordOption -import androidx.credentials.PasswordCredential -import androidx.credentials.exceptions.GetCredentialException -import org.signal.core.util.PlayServicesUtil -import org.signal.core.util.logging.Log - -/** - * Retrieves a previously saved password credential (e.g. a backup/recovery key) using Android's - * Credential Manager. - */ -object RegistrationCredentialManager { - - private val TAG = Log.tag(RegistrationCredentialManager::class) - - /** - * Whether a password manager / credential provider is available to fill credentials. On API 26+ - * this tracks whether the user has an autofill service enabled; older devices fall back to the - * Credential Manager Play Services backend, so they are supported only when Play Services is. - */ - fun isSupported(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= 26) { - context.getSystemService()?.isEnabled == true - } else { - PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.SUCCESS - } - } - - /** - * Prompts the device password manager to let the user pick a saved password credential and returns - * its value, or null if none was chosen or retrieval failed. Must be called with an Activity - * context so the Credential Manager UI can be shown. - */ - suspend fun getPasswordCredential(@UiContext activityContext: Context): String? { - return try { - val result = CredentialManager.create(activityContext).getCredential(activityContext, GetCredentialRequest(listOf(GetPasswordOption()))) - (result.credential as? PasswordCredential)?.password - } catch (e: GetCredentialException) { - Log.w(TAG, "Failed to retrieve credential from password manager.", e) - null - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c0c8d6d24b..2fbeab7a6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,6 +59,7 @@ androidx-compose-material3-adaptive = { module = "androidx.compose.material3.ada androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout"} androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation"} androidx-compose-material-icons-extended = "androidx.compose.material:material-icons-extended:1.7.8" +androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-ui-tooling-core = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index fd5c28ad50..3d8ce9358e 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -626,6 +626,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + @@ -1642,6 +1652,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4433,6 +4451,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + diff --git a/lib/password-manager/build.gradle.kts b/lib/password-manager/build.gradle.kts new file mode 100644 index 0000000000..dc3a1f731e --- /dev/null +++ b/lib/password-manager/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("signal-library") + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "org.signal.passwordmanager" + + buildFeatures { + compose = true + } +} + +dependencies { + lintChecks(project(":lintchecks")) + + implementation(project(":core:util")) + + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.compat) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) +} diff --git a/lib/password-manager/src/main/AndroidManifest.xml b/lib/password-manager/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b2d3ea1235 --- /dev/null +++ b/lib/password-manager/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt b/lib/password-manager/src/main/java/org/signal/passwordmanager/SignalCredentialManager.kt similarity index 73% rename from app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt rename to lib/password-manager/src/main/java/org/signal/passwordmanager/SignalCredentialManager.kt index 20f1d1e8e3..99eb05ddae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt +++ b/lib/password-manager/src/main/java/org/signal/passwordmanager/SignalCredentialManager.kt @@ -1,9 +1,9 @@ /* - * Copyright 2025 Signal Messenger, LLC + * Copyright 2026 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ -package org.thoughtcrime.securesms.util.storage +package org.signal.passwordmanager import android.content.Context import android.content.Intent @@ -11,6 +11,7 @@ import android.net.Uri import android.os.Build import android.provider.Settings import android.view.autofill.AutofillManager +import androidx.annotation.UiContext import androidx.core.content.getSystemService import androidx.credentials.CreatePasswordRequest import androidx.credentials.CredentialManager @@ -23,28 +24,40 @@ import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException import androidx.credentials.exceptions.CreateCredentialUnknownException import androidx.credentials.exceptions.GetCredentialException +import org.signal.core.util.PlayServicesUtil import org.signal.core.util.logging.Log /** - * Responsible for storing and retrieving credentials using Android's Credential Manager. + * Stores and retrieves password credentials (e.g. backup/recovery keys) using Android's + * Credential Manager, which delegates to the user's password manager. */ -object AndroidCredentialRepository { - private val TAG = Log.tag(AndroidCredentialRepository::class) +object SignalCredentialManager { + + private val TAG = Log.tag(SignalCredentialManager::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]" - fun isCredentialManagerSupported(context: Context): Boolean { + /** + * Whether a password manager / credential provider is available. On API 26+ this tracks whether + * the user has an autofill service enabled; older devices fall back to the Credential Manager + * Play Services backend, so they are supported only when Play Services is. + */ + fun isSupported(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= 26) { context.getSystemService()?.isEnabled == true } else { - true + PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.SUCCESS } } + /** + * Prompts the user to save a password credential to their password manager. Must be called with + * an Activity context so the Credential Manager UI can be shown. + */ suspend fun saveCredential( - activityContext: Context, + @UiContext activityContext: Context, username: String, password: String ): CredentialManagerResult = try { @@ -92,30 +105,29 @@ object AndroidCredentialRepository { } /** - * Retrieves a previously saved password credential by username. Returns null if the credential - * cannot be found or if retrieval fails. + * Prompts the device password manager to let the user pick a saved password credential and + * returns its value, or null if none was chosen or retrieval failed. If [id] is provided, only a + * credential with that id will be returned. Must be called with an Activity context so the + * Credential Manager UI can be shown. */ - suspend fun getCredential( - activityContext: Context, - id: String - ): String? = try { + suspend fun getCredential(@UiContext activityContext: Context, id: String? = null): String? = try { val result = CredentialManager.create(activityContext).getCredential(activityContext, GetCredentialRequest(listOf(GetPasswordOption()))) val credential = result.credential - if (credential is PasswordCredential && credential.id == id) { + if (credential is PasswordCredential && (id == null || credential.id == id)) { credential.password } else { - Log.w(TAG, "Failed to find credential from password manager") + Log.w(TAG, "Failed to find a matching credential from the password manager.") null } } catch (e: GetCredentialException) { - Log.w(TAG, "Failed to find credential from password manager.", e) + Log.w(TAG, "Failed to retrieve credential from password manager.", e) null } /** * Returns an [Intent] that can be used to launch the device's password manager settings. */ - fun getCredentialManagerSettingsIntent(context: Context): Intent? { + fun getSettingsIntent(context: Context): Intent? { if (Build.VERSION.SDK_INT >= 34) { val intent = Intent( Settings.ACTION_CREDENTIAL_PROVIDER, @@ -142,11 +154,12 @@ object AndroidCredentialRepository { } } +/** Represents the result of a [SignalCredentialManager] save operation. */ sealed interface CredentialManagerResult { data object Success : CredentialManagerResult data object UserCanceled : CredentialManagerResult - /** The backup key save operation was interrupted and should be retried. */ + /** The save operation was interrupted and should be retried. */ data class Interrupted(val exception: Exception) : CredentialManagerResult } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/BackupKeyAutoFill.kt b/lib/password-manager/src/main/java/org/signal/passwordmanager/compose/PasswordAutoFill.kt similarity index 85% rename from app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/BackupKeyAutoFill.kt rename to lib/password-manager/src/main/java/org/signal/passwordmanager/compose/PasswordAutoFill.kt index 2275d3481a..5f53951ea4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/BackupKeyAutoFill.kt +++ b/lib/password-manager/src/main/java/org/signal/passwordmanager/compose/PasswordAutoFill.kt @@ -1,9 +1,9 @@ /* - * Copyright 2025 Signal Messenger, LLC + * Copyright 2026 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ -package org.thoughtcrime.securesms.registration.ui.restore +package org.signal.passwordmanager.compose import android.annotation.SuppressLint import android.content.Context @@ -29,14 +29,14 @@ import android.graphics.Rect as ViewRect import androidx.compose.ui.geometry.Rect as ComposeRect /** - * Provide a compose friendly way to autofill the backup key from a password manager. + * Provide a compose friendly way to autofill a password (e.g. a backup key) from a password manager. */ @OptIn(ExperimentalComposeUiApi::class) @SuppressLint("NewApi") @Composable -fun backupKeyAutoFillHelper( +fun passwordAutoFillHelper( onFill: (String) -> Unit -): BackupKeyAutoFillHelper { +): PasswordAutoFillHelper { val view = LocalView.current val context = LocalContext.current val autofill: Autofill? = LocalAutofill.current @@ -45,7 +45,7 @@ fun backupKeyAutoFillHelper( LocalAutofillTree.current += node return remember { - object : BackupKeyAutoFillHelper(context) { + object : PasswordAutoFillHelper(context) { override fun request() { if (node.boundingBox != null) { autofill?.requestAutofillForNode(node) @@ -75,10 +75,10 @@ fun backupKeyAutoFillHelper( } /** - * Attach a [BackupKeyAutoFillHelper] return from [backupKeyAutoFillHelper] to setup the default + * Attach a [PasswordAutoFillHelper] returned from [passwordAutoFillHelper] to setup the default * callbacks needed to make requests on the view's behalf. */ -fun Modifier.attachBackupKeyAutoFillHelper(helper: BackupKeyAutoFillHelper): Modifier { +fun Modifier.attachPasswordAutoFillHelper(helper: PasswordAutoFillHelper): Modifier { return this.then( Modifier .onFocusChanged { @@ -95,10 +95,10 @@ fun Modifier.attachBackupKeyAutoFillHelper(helper: BackupKeyAutoFillHelper): Mod } /** - * Weird compose-interop abstract class to let us return something to the caller of [backupKeyAutoFillHelper] + * Weird compose-interop abstract class to let us return something to the caller of [passwordAutoFillHelper] * and capture inner compose data to implement the methods that need various compose provided things. */ -abstract class BackupKeyAutoFillHelper(context: Context) { +abstract class PasswordAutoFillHelper(context: Context) { protected val autoFillManager: AutofillManager? = if (Build.VERSION.SDK_INT >= 26) { ContextCompat.getSystemService(context, AutofillManager::class.java) } else { diff --git a/settings.gradle.kts b/settings.gradle.kts index bea7b3ff6d..1d8335ce8c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -126,6 +126,7 @@ include(":lib:emoji") include(":lib:archive") include(":lib:ui-components") include(":lib:signal-login") +include(":lib:password-manager") // Feature modules include(":feature:app-settings")