Improve re-register flow for numberless account.

This commit is contained in:
Greyson Parrelli
2026-09-15 18:26:03 -04:00
parent 88a994e9ed
commit 6dc4a017ca
7 changed files with 137 additions and 2 deletions
@@ -82,6 +82,7 @@ import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.registration.PendingRestoreOption
import org.signal.registration.R
import org.signal.registration.RegistrationDependencies
import org.signal.registration.screens.OnePaneRegistrationScaffold
@@ -413,7 +414,15 @@ private fun NextButton(
enabled = !state.showSpinner,
modifier = Modifier.testTag(TestTags.PHONE_NUMBER_REGISTER_WITHOUT_NUMBER_BUTTON)
) {
Text(stringResource(R.string.RegistrationActivity_register_without_number))
Text(
stringResource(
if (state.hasExistingAccount) {
R.string.RegistrationActivity_use_account_id
} else {
R.string.RegistrationActivity_register_without_number
}
)
)
}
Spacer(modifier = Modifier.weight(1f))
@@ -646,6 +655,20 @@ private fun PhoneNumberScreenRegisterWithoutNumberPreview() {
}
}
@AllDevicePreviews
@Composable
private fun PhoneNumberScreenUseAccountIdPreview() {
Previews.Preview {
PhoneNumberScreen(
state = PhoneNumberEntryState(
isPhoneNumberlessRegistrationAvailable = true,
pendingRestoreOption = PendingRestoreOption.RemoteBackup
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun PhoneNumberScreenAccountIdPreview() {
@@ -52,6 +52,10 @@ data class PhoneNumberEntryState(
null
}
/** Whether the user already told us they have an account to restore, which lets us skip past the Signal Login purchase screen. */
val hasExistingAccount: Boolean
get() = pendingRestoreOption != null
/** Whether what has been entered is complete enough to submit, be it a phone number or an account ID. */
val isNextEnabled: Boolean
get() {
@@ -151,7 +151,11 @@ class PhoneNumberEntryViewModel(
parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount())
}
is PhoneNumberEntryScreenEvents.RegisterWithoutNumber -> {
parentEventEmitter.navigateTo(RegistrationRoute.SignalLoginPayment)
if (state.hasExistingAccount) {
parentEventEmitter.navigateTo(RegistrationRoute.SignalLoginCredentialEntry())
} else {
parentEventEmitter.navigateTo(RegistrationRoute.SignalLoginPayment)
}
}
is PhoneNumberEntryScreenEvents.CaptchaCompleted -> {
stateEmitter(applyCaptchaCompleted(state, event.token, parentEventEmitter))
@@ -55,6 +55,8 @@
<string name="RegistrationActivity_next">Next</string>
<!-- Button on the phone number screen that starts registering with a purchased Signal Login instead of a phone number -->
<string name="RegistrationActivity_register_without_number">Register without number</string>
<!-- Button on the phone number screen, shown when the user already said they have an account to restore, that goes straight to entering their existing Signal Login -->
<string name="RegistrationActivity_use_account_id">Use account ID</string>
<!-- Dialog title to confirm phone number -->
<string name="RegistrationActivity_is_the_phone_number">Is the phone number below correct?</string>
<!-- Dialog body to confirm phone number -->
@@ -1840,6 +1840,38 @@ class RegistrationEndToEndTest {
assert(storageController.restoreDecision == RestoreDecision.SKIPPED) { "Expected SKIPPED restore decision but was ${storageController.restoreDecision}" }
}
@Test
fun `a pending restore turns the numberless button into a direct jump to signal login entry`() {
enableSignalLoginRegistration()
val login = signalLoginFor(reregistration = true)
var registrationComplete = false
launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true })
startManualRestore()
chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_SIGNAL_BACKUPS)
// The user already said they have an account to restore, so the purchase screen is skipped entirely
waitForTag(TestTags.PHONE_NUMBER_SCREEN)
composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_REGISTER_WITHOUT_NUMBER_BUTTON).performClick()
waitForTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ENTRY_SCREEN)
assert(composeTestRule.onAllNodesWithTag(TestTags.SIGNAL_LOGIN_PAYMENT_SCREEN).fetchSemanticsNodes().isEmpty()) {
"Expected the Signal Login payment screen to be skipped for a user with a pending restore"
}
enterSignalLogin(login)
chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_NONE)
waitForTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON)
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick()
waitFor("registration to complete") { registrationComplete }
assert(purchaseApi.launchCount == 0) { "A user with an existing login should never hit Google Play" }
assert(networkController.lastCreateSessionE164 == null) { "Expected no verification session for a numberless login" }
assert(networkController.lastRegisterAccountRequest?.aci == login.aci) { "Expected the entered login to be reclaimed but was ${networkController.lastRegisterAccountRequest}" }
}
@Test
fun `typing an account id into the phone number field goes straight to signal login entry with it filled in`() {
enableSignalLoginRegistration()
@@ -46,6 +46,7 @@ import org.signal.network.api.RegistrationApiV2.SvrCredentials
import org.signal.network.api.RegistrationApiV2.ThirdPartyServiceErrorResponse
import org.signal.network.api.RegistrationApiV2.UpdateSessionError
import org.signal.registration.KeyMaterial
import org.signal.registration.PendingRestoreOption
import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.RegisteredAccountData
import org.signal.registration.RegistrationFlowEvent
@@ -449,6 +450,38 @@ class PhoneNumberEntryViewModelTest {
.isInstanceOf<RegistrationRoute.LinkAccount>()
}
@Test
fun `RegisterWithoutNumber navigates to the Signal Login payment screen`() = runTest {
viewModel.applyEvent(
PhoneNumberEntryState(),
PhoneNumberEntryScreenEvents.RegisterWithoutNumber,
parentEventEmitter,
stateEmitter
)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.SignalLoginPayment>()
}
@Test
fun `RegisterWithoutNumber skips payment and goes straight to credential entry when a restore is pending`() = runTest {
viewModel.applyEvent(
PhoneNumberEntryState(pendingRestoreOption = PendingRestoreOption.RemoteBackup),
PhoneNumberEntryScreenEvents.RegisterWithoutNumber,
parentEventEmitter,
stateEmitter
)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isEqualTo(RegistrationRoute.SignalLoginCredentialEntry())
}
@Test
fun `initial state reflects repository link and sync availability`() = runTest {
every { mockRepository.isLinkAndSyncAvailable } returns true
@@ -25,6 +25,7 @@ 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.registration.PendingRestoreOption
import org.signal.registration.R
import org.signal.registration.screens.shared.AccountIdError
import org.signal.registration.test.TestTags
@@ -326,6 +327,42 @@ class PhoneNumberScreenTest {
}
}
@Test
fun `the numberless button offers to register without a number when there is no pending restore`() {
// Given
composeTestRule.setContent {
SignalTheme {
PhoneNumberScreen(
state = PhoneNumberEntryState(isPhoneNumberlessRegistrationAvailable = true),
onEvent = {}
)
}
}
// Then
composeTestRule.onNodeWithText(context.getString(R.string.RegistrationActivity_register_without_number)).assertExists()
}
@Test
fun `the numberless button offers to use an account ID when a restore is pending`() {
// Given
composeTestRule.setContent {
SignalTheme {
PhoneNumberScreen(
state = PhoneNumberEntryState(
isPhoneNumberlessRegistrationAvailable = true,
pendingRestoreOption = PendingRestoreOption.RemoteBackup
),
onEvent = {}
)
}
}
// Then
composeTestRule.onNodeWithText(context.getString(R.string.RegistrationActivity_use_account_id)).assertExists()
composeTestRule.onNodeWithText(context.getString(R.string.RegistrationActivity_register_without_number)).assertDoesNotExist()
}
private fun accountIdState(accountId: String = "a6b284822e3283d07f2391360a4c2b91") = PhoneNumberEntryState(
accountId = accountId,
formattedNumber = accountId,