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 cfe20b9789..0a61262284 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 @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt index 9644bbc60f..b8f60feb54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -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 diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt deleted file mode 100644 index a5bb235fdf..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt +++ /dev/null @@ -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() - } -} 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 bd188e1257..d3502b2edd 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 @@ -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)) } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt index e1b9e1182f..882531f3db 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt @@ -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) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt index 14f3bc0869..ad3e5c188a 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt @@ -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() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt index c249c8790b..93fd091905 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt @@ -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, diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt index 0204cab178..567205dc43 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt @@ -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() + @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, diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModelTest.kt index d92f4447e8..0344d824ca 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModelTest.kt @@ -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) diff --git a/lib/password-manager/src/main/java/org/signal/passwordmanager/SignalCredentialManager.kt b/lib/password-manager/src/main/java/org/signal/passwordmanager/SignalCredentialManager.kt index 99eb05ddae..65fcb39802 100644 --- a/lib/password-manager/src/main/java/org/signal/passwordmanager/SignalCredentialManager.kt +++ b/lib/password-manager/src/main/java/org/signal/passwordmanager/SignalCredentialManager.kt @@ -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