Add password manager support to numberless login.

This commit is contained in:
Greyson Parrelli
2026-09-02 16:11:29 -03:00
committed by Alex Hart
parent 59d8b5f61f
commit c75cbcb05f
10 changed files with 191 additions and 40 deletions
@@ -775,7 +775,7 @@ private suspend fun getKeyFromCredentialManager(
@UiContext activityContext: Context,
id: String
): String? {
return SignalCredentialManager.getCredential(activityContext, id)
return SignalCredentialManager.getCredential(activityContext, id)?.password
}
@DayNightPreviews
@@ -28,7 +28,7 @@ object Environment {
@JvmField
val IS_LINK_AND_SYNC_AVAILABLE: Boolean = true
const val PHONENUMBERLESS_REGISTRATION: Boolean = false
const val PHONENUMBERLESS_REGISTRATION: Boolean = IS_STAGING
object Backups {
@JvmStatic
@@ -1,25 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.util
import assertk.assertThat
import assertk.assertions.isFalse
import org.junit.Test
/**
* Guards flags in [Environment] that are only meant to be flipped on for local development.
*/
class EnvironmentTest {
/**
* The phone-numberless registration flow is incomplete. If this test fails, someone left the flag enabled after
* testing locally. Do not "fix" it by updating the test.
*/
@Test
fun `phone-numberless registration is disabled`() {
assertThat(Environment.PHONENUMBERLESS_REGISTRATION).isFalse()
}
}
@@ -340,9 +340,9 @@ private fun FillFromPasswordManagerButton(onEvent: (EnterAepEvents) -> Unit, mod
modifier = modifier,
onClick = {
coroutineScope.launch {
val password = SignalCredentialManager.getCredential(context)
if (password != null) {
onEvent(EnterAepEvents.BackupKeyChanged(password))
val credential = SignalCredentialManager.getCredential(context)
if (credential != null) {
onEvent(EnterAepEvents.BackupKeyChanged(credential.password))
}
}
}
@@ -33,10 +33,17 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@@ -53,11 +60,13 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
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.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.passwordmanager.SignalCredentialManager
import org.signal.passwordmanager.compose.attachPasswordAutoFillHelper
import org.signal.passwordmanager.compose.passwordAutoFillHelper
import org.signal.registration.R
@@ -231,17 +240,48 @@ private fun CredentialTextFields(
state: SignalLoginCredentialEntryState,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit
) {
AccountIdTextField(state = state, onEvent = onEvent)
val passwordManagerPrompt = passwordManagerPromptOnFocus(state, onEvent)
AccountIdTextField(state = state, onEvent = onEvent, modifier = passwordManagerPrompt)
Spacer(modifier = Modifier.height(12.dp))
RecoveryKeyTextField(state = state, onEvent = onEvent)
RecoveryKeyTextField(state = state, onEvent = onEvent, modifier = passwordManagerPrompt)
}
/**
* Builds a modifier that prompts the password manager the first time either credential field is tapped, so a saved
* login can fill both halves at once. Only fires while the fields are still empty, and only once per screen so a
* dismissed prompt doesn't keep coming back.
*/
@Composable
private fun passwordManagerPromptOnFocus(
state: SignalLoginCredentialEntryState,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit
): Modifier {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var hasPrompted by rememberSaveable { mutableStateOf(false) }
return Modifier.onFocusChanged { focusState ->
val fieldsAreEmpty = state.accountId.isEmpty() && state.recoveryKey.enteredText.isEmpty()
if (focusState.isFocused && !hasPrompted && fieldsAreEmpty && SignalCredentialManager.isSupported(context)) {
hasPrompted = true
coroutineScope.launch {
val credential = SignalCredentialManager.getCredential(context)
if (credential != null) {
onEvent(SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected(accountId = credential.username, recoveryKey = credential.password))
}
}
}
}
}
@Composable
private fun AccountIdTextField(
state: SignalLoginCredentialEntryState,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = state.accountId,
@@ -273,7 +313,7 @@ private fun AccountIdTextField(
},
isError = state.accountIdError != null || state.areCredentialsIncorrect,
visualTransformation = AccountIdVisualTransformation,
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.testTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ACCOUNT_ID_FIELD)
)
@@ -282,7 +322,8 @@ private fun AccountIdTextField(
@Composable
private fun RecoveryKeyTextField(
state: SignalLoginCredentialEntryState,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val keyboardController = LocalSoftwareKeyboardController.current
val autoFillHelper = passwordAutoFillHelper { onEvent(SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged(it)) }
@@ -334,7 +375,7 @@ private fun RecoveryKeyTextField(
},
isError = state.recoveryKey.error != null || state.areCredentialsIncorrect,
visualTransformation = visualTransformation,
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.testTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_RECOVERY_KEY_FIELD)
.attachPasswordAutoFillHelper(autoFillHelper)
@@ -21,6 +21,11 @@ sealed class SignalLoginCredentialEntryScreenEvents {
override fun toString(): String = "RecoveryKeyChanged(value=${value.censor()})"
}
/** The user picked a saved login from the password manager prompt. Carries both halves as the manager stored them. */
data class PasswordManagerCredentialSelected(val accountId: String, val recoveryKey: String) : SignalLoginCredentialEntryScreenEvents() {
override fun toString(): String = "PasswordManagerCredentialSelected(accountId=${accountId.censor()}, recoveryKey=${recoveryKey.censor()})"
}
/** The user tapped the eye button that switches the recovery key between masked and spelled out. */
data object RecoveryKeyVisibilityToggled : SignalLoginCredentialEntryScreenEvents()
@@ -87,6 +87,10 @@ class SignalLoginCredentialEntryViewModel(
stateEmitter(state.copy(recoveryKey = AepInput.from(event.value, state.recoveryKey.error), areCredentialsIncorrect = false))
}
is SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected -> {
applyPasswordManagerCredentialSelected(state, event, parentEventEmitter, stateEmitter)
}
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyVisibilityToggled -> {
stateEmitter(state.copy(isRecoveryKeyRevealed = !state.isRecoveryKeyRevealed))
}
@@ -105,6 +109,34 @@ class SignalLoginCredentialEntryViewModel(
}
}
/**
* Fills both halves of the login with what the password manager handed back, then submits it right away if the pair
* is complete so the user doesn't have to tap the next button themselves.
*/
private suspend fun applyPasswordManagerCredentialSelected(
state: SignalLoginCredentialEntryState,
event: SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (SignalLoginCredentialEntryState) -> Unit
) {
val accountId = event.accountId.replace(FORMATTING_CHARACTERS, "").lowercase()
val filledState = state.copy(
accountId = accountId,
accountIdError = validateAccountId(accountId),
recoveryKey = AepInput.from(event.recoveryKey),
areCredentialsIncorrect = false
)
stateEmitter(filledState)
if (filledState.isNextEnabled) {
Log.i(TAG, "[CredentialSelected] The password manager supplied a complete login. Submitting it.")
applyNextClicked(filledState, parentEventEmitter, stateEmitter)
} else {
Log.w(TAG, "[CredentialSelected] The password manager supplied a login we can't submit as-is. Leaving it in the fields for the user to fix.")
}
}
private suspend fun applyNextClicked(
state: SignalLoginCredentialEntryState,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
@@ -16,6 +16,12 @@ import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.contains
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -23,6 +29,8 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.passwordmanager.SignalCredentialManager
import org.signal.passwordmanager.UsernamePasswordCredential
import org.signal.registration.screens.aepentry.AepInput
import org.signal.registration.test.TestTags
@@ -43,6 +51,33 @@ class SignalLoginCredentialEntryScreenTest {
private val events = mutableListOf<SignalLoginCredentialEntryScreenEvents>()
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `when an empty field is tapped, the password manager is prompted and the picked credential is emitted`() {
stubPasswordManager()
setContent(SignalLoginCredentialEntryState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ACCOUNT_ID_FIELD).performClick()
composeTestRule.waitForIdle()
assertThat(events).contains(SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected(accountId = VALID_ACCOUNT_ID, recoveryKey = VALID_RECOVERY_KEY))
}
@Test
fun `when a field is tapped with a login already entered, the password manager is not prompted`() {
stubPasswordManager()
setContent(completeState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ACCOUNT_ID_FIELD).performClick()
composeTestRule.waitForIdle()
coVerify(exactly = 0) { SignalCredentialManager.getCredential(any()) }
}
@Test
fun `when text is typed into the account ID field, AccountIdChanged is emitted`() {
setContent(SignalLoginCredentialEntryState())
@@ -130,6 +165,12 @@ class SignalLoginCredentialEntryScreenTest {
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON).assertIsNotEnabled()
}
private fun stubPasswordManager() {
mockkObject(SignalCredentialManager)
every { SignalCredentialManager.isSupported(any()) } returns true
coEvery { SignalCredentialManager.getCredential(any()) } returns UsernamePasswordCredential(username = VALID_ACCOUNT_ID, password = VALID_RECOVERY_KEY)
}
private fun completeState(): SignalLoginCredentialEntryState {
return SignalLoginCredentialEntryState(
accountId = VALID_ACCOUNT_ID,
@@ -144,6 +144,57 @@ class SignalLoginCredentialEntryViewModelTest {
assertThat(emittedStates.last().areCredentialsIncorrect).isFalse()
}
@Test
fun `PasswordManagerCredentialSelected with a complete credential fills both fields and submits the login`() = runTest(testDispatcher) {
val aep = AccountEntropyPool(VALID_AEP)
stubSuccessfulLogin(aep)
applyEvent(
SignalLoginCredentialEntryState(),
SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected(
accountId = "A6B28482-2E32-83D0-7F23-91360A4C2B91",
recoveryKey = VALID_AEP.uppercase()
)
)
assertThat(emittedStates.first().accountId).isEqualTo(VALID_ACCOUNT_ID)
assertThat(emittedStates.first().recoveryKey.normalized).isEqualTo(VALID_AEP)
coVerify {
mockRepository.reRegisterAccountWithoutPhoneNumber(
aci = VALID_ACI,
recoveryPassword = aep.deriveMasterKey().deriveRegistrationRecoveryPassword(),
aep = match { it.value == VALID_AEP },
registrationLock = null
)
}
}
@Test
fun `PasswordManagerCredentialSelected with an unusable credential fills the fields without submitting`() = runTest(testDispatcher) {
applyEvent(
SignalLoginCredentialEntryState(),
SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected(accountId = "not-an-account-id", recoveryKey = "short")
)
assertThat(emittedParentEvents).isEmpty()
assertThat(emittedStates.last().isNextEnabled).isFalse()
coVerify(exactly = 0) { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), any(), any()) }
}
@Test
fun `PasswordManagerCredentialSelected clears a previously rejected login`() = runTest(testDispatcher) {
stubSuccessfulLogin(AccountEntropyPool(VALID_AEP))
applyEvent(
completeState().copy(areCredentialsIncorrect = true),
SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected(accountId = VALID_ACCOUNT_ID, recoveryKey = VALID_AEP)
)
assertThat(emittedStates.first().areCredentialsIncorrect).isFalse()
coVerify { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), any(), any()) }
}
@Test
fun `RecoveryKeyVisibilityToggled flips whether the key is spelled out`() = runTest(testDispatcher) {
applyEvent(SignalLoginCredentialEntryState(), SignalLoginCredentialEntryScreenEvents.RecoveryKeyVisibilityToggled)
@@ -25,6 +25,7 @@ import androidx.credentials.exceptions.CreateCredentialProviderConfigurationExce
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import org.signal.core.util.PlayServicesUtil
import org.signal.core.util.censor
import org.signal.core.util.logging.Log
/**
@@ -106,15 +107,15 @@ object SignalCredentialManager {
/**
* 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
* returns both halves of it, 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(@UiContext activityContext: Context, id: String? = null): String? = try {
suspend fun getCredential(@UiContext activityContext: Context, id: String? = null): UsernamePasswordCredential? = try {
val result = CredentialManager.create(activityContext).getCredential(activityContext, GetCredentialRequest(listOf(GetPasswordOption())))
val credential = result.credential
if (credential is PasswordCredential && (id == null || credential.id == id)) {
credential.password
UsernamePasswordCredential(username = credential.id, password = credential.password)
} else {
Log.w(TAG, "Failed to find a matching credential from the password manager.")
null
@@ -154,6 +155,11 @@ object SignalCredentialManager {
}
}
/** A username/password pair the user picked from their password manager. */
data class UsernamePasswordCredential(val username: String, val password: String) {
override fun toString(): String = "UsernamePasswordCredential(username=${username.censor()}, password=${password.censor()})"
}
/** Represents the result of a [SignalCredentialManager] save operation. */
sealed interface CredentialManagerResult {
data object Success : CredentialManagerResult