mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Add password manager support to numberless login.
This commit is contained in:
committed by
Alex Hart
parent
59d8b5f61f
commit
c75cbcb05f
+1
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-6
@@ -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)
|
||||
|
||||
+5
@@ -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()
|
||||
|
||||
|
||||
+32
@@ -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,
|
||||
|
||||
+41
@@ -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,
|
||||
|
||||
+51
@@ -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)
|
||||
|
||||
+10
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user