Improve UI for regV5 verification code submission.

This commit is contained in:
Greyson Parrelli
2026-02-13 19:32:02 -05:00
committed by Cody Henthorne
parent 376cb926b0
commit 7428e1e2ea
17 changed files with 970 additions and 422 deletions

View File

@@ -69,7 +69,7 @@ fun NetworkDebugOverlay(
onClick = { showDialog = true },
dragOffset = dragOffset,
onDrag = { delta -> dragOffset += delta },
modifier = Modifier.align(Alignment.BottomEnd)
modifier = Modifier.align(Alignment.CenterEnd)
)
if (showDialog) {

View File

@@ -5,6 +5,9 @@
package org.signal.registration.screens.phonenumber
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -14,13 +17,14 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -31,16 +35,23 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
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 org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.CircularProgressWrapper
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
import org.signal.registration.test.TestTags
@@ -78,10 +89,6 @@ fun PhoneNumberScreen(
Box(modifier = modifier.fillMaxSize().testTag(TestTags.PHONE_NUMBER_SCREEN)) {
ScreenContent(state, onEvent)
if (state.showFullScreenSpinner) {
Dialogs.IndeterminateProgressDialog()
}
}
}
@@ -90,23 +97,170 @@ private fun ScreenContent(state: PhoneNumberEntryState, onEvent: (PhoneNumberEnt
val selectedCountry = state.countryName
val selectedCountryEmoji = state.countryEmoji
// Track the phone number text field value with cursor position
var phoneNumberTextFieldValue by remember { mutableStateOf(TextFieldValue(state.formattedNumber)) }
val scrollState = rememberScrollState()
// Update the text field value when state.formattedNumber changes, preserving cursor position
LaunchedEffect(state.formattedNumber) {
if (phoneNumberTextFieldValue.text != state.formattedNumber) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
) {
// Toolbar spacer (matching the Toolbar height in the XML)
Spacer(modifier = Modifier.height(56.dp))
// Title - "Phone number"
Text(
text = stringResource(R.string.RegistrationActivity_phone_number),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.height(16.dp))
// Subtitle - "You will receive a verification code..."
Text(
text = stringResource(R.string.RegistrationActivity_you_will_receive_a_verification_code),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.height(36.dp))
// Country Picker - styled with surfaceVariant background and outline bottom border
CountryPicker(
emoji = selectedCountryEmoji,
country = selectedCountry,
onClick = { onEvent(PhoneNumberEntryScreenEvents.CountryPicker) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.testTag(TestTags.PHONE_NUMBER_COUNTRY_PICKER)
)
Spacer(modifier = Modifier.height(16.dp))
PhoneNumberInputFields(
countryCode = state.countryCode,
formattedNumber = state.formattedNumber,
onCountryCodeChanged = { onEvent(PhoneNumberEntryScreenEvents.CountryCodeChanged(it)) },
onPhoneNumberChanged = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberChanged(it)) },
onPhoneNumberSubmitted = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.weight(1f))
// Bottom row with the next/spinner button aligned to end
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressWrapper(
isLoading = state.showSpinner
) {
Buttons.LargeTonal(
onClick = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) },
enabled = state.countryCode.isNotEmpty() && state.nationalNumber.isNotEmpty(),
modifier = Modifier.testTag(TestTags.PHONE_NUMBER_NEXT_BUTTON)
) {
Text(stringResource(R.string.RegistrationActivity_next))
}
}
}
}
}
/**
* Country picker row styled to match the XML layout:
* - surfaceVariant background with outline bottom border
* - Rounded top corners (8dp outline, 4dp inner)
* - Country emoji, country name, and dropdown triangle
*/
@Composable
private fun CountryPicker(
emoji: String,
country: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
.background(MaterialTheme.colorScheme.outline)
.padding(bottom = 1.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)
)
.clickable(onClick = onClick)
.height(56.dp)
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(start = 16.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = emoji,
fontSize = 24.sp
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = country,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
DropdownTriangle(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
}
}
}
/**
* Phone number input fields containing the country code and phone number text fields.
* Handles cursor position preservation when the formatted number changes.
*/
@Composable
private fun PhoneNumberInputFields(
countryCode: String,
formattedNumber: String,
onCountryCodeChanged: (String) -> Unit,
onPhoneNumberChanged: (String) -> Unit,
onPhoneNumberSubmitted: () -> Unit,
modifier: Modifier = Modifier
) {
// Track the phone number text field value with cursor position
var phoneNumberTextFieldValue by remember { mutableStateOf(TextFieldValue(formattedNumber)) }
// Update the text field value when formattedNumber changes, preserving cursor position
LaunchedEffect(formattedNumber) {
if (phoneNumberTextFieldValue.text != formattedNumber) {
// Calculate cursor position: count digits before cursor in old text,
// then find position with same digit count in new text
val oldText = phoneNumberTextFieldValue.text
val oldCursorPos = phoneNumberTextFieldValue.selection.end
val digitsBeforeCursor = oldText.take(oldCursorPos).count { it.isDigit() }
val newText = state.formattedNumber
var digitCount = 0
var newCursorPos = newText.length
for (i in newText.indices) {
if (newText[i].isDigit()) {
var newCursorPos = formattedNumber.length
for (i in formattedNumber.indices) {
if (formattedNumber[i].isDigit()) {
digitCount++
}
if (digitCount >= digitsBeforeCursor) {
@@ -116,140 +270,92 @@ private fun ScreenContent(state: PhoneNumberEntryState, onEvent: (PhoneNumberEnt
}
phoneNumberTextFieldValue = TextFieldValue(
text = newText,
text = formattedNumber,
selection = TextRange(newCursorPos)
)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.Start
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
// Title
Text(
text = "Phone number",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Subtitle
Text(
text = "You will receive a verification code",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(36.dp))
// Country Picker Button
OutlinedButton(
onClick = {
onEvent(PhoneNumberEntryScreenEvents.CountryPicker)
},
// Country code field
OutlinedTextField(
value = countryCode,
onValueChange = onCountryCodeChanged,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.testTag(TestTags.PHONE_NUMBER_COUNTRY_PICKER)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
.width(76.dp)
.testTag(TestTags.PHONE_NUMBER_COUNTRY_CODE_FIELD),
prefix = {
Text(
text = selectedCountryEmoji,
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = selectedCountry,
text = "+",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Icon(
painter = painterResource(android.R.drawable.arrow_down_float),
contentDescription = "Select country",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// Phone number input fields
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(20.dp)
) {
// Country code field
OutlinedTextField(
value = state.countryCode,
onValueChange = { onEvent(PhoneNumberEntryScreenEvents.CountryCodeChanged(it)) },
modifier = Modifier
.width(76.dp)
.testTag(TestTags.PHONE_NUMBER_COUNTRY_CODE_FIELD),
leadingIcon = {
Text(
text = "+",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
singleLine = true
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
)
// Phone number field
OutlinedTextField(
value = phoneNumberTextFieldValue,
onValueChange = { newValue ->
phoneNumberTextFieldValue = newValue
onEvent(PhoneNumberEntryScreenEvents.PhoneNumberChanged(newValue.text))
},
modifier = Modifier
.weight(1f)
.testTag(TestTags.PHONE_NUMBER_PHONE_FIELD),
placeholder = {
Text("Phone number")
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
}
),
singleLine = true
)
}
Spacer(modifier = Modifier.width(20.dp))
Spacer(modifier = Modifier.weight(1f))
// Next button
Button(
onClick = {
onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
// Phone number field
OutlinedTextField(
value = phoneNumberTextFieldValue,
onValueChange = { newValue ->
phoneNumberTextFieldValue = newValue
onPhoneNumberChanged(newValue.text)
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.testTag(TestTags.PHONE_NUMBER_NEXT_BUTTON),
enabled = state.countryCode.isNotEmpty() && state.nationalNumber.isNotEmpty()
) {
Text("Next")
.weight(1f)
.testTag(TestTags.PHONE_NUMBER_PHONE_FIELD),
label = {
Text(stringResource(R.string.RegistrationActivity_phone_number_description))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onPhoneNumberSubmitted() }
),
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
)
)
}
}
/**
* Simple dropdown triangle icon matching the symbol_dropdown_triangle_24 vector drawable.
*/
@Composable
private fun DropdownTriangle(
tint: Color,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier) {
val w = size.width
val h = size.height
val path = Path().apply {
// Triangle pointing down, centered in the 18x24 viewport
val scaleX = w / 18f
val scaleY = h / 24f
moveTo(5.2f * scaleX, 9.5f * scaleY)
lineTo(12.8f * scaleX, 9.5f * scaleY)
lineTo(9f * scaleX, 14.95f * scaleY)
close()
}
drawPath(path, tint)
}
}
@@ -269,7 +375,7 @@ private fun PhoneNumberScreenPreview() {
private fun PhoneNumberScreenSpinnerPreview() {
Previews.Preview {
PhoneNumberScreen(
state = PhoneNumberEntryState(showFullScreenSpinner = true),
state = PhoneNumberEntryState(showSpinner = true),
onEvent = {}
)
}

View File

@@ -19,7 +19,7 @@ data class PhoneNumberEntryState(
val formattedNumber: String = "",
val sessionE164: String? = null,
val sessionMetadata: SessionMetadata? = null,
val showFullScreenSpinner: Boolean = false,
val showSpinner: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null,
val preExistingRegistrationData: PreExistingRegistrationData? = null,
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList()

View File

@@ -46,7 +46,7 @@ class PhoneNumberEntryViewModel(
val state = _state
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.Eagerly, PhoneNumberEntryState())
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PhoneNumberEntryState())
init {
viewModelScope.launch {
@@ -78,10 +78,10 @@ class PhoneNumberEntryViewModel(
stateEmitter(applyPhoneNumberChanged(state, event.value))
}
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> {
var localState = state.copy(showFullScreenSpinner = true)
var localState = state.copy(showSpinner = true)
stateEmitter(localState)
localState = applyPhoneNumberSubmitted(localState, parentEventEmitter)
stateEmitter(localState.copy(showFullScreenSpinner = false))
stateEmitter(localState.copy(showSpinner = false))
}
is PhoneNumberEntryScreenEvents.CountryPicker -> {
state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) }

View File

@@ -48,7 +48,7 @@ class PinCreationViewModel(
val state: StateFlow<PinCreationState> = _state
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.Eagerly, PinCreationState(inputLabel = "PIN must be at least 4 digits"))
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinCreationState(inputLabel = "PIN must be at least 4 digits"))
fun onEvent(event: PinCreationScreenEvents) {
viewModelScope.launch {

View File

@@ -50,7 +50,7 @@ class PinEntryForRegistrationLockViewModel(
val state: StateFlow<PinEntryState> = _state
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.Eagerly, PinEntryState(showNeedHelp = true))
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) {
viewModelScope.launch {

View File

@@ -54,7 +54,7 @@ class PinEntryForSmsBypassViewModel(
val state: StateFlow<PinEntryState> = _state
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.Eagerly, PinEntryState(showNeedHelp = true))
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) {
viewModelScope.launch {

View File

@@ -47,7 +47,7 @@ class PinEntryForSvrRestoreViewModel(
val state: StateFlow<PinEntryState> = _state
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.Eagerly, PinEntryState(showNeedHelp = true))
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) {
viewModelScope.launch {

View File

@@ -6,6 +6,7 @@
package org.signal.registration.screens.verificationcode
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -13,10 +14,19 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -29,17 +39,27 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.test.TestTags
import kotlin.time.Duration.Companion.seconds
/**
* Verification code entry screen for the registration flow.
* Displays a 6-digit code input in XXX-XXX format.
* Displays a 6-digit code input in XXX-XXX format with countdown buttons
* for resend SMS and call me actions.
*/
@Composable
fun VerificationCodeScreen(
@@ -49,177 +69,372 @@ fun VerificationCodeScreen(
) {
var digits by remember { mutableStateOf(List(6) { "" }) }
val focusRequesters = remember { List(6) { FocusRequester() } }
val scrollState = rememberScrollState()
val snackbarHostState = remember { SnackbarHostState() }
// Preload error strings for use in LaunchedEffect
val incorrectCodeMsg = stringResource(R.string.VerificationCodeScreen__incorrect_code)
val networkErrorMsg = stringResource(R.string.VerificationCodeScreen__network_error)
val unknownErrorMsg = stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred)
val smsProviderErrorMsg = stringResource(R.string.VerificationCodeScreen__sms_provider_error)
val transportErrorMsg = stringResource(R.string.VerificationCodeScreen__could_not_send_code_via_selected_method)
val registrationErrorMsg = stringResource(R.string.VerificationCodeScreen__registration_error)
// Preformat the rate-limited message template
val rateLimitedEvent = state.oneTimeEvent as? VerificationCodeState.OneTimeEvent.RateLimited
val rateLimitedMsg = if (rateLimitedEvent != null) {
stringResource(R.string.VerificationCodeScreen__too_many_attempts_try_again_in_s, rateLimitedEvent.retryAfter.toString())
} else {
""
}
// Countdown timer effect - emits CountdownTick every second while timers are active
LaunchedEffect(state.rateLimits) {
if (state.rateLimits.smsResendTimeRemaining > 0.seconds || state.rateLimits.callRequestTimeRemaining > 0.seconds) {
while (true) {
delay(1000)
onEvent(VerificationCodeScreenEvents.CountdownTick)
}
}
}
// Auto-submit when all digits are entered
LaunchedEffect(digits) {
if (digits.all { it.isNotEmpty() }) {
if (digits.all { it.isNotEmpty() } && !state.isSubmittingCode) {
val code = digits.joinToString("")
onEvent(VerificationCodeScreenEvents.CodeEntered(code))
}
}
// Handle one-time events — handle first, then consume
LaunchedEffect(state.oneTimeEvent) {
val event = state.oneTimeEvent ?: return@LaunchedEffect
when (event) {
VerificationCodeState.OneTimeEvent.IncorrectVerificationCode -> {
digits = List(6) { "" }
focusRequesters[0].requestFocus()
snackbarHostState.showSnackbar(incorrectCodeMsg)
}
VerificationCodeState.OneTimeEvent.NetworkError -> {
snackbarHostState.showSnackbar(networkErrorMsg)
}
is VerificationCodeState.OneTimeEvent.RateLimited -> {
snackbarHostState.showSnackbar(rateLimitedMsg)
}
VerificationCodeState.OneTimeEvent.ThirdPartyError -> {
snackbarHostState.showSnackbar(smsProviderErrorMsg)
}
VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> {
snackbarHostState.showSnackbar(transportErrorMsg)
}
VerificationCodeState.OneTimeEvent.UnknownError -> {
snackbarHostState.showSnackbar(unknownErrorMsg)
}
VerificationCodeState.OneTimeEvent.RegistrationError -> {
snackbarHostState.showSnackbar(registrationErrorMsg)
}
}
onEvent(VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent)
when (state.oneTimeEvent) {
VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> { }
VerificationCodeState.OneTimeEvent.IncorrectVerificationCode -> { }
VerificationCodeState.OneTimeEvent.NetworkError -> { }
is VerificationCodeState.OneTimeEvent.RateLimited -> { }
VerificationCodeState.OneTimeEvent.ThirdPartyError -> { }
VerificationCodeState.OneTimeEvent.UnknownError -> { }
VerificationCodeState.OneTimeEvent.RegistrationError -> { }
null -> { }
}
}
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(modifier = Modifier.height(48.dp))
Text(
text = "Enter verification code",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Enter the code we sent to ${state.e164}",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Code input fields - XXX-XXX format
Row(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.VERIFICATION_CODE_INPUT),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// First three digits
for (i in 0..2) {
DigitField(
value = digits[i],
onValueChange = { newValue ->
if (newValue.length <= 1 && (newValue.isEmpty() || newValue.all { it.isDigit() })) {
digits = digits.toMutableList().apply { this[i] = newValue }
if (newValue.isNotEmpty() && i < 5) {
focusRequesters[i + 1].requestFocus()
}
}
},
focusRequester = focusRequesters[i],
testTag = when (i) {
0 -> TestTags.VERIFICATION_CODE_DIGIT_0
1 -> TestTags.VERIFICATION_CODE_DIGIT_1
else -> TestTags.VERIFICATION_CODE_DIGIT_2
}
)
if (i < 2) {
Spacer(modifier = Modifier.width(4.dp))
}
}
// Separator
Text(
text = "-",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(horizontal = 8.dp)
)
// Last three digits
for (i in 3..5) {
if (i > 3) {
Spacer(modifier = Modifier.width(4.dp))
}
DigitField(
value = digits[i],
onValueChange = { newValue ->
if (newValue.length <= 1 && (newValue.isEmpty() || newValue.all { it.isDigit() })) {
digits = digits.toMutableList().apply { this[i] = newValue }
if (newValue.isNotEmpty() && i < 5) {
focusRequesters[i + 1].requestFocus()
}
}
},
focusRequester = focusRequesters[i],
testTag = when (i) {
3 -> TestTags.VERIFICATION_CODE_DIGIT_3
4 -> TestTags.VERIFICATION_CODE_DIGIT_4
else -> TestTags.VERIFICATION_CODE_DIGIT_5
}
)
}
}
Spacer(modifier = Modifier.height(32.dp))
TextButton(
onClick = { onEvent(VerificationCodeScreenEvents.WrongNumber) },
modifier = Modifier.testTag(TestTags.VERIFICATION_CODE_WRONG_NUMBER_BUTTON)
) {
Text("Wrong number?")
}
Spacer(modifier = Modifier.weight(1f))
TextButton(
onClick = { onEvent(VerificationCodeScreenEvents.ResendSms) },
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.VERIFICATION_CODE_RESEND_SMS_BUTTON)
) {
Text("Resend SMS")
}
TextButton(
onClick = { onEvent(VerificationCodeScreenEvents.CallMe) },
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.VERIFICATION_CODE_CALL_ME_BUTTON)
) {
Text("Call me instead")
}
}
// Auto-focus first field on initial composition
LaunchedEffect(Unit) {
focusRequesters[0].requestFocus()
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = modifier
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(scrollState)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(40.dp))
// Header
Text(
text = stringResource(R.string.VerificationCodeScreen__verification_code),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.Start)
)
Spacer(modifier = Modifier.height(16.dp))
// Subheader with phone number
Text(
text = stringResource(R.string.VerificationCodeScreen__enter_the_code_we_sent_to_s, state.e164),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.Start)
)
Spacer(modifier = Modifier.height(8.dp))
// Wrong number button - aligned to start like in XML
TextButton(
onClick = { onEvent(VerificationCodeScreenEvents.WrongNumber) },
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.Start)
.testTag(TestTags.VERIFICATION_CODE_WRONG_NUMBER_BUTTON)
) {
Text(
text = stringResource(R.string.VerificationCodeScreen__wrong_number),
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(32.dp))
// Code input with spinner overlay when submitting
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
// Code input fields - XXX-XXX format
Row(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.VERIFICATION_CODE_INPUT),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// First three digits
for (i in 0..2) {
DigitField(
value = digits[i],
onValueChange = { newValue, isBackspace ->
handleDigitChange(
index = i,
newValue = newValue,
isBackspace = isBackspace,
digits = digits,
focusRequesters = focusRequesters,
onDigitsChanged = { digits = it }
)
},
focusRequester = focusRequesters[i],
testTag = when (i) {
0 -> TestTags.VERIFICATION_CODE_DIGIT_0
1 -> TestTags.VERIFICATION_CODE_DIGIT_1
else -> TestTags.VERIFICATION_CODE_DIGIT_2
},
enabled = !state.isSubmittingCode
)
if (i < 2) {
Spacer(modifier = Modifier.width(4.dp))
}
}
// Separator
Text(
text = "-",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(horizontal = 8.dp),
color = if (state.isSubmittingCode) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) else MaterialTheme.colorScheme.onSurface
)
// Last three digits
for (i in 3..5) {
if (i > 3) {
Spacer(modifier = Modifier.width(4.dp))
}
DigitField(
value = digits[i],
onValueChange = { newValue, isBackspace ->
handleDigitChange(
index = i,
newValue = newValue,
isBackspace = isBackspace,
digits = digits,
focusRequesters = focusRequesters,
onDigitsChanged = { digits = it }
)
},
focusRequester = focusRequesters[i],
testTag = when (i) {
3 -> TestTags.VERIFICATION_CODE_DIGIT_3
4 -> TestTags.VERIFICATION_CODE_DIGIT_4
else -> TestTags.VERIFICATION_CODE_DIGIT_5
},
enabled = !state.isSubmittingCode
)
}
}
// Loading spinner overlay
if (state.isSubmittingCode) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Having trouble button - shown after 3 incorrect code attempts (matching old behavior)
if (state.shouldShowHavingTrouble()) {
TextButton(
onClick = { onEvent(VerificationCodeScreenEvents.HavingTrouble) },
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.testTag(TestTags.VERIFICATION_CODE_HAVING_TROUBLE_BUTTON)
) {
Text(
text = stringResource(R.string.VerificationCodeScreen__having_trouble),
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Bottom buttons - Resend SMS and Call Me side by side
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
// Resend SMS button with countdown — fits on one line if space allows, wraps if not
val canResendSms = state.canResendSms()
val disabledColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
TextButton(
onClick = { onEvent(VerificationCodeScreenEvents.ResendSms) },
enabled = canResendSms,
modifier = Modifier
.weight(1f)
.testTag(TestTags.VERIFICATION_CODE_RESEND_SMS_BUTTON)
) {
Text(
text = if (canResendSms) {
stringResource(R.string.VerificationCodeScreen__resend_code)
} else {
val totalSeconds = state.rateLimits.smsResendTimeRemaining.inWholeSeconds.toInt()
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
stringResource(R.string.VerificationCodeScreen__resend_code) + " " +
stringResource(R.string.VerificationCodeScreen__countdown_format, minutes, seconds)
},
color = if (canResendSms) MaterialTheme.colorScheme.primary else disabledColor,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.width(8.dp))
// Call Me button with inline countdown
val canRequestCall = state.canRequestCall()
TextButton(
onClick = { onEvent(VerificationCodeScreenEvents.CallMe) },
enabled = canRequestCall,
modifier = Modifier
.weight(1f)
.testTag(TestTags.VERIFICATION_CODE_CALL_ME_BUTTON)
) {
Text(
text = if (canRequestCall) {
stringResource(R.string.VerificationCodeScreen__call_me_instead)
} else {
val totalSeconds = state.rateLimits.callRequestTimeRemaining.inWholeSeconds.toInt()
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
stringResource(R.string.VerificationCodeScreen__call_me_available_in, minutes, seconds)
},
color = if (canRequestCall) MaterialTheme.colorScheme.primary else disabledColor,
textAlign = TextAlign.Center
)
}
}
}
}
}
/**
* Individual digit input field
* Handles digit input changes including navigation between fields and backspace handling.
*/
private fun handleDigitChange(
index: Int,
newValue: String,
isBackspace: Boolean,
digits: List<String>,
focusRequesters: List<FocusRequester>,
onDigitsChanged: (List<String>) -> Unit
) {
when {
// Handle backspace on empty field - move to previous field
isBackspace && newValue.isEmpty() && index > 0 -> {
val newDigits = digits.toMutableList().apply { this[index] = "" }
onDigitsChanged(newDigits)
focusRequesters[index - 1].requestFocus()
}
// Handle new digit input
newValue.length <= 1 && (newValue.isEmpty() || newValue.all { it.isDigit() }) -> {
val newDigits = digits.toMutableList().apply { this[index] = newValue }
onDigitsChanged(newDigits)
// Move to next field if digit entered and not last field
if (newValue.isNotEmpty() && index < 5) {
focusRequesters[index + 1].requestFocus()
}
}
}
}
/**
* Individual digit input field with backspace handling.
*/
@Composable
private fun DigitField(
value: String,
onValueChange: (String) -> Unit,
onValueChange: (String, Boolean) -> Unit,
focusRequester: FocusRequester,
testTag: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
onValueChange = { newValue ->
// Determine if this is a backspace (new value is empty and old value was not)
val isBackspace = newValue.isEmpty() && value.isNotEmpty()
onValueChange(newValue, isBackspace)
},
modifier = modifier
.width(44.dp)
.width(48.dp)
.focusRequester(focusRequester)
.testTag(testTag),
.testTag(testTag)
.onKeyEvent { keyEvent ->
// Handle hardware backspace key when field is empty
if (keyEvent.type == KeyEventType.KeyUp &&
(keyEvent.key == Key.Backspace || keyEvent.key == Key.Delete) &&
value.isEmpty()
) {
onValueChange("", true)
true
} else {
false
}
},
textStyle = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
enabled = enabled,
colors = OutlinedTextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
disabledBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
)
}
@@ -228,7 +443,40 @@ private fun DigitField(
private fun VerificationCodeScreenPreview() {
Previews.Preview {
VerificationCodeScreen(
state = VerificationCodeState(),
state = VerificationCodeState(
e164 = "+1 555-123-4567"
),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun VerificationCodeScreenWithCountdownPreview() {
Previews.Preview {
VerificationCodeScreen(
state = VerificationCodeState(
e164 = "+1 555-123-4567",
rateLimits = SmsAndCallRateLimits(
smsResendTimeRemaining = 45.seconds,
callRequestTimeRemaining = 64.seconds
)
),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun VerificationCodeScreenSubmittingPreview() {
Previews.Preview {
VerificationCodeScreen(
state = VerificationCodeState(
e164 = "+1 555-123-4567",
isSubmittingCode = true
),
onEvent = {}
)
}

View File

@@ -12,4 +12,9 @@ sealed class VerificationCodeScreenEvents {
data object CallMe : VerificationCodeScreenEvents()
data object HavingTrouble : VerificationCodeScreenEvents()
data object ConsumeInnerOneTimeEvent : VerificationCodeScreenEvents()
/**
* Event to update countdown timers. Should be triggered periodically (e.g., every second).
*/
data object CountdownTick : VerificationCodeScreenEvents()
}

View File

@@ -7,10 +7,14 @@ package org.signal.registration.screens.verificationcode
import org.signal.registration.NetworkController.SessionMetadata
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class VerificationCodeState(
val sessionMetadata: SessionMetadata? = null,
val e164: String = "",
val isSubmittingCode: Boolean = false,
val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(),
val incorrectCodeAttempts: Int = 0,
val oneTimeEvent: OneTimeEvent? = null
) {
sealed interface OneTimeEvent {
@@ -22,4 +26,28 @@ data class VerificationCodeState(
data object IncorrectVerificationCode : OneTimeEvent
data object RegistrationError : OneTimeEvent
}
/**
* Returns true if the user can resend SMS (timer has expired)
*/
fun canResendSms(): Boolean = rateLimits.smsResendTimeRemaining <= 0.seconds
/**
* Returns true if the user can request a call (timer has expired)
*/
fun canRequestCall(): Boolean = rateLimits.callRequestTimeRemaining <= 0.seconds
/**
* Returns true if the "Having Trouble" button should be shown.
* Matches the old behavior of showing after 3 incorrect code attempts.
*/
fun shouldShowHavingTrouble(): Boolean = incorrectCodeAttempts >= 3
}
/**
* Rate limit data for SMS resend and phone call request countdown timers.
*/
data class SmsAndCallRateLimits(
val smsResendTimeRemaining: Duration = 0.seconds,
val callRequestTimeRemaining: Duration = 0.seconds
)

View File

@@ -25,11 +25,15 @@ import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
import org.signal.registration.screens.verificationcode.VerificationCodeState.OneTimeEvent
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class VerificationCodeViewModel(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val clock: () -> Long = { System.currentTimeMillis() }
) : ViewModel() {
companion object {
@@ -39,24 +43,35 @@ class VerificationCodeViewModel(
private val _localState = MutableStateFlow(VerificationCodeState())
val state = combine(_localState, parentState) { state, parentState -> applyParentState(state, parentState) }
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.Eagerly, VerificationCodeState())
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), VerificationCodeState())
private var nextSmsAvailableAt: Duration = 0.seconds
private var nextCallAvailableAt: Duration = 0.seconds
fun onEvent(event: VerificationCodeScreenEvents) {
viewModelScope.launch {
_localState.emit(applyEvent(state.value, event))
val stateEmitter: (VerificationCodeState) -> Unit = { newState ->
_localState.value = newState
}
applyEvent(state.value, event, stateEmitter)
}
}
@VisibleForTesting
suspend fun applyEvent(state: VerificationCodeState, event: VerificationCodeScreenEvents): VerificationCodeState {
return when (event) {
is VerificationCodeScreenEvents.CodeEntered -> transformCodeEntered(state, event.code)
suspend fun applyEvent(state: VerificationCodeState, event: VerificationCodeScreenEvents, stateEmitter: (VerificationCodeState) -> Unit) {
val result = when (event) {
is VerificationCodeScreenEvents.CodeEntered -> {
stateEmitter(state.copy(isSubmittingCode = true))
applyCodeEntered(state, event.code).copy(isSubmittingCode = false)
}
is VerificationCodeScreenEvents.WrongNumber -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) }
is VerificationCodeScreenEvents.ResendSms -> transformResendCode(state, NetworkController.VerificationCodeTransport.SMS)
is VerificationCodeScreenEvents.CallMe -> transformResendCode(state, NetworkController.VerificationCodeTransport.VOICE)
is VerificationCodeScreenEvents.ResendSms -> applyResendCode(state, NetworkController.VerificationCodeTransport.SMS)
is VerificationCodeScreenEvents.CallMe -> applyResendCode(state, NetworkController.VerificationCodeTransport.VOICE)
is VerificationCodeScreenEvents.HavingTrouble -> throw NotImplementedError("having trouble flow") // TODO [registration] - Having trouble flow
is VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent -> state.copy(oneTimeEvent = null)
is VerificationCodeScreenEvents.CountdownTick -> applyCountdownTick(state)
}
stateEmitter(result)
}
@VisibleForTesting
@@ -67,15 +82,38 @@ class VerificationCodeViewModel(
return state
}
val sessionChanged = state.sessionMetadata?.id != parentState.sessionMetadata.id
val rateLimits = if (sessionChanged) {
computeRateLimits(parentState.sessionMetadata)
} else {
state.rateLimits
}
return state.copy(
sessionMetadata = parentState.sessionMetadata,
e164 = parentState.sessionE164
e164 = parentState.sessionE164,
rateLimits = rateLimits
)
}
private suspend fun transformCodeEntered(inputState: VerificationCodeState, code: String): VerificationCodeState {
var state = inputState.copy()
var sessionMetadata = state.sessionMetadata ?: return state.also { parentEventEmitter(RegistrationFlowEvent.ResetState) }
/**
* Decrements countdown timers by 1 second, ensuring they don't go below 0.
*/
private fun applyCountdownTick(state: VerificationCodeState): VerificationCodeState {
return state.copy(
rateLimits = SmsAndCallRateLimits(
smsResendTimeRemaining = (state.rateLimits.smsResendTimeRemaining - 1.seconds).coerceAtLeast(0.seconds),
callRequestTimeRemaining = (state.rateLimits.callRequestTimeRemaining - 1.seconds).coerceAtLeast(0.seconds)
)
)
}
private suspend fun applyCodeEntered(inputState: VerificationCodeState, code: String): VerificationCodeState {
var state = inputState
var sessionMetadata = state.sessionMetadata ?: return state.also {
parentEventEmitter(RegistrationFlowEvent.ResetState)
}
// TODO should we be checking on whether we need to do more captcha stuff?
@@ -89,7 +127,8 @@ class VerificationCodeViewModel(
when (result.error) {
is NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode -> {
Log.w(TAG, "[SubmitCode] Invalid sessionId or verification code entered. This is distinct from an *incorrect* verification code. Body: ${result.error.message}")
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode)
val newAttempts = state.incorrectCodeAttempts + 1
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts)
}
is NetworkController.SubmitVerificationCodeError.SessionNotFound -> {
Log.w(TAG, "[SubmitCode] Session not found: ${result.error.message}")
@@ -114,6 +153,7 @@ class VerificationCodeViewModel(
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[SubmitCode] Network error.", result.exception)
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
@@ -126,7 +166,8 @@ class VerificationCodeViewModel(
if (!sessionMetadata.verified) {
Log.w(TAG, "[SubmitCode] Verification code was incorrect.")
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode)
val newAttempts = state.incorrectCodeAttempts + 1
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts)
}
// Attempt to register
@@ -192,68 +233,103 @@ class VerificationCodeViewModel(
}
}
private suspend fun transformResendCode(
inputState: VerificationCodeState,
private suspend fun applyResendCode(
state: VerificationCodeState,
transport: NetworkController.VerificationCodeTransport
): VerificationCodeState {
val state = inputState.copy()
if (state.sessionMetadata == null) {
parentEventEmitter(RegistrationFlowEvent.ResetState)
return inputState
return state
}
val sessionMetadata = state.sessionMetadata
val result = repository.requestVerificationCode(
sessionId = sessionMetadata.id,
sessionId = state.sessionMetadata.id,
smsAutoRetrieveCodeSupported = false,
transport = transport
)
return when (result) {
is NetworkController.RegistrationNetworkResult.Success -> {
state.copy(sessionMetadata = result.data)
Log.i(TAG, "[RequestCode][$transport] Successfully requested verification code.")
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(result.data))
state.copy(
sessionMetadata = result.data,
rateLimits = computeRateLimits(result.data)
)
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (result.error) {
is NetworkController.RequestVerificationCodeError.InvalidRequest -> {
Log.w(TAG, "[RequestCode][$transport] Invalid request: ${result.error.message}")
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
}
is NetworkController.RequestVerificationCodeError.RateLimited -> {
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(result.error.retryAfter))
Log.w(TAG, "[RequestCode][$transport] Rate limited (retryAfter: ${result.error.retryAfter}).")
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(result.error.session))
state.copy(
oneTimeEvent = OneTimeEvent.RateLimited(result.error.retryAfter),
sessionMetadata = result.error.session,
rateLimits = computeRateLimits(result.error.session)
)
}
is NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport -> {
state.copy(oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
Log.w(TAG, "[RequestCode][$transport] Could not fulfill with requested transport.")
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(result.error.session))
state.copy(
oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport,
sessionMetadata = result.error.session,
rateLimits = computeRateLimits(result.error.session)
)
}
is NetworkController.RequestVerificationCodeError.InvalidSessionId -> {
Log.w(TAG, "[RequestCode][$transport] Invalid session ID: ${result.error.message}")
// TODO don't start over, go back to phone number entry
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
}
is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> {
Log.w(TAG, "When requesting verification code, missing request information or already verified.")
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
Log.w(TAG, "[RequestCode][$transport] Missing request information or already verified.")
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(result.error.session))
state.copy(
oneTimeEvent = OneTimeEvent.NetworkError,
sessionMetadata = result.error.session,
rateLimits = computeRateLimits(result.error.session)
)
}
is NetworkController.RequestVerificationCodeError.SessionNotFound -> {
Log.w(TAG, "[RequestCode][$transport] Session not found: ${result.error.message}")
// TODO don't start over, go back to phone number entry
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
}
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
Log.w(TAG, "[RequestCode][$transport] Third party service error. ${result.error.data}")
state.copy(oneTimeEvent = OneTimeEvent.ThirdPartyError)
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[RequestCode][$transport] Network error.", result.exception)
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "Unknown error when requesting verification code.", result.exception)
Log.w(TAG, "[RequestCode][$transport] Unknown application error.", result.exception)
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
}
}
}
private fun computeRateLimits(session: NetworkController.SessionMetadata): SmsAndCallRateLimits {
val now = clock().milliseconds
nextSmsAvailableAt = now + (session.nextSms?.seconds ?: nextSmsAvailableAt)
nextCallAvailableAt = now + (session.nextCall?.seconds ?: nextCallAvailableAt)
return SmsAndCallRateLimits(
smsResendTimeRemaining = (nextSmsAvailableAt - clock().milliseconds).coerceAtLeast(0.seconds),
callRequestTimeRemaining = (nextCallAvailableAt - clock().milliseconds).coerceAtLeast(0.seconds)
)
}
class Factory(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,

View File

@@ -41,4 +41,5 @@ object TestTags {
const val VERIFICATION_CODE_WRONG_NUMBER_BUTTON = "verification_code_wrong_number_button"
const val VERIFICATION_CODE_RESEND_SMS_BUTTON = "verification_code_resend_sms_button"
const val VERIFICATION_CODE_CALL_ME_BUTTON = "verification_code_call_me_button"
const val VERIFICATION_CODE_HAVING_TROUBLE_BUTTON = "verification_code_having_trouble_button"
}

View File

@@ -33,6 +33,16 @@
<!-- Storage permission row description -->
<string name="GrantPermissionsFragment__send_photos_videos_and_files">Send photos, videos and files from your device.</string>
<!-- PhoneNumberEntryScreen -->
<!-- Title of registration screen when asking for the users phone number -->
<string name="RegistrationActivity_phone_number">Phone number</string>
<!-- Text explaining to users that they will be receiving a verification for their phone number and that carrier rates could apply -->
<string name="RegistrationActivity_you_will_receive_a_verification_code">You will receive a verification code. Carrier rates may apply.</string>
<!-- Hint text to select a country -->
<string name="RegistrationActivity_select_a_country">Select a country</string>
<string name="RegistrationActivity_phone_number_description">Phone number</string>
<string name="RegistrationActivity_next">Next</string>
<!-- CountryCodePickerScreen -->
<!-- Title of the country code selection screen -->
<string name="CountryCodeSelectScreen__your_country">Your country</string>
@@ -42,4 +52,36 @@
<string name="CountryCodeSelectScreen__search_by">Search by name or number</string>
<!-- Fallback text for countries with no name -->
<string name="CountryCodeSelectScreen__unknown_country">Unknown country</string>
<!-- VerificationCodeScreen -->
<!-- Title of the verification code entry screen -->
<string name="VerificationCodeScreen__verification_code">Verification code</string>
<!-- Subtitle explaining where the code was sent. Placeholder is the phone number -->
<string name="VerificationCodeScreen__enter_the_code_we_sent_to_s">Enter the code we sent to %s</string>
<!-- Button text for wrong number action -->
<string name="VerificationCodeScreen__wrong_number">Wrong number?</string>
<!-- Button text for resend SMS action -->
<string name="VerificationCodeScreen__resend_code">Resend Code</string>
<!-- Button text for call me action -->
<string name="VerificationCodeScreen__call_me_instead">Call me instead</string>
<!-- Countdown text shown below the resend code button. Placeholders are minutes and seconds -->
<string name="VerificationCodeScreen__countdown_format">(%1$02d:%2$02d)</string>
<!-- Button text for call me when countdown is active. Placeholders are minutes and seconds -->
<string name="VerificationCodeScreen__call_me_available_in">Call me (%1$02d:%2$02d)</string>
<!-- Toast shown when the user enters an incorrect verification code -->
<string name="VerificationCodeScreen__incorrect_code">Incorrect code</string>
<!-- Snackbar shown when there is a network error -->
<string name="VerificationCodeScreen__network_error">Unable to connect. Please check your network and try again.</string>
<!-- Snackbar shown when rate limited. Placeholder is the retry duration -->
<string name="VerificationCodeScreen__too_many_attempts_try_again_in_s">Too many attempts. Try again in %s.</string>
<!-- Snackbar shown for generic/unknown errors -->
<string name="VerificationCodeScreen__an_unexpected_error_occurred">An unexpected error occurred. Please try again.</string>
<!-- Snackbar shown when the SMS provider has an error -->
<string name="VerificationCodeScreen__sms_provider_error">There was a problem sending your verification code. Please try again.</string>
<!-- Snackbar shown when the selected transport (SMS/voice) is unavailable -->
<string name="VerificationCodeScreen__could_not_send_code_via_selected_method">Could not send code via the selected method. Please try another option.</string>
<!-- Snackbar shown for registration errors -->
<string name="VerificationCodeScreen__registration_error">Registration failed. Please try again.</string>
<!-- Button text for having trouble with verification -->
<string name="VerificationCodeScreen__having_trouble">Having trouble?</string>
</resources>

View File

@@ -288,8 +288,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().sessionMetadata).isNotNull()
assertThat(emittedEvents).hasSize(1)
@@ -314,8 +314,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
@@ -339,8 +339,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
@@ -363,8 +363,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
}
@@ -382,8 +382,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
}
@@ -401,8 +401,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
}
@@ -422,8 +422,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
// Should not create a new session, just request verification code
assertThat(emittedEvents).hasSize(1)
@@ -452,8 +452,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isNotNull().isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
}
@@ -477,8 +477,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
@@ -503,8 +503,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
}
@@ -530,8 +530,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.ThirdPartyError)
}
@@ -559,8 +559,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
// Verify navigation to verification code entry
assertThat(emittedEvents).hasSize(1)
@@ -591,8 +591,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
// Verify navigation continues despite no push challenge token
assertThat(emittedEvents).hasSize(1)
@@ -627,8 +627,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
// Verify navigation continues despite push challenge submission failure
assertThat(emittedEvents).hasSize(1)
@@ -658,8 +658,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
// Verify navigation continues despite network error
assertThat(emittedEvents).hasSize(1)
@@ -689,8 +689,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
// Verify navigation continues despite application error
assertThat(emittedEvents).hasSize(1)
@@ -719,8 +719,8 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
// Verify navigation to captcha
assertThat(emittedEvents).hasSize(1)

View File

@@ -49,7 +49,7 @@ class VerificationCodeScreenTest {
}
// Then
composeTestRule.onNodeWithText("Enter verification code").assertIsDisplayed()
composeTestRule.onNodeWithText("Verification code").assertIsDisplayed()
}
@Test
@@ -70,7 +70,7 @@ class VerificationCodeScreenTest {
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_4).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).fetchSemanticsNode()
}
@Test
@@ -191,7 +191,7 @@ class VerificationCodeScreenTest {
// Then
composeTestRule.onNodeWithText("Wrong number?").assertIsDisplayed()
composeTestRule.onNodeWithText("Resend SMS").assertIsDisplayed()
composeTestRule.onNodeWithText("Resend Code").assertIsDisplayed()
composeTestRule.onNodeWithText("Call me instead").assertIsDisplayed()
}
}

View File

@@ -11,6 +11,7 @@ import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.mockk
@@ -34,6 +35,8 @@ class VerificationCodeViewModelTest {
private lateinit var parentState: MutableStateFlow<RegistrationFlowState>
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<VerificationCodeState>
private lateinit var stateEmitter: (VerificationCodeState) -> Unit
@Before
fun setup() {
@@ -47,6 +50,8 @@ class VerificationCodeViewModelTest {
)
emittedEvents = mutableListOf()
parentEventEmitter = { event -> emittedEvents.add(event) }
emittedStates = mutableListOf()
stateEmitter = { state -> emittedStates.add(state) }
viewModel = VerificationCodeViewModel(mockRepository, parentState, parentEventEmitter)
}
@@ -133,24 +138,26 @@ class VerificationCodeViewModelTest {
oneTimeEvent = VerificationCodeState.OneTimeEvent.NetworkError
)
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent,
stateEmitter
)
assertThat(result.oneTimeEvent).isNull()
assertThat(emittedStates.last().oneTimeEvent).isNull()
}
@Test
fun `ConsumeInnerOneTimeEvent with null event returns state with null event`() = runTest {
val initialState = VerificationCodeState(oneTimeEvent = null)
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent,
stateEmitter
)
assertThat(result.oneTimeEvent).isNull()
assertThat(emittedStates.last().oneTimeEvent).isNull()
}
// ==================== applyEvent: WrongNumber Tests ====================
@@ -159,7 +166,7 @@ class VerificationCodeViewModelTest {
fun `WrongNumber navigates to PhoneNumberEntry`() = runTest {
val initialState = VerificationCodeState()
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.WrongNumber)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.WrongNumber, stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
@@ -170,16 +177,42 @@ class VerificationCodeViewModelTest {
// ==================== applyEvent: CodeEntered Tests ====================
@Test
fun `CodeEntered emits isSubmittingCode true then false`() = runTest {
val sessionMetadata = createSessionMetadata()
val initialState = VerificationCodeState(
sessionMetadata = sessionMetadata,
e164 = "+15551234567"
)
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode("Wrong code")
)
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
// First emitted state should have isSubmittingCode = true
assertThat(emittedStates.first().isSubmittingCode).isTrue()
// Final emitted state should have isSubmittingCode = false
assertThat(emittedStates.last().isSubmittingCode).isEqualTo(false)
}
@Test
fun `CodeEntered emits ResetState when sessionMetadata is null`() = runTest {
val initialState = VerificationCodeState(sessionMetadata = null)
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456")
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
assertThat(result).isEqualTo(initialState)
assertThat(emittedStates.last()).isEqualTo(initialState)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.ResetState>()
@@ -201,7 +234,7 @@ class VerificationCodeViewModelTest {
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(registerResponse to keyMaterial)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"), stateEmitter)
assertThat(emittedEvents).hasSize(2)
assertThat(emittedEvents[0]).isInstanceOf<RegistrationFlowEvent.Registered>()
@@ -224,12 +257,13 @@ class VerificationCodeViewModelTest {
NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode("Wrong code")
)
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456")
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.IncorrectVerificationCode)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.IncorrectVerificationCode)
}
@Test
@@ -245,7 +279,7 @@ class VerificationCodeViewModelTest {
NetworkController.SubmitVerificationCodeError.SessionNotFound("Session expired")
)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"), stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
@@ -269,7 +303,7 @@ class VerificationCodeViewModelTest {
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(registerResponse to keyMaterial)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"), stateEmitter)
assertThat(emittedEvents).hasSize(2)
assertThat(emittedEvents[0]).isInstanceOf<RegistrationFlowEvent.Registered>()
@@ -292,7 +326,7 @@ class VerificationCodeViewModelTest {
NetworkController.SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(unverifiedSession)
)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"), stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
@@ -311,12 +345,13 @@ class VerificationCodeViewModelTest {
NetworkController.SubmitVerificationCodeError.RateLimited(60.seconds, sessionMetadata)
)
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456")
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
assertThat(result.oneTimeEvent).isNotNull()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(60.seconds)
@@ -333,12 +368,13 @@ class VerificationCodeViewModelTest {
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456")
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
}
@Test
@@ -352,12 +388,13 @@ class VerificationCodeViewModelTest {
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456")
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
}
// ==================== applyEvent: CodeEntered - Registration Errors ====================
@@ -378,7 +415,7 @@ class VerificationCodeViewModelTest {
NetworkController.RegisterAccountError.DeviceTransferPossible
)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"), stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
@@ -400,12 +437,13 @@ class VerificationCodeViewModelTest {
NetworkController.RegisterAccountError.RateLimited(30.seconds)
)
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456")
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
assertThat(result.oneTimeEvent).isNotNull()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(30.seconds)
@@ -427,12 +465,13 @@ class VerificationCodeViewModelTest {
NetworkController.RegisterAccountError.InvalidRequest("Bad request")
)
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456")
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
}
@Ignore
@@ -451,12 +490,13 @@ class VerificationCodeViewModelTest {
NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect("Wrong password")
)
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456")
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
}
@Ignore
@@ -473,12 +513,13 @@ class VerificationCodeViewModelTest {
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456")
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
}
@Ignore
@@ -495,12 +536,13 @@ class VerificationCodeViewModelTest {
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeEntered("123456")
VerificationCodeScreenEvents.CodeEntered("123456"),
stateEmitter
)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
}
// ==================== applyEvent: ResendSms Tests ====================
@@ -509,11 +551,11 @@ class VerificationCodeViewModelTest {
fun `ResendSms with null sessionMetadata emits ResetState`() = runTest {
val initialState = VerificationCodeState(sessionMetadata = null)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
assertThat(result).isEqualTo(initialState)
assertThat(emittedStates.last()).isEqualTo(initialState)
}
@Test
@@ -525,9 +567,9 @@ class VerificationCodeViewModelTest {
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
NetworkController.RegistrationNetworkResult.Success(updatedSession)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(result.sessionMetadata).isEqualTo(updatedSession)
assertThat(emittedStates.last().sessionMetadata).isEqualTo(updatedSession)
}
@Test
@@ -540,9 +582,9 @@ class VerificationCodeViewModelTest {
NetworkController.RequestVerificationCodeError.RateLimited(45.seconds, sessionMetadata)
)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(result.oneTimeEvent).isNotNull()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(45.seconds)
@@ -558,9 +600,9 @@ class VerificationCodeViewModelTest {
NetworkController.RequestVerificationCodeError.InvalidRequest("Bad request")
)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
}
@Test
@@ -573,9 +615,9 @@ class VerificationCodeViewModelTest {
NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(sessionMetadata)
)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
}
@Test
@@ -588,7 +630,7 @@ class VerificationCodeViewModelTest {
NetworkController.RequestVerificationCodeError.InvalidSessionId("Invalid session")
)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
@@ -604,7 +646,7 @@ class VerificationCodeViewModelTest {
NetworkController.RequestVerificationCodeError.SessionNotFound("Session not found")
)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
@@ -620,9 +662,9 @@ class VerificationCodeViewModelTest {
NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified(sessionMetadata)
)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
}
@Test
@@ -637,9 +679,9 @@ class VerificationCodeViewModelTest {
)
)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.ThirdPartyError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.ThirdPartyError)
}
@Test
@@ -650,9 +692,9 @@ class VerificationCodeViewModelTest {
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
}
@Test
@@ -663,9 +705,9 @@ class VerificationCodeViewModelTest {
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
}
// ==================== applyEvent: CallMe Tests ====================
@@ -674,11 +716,11 @@ class VerificationCodeViewModelTest {
fun `CallMe with null sessionMetadata emits ResetState`() = runTest {
val initialState = VerificationCodeState(sessionMetadata = null)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
assertThat(result).isEqualTo(initialState)
assertThat(emittedStates.last()).isEqualTo(initialState)
}
@Test
@@ -690,9 +732,9 @@ class VerificationCodeViewModelTest {
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.VOICE)) } returns
NetworkController.RegistrationNetworkResult.Success(updatedSession)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter)
assertThat(result.sessionMetadata).isEqualTo(updatedSession)
assertThat(emittedStates.last().sessionMetadata).isEqualTo(updatedSession)
}
@Test
@@ -705,9 +747,9 @@ class VerificationCodeViewModelTest {
NetworkController.RequestVerificationCodeError.RateLimited(90.seconds, sessionMetadata)
)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter)
assertThat(result.oneTimeEvent).isNotNull()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(90.seconds)
@@ -723,9 +765,9 @@ class VerificationCodeViewModelTest {
NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(sessionMetadata)
)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
}
@Test
@@ -740,9 +782,9 @@ class VerificationCodeViewModelTest {
)
)
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter)
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.ThirdPartyError)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.ThirdPartyError)
}
// ==================== Helper Functions ====================