Auto-populate phone number in regV5.

This commit is contained in:
Greyson Parrelli 2026-06-24 10:40:53 -04:00 committed by jeffrey-signal
parent 8615dfc463
commit 9cbe204141
No known key found for this signature in database
8 changed files with 357 additions and 62 deletions

View File

@ -79,6 +79,9 @@ dependencies {
// Phone number formatting
implementation(libs.google.libphonenumber)
// Phone number hint
implementation(libs.google.play.services.auth)
// Testing
testImplementation(testFixtures(project(":core:ui")))
testImplementation(testLibs.junit.junit)

View File

@ -5,6 +5,13 @@
package org.signal.registration.screens.phonenumber
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -36,6 +43,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -44,6 +53,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@ -54,6 +64,10 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.google.android.gms.auth.api.identity.GetPhoneNumberHintIntentRequest
import com.google.android.gms.auth.api.identity.Identity
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
@ -61,6 +75,8 @@ import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.IconButtons.IconButton
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.registration.R
import org.signal.registration.screens.OnePaneRegistrationScaffold
import org.signal.registration.screens.RegistrationScaffold
@ -70,6 +86,26 @@ import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTime
import org.signal.registration.test.TestTags
import org.signal.core.ui.R as CoreR
private const val TAG = "PhoneNumberScreen"
/**
* Reads the device's own phone number from the SIM as an E164 string, but only if the relevant phone permission has
* already been granted. Returns null if the permission is missing or the number is unavailable. We never prompt for
* the permission solely to prefill the number.
*/
@SuppressLint("MissingPermission")
private fun readDeviceNumberE164(context: Context): String? {
val hasPhonePermission = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_NUMBERS) == PackageManager.PERMISSION_GRANTED
if (!hasPhonePermission) {
return null
}
val deviceNumber = Util.getDeviceNumber(context).orElse(null) ?: return null
return PhoneNumberUtil.getInstance().format(deviceNumber, PhoneNumberUtil.PhoneNumberFormat.E164)
}
/**
* Phone number entry screen
*/
@ -80,7 +116,58 @@ fun PhoneNumberScreen(
modifier: Modifier = Modifier
) {
val resources = LocalResources.current
val context = LocalContext.current
var simpleErrorMessage: String? by remember { mutableStateOf(null) }
var hasRequestedPhoneNumberHint by rememberSaveable { mutableStateOf(false) }
val currentNationalNumber by rememberUpdatedState(state.nationalNumber)
val prefillFromDeviceNumberIfAllowed = {
if (currentNationalNumber.isEmpty()) {
readDeviceNumberE164(context)?.let { e164 ->
onEvent(PhoneNumberEntryScreenEvents.FullPhoneNumberEntered(e164))
}
}
}
val phoneNumberHintLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
val phoneNumber = try {
Identity.getSignInClient(context).getPhoneNumberFromIntent(result.data)
} catch (e: Exception) {
Log.w(TAG, "Failed to retrieve phone number from hint.", e)
null
}
if (phoneNumber != null) {
onEvent(PhoneNumberEntryScreenEvents.FullPhoneNumberEntered(phoneNumber, autoConfirm = true))
}
}
LaunchedEffect(state.initialized) {
if (!state.initialized || hasRequestedPhoneNumberHint || state.nationalNumber.isNotEmpty() || state.preExistingRegistrationData != null) {
return@LaunchedEffect
}
hasRequestedPhoneNumberHint = true
try {
Identity.getSignInClient(context)
.getPhoneNumberHintIntent(GetPhoneNumberHintIntentRequest.builder().build())
.addOnSuccessListener { pendingIntent ->
try {
phoneNumberHintLauncher.launch(IntentSenderRequest.Builder(pendingIntent).build())
} catch (e: Exception) {
Log.w(TAG, "Failed to launch phone number hint intent.", e)
prefillFromDeviceNumberIfAllowed()
}
}
.addOnFailureListener { e ->
Log.w(TAG, "Phone number hint unavailable. Falling back to device number.", e)
prefillFromDeviceNumberIfAllowed()
}
} catch (e: Exception) {
Log.w(TAG, "Unable to request phone number hint. Falling back to device number.", e)
prefillFromDeviceNumberIfAllowed()
}
}
if (state.showDialog) {
Dialogs.SimpleAlertDialog(
@ -88,7 +175,7 @@ fun PhoneNumberScreen(
body = "+${state.countryCode} ${state.formattedNumber}\n\n${stringResource(R.string.RegistrationActivity_a_verification_code)}",
confirm = stringResource(id = android.R.string.ok),
dismiss = stringResource(R.string.RegistrationActivity_edit_number),
onConfirm = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) },
onConfirm = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberConfirmed) },
onDismiss = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberCancelled) }
)
}
@ -169,8 +256,8 @@ private fun OnePaneLayout(
countryCode = state.countryCode,
formattedNumber = state.formattedNumber,
onCountryCodeChanged = { onEvent(PhoneNumberEntryScreenEvents.CountryCodeChanged(it)) },
onPhoneNumberChanged = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberChanged(it)) },
onPhoneNumberEntered = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberEntered) },
onPhoneNumberChanged = { onEvent(PhoneNumberEntryScreenEvents.NationalNumberChanged(it)) },
onPhoneNumberSubmitted = { onEvent(PhoneNumberEntryScreenEvents.NextClicked) },
modifier = Modifier.fillMaxWidth()
)
}
@ -237,8 +324,8 @@ private fun TwoPaneLayout(
countryCode = state.countryCode,
formattedNumber = state.formattedNumber,
onCountryCodeChanged = { onEvent(PhoneNumberEntryScreenEvents.CountryCodeChanged(it)) },
onPhoneNumberChanged = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberChanged(it)) },
onPhoneNumberEntered = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberEntered) },
onPhoneNumberChanged = { onEvent(PhoneNumberEntryScreenEvents.NationalNumberChanged(it)) },
onPhoneNumberSubmitted = { onEvent(PhoneNumberEntryScreenEvents.NextClicked) },
modifier = Modifier.fillMaxWidth()
)
}
@ -332,7 +419,7 @@ private fun NextButton(
verticalAlignment = Alignment.CenterVertically
) {
Buttons.LargeTonal(
onClick = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberEntered) },
onClick = { onEvent(PhoneNumberEntryScreenEvents.NextClicked) },
enabled = !state.showSpinner && state.isNumberPossible,
modifier = Modifier.testTag(TestTags.PHONE_NUMBER_NEXT_BUTTON)
) {
@ -410,7 +497,7 @@ private fun PhoneNumberInputFields(
formattedNumber: String,
onCountryCodeChanged: (String) -> Unit,
onPhoneNumberChanged: (String) -> Unit,
onPhoneNumberEntered: () -> Unit,
onPhoneNumberSubmitted: () -> Unit,
modifier: Modifier = Modifier
) {
var phoneNumberTextFieldValue by remember { mutableStateOf(TextFieldValue(formattedNumber)) }
@ -499,7 +586,7 @@ private fun PhoneNumberInputFields(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onPhoneNumberEntered() }
onDone = { onPhoneNumberSubmitted() }
),
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(

View File

@ -9,12 +9,32 @@ import org.signal.core.util.censor
import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreResult
sealed class PhoneNumberEntryScreenEvents {
/** The phone country code prefix (i.e. +1) was changed by the user. */
data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents()
data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents()
/** The national number (basically the number without the country code) was changed by the user. */
data class NationalNumberChanged(val value: String) : PhoneNumberEntryScreenEvents()
/** The user changed the country via the country picker. */
data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents()
data object PhoneNumberEntered : PhoneNumberEntryScreenEvents()
/**
* The user entered their full number all at once. This could be from the Google Play picker or autofilled using READ_PHONE_STATE permission.
*
* @param autoConfirm If true, we trust the entry enough to skip straight to the confirmation dialog (as if the user had clicked 'next').
*/
data class FullPhoneNumberEntered(val e164: String, val autoConfirm: Boolean = false) : PhoneNumberEntryScreenEvents()
/** The user clicked the 'next' button. */
data object NextClicked : PhoneNumberEntryScreenEvents()
/** The user dismissed or otherwise canceled the dialog that was shown to confirm their phone number. */
data object PhoneNumberCancelled : PhoneNumberEntryScreenEvents()
data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents()
/** The user confirmed their phone number in the dialog. */
data object PhoneNumberConfirmed : PhoneNumberEntryScreenEvents()
/** The user requested to open the country picker. */
data object CountryPicker : PhoneNumberEntryScreenEvents()
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents() {
override fun toString(): String = "CaptchaCompleted(token=${token.censor()})"

View File

@ -27,9 +27,10 @@ data class PhoneNumberEntryState(
val oneTimeEvent: OneTimeEvent? = null,
val preExistingRegistrationData: PreExistingRegistrationData? = null,
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList(),
val pendingRestoreOption: PendingRestoreOption? = null
val pendingRestoreOption: PendingRestoreOption? = null,
val initialized: Boolean = false
) {
override fun toString(): String = "PhoneNumberEntryState(regionCode=$regionCode, countryCode=$countryCode, countryName=$countryName, countryEmoji=$countryEmoji, nationalNumber=$nationalNumber, formattedNumber=$formattedNumber, sessionE164=$sessionE164, sessionMetadata=${sessionMetadata?.let { "present" }}, showSpinner=$showSpinner, showDialog=$showDialog, oneTimeEvent=$oneTimeEvent, preExistingRegistrationData=${preExistingRegistrationData?.let { "present" }}, restoredSvrCredentials=${restoredSvrCredentials.size} items, pendingRestoreOption=$pendingRestoreOption)"
override fun toString(): String = "PhoneNumberEntryState(regionCode=$regionCode, countryCode=$countryCode, countryName=$countryName, countryEmoji=$countryEmoji, nationalNumber=$nationalNumber, formattedNumber=$formattedNumber, sessionE164=$sessionE164, sessionMetadata=${sessionMetadata?.let { "present" }}, showSpinner=$showSpinner, showDialog=$showDialog, oneTimeEvent=$oneTimeEvent, preExistingRegistrationData=${preExistingRegistrationData?.let { "present" }}, restoredSvrCredentials=${restoredSvrCredentials.size} items, pendingRestoreOption=$pendingRestoreOption, initialized=$initialized)"
sealed interface OneTimeEvent {
data object NetworkError : OneTimeEvent

View File

@ -10,11 +10,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.google.i18n.phonenumbers.AsYouTypeFormatter
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@ -63,6 +65,14 @@ class PhoneNumberEntryViewModel(
restoredSvrCredentials = repository.getRestoredSvrCredentials()
)
setDefaultCountry()
parentState.firstOrNull()?.preExistingRegistrationData?.e164?.let { preExistingE164 ->
if (state.value.formattedNumber.isEmpty()) {
_state.value = applyFullPhoneNumberEntered(_state.value, preExistingE164)
}
}
_state.update { it.copy(initialized = true) }
}
}
@ -94,16 +104,20 @@ class PhoneNumberEntryViewModel(
is PhoneNumberEntryScreenEvents.CountrySelected -> {
stateEmitter(applyCountrySelected(state, event.countryCode, event.regionCode, event.countryName, event.countryEmoji))
}
is PhoneNumberEntryScreenEvents.PhoneNumberChanged -> {
is PhoneNumberEntryScreenEvents.FullPhoneNumberEntered -> {
val populatedState = applyFullPhoneNumberEntered(state, event.e164)
stateEmitter(populatedState.copy(showDialog = event.autoConfirm && populatedState.isNumberPossible))
}
is PhoneNumberEntryScreenEvents.NationalNumberChanged -> {
stateEmitter(applyPhoneNumberChanged(state, event.value))
}
is PhoneNumberEntryScreenEvents.PhoneNumberEntered -> {
is PhoneNumberEntryScreenEvents.NextClicked -> {
stateEmitter(state.copy(showDialog = true))
}
is PhoneNumberEntryScreenEvents.PhoneNumberCancelled -> {
stateEmitter(state.copy(showDialog = false))
}
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> {
is PhoneNumberEntryScreenEvents.PhoneNumberConfirmed -> {
var localState = state.copy(showSpinner = true, showDialog = false)
stateEmitter(localState)
localState = applyPhoneNumberSubmitted(localState, parentEventEmitter)
@ -167,6 +181,32 @@ class PhoneNumberEntryViewModel(
)
}
@VisibleForTesting
fun applyFullPhoneNumberEntered(state: PhoneNumberEntryState, e164: String): PhoneNumberEntryState {
val parsedNumber = try {
phoneNumberUtil.parse(e164, null)
} catch (e: NumberParseException) {
Log.w(TAG, "Failed to parse E164 used to populate phone number.", e)
return state
}
val countryCode = parsedNumber.countryCode
val nationalNumber = parsedNumber.nationalNumber.toString()
val regionCode = phoneNumberUtil.getRegionCodeForNumber(parsedNumber) ?: phoneNumberUtil.getRegionCodeForCountryCode(countryCode)
formatter = phoneNumberUtil.getAsYouTypeFormatter(regionCode)
val formattedNumber = formatNumber(nationalNumber)
return state.copy(
countryCode = countryCode.toString(),
regionCode = regionCode,
countryName = E164Util.getRegionDisplayName(regionCode).orElse(""),
countryEmoji = CountryUtils.countryToEmoji(regionCode).takeIf { regionCode != "ZZ" } ?: "",
nationalNumber = nationalNumber,
formattedNumber = formattedNumber
)
}
private fun applyCountryCodeChanged(state: PhoneNumberEntryState, countryCode: String): PhoneNumberEntryState {
// Only allow digits, max 3 characters
val sanitized = countryCode.filter { it.isDigit() }.take(3)

View File

@ -22,7 +22,9 @@ import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
@ -79,7 +81,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567"),
PhoneNumberEntryScreenEvents.NationalNumberChanged("555-123-4567"),
parentEventEmitter,
stateEmitter
)
@ -95,7 +97,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551234567"),
PhoneNumberEntryScreenEvents.NationalNumberChanged("5551234567"),
parentEventEmitter,
stateEmitter
)
@ -109,25 +111,25 @@ class PhoneNumberEntryViewModelTest {
fun `PhoneNumberChanged formats progressively as digits are added`() = runTest {
var state = PhoneNumberEntryState()
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5"), parentEventEmitter, stateEmitter)
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.NationalNumberChanged("5"), parentEventEmitter, stateEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("5")
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55"), parentEventEmitter, stateEmitter)
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.NationalNumberChanged("55"), parentEventEmitter, stateEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("55")
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555"), parentEventEmitter, stateEmitter)
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.NationalNumberChanged("555"), parentEventEmitter, stateEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("555")
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551"), parentEventEmitter, stateEmitter)
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.NationalNumberChanged("5551"), parentEventEmitter, stateEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("5551")
// libphonenumber formats progressively - at 4 digits it's still building the format
assertThat(state.formattedNumber).isEqualTo("555-1")
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55512"), parentEventEmitter, stateEmitter)
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.NationalNumberChanged("55512"), parentEventEmitter, stateEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("55512")
assertThat(state.formattedNumber).isEqualTo("555-12")
@ -139,7 +141,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.PhoneNumberChanged("(555) abc 123-4567!"),
PhoneNumberEntryScreenEvents.NationalNumberChanged("(555) abc 123-4567!"),
parentEventEmitter,
stateEmitter
)
@ -154,7 +156,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567"),
PhoneNumberEntryScreenEvents.NationalNumberChanged("555-123-4567"),
parentEventEmitter,
stateEmitter
)
@ -274,11 +276,143 @@ class PhoneNumberEntryViewModelTest {
assertThat(state.regionCode).isEqualTo("DE")
// Enter a German number
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("15123456789"), parentEventEmitter, stateEmitter)
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.NationalNumberChanged("15123456789"), parentEventEmitter, stateEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("15123456789")
}
// ==================== FullPhoneNumberEntered Tests ====================
@Test
fun `PhoneNumberHintSelected populates country and number from US E164`() = runTest {
viewModel.applyEvent(
PhoneNumberEntryState(),
PhoneNumberEntryScreenEvents.FullPhoneNumberEntered("+15551234567"),
parentEventEmitter,
stateEmitter
)
assertThat(emittedStates).hasSize(1)
val result = emittedStates.last()
assertThat(result.countryCode).isEqualTo("1")
assertThat(result.regionCode).isEqualTo("US")
assertThat(result.nationalNumber).isEqualTo("5551234567")
assertThat(result.formattedNumber).isEqualTo("(555) 123-4567")
}
@Test
fun `PhoneNumberHintSelected populates country and number from GB E164`() = runTest {
viewModel.applyEvent(
PhoneNumberEntryState(),
PhoneNumberEntryScreenEvents.FullPhoneNumberEntered("+442079460958"),
parentEventEmitter,
stateEmitter
)
assertThat(emittedStates).hasSize(1)
val result = emittedStates.last()
assertThat(result.countryCode).isEqualTo("44")
assertThat(result.regionCode).isEqualTo("GB")
assertThat(result.nationalNumber).isEqualTo("2079460958")
}
@Test
fun `PhoneNumberHintSelected leaves state unchanged for unparseable number`() = runTest {
val initialState = PhoneNumberEntryState(countryCode = "1", regionCode = "US")
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.FullPhoneNumberEntered("not-a-number"),
parentEventEmitter,
stateEmitter
)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last()).isEqualTo(initialState)
}
@Test
fun `FullPhoneNumberEntered with autoConfirm populates and opens the confirmation dialog`() = runTest {
viewModel.applyEvent(
PhoneNumberEntryState(),
PhoneNumberEntryScreenEvents.FullPhoneNumberEntered("+15551234567", autoConfirm = true),
parentEventEmitter,
stateEmitter
)
assertThat(emittedStates).hasSize(1)
val result = emittedStates.last()
assertThat(result.nationalNumber).isEqualTo("5551234567")
assertThat(result.showDialog).isTrue()
// We only open the dialog; we do not submit on our own.
assertThat(emittedEvents).isEmpty()
coVerify(exactly = 0) { mockRepository.createSession(any()) }
}
@Test
fun `FullPhoneNumberEntered with autoConfirm does not open dialog when number is not possible`() = runTest {
viewModel.applyEvent(
PhoneNumberEntryState(),
PhoneNumberEntryScreenEvents.FullPhoneNumberEntered("not-a-number", autoConfirm = true),
parentEventEmitter,
stateEmitter
)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().showDialog).isFalse()
assertThat(emittedEvents).isEmpty()
}
@Test
fun `FullPhoneNumberEntered without autoConfirm only populates and does not open dialog`() = runTest {
viewModel.applyEvent(
PhoneNumberEntryState(),
PhoneNumberEntryScreenEvents.FullPhoneNumberEntered("+15551234567", autoConfirm = false),
parentEventEmitter,
stateEmitter
)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
assertThat(emittedStates.last().showDialog).isFalse()
assertThat(emittedEvents).isEmpty()
}
// ==================== Pre-existing Registration Data Prefill Tests ====================
@Test
fun `prefills phone number from preExistingRegistrationData when number is empty`() = runTest {
val preExisting = mockk<PreExistingRegistrationData>(relaxed = true)
every { preExisting.e164 } returns "+15551234567"
val populatedParentState = MutableStateFlow(RegistrationFlowState(preExistingRegistrationData = preExisting))
val vm = PhoneNumberEntryViewModel(mockRepository, populatedParentState, parentEventEmitter)
val states = mutableListOf<PhoneNumberEntryState>()
val job = launch { vm.state.collect { states.add(it) } }
advanceUntilIdle()
job.cancel()
val latest = states.last()
assertThat(latest.nationalNumber).isEqualTo("5551234567")
assertThat(latest.countryCode).isEqualTo("1")
assertThat(latest.regionCode).isEqualTo("US")
}
@Test
fun `does not prefill phone number when there is no preExistingRegistrationData`() = runTest {
val emptyParentState = MutableStateFlow(RegistrationFlowState())
val vm = PhoneNumberEntryViewModel(mockRepository, emptyParentState, parentEventEmitter)
val states = mutableListOf<PhoneNumberEntryState>()
val job = launch { vm.state.collect { states.add(it) } }
advanceUntilIdle()
job.cancel()
assertThat(states.last().nationalNumber).isEmpty()
}
// ==================== PhoneNumberSubmitted Tests ====================
@Test
@ -295,7 +429,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -323,7 +457,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -350,7 +484,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -374,7 +508,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -393,7 +527,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -412,7 +546,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -433,7 +567,7 @@ class PhoneNumberEntryViewModelTest {
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
RequestResult.Success(existingSession)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -465,7 +599,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -490,7 +624,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -516,7 +650,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -543,7 +677,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -572,7 +706,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -606,7 +740,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -644,7 +778,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -677,7 +811,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -710,7 +844,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -742,7 +876,7 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Verify spinner states
assertThat(emittedStates.first().showSpinner).isTrue()
@ -916,7 +1050,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedEvents.first()).isInstanceOf<RegistrationFlowEvent.Registered>()
assertThat(emittedEvents[1])
@ -943,7 +1077,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedEvents.first()).isInstanceOf<RegistrationFlowEvent.Registered>()
assertThat(emittedEvents[1])
@ -970,7 +1104,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
}
@Test(expected = IllegalStateException::class)
@ -991,7 +1125,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
}
@Test
@ -1017,7 +1151,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
@ -1044,7 +1178,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
@ -1075,7 +1209,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Should emit RecoveryPasswordInvalid and then continue to session creation
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid)
@ -1106,7 +1240,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid)
assertThat(emittedStates.last().preExistingRegistrationData).isNull()
@ -1128,7 +1262,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
}
@ -1149,7 +1283,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
}
@ -1173,7 +1307,7 @@ class PhoneNumberEntryViewModelTest {
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Should skip RRP and go to session creation flow
coVerify(exactly = 0) { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) }
@ -1204,7 +1338,7 @@ class PhoneNumberEntryViewModelTest {
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedEvents).hasSize(2)
assertThat(emittedEvents[0]).isInstanceOf<RegistrationFlowEvent.E164Chosen>()
@ -1237,7 +1371,7 @@ class PhoneNumberEntryViewModelTest {
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Should fall through to session creation
assertThat(emittedEvents.last())
@ -1266,7 +1400,7 @@ class PhoneNumberEntryViewModelTest {
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
// Should ignore error and fall through
assertThat(emittedEvents.last())
@ -1295,7 +1429,7 @@ class PhoneNumberEntryViewModelTest {
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
@ -1325,7 +1459,7 @@ class PhoneNumberEntryViewModelTest {
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
@ -1355,7 +1489,7 @@ class PhoneNumberEntryViewModelTest {
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
@ -1378,7 +1512,7 @@ class PhoneNumberEntryViewModelTest {
restoredSvrCredentials = emptyList()
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
coVerify(exactly = 0) { mockRepository.checkSvrCredentials(any(), any()) }
assertThat(emittedEvents.last())

View File

@ -94,7 +94,7 @@ class PhoneNumberScreenTest {
composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).performClick()
// Then
assert(emittedEvent is PhoneNumberEntryScreenEvents.PhoneNumberEntered) {
assert(emittedEvent is PhoneNumberEntryScreenEvents.NextClicked) {
"Expected PhoneNumberEntered event but got $emittedEvent"
}
}

View File

@ -20302,6 +20302,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="f9522095aedcc2a6ab32c7484061ea698352c71be1390adb403b59aa48a38fdc" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core" version="1.8.0">
<artifact name="kotlinx-coroutines-core-1.8.0.module">
<sha256 value="144eecd5365de3e30d7b46226c058051e39955b5c189e31fa4c7cffb99d620ba" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core" version="1.8.1">
<artifact name="kotlinx-coroutines-core-1.8.1.module">
<md5 value="feabc877484a8ccfdb34cf23510daa06" origin="Generated by Gradle"/>
@ -20425,6 +20430,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="da262c1a35229c46c0b2e055e82406de860ce1a42659a3a17f932c7ab39550ff" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-play-services" version="1.8.0">
<artifact name="kotlinx-coroutines-play-services-1.8.0.module">
<sha256 value="e6ca6a18c12ac757a5c292e41d089813ba45553bea46d0677f03af29b9e9f047" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-play-services" version="1.9.0">
<artifact name="kotlinx-coroutines-play-services-1.9.0.jar">
<md5 value="d8f944d026f0960302df81f46d2fdc3d" origin="Generated by Gradle"/>