From 7a2c084b495c11bb04584232ed05ad92164aaa69 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 22 Jul 2026 12:47:56 -0400 Subject: [PATCH] Remove unnecessary leading digits in phone number. --- .../phonenumber/PhoneNumberEntryViewModel.kt | 35 +++++++++- .../PhoneNumberEntryViewModelTest.kt | 67 +++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index 2491eb5b86..5f8fe4fece 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -117,7 +117,8 @@ class PhoneNumberEntryViewModel( stateEmitter(applyPhoneNumberChanged(state, event.oldValue, event.newValue)) } is PhoneNumberEntryScreenEvents.NextClicked -> { - stateEmitter(state.copy(dialogs = state.dialogs.copy(confirmNumber = true))) + val normalized = state.withNormalizedNationalNumber() + stateEmitter(normalized.copy(dialogs = normalized.dialogs.copy(confirmNumber = true))) } is PhoneNumberEntryScreenEvents.PhoneNumberCancelled -> { stateEmitter(state.copy(dialogs = state.dialogs.copy(confirmNumber = false))) @@ -275,8 +276,8 @@ class PhoneNumberEntryViewModel( inputState: PhoneNumberEntryState, parentEventEmitter: (RegistrationFlowEvent) -> Unit ): PhoneNumberEntryState { - val e164 = "+${inputState.countryCode}${inputState.nationalNumber}" - var state = inputState.copy() + var state = inputState.withNormalizedNationalNumber() + val e164 = "+${state.countryCode}${state.nationalNumber}" // If the user selected a restore option before entering their phone number, navigate to the restore flow if (state.pendingRestoreOption != null) { @@ -891,6 +892,34 @@ class PhoneNumberEntryViewModel( return (new.length - prefix - suffix).coerceAtLeast(0) } + /** + * Strips a redundant national trunk prefix from the national number (e.g. the leading 0 a Dutch user habitually + * types in "0612345678"), which would otherwise produce a wrong E164 when naively concatenated with the country + * code (+310612345678 instead of +31612345678). Leading zeros that are a significant part of the number (e.g. + * Italian landlines) are preserved. + */ + private fun PhoneNumberEntryState.withNormalizedNationalNumber(): PhoneNumberEntryState { + if (countryCode.isEmpty() || nationalNumber.isEmpty()) { + return this + } + + val parsedNumber = try { + phoneNumberUtil.parse("+$countryCode$nationalNumber", null) + } catch (_: NumberParseException) { + return this + } + + val significantNumber = phoneNumberUtil.getNationalSignificantNumber(parsedNumber) + if (significantNumber == nationalNumber || parsedNumber.countryCode.toString() != countryCode) { + return this + } + + return copy( + nationalNumber = significantNumber, + formattedNumber = formatNumber(significantNumber) + ).withNumberValidity() + } + /** * Recomputes [PhoneNumberEntryState.isNumberPossible] and [PhoneNumberEntryState.isNumberInvalid] from the current * country code and national number. Should be applied to any state that changes either of those fields. diff --git a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt index b69a87fb01..560025ac98 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt @@ -485,6 +485,73 @@ class PhoneNumberEntryViewModelTest { assertThat(state.nationalNumber).isEqualTo("15123456789") } + // ==================== Trunk Prefix Normalization Tests ==================== + + @Test + fun `NextClicked strips a redundant leading trunk prefix before showing the confirmation dialog`() = runTest { + // Dutch users habitually type their number with the leading national '0' (e.g. 0612345678), which must not end + // up in the E164 (+31612345678, not +310612345678). + val initialState = PhoneNumberEntryState(regionCode = "NL", countryCode = "31", nationalNumber = "0612345678", formattedNumber = "06 12345678") + + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.NextClicked, parentEventEmitter, stateEmitter) + + assertThat(emittedStates).hasSize(1) + val result = emittedStates.last() + assertThat(result.nationalNumber).isEqualTo("612345678") + assertThat(result.countryCode).isEqualTo("31") + assertThat(result.isNumberPossible).isTrue() + assertThat(result.dialogs.confirmNumber).isTrue() + } + + @Test + fun `NextClicked leaves a number without a trunk prefix unchanged`() = runTest { + val initialState = PhoneNumberEntryState(regionCode = "US", countryCode = "1", nationalNumber = "5551234567", formattedNumber = "(555) 123-4567") + + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.NextClicked, parentEventEmitter, stateEmitter) + + assertThat(emittedStates).hasSize(1) + val result = emittedStates.last() + assertThat(result.nationalNumber).isEqualTo("5551234567") + assertThat(result.formattedNumber).isEqualTo("(555) 123-4567") + assertThat(result.dialogs.confirmNumber).isTrue() + } + + @Test + fun `NextClicked preserves a leading zero that is a significant part of the number`() = runTest { + // Italian landlines include the leading zero as part of the number itself, so it must not be stripped. + val initialState = PhoneNumberEntryState(regionCode = "IT", countryCode = "39", nationalNumber = "0612345678", formattedNumber = "06 1234 5678") + + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.NextClicked, parentEventEmitter, stateEmitter) + + assertThat(emittedStates).hasSize(1) + val result = emittedStates.last() + assertThat(result.nationalNumber).isEqualTo("0612345678") + assertThat(result.dialogs.confirmNumber).isTrue() + } + + @Test + fun `PhoneNumberConfirmed submits the E164 without a redundant leading trunk prefix`() = runTest { + val sessionMetadata = createSessionMetadata(requestedInformation = emptyList()) + + coEvery { mockRepository.createSession(any()) } returns + RequestResult.Success(sessionMetadata) + coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns + RequestResult.Success(sessionMetadata) + + val initialState = PhoneNumberEntryState( + regionCode = "NL", + countryCode = "31", + nationalNumber = "0612345678" + ) + + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter) + + coVerify(exactly = 1) { mockRepository.createSession("+31612345678") } + assertThat(emittedStates.last().nationalNumber).isEqualTo("612345678") + assertThat(emittedEvents.filterIsInstance()) + .isEqualTo(listOf(RegistrationFlowEvent.E164Chosen("+31612345678"))) + } + // ==================== FullPhoneNumberEntered Tests ==================== @Test