mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-02 06:33:38 +01:00
Improve UI for regV5 verification code submission.
This commit is contained in:
committed by
Cody Henthorne
parent
376cb926b0
commit
7428e1e2ea
@@ -69,7 +69,7 @@ fun NetworkDebugOverlay(
|
|||||||
onClick = { showDialog = true },
|
onClick = { showDialog = true },
|
||||||
dragOffset = dragOffset,
|
dragOffset = dragOffset,
|
||||||
onDrag = { delta -> dragOffset += delta },
|
onDrag = { delta -> dragOffset += delta },
|
||||||
modifier = Modifier.align(Alignment.BottomEnd)
|
modifier = Modifier.align(Alignment.CenterEnd)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.phonenumber
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -31,16 +35,23 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.TextRange
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
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.DayNightPreviews
|
||||||
import org.signal.core.ui.compose.Dialogs
|
import org.signal.core.ui.compose.Dialogs
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
|
import org.signal.registration.R
|
||||||
import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
|
import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
|
||||||
import org.signal.registration.test.TestTags
|
import org.signal.registration.test.TestTags
|
||||||
|
|
||||||
@@ -78,10 +89,6 @@ fun PhoneNumberScreen(
|
|||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize().testTag(TestTags.PHONE_NUMBER_SCREEN)) {
|
Box(modifier = modifier.fillMaxSize().testTag(TestTags.PHONE_NUMBER_SCREEN)) {
|
||||||
ScreenContent(state, onEvent)
|
ScreenContent(state, onEvent)
|
||||||
|
|
||||||
if (state.showFullScreenSpinner) {
|
|
||||||
Dialogs.IndeterminateProgressDialog()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,23 +97,170 @@ private fun ScreenContent(state: PhoneNumberEntryState, onEvent: (PhoneNumberEnt
|
|||||||
val selectedCountry = state.countryName
|
val selectedCountry = state.countryName
|
||||||
val selectedCountryEmoji = state.countryEmoji
|
val selectedCountryEmoji = state.countryEmoji
|
||||||
|
|
||||||
// Track the phone number text field value with cursor position
|
val scrollState = rememberScrollState()
|
||||||
var phoneNumberTextFieldValue by remember { mutableStateOf(TextFieldValue(state.formattedNumber)) }
|
|
||||||
|
|
||||||
// Update the text field value when state.formattedNumber changes, preserving cursor position
|
Column(
|
||||||
LaunchedEffect(state.formattedNumber) {
|
modifier = Modifier
|
||||||
if (phoneNumberTextFieldValue.text != state.formattedNumber) {
|
.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,
|
// Calculate cursor position: count digits before cursor in old text,
|
||||||
// then find position with same digit count in new text
|
// then find position with same digit count in new text
|
||||||
val oldText = phoneNumberTextFieldValue.text
|
val oldText = phoneNumberTextFieldValue.text
|
||||||
val oldCursorPos = phoneNumberTextFieldValue.selection.end
|
val oldCursorPos = phoneNumberTextFieldValue.selection.end
|
||||||
val digitsBeforeCursor = oldText.take(oldCursorPos).count { it.isDigit() }
|
val digitsBeforeCursor = oldText.take(oldCursorPos).count { it.isDigit() }
|
||||||
|
|
||||||
val newText = state.formattedNumber
|
|
||||||
var digitCount = 0
|
var digitCount = 0
|
||||||
var newCursorPos = newText.length
|
var newCursorPos = formattedNumber.length
|
||||||
for (i in newText.indices) {
|
for (i in formattedNumber.indices) {
|
||||||
if (newText[i].isDigit()) {
|
if (formattedNumber[i].isDigit()) {
|
||||||
digitCount++
|
digitCount++
|
||||||
}
|
}
|
||||||
if (digitCount >= digitsBeforeCursor) {
|
if (digitCount >= digitsBeforeCursor) {
|
||||||
@@ -116,140 +270,92 @@ private fun ScreenContent(state: PhoneNumberEntryState, onEvent: (PhoneNumberEnt
|
|||||||
}
|
}
|
||||||
|
|
||||||
phoneNumberTextFieldValue = TextFieldValue(
|
phoneNumberTextFieldValue = TextFieldValue(
|
||||||
text = newText,
|
text = formattedNumber,
|
||||||
selection = TextRange(newCursorPos)
|
selection = TextRange(newCursorPos)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Row(
|
||||||
modifier = Modifier
|
modifier = modifier,
|
||||||
.fillMaxSize()
|
horizontalArrangement = Arrangement.Start,
|
||||||
.padding(24.dp),
|
verticalAlignment = Alignment.Bottom
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
// Title
|
// Country code field
|
||||||
Text(
|
OutlinedTextField(
|
||||||
text = "Phone number",
|
value = countryCode,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
onValueChange = onCountryCodeChanged,
|
||||||
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)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.width(76.dp)
|
||||||
.height(56.dp)
|
.testTag(TestTags.PHONE_NUMBER_COUNTRY_CODE_FIELD),
|
||||||
.testTag(TestTags.PHONE_NUMBER_COUNTRY_PICKER)
|
prefix = {
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Start,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = selectedCountryEmoji,
|
text = "+",
|
||||||
style = MaterialTheme.typography.headlineSmall
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
Text(
|
|
||||||
text = selectedCountry,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
)
|
||||||
Icon(
|
},
|
||||||
painter = painterResource(android.R.drawable.arrow_down_float),
|
keyboardOptions = KeyboardOptions(
|
||||||
contentDescription = "Select country",
|
keyboardType = KeyboardType.Number,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
imeAction = ImeAction.Done
|
||||||
)
|
),
|
||||||
}
|
singleLine = true,
|
||||||
}
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
color = 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
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// Phone number field
|
Spacer(modifier = Modifier.width(20.dp))
|
||||||
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.weight(1f))
|
// Phone number field
|
||||||
|
OutlinedTextField(
|
||||||
// Next button
|
value = phoneNumberTextFieldValue,
|
||||||
Button(
|
onValueChange = { newValue ->
|
||||||
onClick = {
|
phoneNumberTextFieldValue = newValue
|
||||||
onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
onPhoneNumberChanged(newValue.text)
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.weight(1f)
|
||||||
.height(56.dp)
|
.testTag(TestTags.PHONE_NUMBER_PHONE_FIELD),
|
||||||
.testTag(TestTags.PHONE_NUMBER_NEXT_BUTTON),
|
label = {
|
||||||
enabled = state.countryCode.isNotEmpty() && state.nationalNumber.isNotEmpty()
|
Text(stringResource(R.string.RegistrationActivity_phone_number_description))
|
||||||
) {
|
},
|
||||||
Text("Next")
|
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() {
|
private fun PhoneNumberScreenSpinnerPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
PhoneNumberScreen(
|
PhoneNumberScreen(
|
||||||
state = PhoneNumberEntryState(showFullScreenSpinner = true),
|
state = PhoneNumberEntryState(showSpinner = true),
|
||||||
onEvent = {}
|
onEvent = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ data class PhoneNumberEntryState(
|
|||||||
val formattedNumber: String = "",
|
val formattedNumber: String = "",
|
||||||
val sessionE164: String? = null,
|
val sessionE164: String? = null,
|
||||||
val sessionMetadata: SessionMetadata? = null,
|
val sessionMetadata: SessionMetadata? = null,
|
||||||
val showFullScreenSpinner: Boolean = false,
|
val showSpinner: Boolean = false,
|
||||||
val oneTimeEvent: OneTimeEvent? = null,
|
val oneTimeEvent: OneTimeEvent? = null,
|
||||||
val preExistingRegistrationData: PreExistingRegistrationData? = null,
|
val preExistingRegistrationData: PreExistingRegistrationData? = null,
|
||||||
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList()
|
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList()
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class PhoneNumberEntryViewModel(
|
|||||||
val state = _state
|
val state = _state
|
||||||
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
|
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
|
||||||
.onEach { Log.d(TAG, "[State] $it") }
|
.onEach { Log.d(TAG, "[State] $it") }
|
||||||
.stateIn(viewModelScope, SharingStarted.Eagerly, PhoneNumberEntryState())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PhoneNumberEntryState())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -78,10 +78,10 @@ class PhoneNumberEntryViewModel(
|
|||||||
stateEmitter(applyPhoneNumberChanged(state, event.value))
|
stateEmitter(applyPhoneNumberChanged(state, event.value))
|
||||||
}
|
}
|
||||||
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> {
|
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> {
|
||||||
var localState = state.copy(showFullScreenSpinner = true)
|
var localState = state.copy(showSpinner = true)
|
||||||
stateEmitter(localState)
|
stateEmitter(localState)
|
||||||
localState = applyPhoneNumberSubmitted(localState, parentEventEmitter)
|
localState = applyPhoneNumberSubmitted(localState, parentEventEmitter)
|
||||||
stateEmitter(localState.copy(showFullScreenSpinner = false))
|
stateEmitter(localState.copy(showSpinner = false))
|
||||||
}
|
}
|
||||||
is PhoneNumberEntryScreenEvents.CountryPicker -> {
|
is PhoneNumberEntryScreenEvents.CountryPicker -> {
|
||||||
state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) }
|
state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) }
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class PinCreationViewModel(
|
|||||||
val state: StateFlow<PinCreationState> = _state
|
val state: StateFlow<PinCreationState> = _state
|
||||||
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
|
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
|
||||||
.onEach { Log.d(TAG, "[State] $it") }
|
.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) {
|
fun onEvent(event: PinCreationScreenEvents) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class PinEntryForRegistrationLockViewModel(
|
|||||||
|
|
||||||
val state: StateFlow<PinEntryState> = _state
|
val state: StateFlow<PinEntryState> = _state
|
||||||
.onEach { Log.d(TAG, "[State] $it") }
|
.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) {
|
fun onEvent(event: PinEntryScreenEvents) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class PinEntryForSmsBypassViewModel(
|
|||||||
val state: StateFlow<PinEntryState> = _state
|
val state: StateFlow<PinEntryState> = _state
|
||||||
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
|
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
|
||||||
.onEach { Log.d(TAG, "[State] $it") }
|
.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) {
|
fun onEvent(event: PinEntryScreenEvents) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class PinEntryForSvrRestoreViewModel(
|
|||||||
|
|
||||||
val state: StateFlow<PinEntryState> = _state
|
val state: StateFlow<PinEntryState> = _state
|
||||||
.onEach { Log.d(TAG, "[State] $it") }
|
.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) {
|
fun onEvent(event: PinEntryScreenEvents) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
package org.signal.registration.screens.verificationcode
|
package org.signal.registration.screens.verificationcode
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
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.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -29,17 +39,27 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.DayNightPreviews
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
|
import org.signal.registration.R
|
||||||
import org.signal.registration.test.TestTags
|
import org.signal.registration.test.TestTags
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verification code entry screen for the registration flow.
|
* 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
|
@Composable
|
||||||
fun VerificationCodeScreen(
|
fun VerificationCodeScreen(
|
||||||
@@ -49,177 +69,372 @@ fun VerificationCodeScreen(
|
|||||||
) {
|
) {
|
||||||
var digits by remember { mutableStateOf(List(6) { "" }) }
|
var digits by remember { mutableStateOf(List(6) { "" }) }
|
||||||
val focusRequesters = remember { List(6) { FocusRequester() } }
|
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
|
// Auto-submit when all digits are entered
|
||||||
LaunchedEffect(digits) {
|
LaunchedEffect(digits) {
|
||||||
if (digits.all { it.isNotEmpty() }) {
|
if (digits.all { it.isNotEmpty() } && !state.isSubmittingCode) {
|
||||||
val code = digits.joinToString("")
|
val code = digits.joinToString("")
|
||||||
onEvent(VerificationCodeScreenEvents.CodeEntered(code))
|
onEvent(VerificationCodeScreenEvents.CodeEntered(code))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle one-time events — handle first, then consume
|
||||||
LaunchedEffect(state.oneTimeEvent) {
|
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)
|
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
|
// Auto-focus first field on initial composition
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequesters[0].requestFocus()
|
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
|
@Composable
|
||||||
private fun DigitField(
|
private fun DigitField(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String, Boolean) -> Unit,
|
||||||
focusRequester: FocusRequester,
|
focusRequester: FocusRequester,
|
||||||
testTag: String,
|
testTag: String,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
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
|
modifier = modifier
|
||||||
.width(44.dp)
|
.width(48.dp)
|
||||||
.focusRequester(focusRequester)
|
.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),
|
textStyle = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center),
|
||||||
singleLine = true,
|
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() {
|
private fun VerificationCodeScreenPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
VerificationCodeScreen(
|
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 = {}
|
onEvent = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,9 @@ sealed class VerificationCodeScreenEvents {
|
|||||||
data object CallMe : VerificationCodeScreenEvents()
|
data object CallMe : VerificationCodeScreenEvents()
|
||||||
data object HavingTrouble : VerificationCodeScreenEvents()
|
data object HavingTrouble : VerificationCodeScreenEvents()
|
||||||
data object ConsumeInnerOneTimeEvent : VerificationCodeScreenEvents()
|
data object ConsumeInnerOneTimeEvent : VerificationCodeScreenEvents()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event to update countdown timers. Should be triggered periodically (e.g., every second).
|
||||||
|
*/
|
||||||
|
data object CountdownTick : VerificationCodeScreenEvents()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ package org.signal.registration.screens.verificationcode
|
|||||||
|
|
||||||
import org.signal.registration.NetworkController.SessionMetadata
|
import org.signal.registration.NetworkController.SessionMetadata
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
data class VerificationCodeState(
|
data class VerificationCodeState(
|
||||||
val sessionMetadata: SessionMetadata? = null,
|
val sessionMetadata: SessionMetadata? = null,
|
||||||
val e164: String = "",
|
val e164: String = "",
|
||||||
|
val isSubmittingCode: Boolean = false,
|
||||||
|
val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(),
|
||||||
|
val incorrectCodeAttempts: Int = 0,
|
||||||
val oneTimeEvent: OneTimeEvent? = null
|
val oneTimeEvent: OneTimeEvent? = null
|
||||||
) {
|
) {
|
||||||
sealed interface OneTimeEvent {
|
sealed interface OneTimeEvent {
|
||||||
@@ -22,4 +26,28 @@ data class VerificationCodeState(
|
|||||||
data object IncorrectVerificationCode : OneTimeEvent
|
data object IncorrectVerificationCode : OneTimeEvent
|
||||||
data object RegistrationError : 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
|
||||||
|
)
|
||||||
|
|||||||
@@ -25,11 +25,15 @@ import org.signal.registration.RegistrationRoute
|
|||||||
import org.signal.registration.screens.util.navigateBack
|
import org.signal.registration.screens.util.navigateBack
|
||||||
import org.signal.registration.screens.util.navigateTo
|
import org.signal.registration.screens.util.navigateTo
|
||||||
import org.signal.registration.screens.verificationcode.VerificationCodeState.OneTimeEvent
|
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(
|
class VerificationCodeViewModel(
|
||||||
private val repository: RegistrationRepository,
|
private val repository: RegistrationRepository,
|
||||||
private val parentState: StateFlow<RegistrationFlowState>,
|
private val parentState: StateFlow<RegistrationFlowState>,
|
||||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||||
|
private val clock: () -> Long = { System.currentTimeMillis() }
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -39,24 +43,35 @@ class VerificationCodeViewModel(
|
|||||||
private val _localState = MutableStateFlow(VerificationCodeState())
|
private val _localState = MutableStateFlow(VerificationCodeState())
|
||||||
val state = combine(_localState, parentState) { state, parentState -> applyParentState(state, parentState) }
|
val state = combine(_localState, parentState) { state, parentState -> applyParentState(state, parentState) }
|
||||||
.onEach { Log.d(TAG, "[State] $it") }
|
.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) {
|
fun onEvent(event: VerificationCodeScreenEvents) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_localState.emit(applyEvent(state.value, event))
|
val stateEmitter: (VerificationCodeState) -> Unit = { newState ->
|
||||||
|
_localState.value = newState
|
||||||
|
}
|
||||||
|
applyEvent(state.value, event, stateEmitter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
suspend fun applyEvent(state: VerificationCodeState, event: VerificationCodeScreenEvents): VerificationCodeState {
|
suspend fun applyEvent(state: VerificationCodeState, event: VerificationCodeScreenEvents, stateEmitter: (VerificationCodeState) -> Unit) {
|
||||||
return when (event) {
|
val result = when (event) {
|
||||||
is VerificationCodeScreenEvents.CodeEntered -> transformCodeEntered(state, event.code)
|
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.WrongNumber -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) }
|
||||||
is VerificationCodeScreenEvents.ResendSms -> transformResendCode(state, NetworkController.VerificationCodeTransport.SMS)
|
is VerificationCodeScreenEvents.ResendSms -> applyResendCode(state, NetworkController.VerificationCodeTransport.SMS)
|
||||||
is VerificationCodeScreenEvents.CallMe -> transformResendCode(state, NetworkController.VerificationCodeTransport.VOICE)
|
is VerificationCodeScreenEvents.CallMe -> applyResendCode(state, NetworkController.VerificationCodeTransport.VOICE)
|
||||||
is VerificationCodeScreenEvents.HavingTrouble -> throw NotImplementedError("having trouble flow") // TODO [registration] - Having trouble flow
|
is VerificationCodeScreenEvents.HavingTrouble -> throw NotImplementedError("having trouble flow") // TODO [registration] - Having trouble flow
|
||||||
is VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent -> state.copy(oneTimeEvent = null)
|
is VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent -> state.copy(oneTimeEvent = null)
|
||||||
|
is VerificationCodeScreenEvents.CountdownTick -> applyCountdownTick(state)
|
||||||
}
|
}
|
||||||
|
stateEmitter(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
@@ -67,15 +82,38 @@ class VerificationCodeViewModel(
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val sessionChanged = state.sessionMetadata?.id != parentState.sessionMetadata.id
|
||||||
|
|
||||||
|
val rateLimits = if (sessionChanged) {
|
||||||
|
computeRateLimits(parentState.sessionMetadata)
|
||||||
|
} else {
|
||||||
|
state.rateLimits
|
||||||
|
}
|
||||||
|
|
||||||
return state.copy(
|
return state.copy(
|
||||||
sessionMetadata = parentState.sessionMetadata,
|
sessionMetadata = parentState.sessionMetadata,
|
||||||
e164 = parentState.sessionE164
|
e164 = parentState.sessionE164,
|
||||||
|
rateLimits = rateLimits
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun transformCodeEntered(inputState: VerificationCodeState, code: String): VerificationCodeState {
|
/**
|
||||||
var state = inputState.copy()
|
* Decrements countdown timers by 1 second, ensuring they don't go below 0.
|
||||||
var sessionMetadata = state.sessionMetadata ?: return state.also { parentEventEmitter(RegistrationFlowEvent.ResetState) }
|
*/
|
||||||
|
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?
|
// TODO should we be checking on whether we need to do more captcha stuff?
|
||||||
|
|
||||||
@@ -89,7 +127,8 @@ class VerificationCodeViewModel(
|
|||||||
when (result.error) {
|
when (result.error) {
|
||||||
is NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode -> {
|
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}")
|
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 -> {
|
is NetworkController.SubmitVerificationCodeError.SessionNotFound -> {
|
||||||
Log.w(TAG, "[SubmitCode] Session not found: ${result.error.message}")
|
Log.w(TAG, "[SubmitCode] Session not found: ${result.error.message}")
|
||||||
@@ -114,6 +153,7 @@ class VerificationCodeViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||||
|
Log.w(TAG, "[SubmitCode] Network error.", result.exception)
|
||||||
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||||
}
|
}
|
||||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||||
@@ -126,7 +166,8 @@ class VerificationCodeViewModel(
|
|||||||
|
|
||||||
if (!sessionMetadata.verified) {
|
if (!sessionMetadata.verified) {
|
||||||
Log.w(TAG, "[SubmitCode] Verification code was incorrect.")
|
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
|
// Attempt to register
|
||||||
@@ -192,68 +233,103 @@ class VerificationCodeViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun transformResendCode(
|
private suspend fun applyResendCode(
|
||||||
inputState: VerificationCodeState,
|
state: VerificationCodeState,
|
||||||
transport: NetworkController.VerificationCodeTransport
|
transport: NetworkController.VerificationCodeTransport
|
||||||
): VerificationCodeState {
|
): VerificationCodeState {
|
||||||
val state = inputState.copy()
|
|
||||||
if (state.sessionMetadata == null) {
|
if (state.sessionMetadata == null) {
|
||||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||||
return inputState
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
val sessionMetadata = state.sessionMetadata
|
|
||||||
|
|
||||||
val result = repository.requestVerificationCode(
|
val result = repository.requestVerificationCode(
|
||||||
sessionId = sessionMetadata.id,
|
sessionId = state.sessionMetadata.id,
|
||||||
smsAutoRetrieveCodeSupported = false,
|
smsAutoRetrieveCodeSupported = false,
|
||||||
transport = transport
|
transport = transport
|
||||||
)
|
)
|
||||||
|
|
||||||
return when (result) {
|
return when (result) {
|
||||||
is NetworkController.RegistrationNetworkResult.Success -> {
|
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 -> {
|
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||||
when (result.error) {
|
when (result.error) {
|
||||||
is NetworkController.RequestVerificationCodeError.InvalidRequest -> {
|
is NetworkController.RequestVerificationCodeError.InvalidRequest -> {
|
||||||
|
Log.w(TAG, "[RequestCode][$transport] Invalid request: ${result.error.message}")
|
||||||
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||||
}
|
}
|
||||||
is NetworkController.RequestVerificationCodeError.RateLimited -> {
|
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 -> {
|
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 -> {
|
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
|
// TODO don't start over, go back to phone number entry
|
||||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> {
|
is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> {
|
||||||
Log.w(TAG, "When requesting verification code, missing request information or already verified.")
|
Log.w(TAG, "[RequestCode][$transport] Missing request information or already verified.")
|
||||||
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(result.error.session))
|
||||||
|
state.copy(
|
||||||
|
oneTimeEvent = OneTimeEvent.NetworkError,
|
||||||
|
sessionMetadata = result.error.session,
|
||||||
|
rateLimits = computeRateLimits(result.error.session)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is NetworkController.RequestVerificationCodeError.SessionNotFound -> {
|
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
|
// TODO don't start over, go back to phone number entry
|
||||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
|
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
|
||||||
|
Log.w(TAG, "[RequestCode][$transport] Third party service error. ${result.error.data}")
|
||||||
state.copy(oneTimeEvent = OneTimeEvent.ThirdPartyError)
|
state.copy(oneTimeEvent = OneTimeEvent.ThirdPartyError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||||
|
Log.w(TAG, "[RequestCode][$transport] Network error.", result.exception)
|
||||||
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||||
}
|
}
|
||||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
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)
|
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(
|
class Factory(
|
||||||
private val repository: RegistrationRepository,
|
private val repository: RegistrationRepository,
|
||||||
private val parentState: StateFlow<RegistrationFlowState>,
|
private val parentState: StateFlow<RegistrationFlowState>,
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ object TestTags {
|
|||||||
const val VERIFICATION_CODE_WRONG_NUMBER_BUTTON = "verification_code_wrong_number_button"
|
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_RESEND_SMS_BUTTON = "verification_code_resend_sms_button"
|
||||||
const val VERIFICATION_CODE_CALL_ME_BUTTON = "verification_code_call_me_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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,16 @@
|
|||||||
<!-- Storage permission row description -->
|
<!-- Storage permission row description -->
|
||||||
<string name="GrantPermissionsFragment__send_photos_videos_and_files">Send photos, videos and files from your device.</string>
|
<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 -->
|
<!-- CountryCodePickerScreen -->
|
||||||
<!-- Title of the country code selection screen -->
|
<!-- Title of the country code selection screen -->
|
||||||
<string name="CountryCodeSelectScreen__your_country">Your country</string>
|
<string name="CountryCodeSelectScreen__your_country">Your country</string>
|
||||||
@@ -42,4 +52,36 @@
|
|||||||
<string name="CountryCodeSelectScreen__search_by">Search by name or number</string>
|
<string name="CountryCodeSelectScreen__search_by">Search by name or number</string>
|
||||||
<!-- Fallback text for countries with no name -->
|
<!-- Fallback text for countries with no name -->
|
||||||
<string name="CountryCodeSelectScreen__unknown_country">Unknown country</string>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -288,8 +288,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
assertThat(emittedStates.last().sessionMetadata).isNotNull()
|
assertThat(emittedStates.last().sessionMetadata).isNotNull()
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
@@ -314,8 +314,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first())
|
assertThat(emittedEvents.first())
|
||||||
@@ -339,8 +339,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
|
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
|
||||||
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
||||||
@@ -363,8 +363,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||||
}
|
}
|
||||||
@@ -382,8 +382,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
|
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
|
||||||
}
|
}
|
||||||
@@ -401,8 +401,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||||
}
|
}
|
||||||
@@ -422,8 +422,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
// Should not create a new session, just request verification code
|
// Should not create a new session, just request verification code
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
@@ -452,8 +452,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
assertThat(emittedStates.last().oneTimeEvent).isNotNull().isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
assertThat(emittedStates.last().oneTimeEvent).isNotNull().isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
||||||
}
|
}
|
||||||
@@ -477,8 +477,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||||
@@ -503,8 +503,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
|
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
|
||||||
}
|
}
|
||||||
@@ -530,8 +530,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.ThirdPartyError)
|
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.ThirdPartyError)
|
||||||
}
|
}
|
||||||
@@ -559,8 +559,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
// Verify navigation to verification code entry
|
// Verify navigation to verification code entry
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
@@ -591,8 +591,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
// Verify navigation continues despite no push challenge token
|
// Verify navigation continues despite no push challenge token
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
@@ -627,8 +627,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
// Verify navigation continues despite push challenge submission failure
|
// Verify navigation continues despite push challenge submission failure
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
@@ -658,8 +658,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
// Verify navigation continues despite network error
|
// Verify navigation continues despite network error
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
@@ -689,8 +689,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
// Verify navigation continues despite application error
|
// Verify navigation continues despite application error
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
@@ -719,8 +719,8 @@ class PhoneNumberEntryViewModelTest {
|
|||||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||||
|
|
||||||
// Verify spinner states
|
// Verify spinner states
|
||||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
assertThat(emittedStates.first().showSpinner).isTrue()
|
||||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
assertThat(emittedStates.last().showSpinner).isFalse()
|
||||||
|
|
||||||
// Verify navigation to captcha
|
// Verify navigation to captcha
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class VerificationCodeScreenTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
composeTestRule.onNodeWithText("Enter verification code").assertIsDisplayed()
|
composeTestRule.onNodeWithText("Verification code").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -70,7 +70,7 @@ class VerificationCodeScreenTest {
|
|||||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).assertIsDisplayed()
|
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).assertIsDisplayed()
|
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_4).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
|
@Test
|
||||||
@@ -191,7 +191,7 @@ class VerificationCodeScreenTest {
|
|||||||
|
|
||||||
// Then
|
// Then
|
||||||
composeTestRule.onNodeWithText("Wrong number?").assertIsDisplayed()
|
composeTestRule.onNodeWithText("Wrong number?").assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithText("Resend SMS").assertIsDisplayed()
|
composeTestRule.onNodeWithText("Resend Code").assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithText("Call me instead").assertIsDisplayed()
|
composeTestRule.onNodeWithText("Call me instead").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import assertk.assertions.isEqualTo
|
|||||||
import assertk.assertions.isInstanceOf
|
import assertk.assertions.isInstanceOf
|
||||||
import assertk.assertions.isNotNull
|
import assertk.assertions.isNotNull
|
||||||
import assertk.assertions.isNull
|
import assertk.assertions.isNull
|
||||||
|
import assertk.assertions.isTrue
|
||||||
import assertk.assertions.prop
|
import assertk.assertions.prop
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
@@ -34,6 +35,8 @@ class VerificationCodeViewModelTest {
|
|||||||
private lateinit var parentState: MutableStateFlow<RegistrationFlowState>
|
private lateinit var parentState: MutableStateFlow<RegistrationFlowState>
|
||||||
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
|
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
|
||||||
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||||
|
private lateinit var emittedStates: MutableList<VerificationCodeState>
|
||||||
|
private lateinit var stateEmitter: (VerificationCodeState) -> Unit
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
@@ -47,6 +50,8 @@ class VerificationCodeViewModelTest {
|
|||||||
)
|
)
|
||||||
emittedEvents = mutableListOf()
|
emittedEvents = mutableListOf()
|
||||||
parentEventEmitter = { event -> emittedEvents.add(event) }
|
parentEventEmitter = { event -> emittedEvents.add(event) }
|
||||||
|
emittedStates = mutableListOf()
|
||||||
|
stateEmitter = { state -> emittedStates.add(state) }
|
||||||
viewModel = VerificationCodeViewModel(mockRepository, parentState, parentEventEmitter)
|
viewModel = VerificationCodeViewModel(mockRepository, parentState, parentEventEmitter)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,24 +138,26 @@ class VerificationCodeViewModelTest {
|
|||||||
oneTimeEvent = VerificationCodeState.OneTimeEvent.NetworkError
|
oneTimeEvent = VerificationCodeState.OneTimeEvent.NetworkError
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
initialState,
|
||||||
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent
|
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent,
|
||||||
|
stateEmitter
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(result.oneTimeEvent).isNull()
|
assertThat(emittedStates.last().oneTimeEvent).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ConsumeInnerOneTimeEvent with null event returns state with null event`() = runTest {
|
fun `ConsumeInnerOneTimeEvent with null event returns state with null event`() = runTest {
|
||||||
val initialState = VerificationCodeState(oneTimeEvent = null)
|
val initialState = VerificationCodeState(oneTimeEvent = null)
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
initialState,
|
||||||
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent
|
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent,
|
||||||
|
stateEmitter
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(result.oneTimeEvent).isNull()
|
assertThat(emittedStates.last().oneTimeEvent).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== applyEvent: WrongNumber Tests ====================
|
// ==================== applyEvent: WrongNumber Tests ====================
|
||||||
@@ -159,7 +166,7 @@ class VerificationCodeViewModelTest {
|
|||||||
fun `WrongNumber navigates to PhoneNumberEntry`() = runTest {
|
fun `WrongNumber navigates to PhoneNumberEntry`() = runTest {
|
||||||
val initialState = VerificationCodeState()
|
val initialState = VerificationCodeState()
|
||||||
|
|
||||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.WrongNumber)
|
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.WrongNumber, stateEmitter)
|
||||||
|
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first())
|
assertThat(emittedEvents.first())
|
||||||
@@ -170,16 +177,42 @@ class VerificationCodeViewModelTest {
|
|||||||
|
|
||||||
// ==================== applyEvent: CodeEntered Tests ====================
|
// ==================== 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
|
@Test
|
||||||
fun `CodeEntered emits ResetState when sessionMetadata is null`() = runTest {
|
fun `CodeEntered emits ResetState when sessionMetadata is null`() = runTest {
|
||||||
val initialState = VerificationCodeState(sessionMetadata = null)
|
val initialState = VerificationCodeState(sessionMetadata = null)
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
initialState,
|
||||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
VerificationCodeScreenEvents.CodeEntered("123456"),
|
||||||
|
stateEmitter
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(initialState)
|
assertThat(emittedStates.last()).isEqualTo(initialState)
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first())
|
assertThat(emittedEvents.first())
|
||||||
.isInstanceOf<RegistrationFlowEvent.ResetState>()
|
.isInstanceOf<RegistrationFlowEvent.ResetState>()
|
||||||
@@ -201,7 +234,7 @@ class VerificationCodeViewModelTest {
|
|||||||
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
|
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
|
||||||
NetworkController.RegistrationNetworkResult.Success(registerResponse to keyMaterial)
|
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).hasSize(2)
|
||||||
assertThat(emittedEvents[0]).isInstanceOf<RegistrationFlowEvent.Registered>()
|
assertThat(emittedEvents[0]).isInstanceOf<RegistrationFlowEvent.Registered>()
|
||||||
@@ -224,12 +257,13 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode("Wrong code")
|
NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode("Wrong code")
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
initialState,
|
||||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
VerificationCodeScreenEvents.CodeEntered("123456"),
|
||||||
|
stateEmitter
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.IncorrectVerificationCode)
|
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.IncorrectVerificationCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -245,7 +279,7 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.SubmitVerificationCodeError.SessionNotFound("Session expired")
|
NetworkController.SubmitVerificationCodeError.SessionNotFound("Session expired")
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
|
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"), stateEmitter)
|
||||||
|
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||||
@@ -269,7 +303,7 @@ class VerificationCodeViewModelTest {
|
|||||||
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
|
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
|
||||||
NetworkController.RegistrationNetworkResult.Success(registerResponse to keyMaterial)
|
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).hasSize(2)
|
||||||
assertThat(emittedEvents[0]).isInstanceOf<RegistrationFlowEvent.Registered>()
|
assertThat(emittedEvents[0]).isInstanceOf<RegistrationFlowEvent.Registered>()
|
||||||
@@ -292,7 +326,7 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(unverifiedSession)
|
NetworkController.SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(unverifiedSession)
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
|
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"), stateEmitter)
|
||||||
|
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
|
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
|
||||||
@@ -311,12 +345,13 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.SubmitVerificationCodeError.RateLimited(60.seconds, sessionMetadata)
|
NetworkController.SubmitVerificationCodeError.RateLimited(60.seconds, sessionMetadata)
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
initialState,
|
||||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
VerificationCodeScreenEvents.CodeEntered("123456"),
|
||||||
|
stateEmitter
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(result.oneTimeEvent).isNotNull()
|
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
|
||||||
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
|
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
|
||||||
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
||||||
.isEqualTo(60.seconds)
|
.isEqualTo(60.seconds)
|
||||||
@@ -333,12 +368,13 @@ class VerificationCodeViewModelTest {
|
|||||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
|
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
initialState,
|
||||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
VerificationCodeScreenEvents.CodeEntered("123456"),
|
||||||
|
stateEmitter
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
|
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -352,12 +388,13 @@ class VerificationCodeViewModelTest {
|
|||||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||||
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
|
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
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 ====================
|
// ==================== applyEvent: CodeEntered - Registration Errors ====================
|
||||||
@@ -378,7 +415,7 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RegisterAccountError.DeviceTransferPossible
|
NetworkController.RegisterAccountError.DeviceTransferPossible
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
|
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"), stateEmitter)
|
||||||
|
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||||
@@ -400,12 +437,13 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RegisterAccountError.RateLimited(30.seconds)
|
NetworkController.RegisterAccountError.RateLimited(30.seconds)
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
initialState,
|
||||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
VerificationCodeScreenEvents.CodeEntered("123456"),
|
||||||
|
stateEmitter
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(result.oneTimeEvent).isNotNull()
|
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
|
||||||
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
|
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
|
||||||
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
||||||
.isEqualTo(30.seconds)
|
.isEqualTo(30.seconds)
|
||||||
@@ -427,12 +465,13 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RegisterAccountError.InvalidRequest("Bad request")
|
NetworkController.RegisterAccountError.InvalidRequest("Bad request")
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
initialState,
|
||||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
VerificationCodeScreenEvents.CodeEntered("123456"),
|
||||||
|
stateEmitter
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
|
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Ignore
|
@Ignore
|
||||||
@@ -451,12 +490,13 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect("Wrong password")
|
NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect("Wrong password")
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
initialState,
|
||||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
VerificationCodeScreenEvents.CodeEntered("123456"),
|
||||||
|
stateEmitter
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
|
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Ignore
|
@Ignore
|
||||||
@@ -473,12 +513,13 @@ class VerificationCodeViewModelTest {
|
|||||||
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
|
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
|
||||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
|
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
initialState,
|
||||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
VerificationCodeScreenEvents.CodeEntered("123456"),
|
||||||
|
stateEmitter
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
|
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Ignore
|
@Ignore
|
||||||
@@ -495,12 +536,13 @@ class VerificationCodeViewModelTest {
|
|||||||
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
|
coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns
|
||||||
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
|
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
|
||||||
|
|
||||||
val result = viewModel.applyEvent(
|
viewModel.applyEvent(
|
||||||
initialState,
|
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 ====================
|
// ==================== applyEvent: ResendSms Tests ====================
|
||||||
@@ -509,11 +551,11 @@ class VerificationCodeViewModelTest {
|
|||||||
fun `ResendSms with null sessionMetadata emits ResetState`() = runTest {
|
fun `ResendSms with null sessionMetadata emits ResetState`() = runTest {
|
||||||
val initialState = VerificationCodeState(sessionMetadata = null)
|
val initialState = VerificationCodeState(sessionMetadata = null)
|
||||||
|
|
||||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
|
||||||
|
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||||
assertThat(result).isEqualTo(initialState)
|
assertThat(emittedStates.last()).isEqualTo(initialState)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -525,9 +567,9 @@ class VerificationCodeViewModelTest {
|
|||||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||||
NetworkController.RegistrationNetworkResult.Success(updatedSession)
|
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
|
@Test
|
||||||
@@ -540,9 +582,9 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RequestVerificationCodeError.RateLimited(45.seconds, sessionMetadata)
|
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>()
|
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
|
||||||
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
||||||
.isEqualTo(45.seconds)
|
.isEqualTo(45.seconds)
|
||||||
@@ -558,9 +600,9 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RequestVerificationCodeError.InvalidRequest("Bad request")
|
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
|
@Test
|
||||||
@@ -573,9 +615,9 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(sessionMetadata)
|
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
|
@Test
|
||||||
@@ -588,7 +630,7 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RequestVerificationCodeError.InvalidSessionId("Invalid session")
|
NetworkController.RequestVerificationCodeError.InvalidSessionId("Invalid session")
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
|
||||||
|
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||||
@@ -604,7 +646,7 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RequestVerificationCodeError.SessionNotFound("Session not found")
|
NetworkController.RequestVerificationCodeError.SessionNotFound("Session not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
|
||||||
|
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||||
@@ -620,9 +662,9 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified(sessionMetadata)
|
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
|
@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
|
@Test
|
||||||
@@ -650,9 +692,9 @@ class VerificationCodeViewModelTest {
|
|||||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
|
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
|
@Test
|
||||||
@@ -663,9 +705,9 @@ class VerificationCodeViewModelTest {
|
|||||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||||
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
|
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 ====================
|
// ==================== applyEvent: CallMe Tests ====================
|
||||||
@@ -674,11 +716,11 @@ class VerificationCodeViewModelTest {
|
|||||||
fun `CallMe with null sessionMetadata emits ResetState`() = runTest {
|
fun `CallMe with null sessionMetadata emits ResetState`() = runTest {
|
||||||
val initialState = VerificationCodeState(sessionMetadata = null)
|
val initialState = VerificationCodeState(sessionMetadata = null)
|
||||||
|
|
||||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
|
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter)
|
||||||
|
|
||||||
assertThat(emittedEvents).hasSize(1)
|
assertThat(emittedEvents).hasSize(1)
|
||||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||||
assertThat(result).isEqualTo(initialState)
|
assertThat(emittedStates.last()).isEqualTo(initialState)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -690,9 +732,9 @@ class VerificationCodeViewModelTest {
|
|||||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.VOICE)) } returns
|
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.VOICE)) } returns
|
||||||
NetworkController.RegistrationNetworkResult.Success(updatedSession)
|
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
|
@Test
|
||||||
@@ -705,9 +747,9 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RequestVerificationCodeError.RateLimited(90.seconds, sessionMetadata)
|
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>()
|
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
|
||||||
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
||||||
.isEqualTo(90.seconds)
|
.isEqualTo(90.seconds)
|
||||||
@@ -723,9 +765,9 @@ class VerificationCodeViewModelTest {
|
|||||||
NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(sessionMetadata)
|
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
|
@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 ====================
|
// ==================== Helper Functions ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user