mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-14 20:57:26 +01:00
Improve digit interactions on regV5 verification screen.
This commit is contained in:
+1
-1
@@ -319,7 +319,7 @@ private fun PinDescription(
|
||||
Text(
|
||||
text = titleString,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.attachDebugLogHelper()
|
||||
|
||||
+19
-97
@@ -33,10 +33,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -74,8 +71,7 @@ fun VerificationCodeScreen(
|
||||
onEvent: (VerificationCodeScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var digits by remember { mutableStateOf(List(6) { "" }) }
|
||||
val focusRequesters = remember { List(6) { FocusRequester() } }
|
||||
val focusRequesters = remember { List(VerificationCodeState.CODE_LENGTH) { FocusRequester() } }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val resources = LocalResources.current
|
||||
|
||||
@@ -88,20 +84,12 @@ fun VerificationCodeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(digits) {
|
||||
if (digits.all { it.isNotEmpty() } && !state.isSubmittingCode) {
|
||||
val code = digits.joinToString("")
|
||||
onEvent(VerificationCodeScreenEvents.CodeEntered(code))
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.autoFillCode) {
|
||||
val code = state.autoFillCode ?: return@LaunchedEffect
|
||||
|
||||
if (code.length == 6 && code.all { it.isDigit() } && !state.isSubmittingCode) {
|
||||
if (code.length == VerificationCodeState.CODE_LENGTH && code.all { it.isDigit() } && !state.isSubmittingCode) {
|
||||
code.forEachIndexed { index, digit ->
|
||||
digits = digits.toMutableList().also { it[index] = digit.toString() }
|
||||
delay(200)
|
||||
onEvent(VerificationCodeScreenEvents.DigitChanged(index, digit.toString()))
|
||||
}
|
||||
}
|
||||
onEvent(VerificationCodeScreenEvents.ConsumeAutoFillCode)
|
||||
@@ -112,8 +100,6 @@ fun VerificationCodeScreen(
|
||||
|
||||
when (event) {
|
||||
VerificationCodeState.OneTimeEvent.IncorrectVerificationCode -> {
|
||||
digits = List(6) { "" }
|
||||
focusRequesters[0].requestFocus()
|
||||
snackbarHostState.showSnackbar(resources.getString(R.string.VerificationCodeScreen__incorrect_code))
|
||||
}
|
||||
|
||||
@@ -144,8 +130,8 @@ fun VerificationCodeScreen(
|
||||
onEvent(VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequesters[0].requestFocus()
|
||||
LaunchedEffect(state.focusedDigitIndex) {
|
||||
focusRequesters[state.focusedDigitIndex].requestFocus()
|
||||
}
|
||||
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
@@ -160,21 +146,17 @@ fun VerificationCodeScreen(
|
||||
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(
|
||||
params = layoutParams,
|
||||
innerPadding = innerPadding,
|
||||
digits = digits,
|
||||
focusRequesters = focusRequesters,
|
||||
state = state,
|
||||
onEvent = onEvent,
|
||||
onDigitsChanged = { digits = it }
|
||||
onEvent = onEvent
|
||||
)
|
||||
|
||||
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(
|
||||
params = layoutParams,
|
||||
innerPadding = innerPadding,
|
||||
digits = digits,
|
||||
focusRequesters = focusRequesters,
|
||||
state = state,
|
||||
onEvent = onEvent,
|
||||
onDigitsChanged = { digits = it }
|
||||
onEvent = onEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -184,11 +166,9 @@ fun VerificationCodeScreen(
|
||||
private fun OnePaneLayout(
|
||||
params: RegistrationScaffold.Params.OnePane,
|
||||
innerPadding: PaddingValues,
|
||||
digits: List<String>,
|
||||
focusRequesters: List<FocusRequester>,
|
||||
state: VerificationCodeState,
|
||||
onEvent: (VerificationCodeScreenEvents) -> Unit,
|
||||
onDigitsChanged: (List<String>) -> Unit
|
||||
onEvent: (VerificationCodeScreenEvents) -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
@@ -210,10 +190,9 @@ private fun OnePaneLayout(
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
CodeField(
|
||||
digits = digits,
|
||||
focusRequesters = focusRequesters,
|
||||
state = state,
|
||||
onDigitsChanged = onDigitsChanged
|
||||
emitter = onEvent
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
@@ -244,11 +223,9 @@ private fun OnePaneLayout(
|
||||
private fun TwoPaneLayout(
|
||||
params: RegistrationScaffold.Params.TwoPane,
|
||||
innerPadding: PaddingValues,
|
||||
digits: List<String>,
|
||||
focusRequesters: List<FocusRequester>,
|
||||
state: VerificationCodeState,
|
||||
onEvent: (VerificationCodeScreenEvents) -> Unit,
|
||||
onDigitsChanged: (List<String>) -> Unit
|
||||
onEvent: (VerificationCodeScreenEvents) -> Unit
|
||||
) {
|
||||
val firstPaneScrollState = rememberScrollState()
|
||||
val secondPaneScrollState = rememberScrollState()
|
||||
@@ -277,10 +254,9 @@ private fun TwoPaneLayout(
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
CodeField(
|
||||
digits = digits,
|
||||
focusRequesters = focusRequesters,
|
||||
state = state,
|
||||
onDigitsChanged = onDigitsChanged
|
||||
emitter = onEvent
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
@@ -325,11 +301,12 @@ private fun TroubleButton(onEvent: (VerificationCodeScreenEvents) -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun CodeField(
|
||||
digits: List<String>,
|
||||
focusRequesters: List<FocusRequester>,
|
||||
state: VerificationCodeState,
|
||||
onDigitsChanged: (List<String>) -> Unit
|
||||
emitter: (VerificationCodeScreenEvents) -> Unit
|
||||
) {
|
||||
val digits = state.digits
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -344,16 +321,7 @@ private fun CodeField(
|
||||
for (i in 0..2) {
|
||||
DigitField(
|
||||
value = digits[i],
|
||||
onValueChange = { newValue, isBackspace ->
|
||||
handleDigitChange(
|
||||
index = i,
|
||||
newValue = newValue,
|
||||
isBackspace = isBackspace,
|
||||
digits = digits,
|
||||
focusRequesters = focusRequesters,
|
||||
onDigitsChanged = onDigitsChanged
|
||||
)
|
||||
},
|
||||
onValueChange = { newValue -> emitter(VerificationCodeScreenEvents.DigitChanged(i, newValue)) },
|
||||
focusRequester = focusRequesters[i],
|
||||
testTag = when (i) {
|
||||
0 -> TestTags.VERIFICATION_CODE_DIGIT_0
|
||||
@@ -380,16 +348,7 @@ private fun CodeField(
|
||||
}
|
||||
DigitField(
|
||||
value = digits[i],
|
||||
onValueChange = { newValue, isBackspace ->
|
||||
handleDigitChange(
|
||||
index = i,
|
||||
newValue = newValue,
|
||||
isBackspace = isBackspace,
|
||||
digits = digits,
|
||||
focusRequesters = focusRequesters,
|
||||
onDigitsChanged = onDigitsChanged
|
||||
)
|
||||
},
|
||||
onValueChange = { newValue -> emitter(VerificationCodeScreenEvents.DigitChanged(i, newValue)) },
|
||||
focusRequester = focusRequesters[i],
|
||||
testTag = when (i) {
|
||||
3 -> TestTags.VERIFICATION_CODE_DIGIT_3
|
||||
@@ -494,37 +453,10 @@ private fun Description(state: VerificationCodeState, onEvent: (VerificationCode
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDigitChange(
|
||||
index: Int,
|
||||
newValue: String,
|
||||
isBackspace: Boolean,
|
||||
digits: List<String>,
|
||||
focusRequesters: List<FocusRequester>,
|
||||
onDigitsChanged: (List<String>) -> Unit
|
||||
) {
|
||||
if (isBackspace) {
|
||||
val deleteAt = if (digits[index].isNotEmpty()) index else index - 1
|
||||
if (deleteAt >= 0) {
|
||||
onDigitsChanged(
|
||||
digits.toMutableList().apply {
|
||||
for (j in deleteAt until 5) {
|
||||
this[j] = this[j + 1]
|
||||
}
|
||||
this[5] = ""
|
||||
}
|
||||
)
|
||||
focusRequesters[(index - 1).coerceAtLeast(0)].requestFocus()
|
||||
}
|
||||
} else if (newValue.isNotEmpty() && newValue[0].isDigit()) {
|
||||
onDigitsChanged(digits.toMutableList().apply { this[index] = newValue })
|
||||
focusRequesters[(index + 1).coerceAtMost(5)].requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DigitField(
|
||||
value: String,
|
||||
onValueChange: (String, Boolean) -> Unit,
|
||||
onValueChange: (String) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
testTag: String,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -532,24 +464,14 @@ private fun DigitField(
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { newValue ->
|
||||
val capped = if (newValue.length > 1) {
|
||||
if (newValue.first().toString() == value) {
|
||||
newValue.last().toString()
|
||||
} else {
|
||||
newValue.first().toString()
|
||||
}
|
||||
} else newValue
|
||||
val isBackspace = capped.isEmpty() && value.isNotEmpty()
|
||||
onValueChange(capped, isBackspace)
|
||||
},
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier
|
||||
.width(48.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.testTag(testTag)
|
||||
.onKeyEvent { keyEvent ->
|
||||
if ((keyEvent.key == Key.Backspace || keyEvent.key == Key.Delete) && value.isEmpty()) {
|
||||
onValueChange("", true)
|
||||
onValueChange("")
|
||||
true
|
||||
} else {
|
||||
false
|
||||
|
||||
+9
@@ -12,6 +12,15 @@ sealed class VerificationCodeScreenEvents {
|
||||
override fun toString(): String = "CodeEntered(code=${code.censor()})"
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw [value] of the digit field at [index] changed. The view model interprets it: a single digit is recorded
|
||||
* (submitting once the full code is present), an empty [value] is a backspace (deleting a digit and shifting the
|
||||
* following ones left), and multi-character input (e.g. a pasted "123-456") is treated as a pasted code.
|
||||
*/
|
||||
data class DigitChanged(val index: Int, val value: String) : VerificationCodeScreenEvents() {
|
||||
override fun toString(): String = "DigitChanged(index=$index)"
|
||||
}
|
||||
|
||||
/**
|
||||
* A verification code was automatically retrieved from an incoming SMS via the Play Services SMS retriever.
|
||||
*/
|
||||
|
||||
+22
-1
@@ -16,9 +16,30 @@ data class VerificationCodeState(
|
||||
val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(),
|
||||
val incorrectCodeAttempts: Int = 0,
|
||||
val autoFillCode: String? = null,
|
||||
val digits: List<String> = List(CODE_LENGTH) { "" },
|
||||
val focusedDigitIndex: Int = 0,
|
||||
val oneTimeEvent: OneTimeEvent? = null
|
||||
) {
|
||||
override fun toString(): String = "VerificationCodeState(sessionMetadata=${sessionMetadata?.let { "present" }}, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, oneTimeEvent=$oneTimeEvent)"
|
||||
override fun toString(): String = "VerificationCodeState(sessionMetadata=${sessionMetadata?.let { "present" }}, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, digitsEntered=${digits.count { it.isNotEmpty() }}, focusedDigitIndex=$focusedDigitIndex, oneTimeEvent=$oneTimeEvent)"
|
||||
|
||||
/**
|
||||
* The full code as currently entered. Only meaningful when [isComplete] is true.
|
||||
*/
|
||||
val code: String get() = digits.joinToString("")
|
||||
|
||||
/**
|
||||
* True once every digit field has a value.
|
||||
*/
|
||||
val isComplete: Boolean get() = digits.size == CODE_LENGTH && digits.all { it.isNotEmpty() }
|
||||
|
||||
companion object {
|
||||
const val CODE_LENGTH = 6
|
||||
|
||||
/**
|
||||
* A fully empty set of digits, used to reset the fields.
|
||||
*/
|
||||
fun emptyDigits(): List<String> = List(CODE_LENGTH) { "" }
|
||||
}
|
||||
|
||||
sealed interface OneTimeEvent {
|
||||
data object NetworkError : OneTimeEvent
|
||||
|
||||
+91
-6
@@ -55,6 +55,8 @@ class VerificationCodeViewModel(
|
||||
companion object {
|
||||
private val TAG = Log.tag(VerificationCodeViewModel::class)
|
||||
|
||||
private const val CODE_LENGTH = VerificationCodeState.CODE_LENGTH
|
||||
|
||||
/**
|
||||
* How old the in-progress registration data can be before we assume the verification session has expired and
|
||||
* restart the flow. Checked whenever the screen is foregrounded.
|
||||
@@ -118,10 +120,8 @@ class VerificationCodeViewModel(
|
||||
@VisibleForTesting
|
||||
suspend fun applyEvent(state: VerificationCodeState, event: VerificationCodeScreenEvents, stateEmitter: (VerificationCodeState) -> Unit) {
|
||||
val result = when (event) {
|
||||
is VerificationCodeScreenEvents.CodeEntered -> {
|
||||
stateEmitter(state.copy(isSubmittingCode = true))
|
||||
applyCodeEntered(state, event.code).copy(isSubmittingCode = false)
|
||||
}
|
||||
is VerificationCodeScreenEvents.CodeEntered -> submitCode(state, event.code, stateEmitter)
|
||||
is VerificationCodeScreenEvents.DigitChanged -> applyDigitChanged(state, event.index, event.value, stateEmitter)
|
||||
is VerificationCodeScreenEvents.CodeAutoFilled -> state.copy(autoFillCode = event.code)
|
||||
is VerificationCodeScreenEvents.ConsumeAutoFillCode -> state.copy(autoFillCode = null)
|
||||
is VerificationCodeScreenEvents.WrongNumber -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) }
|
||||
@@ -186,6 +186,91 @@ class VerificationCodeViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interprets the raw [value] reported by the digit field at [index] and updates the digits and focus accordingly:
|
||||
*
|
||||
* - an empty [value] is a backspace, deleting a digit and moving focus back
|
||||
* - a single digit is recorded and focus advances, submitting once the full code is present
|
||||
* - multi-character input (e.g. a pasted "123-456") is treated as a pasted code
|
||||
*/
|
||||
private suspend fun applyDigitChanged(
|
||||
state: VerificationCodeState,
|
||||
index: Int,
|
||||
value: String,
|
||||
stateEmitter: (VerificationCodeState) -> Unit
|
||||
): VerificationCodeState {
|
||||
check(index in state.digits.indices) { "[DigitChanged] Out of bounds index $index." }
|
||||
|
||||
if (value.isEmpty()) {
|
||||
return deleteDigit(state, index)
|
||||
}
|
||||
|
||||
val currentValue = state.digits[index]
|
||||
val remainder = if (currentValue.isNotEmpty()) value.replaceFirst(currentValue, "") else value
|
||||
val addedDigits = remainder.filter { it.isDigit() }
|
||||
|
||||
return when {
|
||||
addedDigits.isEmpty() -> state
|
||||
|
||||
addedDigits.length == 1 -> {
|
||||
val updated = state.copy(
|
||||
digits = state.digits.toMutableList().also { it[index] = addedDigits },
|
||||
focusedDigitIndex = (index + 1).coerceAtMost(CODE_LENGTH - 1)
|
||||
)
|
||||
|
||||
if (updated.isComplete && !updated.isSubmittingCode) {
|
||||
submitCode(updated, updated.code, stateEmitter)
|
||||
} else {
|
||||
updated
|
||||
}
|
||||
}
|
||||
|
||||
else -> applyPastedCode(state, remainder)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the digit at [index] (or the previous one, if [index] is already empty), shifts any following digits left
|
||||
* to fill the gap, and moves focus back.
|
||||
*/
|
||||
private fun deleteDigit(state: VerificationCodeState, index: Int): VerificationCodeState {
|
||||
val deleteAt = if (state.digits[index].isNotEmpty()) index else index - 1
|
||||
if (deleteAt < 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
val newDigits = state.digits.toMutableList().apply {
|
||||
for (j in deleteAt until CODE_LENGTH - 1) {
|
||||
this[j] = this[j + 1]
|
||||
}
|
||||
this[CODE_LENGTH - 1] = ""
|
||||
}
|
||||
|
||||
return state.copy(digits = newDigits, focusedDigitIndex = (index - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an intermediate submitting state and then runs the submission, clearing the submitting flag when done.
|
||||
*/
|
||||
private suspend fun submitCode(state: VerificationCodeState, code: String, stateEmitter: (VerificationCodeState) -> Unit): VerificationCodeState {
|
||||
stateEmitter(state.copy(isSubmittingCode = true))
|
||||
return applyCodeEntered(state, code).copy(isSubmittingCode = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips any formatting (e.g. a hyphen) from pasted text and, if what remains is a full code, populates the fields
|
||||
* by reusing the [VerificationCodeState.autoFillCode] path. Pasted text that doesn't contain a full code is ignored.
|
||||
*/
|
||||
private fun applyPastedCode(state: VerificationCodeState, rawCode: String): VerificationCodeState {
|
||||
val digits = rawCode.filter { it.isDigit() }
|
||||
if (digits.length != CODE_LENGTH) {
|
||||
Log.w(TAG, "[DigitChanged] Ignoring pasted text containing ${digits.length} digits.")
|
||||
return state
|
||||
}
|
||||
|
||||
return state.copy(autoFillCode = digits)
|
||||
}
|
||||
|
||||
private suspend fun applyCodeEntered(inputState: VerificationCodeState, code: String): VerificationCodeState {
|
||||
var state = inputState
|
||||
var sessionMetadata = state.sessionMetadata ?: return state.also {
|
||||
@@ -205,7 +290,7 @@ class VerificationCodeViewModel(
|
||||
is NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode -> {
|
||||
Log.w(TAG, "[SubmitCode] Invalid sessionId or verification code entered. This is distinct from an *incorrect* verification code. Body: ${error.message}")
|
||||
val newAttempts = state.incorrectCodeAttempts + 1
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts)
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts, digits = VerificationCodeState.emptyDigits(), focusedDigitIndex = 0)
|
||||
}
|
||||
is NetworkController.SubmitVerificationCodeError.SessionNotFound -> {
|
||||
Log.w(TAG, "[SubmitCode] Session not found: ${error.message}")
|
||||
@@ -244,7 +329,7 @@ class VerificationCodeViewModel(
|
||||
if (!sessionMetadata.verified) {
|
||||
Log.w(TAG, "[SubmitCode] Verification code was incorrect.")
|
||||
val newAttempts = state.incorrectCodeAttempts + 1
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts)
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts, digits = VerificationCodeState.emptyDigits(), focusedDigitIndex = 0)
|
||||
}
|
||||
|
||||
// Attempt to register
|
||||
|
||||
+63
-29
@@ -7,6 +7,7 @@ package org.signal.registration.screens.verificationcode
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
@@ -143,67 +144,100 @@ class VerificationCodeScreenTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entering complete code emits CodeEntered event`() {
|
||||
fun `entering a digit emits DigitChanged for that field`() {
|
||||
// Given
|
||||
var emittedEvent: VerificationCodeScreenEvents? = null
|
||||
val emittedEvents = mutableListOf<VerificationCodeScreenEvents>()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = { event ->
|
||||
emittedEvent = event
|
||||
}
|
||||
onEvent = { emittedEvents.add(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When - enter all 6 digits
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).performTextInput("1")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_1).performTextInput("2")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).performTextInput("3")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).performTextInput("4")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_4).performTextInput("5")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).performTextInput("6")
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Then
|
||||
assert(emittedEvent is VerificationCodeScreenEvents.CodeEntered) {
|
||||
"Expected CodeEntered event but got $emittedEvent"
|
||||
val digitChanges = emittedEvents.filterIsInstance<VerificationCodeScreenEvents.DigitChanged>()
|
||||
assert(digitChanges.contains(VerificationCodeScreenEvents.DigitChanged(0, "1"))) {
|
||||
"Expected DigitChanged(0, 1) but got $digitChanges"
|
||||
}
|
||||
assert((emittedEvent as VerificationCodeScreenEvents.CodeEntered).code == "123456") {
|
||||
"Expected code '123456' but got ${(emittedEvent as VerificationCodeScreenEvents.CodeEntered).code}"
|
||||
assert(digitChanges.contains(VerificationCodeScreenEvents.DigitChanged(1, "2"))) {
|
||||
"Expected DigitChanged(1, 2) but got $digitChanges"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `autoFillCode populates the fields and emits CodeEntered`() {
|
||||
fun `screen renders the digits from state`() {
|
||||
// Given
|
||||
var emittedEvent: VerificationCodeScreenEvents? = null
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(digits = listOf("1", "2", "3", "4", "5", "6")),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).assertTextEquals("1")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).assertTextEquals("6")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pasting into a field emits DigitChanged with the raw text`() {
|
||||
// Given
|
||||
val emittedEvents = mutableListOf<VerificationCodeScreenEvents>()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = { emittedEvents.add(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When - paste the entire code, including the hyphen, into the first field
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).performTextInput("123-456")
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Then
|
||||
val digitChanges = emittedEvents.filterIsInstance<VerificationCodeScreenEvents.DigitChanged>()
|
||||
assert(digitChanges.contains(VerificationCodeScreenEvents.DigitChanged(0, "123-456"))) {
|
||||
"Expected DigitChanged(0, 123-456) but got $digitChanges"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `autoFillCode emits a DigitChanged for each digit`() {
|
||||
// Given
|
||||
val emittedEvents = mutableListOf<VerificationCodeScreenEvents>()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(autoFillCode = "123456"),
|
||||
onEvent = { event ->
|
||||
if (event is VerificationCodeScreenEvents.CodeEntered) {
|
||||
emittedEvent = event
|
||||
}
|
||||
}
|
||||
onEvent = { emittedEvents.add(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When - the auto-fill effect staggers digits into the fields
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) { emittedEvent != null }
|
||||
// When - the auto-fill effect populates the fields
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||
emittedEvents.filterIsInstance<VerificationCodeScreenEvents.DigitChanged>().size == 6
|
||||
}
|
||||
|
||||
// Then
|
||||
assert(emittedEvent is VerificationCodeScreenEvents.CodeEntered) {
|
||||
"Expected CodeEntered event but got $emittedEvent"
|
||||
}
|
||||
assert((emittedEvent as VerificationCodeScreenEvents.CodeEntered).code == "123456") {
|
||||
"Expected code '123456' but got ${(emittedEvent as VerificationCodeScreenEvents.CodeEntered).code}"
|
||||
val digitChanges = emittedEvents.filterIsInstance<VerificationCodeScreenEvents.DigitChanged>()
|
||||
assert(digitChanges == (0 until 6).map { VerificationCodeScreenEvents.DigitChanged(it, "${it + 1}") }) {
|
||||
"Expected a DigitChanged per digit but got $digitChanges"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+201
@@ -196,6 +196,45 @@ class VerificationCodeViewModelTest {
|
||||
assertThat(emittedStates.last().autoFillCode).isEqualTo("123456")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged with pasted hyphenated text stores the stripped code in autoFillCode`() = runTest {
|
||||
val initialState = VerificationCodeState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(0, "123-456"),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates.last().autoFillCode).isEqualTo("123456")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged with a pasted plain code stores it in autoFillCode`() = runTest {
|
||||
val initialState = VerificationCodeState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(0, "123456"),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates.last().autoFillCode).isEqualTo("123456")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged with pasted text of the wrong length is ignored`() = runTest {
|
||||
val initialState = VerificationCodeState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(0, "12-345"),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates.last().autoFillCode).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConsumeAutoFillCode clears autoFillCode`() = runTest {
|
||||
val initialState = VerificationCodeState(autoFillCode = "123456")
|
||||
@@ -223,6 +262,168 @@ class VerificationCodeViewModelTest {
|
||||
assertThat(vm.state.value.autoFillCode).isEqualTo("123456")
|
||||
}
|
||||
|
||||
// ==================== applyEvent: DigitChanged Tests ====================
|
||||
|
||||
@Test
|
||||
fun `DigitChanged records the value at the given index`() = runTest {
|
||||
val initialState = VerificationCodeState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(2, "7"),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates.last().digits).isEqualTo(listOf("", "", "7", "", "", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged advances the focused digit index`() = runTest {
|
||||
val initialState = VerificationCodeState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(2, "7"),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates.last().focusedDigitIndex).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged with an empty value moves the focused digit index back`() = runTest {
|
||||
val initialState = VerificationCodeState(digits = listOf("1", "2", "3", "", "", ""))
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(2, ""),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates.last().focusedDigitIndex).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged with an out-of-bounds index throws`() = runTest {
|
||||
var threw = false
|
||||
try {
|
||||
viewModel.applyEvent(
|
||||
VerificationCodeState(),
|
||||
VerificationCodeScreenEvents.DigitChanged(9, "7"),
|
||||
stateEmitter
|
||||
)
|
||||
} catch (e: IllegalStateException) {
|
||||
threw = true
|
||||
}
|
||||
|
||||
assertThat(threw).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged completing the code submits it`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567",
|
||||
digits = listOf("1", "2", "3", "4", "5", "")
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
RequestResult.NonSuccess(
|
||||
NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode("Wrong code")
|
||||
)
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(5, "6"),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
coVerify { mockRepository.submitVerificationCode(sessionMetadata.id, "123456") }
|
||||
assertThat(emittedStates.first().isSubmittingCode).isTrue()
|
||||
assertThat(emittedStates.last().isSubmittingCode).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged does not submit until the code is complete`() = runTest {
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = createSessionMetadata(),
|
||||
e164 = "+15551234567",
|
||||
digits = listOf("1", "2", "3", "4", "", "")
|
||||
)
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(4, "5"),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
coVerify(exactly = 0) { mockRepository.submitVerificationCode(any(), any()) }
|
||||
assertThat(emittedStates.last().isSubmittingCode).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an incorrect code clears the entered digits`() = runTest {
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = createSessionMetadata(),
|
||||
e164 = "+15551234567",
|
||||
digits = listOf("1", "2", "3", "4", "5", "")
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
RequestResult.NonSuccess(
|
||||
NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode("Wrong code")
|
||||
)
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(5, "6"),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates.last().digits).isEqualTo(listOf("", "", "", "", "", ""))
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.IncorrectVerificationCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged with an empty value clears the digit at the index`() = runTest {
|
||||
val initialState = VerificationCodeState(digits = listOf("1", "2", "3", "", "", ""))
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(2, ""),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates.last().digits).isEqualTo(listOf("1", "2", "", "", "", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged with an empty value shifts the following digits left`() = runTest {
|
||||
val initialState = VerificationCodeState(digits = listOf("1", "2", "3", "4", "5", "6"))
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(2, ""),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates.last().digits).isEqualTo(listOf("1", "2", "4", "5", "6", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DigitChanged with an empty value on an empty field clears the previous digit`() = runTest {
|
||||
val initialState = VerificationCodeState(digits = listOf("1", "2", "", "", "", ""))
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.DigitChanged(2, ""),
|
||||
stateEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates.last().digits).isEqualTo(listOf("1", "", "", "", "", ""))
|
||||
}
|
||||
|
||||
// ==================== applyEvent: WrongNumber Tests ====================
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user