Add button to help enter AEP from password manager in regV5.

This commit is contained in:
Greyson Parrelli
2026-06-30 12:22:26 -04:00
committed by Michelle Tang
parent f3b7368043
commit ebd64305bb
16 changed files with 141 additions and 26 deletions
@@ -122,7 +122,7 @@ import org.thoughtcrime.securesms.util.BatterySnapshotTracker;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.signal.core.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
@@ -6,12 +6,12 @@ import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.PlayServicesUtil
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SettingsValues.ForceWebsocketMode
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.IncomingMessageObserver
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.webrtc.CallDataMode
@@ -32,7 +32,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
import org.thoughtcrime.securesms.net.SignalNetwork;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.signal.core.util.PlayServicesUtil;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
@@ -38,6 +38,7 @@ import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
import org.signal.core.ui.logging.LoggingFragment
import org.signal.core.util.PlayServicesUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.isNotNullOrBlank
@@ -64,7 +65,6 @@ import org.thoughtcrime.securesms.registration.ui.toE164
import org.thoughtcrime.securesms.registration.util.CountryPrefix
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Dialogs
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.SupportEmailUtil
+1
View File
@@ -20,6 +20,7 @@ dependencies {
implementation(libs.jackson.core)
implementation(libs.jackson.module.kotlin)
implementation(libs.google.libphonenumber)
implementation(libs.google.play.services.base)
testImplementation(libs.androidx.sqlite.framework)
testImplementation(testLibs.junit.junit)
@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.util;
package org.signal.core.util;
import android.content.Context;
+4
View File
@@ -87,6 +87,10 @@ dependencies {
// Phone number hint
implementation(libs.google.play.services.auth)
// Credential Manager (password manager retrieval)
implementation(libs.androidx.credentials)
implementation(libs.androidx.credentials.compat)
// Testing
testImplementation(testFixtures(project(":core:ui")))
testImplementation(testLibs.junit.junit)
@@ -107,6 +107,7 @@ import org.signal.registration.screens.verificationcode.VerificationCodeViewMode
import org.signal.registration.screens.welcome.WelcomeScreen
import org.signal.registration.screens.welcome.WelcomeScreenEvents
import org.signal.registration.util.AccountEntropyPoolParceler
import org.signal.registration.util.RegistrationCredentialManager
/**
* Navigation routes for the registration flow.
@@ -756,11 +757,13 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
// -- Enter AEP
entry<RegistrationRoute.EnterAepForLocalBackup> {
val context = LocalContext.current
val viewModel: EnterAepForLocalBackupViewModel = viewModel(
factory = EnterAepForLocalBackupViewModel.Factory(
parentEventEmitter = registrationViewModel::onEvent,
resultBus = registrationViewModel.resultBus,
resultKey = BACKUP_CREDENTIAL_RESULT
resultKey = BACKUP_CREDENTIAL_RESULT,
isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context)
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -772,11 +775,13 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
}
entry<RegistrationRoute.EnterAepForRemoteBackupPreRegistration> { key ->
val context = LocalContext.current
val viewModel: EnterAepForRemoteBackupPreRegistrationViewModel = viewModel(
factory = EnterAepForRemoteBackupPreRegistrationViewModel.Factory(
e164 = key.e164,
repository = registrationRepository,
parentEventEmitter = registrationViewModel::onEvent
parentEventEmitter = registrationViewModel::onEvent,
isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context)
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -788,9 +793,11 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
}
entry<RegistrationRoute.EnterAepForRemoteBackupPostRegistration> {
val context = LocalContext.current
val viewModel: EnterAepForRemoteBackupPostRegistrationViewModel = viewModel(
factory = EnterAepForRemoteBackupPostRegistrationViewModel.Factory(
parentEventEmitter = registrationViewModel::onEvent
parentEventEmitter = registrationViewModel::onEvent,
isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context)
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -19,14 +19,15 @@ import org.signal.registration.screens.util.navigateBack
class EnterAepForLocalBackupViewModel(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val resultBus: ResultEventBus,
private val resultKey: String
private val resultKey: String,
isPasswordManagerAvailable: Boolean = false
) : ViewModel() {
companion object {
private val TAG = Log.tag(EnterAepForLocalBackupViewModel::class)
}
private val _state = MutableStateFlow(EnterAepState())
private val _state = MutableStateFlow(EnterAepState(isPasswordManagerAvailable = isPasswordManagerAvailable))
val state: StateFlow<EnterAepState> = _state.asStateFlow()
fun onEvent(event: EnterAepEvents) {
@@ -53,10 +54,11 @@ class EnterAepForLocalBackupViewModel(
class Factory(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val resultBus: ResultEventBus,
private val resultKey: String
private val resultKey: String,
private val isPasswordManagerAvailable: Boolean = false
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return EnterAepForLocalBackupViewModel(parentEventEmitter, resultBus, resultKey) as T
return EnterAepForLocalBackupViewModel(parentEventEmitter, resultBus, resultKey, isPasswordManagerAvailable) as T
}
}
}
@@ -18,14 +18,15 @@ import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.util.navigateTo
class EnterAepForRemoteBackupPostRegistrationViewModel(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
isPasswordManagerAvailable: Boolean = false
) : ViewModel() {
companion object {
private val TAG = Log.tag(EnterAepForRemoteBackupPostRegistrationViewModel::class)
}
private val _state = MutableStateFlow(EnterAepState())
private val _state = MutableStateFlow(EnterAepState(isPasswordManagerAvailable = isPasswordManagerAvailable))
val state: StateFlow<EnterAepState> = _state.asStateFlow()
fun onEvent(event: EnterAepEvents) {
@@ -51,10 +52,11 @@ class EnterAepForRemoteBackupPostRegistrationViewModel(
}
class Factory(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val isPasswordManagerAvailable: Boolean = false
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return EnterAepForRemoteBackupPostRegistrationViewModel(parentEventEmitter) as T
return EnterAepForRemoteBackupPostRegistrationViewModel(parentEventEmitter, isPasswordManagerAvailable) as T
}
}
}
@@ -24,14 +24,15 @@ import org.signal.registration.screens.util.navigateTo
class EnterAepForRemoteBackupPreRegistrationViewModel(
private val e164: String,
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
isPasswordManagerAvailable: Boolean = false
) : EventDrivenViewModel<EnterAepEvents>(TAG) {
companion object {
private val TAG = Log.tag(EnterAepForRemoteBackupPreRegistrationViewModel::class)
}
private val _state = MutableStateFlow(EnterAepState())
private val _state = MutableStateFlow(EnterAepState(isPasswordManagerAvailable = isPasswordManagerAvailable))
val state: StateFlow<EnterAepState> = _state.asStateFlow()
@@ -80,7 +81,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModel(
is RequestResult.NonSuccess -> {
when (val error = result.error) {
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
Log.w(TAG, "[Submit] RRP incorrect.")
Log.w(TAG, "[Submit] RRP incorrect. Message: ${error.message}")
stateEmitter(
inputState.copy(
isRegistering = false,
@@ -134,10 +135,11 @@ class EnterAepForRemoteBackupPreRegistrationViewModel(
class Factory(
private val e164: String,
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val isPasswordManagerAvailable: Boolean = false
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return EnterAepForRemoteBackupPreRegistrationViewModel(e164, repository, parentEventEmitter) as T
return EnterAepForRemoteBackupPreRegistrationViewModel(e164, repository, parentEventEmitter, isPasswordManagerAvailable) as T
}
}
}
@@ -29,12 +29,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -46,6 +48,7 @@ import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
@@ -57,6 +60,7 @@ 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.util.RegistrationCredentialManager
@Composable
fun EnterAepScreen(
@@ -93,6 +97,9 @@ private fun OnePaneLayout(
Description()
Spacer(modifier = Modifier.size(24.dp))
RecoveryKeyTextField(state, onEvent)
if (state.isPasswordManagerAvailable) {
FillFromPasswordManagerButton(onEvent)
}
}
},
footer = {
@@ -156,6 +163,9 @@ private fun TwoPaneLayout(
.padding(paddingValues)
) {
RecoveryKeyTextField(state, onEvent)
if (state.isPasswordManagerAvailable) {
FillFromPasswordManagerButton(onEvent)
}
}
},
footer = {
@@ -259,6 +269,26 @@ private fun RecoveryKeyTextField(state: EnterAepState, onEvent: (EnterAepEvents)
)
}
@Composable
private fun FillFromPasswordManagerButton(onEvent: (EnterAepEvents) -> Unit, modifier: Modifier = Modifier) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
Buttons.MediumTonal(
modifier = modifier,
onClick = {
coroutineScope.launch {
val password = RegistrationCredentialManager.getPasswordCredential(context)
if (password != null) {
onEvent(EnterAepEvents.BackupKeyChanged(password))
}
}
}
) {
Text(text = stringResource(R.string.EnterAepScreen__fill_from_password_manager))
}
}
@Composable
private fun NoRecoverKeyButton(onEvent: (EnterAepEvents) -> Unit, modifier: Modifier = Modifier) {
TextButton(
@@ -324,7 +354,7 @@ internal class AepVisualTransformation(private val chunkSize: Int) : VisualTrans
private fun EnterAepScreenPreview() {
Previews.Preview {
EnterAepScreen(
state = EnterAepState(),
state = EnterAepState(isPasswordManagerAvailable = true),
onEvent = {}
)
}
@@ -338,7 +368,8 @@ private fun EnterAepScreenFilledPreview() {
state = EnterAepState(
enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
isBackupKeyValid = true
isBackupKeyValid = true,
isPasswordManagerAvailable = true
),
onEvent = {}
)
@@ -354,7 +385,8 @@ private fun EnterAepScreenErrorPreview() {
enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
isBackupKeyValid = false,
aepValidationError = AepValidationError.Invalid
aepValidationError = AepValidationError.Invalid,
isPasswordManagerAvailable = true
),
onEvent = {}
)
@@ -16,9 +16,11 @@ data class EnterAepState(
val aepValidationError: AepValidationError? = null,
val chunkLength: Int = 4,
val isRegistering: Boolean = false,
val registrationError: RegistrationError? = null
val registrationError: RegistrationError? = null,
/** Whether a password manager / credential provider is available to fill the recovery key. */
val isPasswordManagerAvailable: Boolean = false
) {
override fun toString(): String = "EnterAepState(enteredText=${enteredText.censor()}, backupKey=${backupKey.censor()}, isBackupKeyValid=$isBackupKeyValid, aepValidationError=$aepValidationError, chunkLength=$chunkLength, isRegistering=$isRegistering, registrationError=$registrationError)"
override fun toString(): String = "EnterAepState(enteredText=${enteredText.censor()}, backupKey=${backupKey.censor()}, isBackupKeyValid=$isBackupKeyValid, aepValidationError=$aepValidationError, chunkLength=$chunkLength, isRegistering=$isRegistering, registrationError=$registrationError, isPasswordManagerAvailable=$isPasswordManagerAvailable)"
}
sealed interface AepValidationError {
@@ -0,0 +1,56 @@
/*
* 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
}
}
}
@@ -204,6 +204,8 @@
<string name="EnterAepScreen__invalid_recovery_key">Invalid recovery key</string>
<!-- Error text when key is correct format but does not match account -->
<string name="EnterAepScreen__incorrect_recovery_key">Incorrect recovery key</string>
<!-- Button that prompts the device password manager to fill in the recovery key -->
<string name="EnterAepScreen__fill_from_password_manager">Fill from password manager</string>
<!-- Text shown while preparing the restore -->
<string name="LocalBackupRestoreScreen__preparing_restore">Preparing restore…</string>
<!-- Title shown while restore is in progress -->
+5
View File
@@ -4365,6 +4365,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="c4327257a478febf00da8b921e39052b6aa44db9cf61e00c3d4bf77ec8091ce7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.libraries.identity.googleid" name="googleid" version="1.1.0">
<artifact name="googleid-1.1.0.aar">
<sha256 value="194ac1fc1986dd1f62046fae37ddf77e63770fdc1f3d34baaa397cfbf4d191a2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.libraries.identity.googleid" name="googleid" version="1.2.0">
<artifact name="googleid-1.2.0.aar">
<sha256 value="f49e88107f90ef0d1250d8504e8eba18be8532b1286203c92f28bf6bd15814ff" origin="Generated by Gradle"/>