From 8b4a38a2c60486f3d72e358c623595ada64d8623 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 7 Jul 2026 12:18:06 -0400 Subject: [PATCH] Improve digit interactions on regV5 verification screen. --- .../screens/pinentry/PinEntryScreen.kt | 2 +- .../VerificationCodeScreen.kt | 116 ++-------- .../VerificationCodeScreenEvents.kt | 9 + .../verificationcode/VerificationCodeState.kt | 23 +- .../VerificationCodeViewModel.kt | 97 ++++++++- .../VerificationCodeScreenTest.kt | 92 +++++--- .../VerificationCodeViewModelTest.kt | 201 ++++++++++++++++++ 7 files changed, 406 insertions(+), 134 deletions(-) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt index beca8810a0..d82f09fadf 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt @@ -319,7 +319,7 @@ private fun PinDescription( Text( text = titleString, style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, + textAlign = TextAlign.Start, modifier = Modifier .fillMaxWidth() .attachDebugLogHelper() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt index f16fbe4f8c..fdfad92801 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt @@ -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, focusRequesters: List, state: VerificationCodeState, - onEvent: (VerificationCodeScreenEvents) -> Unit, - onDigitsChanged: (List) -> 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, focusRequesters: List, state: VerificationCodeState, - onEvent: (VerificationCodeScreenEvents) -> Unit, - onDigitsChanged: (List) -> 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, focusRequesters: List, state: VerificationCodeState, - onDigitsChanged: (List) -> 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, - focusRequesters: List, - onDigitsChanged: (List) -> 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 diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt index d5189da4ca..64aa8a3913 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt @@ -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. */ diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt index 3a17afeb71..af6bc2f960 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt @@ -16,9 +16,30 @@ data class VerificationCodeState( val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(), val incorrectCodeAttempts: Int = 0, val autoFillCode: String? = null, + val digits: List = 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 = List(CODE_LENGTH) { "" } + } sealed interface OneTimeEvent { data object NetworkError : OneTimeEvent diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt index 9869ecabf1..f44a2c1039 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt @@ -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 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt index e36795043b..4501c52b34 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt @@ -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() 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() + 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() + + 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() + 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() 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().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() + assert(digitChanges == (0 until 6).map { VerificationCodeScreenEvents.DigitChanged(it, "${it + 1}") }) { + "Expected a DigitChanged per digit but got $digitChanges" } } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt index 9c56b94d21..78dab1ae95 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt @@ -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