Move password manager stuff into a lib.

This commit is contained in:
Greyson Parrelli
2026-09-02 16:11:22 -03:00
committed by Alex Hart
parent 0aaa921833
commit 8bf47713ea
20 changed files with 133 additions and 230 deletions
+1 -2
View File
@@ -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"))
@@ -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()
@@ -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,
@@ -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
@@ -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
)
}
@@ -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.
@@ -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()
@@ -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()
+1 -4
View File
@@ -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)
@@ -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<NavKey>.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<NavKey>.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<NavKey>.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<NavKey>.navigationEntries(
factory = EnterAepForRemoteBackupPostRegistrationViewModel.Factory(
repository = registrationRepository,
parentEventEmitter = registrationViewModel::onEvent,
isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context)
isPasswordManagerAvailable = SignalCredentialManager.isSupported(context)
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -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))
}
@@ -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)
}
@@ -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<AutofillManager>()?.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
}
}
}
+1
View File
@@ -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" }
+23
View File
@@ -626,6 +626,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="8397c169c6ef7218801eeae044f8d11b37ac355bba51841075eb02a3d1b2ab47" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-core" version="1.8.1">
<artifact name="animation-core-1.8.1.module">
<sha256 value="209bfe0d6bc8a700597762c6944eba401f1ae920dd7f6f959738bbce3f4dd1d2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-core" version="1.9.0">
<artifact name="animation-core-1.9.0.module">
<sha256 value="4c6c8ea8e5773499eeab487336c083f6bd966997520b208f05691fd700b052ac" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-core-android" version="1.11.4">
<artifact name="animation-core-android-1.11.4.module">
<sha256 value="6fc0c420dba0eebfe09d4b8b90c8b2424c7fda4d541faf9784afeb8bec54fe77" origin="Generated by Gradle"/>
@@ -1642,6 +1652,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="639ae0bdc1d09a4d0c3cd198850fd8530363cf65c55c7c870b17596883346163" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.credentials" name="credentials" version="1.6.0-beta01">
<artifact name="credentials-1.6.0-beta01.aar">
<sha256 value="b15506c9fd24c1cad961aabb6da8fe2d4394a7e7993bcc3ef51d3ac917d1b0c7" origin="Generated by Gradle"/>
</artifact>
<artifact name="credentials-1.6.0-beta01.module">
<sha256 value="64dca04761a2cc080e8bea1c198fd14bc6ba7aef3d310acf802c51739ea0e3ec" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.credentials" name="credentials-play-services-auth" version="1.6.0">
<artifact name="credentials-play-services-auth-1.6.0.aar">
<sha256 value="837a0a01988f93182bbe3e01007cbcc2a9ac9185c934153a136b37ac1bd8cc48" origin="Generated by Gradle"/>
@@ -4433,6 +4451,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="96f6e6d9f5e5db840ee3da20449e16828ed4ba2c07783dde3397d13aaf814e19" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-auth" version="21.1.1">
<artifact name="play-services-auth-21.1.1.aar">
<sha256 value="d73155e83e97875e93634ca9fd27d0159e2218f3347c53aeaaf0faa2efaa280f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-auth" version="21.5.1">
<artifact name="play-services-auth-21.5.1.aar">
<sha256 value="2b3f114af97c5b256ae244c4059e8470231f3218087a095930e34c4fbc243844" origin="Generated by Gradle"/>
+24
View File
@@ -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)
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -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<AutofillManager>()?.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
}
@@ -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 {
+1
View File
@@ -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")