From 01fae0aaf90d508f9675053485bad96e56d3b50c Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 28 Aug 2026 23:16:45 -0400 Subject: [PATCH] Add UI scaffolding for TotpEntryScreen. --- .../screens/totpentry/TotpEntryAction.kt | 24 ++ .../screens/totpentry/TotpEntryScreen.kt | 361 ++++++++++++++++++ .../totpentry/TotpEntryScreenEvents.kt | 24 ++ .../screens/totpentry/TotpEntryState.kt | 30 ++ .../screens/totpentry/TotpEntryViewModel.kt | 139 +++++++ .../VerificationCodeScreen.kt | 2 + .../org/signal/registration/test/TestTags.kt | 11 + .../main/res/drawable/image_totp_phone.xml | 34 ++ .../src/main/res/values/strings.xml | 8 + ...review_phone portrait (day)_e052ce8c_0.png | 4 +- ...view_phone portrait (night)_cc3c9530_0.png | 4 +- .../screens/totpentry/TotpEntryScreenTest.kt | 109 ++++++ .../totpentry/TotpEntryViewModelTest.kt | 141 +++++++ .../VerificationCodeScreenTest.kt | 2 +- 14 files changed, 888 insertions(+), 5 deletions(-) create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryAction.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreen.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreenEvents.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryState.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryViewModel.kt create mode 100644 feature/registration/src/main/res/drawable/image_totp_phone.xml create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryScreenTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryViewModelTest.kt diff --git a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryAction.kt b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryAction.kt new file mode 100644 index 0000000000..6fd79422ce --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryAction.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.totpentry + +import org.signal.core.util.censor + +/** + * One-shot side effects that have to be carried out by the host rather than the screen itself. + * + * Actions are logged, so be sure `toString()` contains nothing sensitive. + */ +sealed interface TotpEntryAction { + + /** The user cancelled code entry. Leave the screen. */ + data object NavigateBack : TotpEntryAction + + /** All six digits have been entered. The host should verify [code]. */ + data class CodeEntered(val code: String) : TotpEntryAction { + override fun toString(): String = "CodeEntered(code=${code.censor()})" + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreen.kt new file mode 100644 index 0000000000..7d226f77c5 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreen.kt @@ -0,0 +1,361 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.totpentry + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Previews +import org.signal.registration.R +import org.signal.registration.screens.OnePaneRegistrationScaffold +import org.signal.registration.screens.RegistrationScaffold +import org.signal.registration.screens.TwoPaneRegistrationScaffold +import org.signal.registration.screens.attachDebugLogHelper +import org.signal.registration.test.TestTags + +/** + * Two-factor authentication code entry screen. Displays a 6-digit code input in XXX-XXX format for a code from the + * user's authenticator app. + */ +@Composable +fun TotpEntryScreen( + state: TotpEntryState, + onEvent: (TotpEntryScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + val focusRequesters = remember { List(TotpEntryState.CODE_LENGTH) { FocusRequester() } } + + LaunchedEffect(state.focusedDigitIndex) { + focusRequesters[state.focusedDigitIndex].requestFocus() + } + + Surface(modifier = modifier.testTag(TestTags.TOTP_ENTRY_SCREEN)) { + when (val layoutParams = RegistrationScaffold.rememberLayoutParams()) { + is RegistrationScaffold.Params.OnePane -> OnePaneLayout( + params = layoutParams, + focusRequesters = focusRequesters, + state = state, + onEvent = onEvent + ) + + is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout( + params = layoutParams, + focusRequesters = focusRequesters, + state = state, + onEvent = onEvent + ) + } + } +} + +@Composable +private fun OnePaneLayout( + params: RegistrationScaffold.Params.OnePane, + focusRequesters: List, + state: TotpEntryState, + onEvent: (TotpEntryScreenEvents) -> Unit +) { + val scrollState = rememberScrollState() + + OnePaneRegistrationScaffold( + modifier = Modifier.fillMaxSize(), + params = params, + content = { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(paddingValues) + ) { + Illustration() + + Spacer(modifier = Modifier.height(32.dp)) + + Description() + + Spacer(modifier = Modifier.height(32.dp)) + + CodeField( + focusRequesters = focusRequesters, + state = state, + onEvent = onEvent + ) + } + }, + footer = { + RegistrationScaffold.FooterSurface( + isElevated = scrollState.canScrollForward + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(params.footerPadding), + horizontalArrangement = Arrangement.Center + ) { + CancelButton(onEvent) + } + } + } + ) +} + +@Composable +private fun TwoPaneLayout( + params: RegistrationScaffold.Params.TwoPane, + focusRequesters: List, + state: TotpEntryState, + onEvent: (TotpEntryScreenEvents) -> Unit +) { + val firstPaneScrollState = rememberScrollState() + val secondPaneScrollState = rememberScrollState() + + TwoPaneRegistrationScaffold( + modifier = Modifier.fillMaxSize(), + params = params, + firstPane = { paddingValues -> + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(firstPaneScrollState) + .padding(paddingValues) + ) { + Illustration() + + Spacer(modifier = Modifier.height(32.dp)) + + Description(twoPane = true) + } + }, + secondPane = { paddingValues -> + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(secondPaneScrollState) + .padding(paddingValues) + ) { + CodeField( + focusRequesters = focusRequesters, + state = state, + onEvent = onEvent + ) + } + }, + footer = { + RegistrationScaffold.FooterSurface( + isElevated = firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(params.footerPadding), + horizontalArrangement = Arrangement.End + ) { + CancelButton(onEvent) + } + } + } + ) +} + +@Composable +private fun Illustration() { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.image_totp_phone), + contentDescription = null + ) + } +} + +@Composable +private fun Description(twoPane: Boolean = false) { + Text( + text = stringResource(R.string.TotpEntryScreen__two_factor_authentication), + style = if (twoPane) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium, + modifier = Modifier + .fillMaxWidth() + .attachDebugLogHelper() + ) + + Text( + text = stringResource(R.string.TotpEntryScreen__to_continue_enter_the_code), + style = if (twoPane) MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) else MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp) + ) +} + +@Composable +private fun CodeField( + focusRequesters: List, + state: TotpEntryState, + onEvent: (TotpEntryScreenEvents) -> Unit +) { + val digits = state.digits + + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.TOTP_ENTRY_INPUT), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + for (i in 0..2) { + DigitField( + value = digits[i], + onValueChange = { newValue -> onEvent(TotpEntryScreenEvents.DigitChanged(i, newValue)) }, + focusRequester = focusRequesters[i], + testTag = when (i) { + 0 -> TestTags.TOTP_ENTRY_DIGIT_0 + 1 -> TestTags.TOTP_ENTRY_DIGIT_1 + else -> TestTags.TOTP_ENTRY_DIGIT_2 + }, + modifier = Modifier.weight(1f, fill = false) + ) + if (i < 2) { + Spacer(modifier = Modifier.width(4.dp)) + } + } + + Text( + text = "-", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.onSurface + ) + + for (i in 3..5) { + if (i > 3) { + Spacer(modifier = Modifier.width(4.dp)) + } + DigitField( + value = digits[i], + onValueChange = { newValue -> onEvent(TotpEntryScreenEvents.DigitChanged(i, newValue)) }, + focusRequester = focusRequesters[i], + testTag = when (i) { + 3 -> TestTags.TOTP_ENTRY_DIGIT_3 + 4 -> TestTags.TOTP_ENTRY_DIGIT_4 + else -> TestTags.TOTP_ENTRY_DIGIT_5 + }, + modifier = Modifier.weight(1f, fill = false) + ) + } + } +} + +@Composable +private fun DigitField( + value: String, + onValueChange: (String) -> Unit, + focusRequester: FocusRequester, + testTag: String, + modifier: Modifier = Modifier +) { + TextField( + value = value, + 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 + } else { + false + } + }, + textStyle = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center), + singleLine = true, + shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.outline + ) + ) +} + +@Composable +private fun CancelButton(onEvent: (TotpEntryScreenEvents) -> Unit) { + TextButton( + onClick = { onEvent(TotpEntryScreenEvents.CancelClicked) }, + modifier = Modifier.testTag(TestTags.TOTP_ENTRY_CANCEL_BUTTON) + ) { + Text( + text = stringResource(R.string.TotpEntryScreen__cancel), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge + ) + } +} + +@AllDevicePreviews +@Composable +private fun TotpEntryScreenPreview() { + Previews.Preview { + TotpEntryScreen( + state = TotpEntryState(), + onEvent = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun TotpEntryScreenPartiallyFilledPreview() { + Previews.Preview { + TotpEntryScreen( + state = TotpEntryState( + digits = listOf("4", "1", "8", "3", "7", ""), + focusedDigitIndex = 5 + ), + onEvent = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreenEvents.kt new file mode 100644 index 0000000000..301273babd --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreenEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.totpentry + +/** + * Reminder that these events are logged, so don't include anything sensitive in the toString. + */ +sealed class TotpEntryScreenEvents { + + /** + * The raw [value] of the digit field at [index] changed. The view model interprets it: a single digit is recorded, + * an empty [value] is a backspace (deleting a digit and shifting the following ones left), and multi-character + * input (e.g. a pasted code) populates every field at once. + */ + data class DigitChanged(val index: Int, val value: String) : TotpEntryScreenEvents() { + override fun toString(): String = "DigitChanged(index=$index)" + } + + /** The user tapped the cancel button. */ + data object CancelClicked : TotpEntryScreenEvents() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryState.kt b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryState.kt new file mode 100644 index 0000000000..ceee40aae0 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryState.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.totpentry + +/** + * Everything [TotpEntryScreen] needs to render. + */ +data class TotpEntryState( + val digits: List = emptyDigits(), + val focusedDigitIndex: Int = 0 +) { + + override fun toString(): String = "TotpEntryState(digitsEntered=${digits.count { it.isNotEmpty() }}, focusedDigitIndex=$focusedDigitIndex)" + + /** + * The full code as currently entered. Only meaningful when [isComplete] is true. + */ + val code: String get() = digits.joinToString("") + + val isComplete: Boolean get() = digits.size == CODE_LENGTH && digits.all { it.isNotEmpty() } + + companion object { + const val CODE_LENGTH = 6 + + fun emptyDigits(): List = List(CODE_LENGTH) { "" } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryViewModel.kt new file mode 100644 index 0000000000..72e87cf603 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryViewModel.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.totpentry + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log +import org.signal.registration.screens.totpentry.TotpEntryState.Companion.CODE_LENGTH + +/** + * Drives [TotpEntryScreen]. Interprets raw digit-field input into a six-digit code, surfacing the completed code + * (and cancellation) to the host as a [TotpEntryAction]. + */ +class TotpEntryViewModel : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(TotpEntryViewModel::class) + } + + private val _state = MutableStateFlow(TotpEntryState()) + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _actions.receiveAsFlow() + + init { + _state + .onEach { Log.d(TAG, "[State] $it") } + .launchIn(viewModelScope) + } + + override suspend fun processEvent(event: TotpEntryScreenEvents) { + when (event) { + is TotpEntryScreenEvents.DigitChanged -> { + applyDigitChanged(event.index, event.value) + } + TotpEntryScreenEvents.CancelClicked -> { + _actions.send(TotpEntryAction.NavigateBack) + } + } + } + + /** + * 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 + * - multi-character input (e.g. a pasted code) populates every field at once + * + * Once every field has a value, the completed code is emitted as [TotpEntryAction.CodeEntered]. + */ + private suspend fun applyDigitChanged(index: Int, value: String) { + check(index in _state.value.digits.indices) { "[DigitChanged] Out of bounds index $index." } + + if (value.isEmpty()) { + deleteDigit(index) + return + } + + val currentValue = _state.value.digits[index] + val remainder = if (currentValue.isNotEmpty()) value.replaceFirst(currentValue, "") else value + val addedDigits = remainder.filter { it.isDigit() } + + when { + addedDigits.isEmpty() -> Unit + + addedDigits.length == 1 -> { + _state.update { + it.copy( + digits = it.digits.toMutableList().also { digits -> digits[index] = addedDigits }, + focusedDigitIndex = (index + 1).coerceAtMost(CODE_LENGTH - 1) + ) + } + emitCodeIfComplete() + } + + else -> applyFullCode(addedDigits) + } + } + + /** + * Populates every digit field from a full pasted [code] at once. Multi-character input that isn't a complete code + * is ignored. + */ + private suspend fun applyFullCode(code: String) { + if (code.length != CODE_LENGTH) { + Log.w(TAG, "[DigitChanged] Ignoring multi-character input containing ${code.length} digits.") + return + } + + _state.update { + it.copy( + digits = code.map { digit -> digit.toString() }, + focusedDigitIndex = CODE_LENGTH - 1 + ) + } + emitCodeIfComplete() + } + + /** + * 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(index: Int) { + val digits = _state.value.digits + val deleteAt = if (digits[index].isNotEmpty()) index else index - 1 + if (deleteAt < 0) { + return + } + + val newDigits = digits.toMutableList().apply { + for (j in deleteAt until CODE_LENGTH - 1) { + this[j] = this[j + 1] + } + this[CODE_LENGTH - 1] = "" + } + + _state.update { it.copy(digits = newDigits, focusedDigitIndex = (index - 1).coerceAtLeast(0)) } + } + + private suspend fun emitCodeIfComplete() { + val state = _state.value + if (state.isComplete) { + _actions.send(TotpEntryAction.CodeEntered(state.code)) + } + } +} 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 d200429d52..368ffef361 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 @@ -372,6 +372,7 @@ private fun CodeField( 1 -> TestTags.VERIFICATION_CODE_DIGIT_1 else -> TestTags.VERIFICATION_CODE_DIGIT_2 }, + modifier = Modifier.weight(1f, fill = false), enabled = !state.isSubmittingCode ) if (i < 2) { @@ -399,6 +400,7 @@ private fun CodeField( 4 -> TestTags.VERIFICATION_CODE_DIGIT_4 else -> TestTags.VERIFICATION_CODE_DIGIT_5 }, + modifier = Modifier.weight(1f, fill = false), enabled = !state.isSubmittingCode ) } diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index 0710c80471..a0c00c77e2 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -187,6 +187,17 @@ object TestTags { const val QUICK_RESTORE_QR_RETRY_BUTTON = "quick_restore_qr_retry_button" const val QUICK_RESTORE_QR_CANCEL_BUTTON = "quick_restore_qr_cancel_button" + // Totp Entry Screen + const val TOTP_ENTRY_SCREEN = "totp_entry_screen" + const val TOTP_ENTRY_INPUT = "totp_entry_input" + const val TOTP_ENTRY_DIGIT_0 = "totp_entry_digit_0" + const val TOTP_ENTRY_DIGIT_1 = "totp_entry_digit_1" + const val TOTP_ENTRY_DIGIT_2 = "totp_entry_digit_2" + const val TOTP_ENTRY_DIGIT_3 = "totp_entry_digit_3" + const val TOTP_ENTRY_DIGIT_4 = "totp_entry_digit_4" + const val TOTP_ENTRY_DIGIT_5 = "totp_entry_digit_5" + const val TOTP_ENTRY_CANCEL_BUTTON = "totp_entry_cancel_button" + // Remote Backup Restore Screen const val REMOTE_BACKUP_RESTORE_SCREEN = "remote_backup_restore_screen" const val REMOTE_BACKUP_RESTORE_RESTORE_BUTTON = "remote_backup_restore_restore_button" diff --git a/feature/registration/src/main/res/drawable/image_totp_phone.xml b/feature/registration/src/main/res/drawable/image_totp_phone.xml new file mode 100644 index 0000000000..9fed9f74d6 --- /dev/null +++ b/feature/registration/src/main/res/drawable/image_totp_phone.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index ec2b4623ad..eb492f5e5e 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -727,4 +727,12 @@ Submit with debug log Submit without debug log + + + + Two-factor authentication + + To continue enter the 6-digit code from your authenticator app. + + Cancel diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/verificationcode/VerificationCodeScreenScreenshotTests/VerificationCodeScreenPreview_phone portrait (day)_e052ce8c_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/verificationcode/VerificationCodeScreenScreenshotTests/VerificationCodeScreenPreview_phone portrait (day)_e052ce8c_0.png index 48ce5bd3fd..a81991dc50 100644 --- a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/verificationcode/VerificationCodeScreenScreenshotTests/VerificationCodeScreenPreview_phone portrait (day)_e052ce8c_0.png +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/verificationcode/VerificationCodeScreenScreenshotTests/VerificationCodeScreenPreview_phone portrait (day)_e052ce8c_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:742f21af788eaa066a52d6beadca83ea5c699e374df74a6f609f271a1436f4cd -size 42484 +oid sha256:cde64bac51945a95f1f1867c578969b83c7c7efee5098a3a5437cce76cf6e41d +size 42431 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/verificationcode/VerificationCodeScreenScreenshotTests/VerificationCodeScreenPreview_phone portrait (night)_cc3c9530_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/verificationcode/VerificationCodeScreenScreenshotTests/VerificationCodeScreenPreview_phone portrait (night)_cc3c9530_0.png index 76f8d298c7..ea6204406b 100644 --- a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/verificationcode/VerificationCodeScreenScreenshotTests/VerificationCodeScreenPreview_phone portrait (night)_cc3c9530_0.png +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/verificationcode/VerificationCodeScreenScreenshotTests/VerificationCodeScreenPreview_phone portrait (night)_cc3c9530_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9f54e1a65f728ee817e1472b646afb824e315e3ff566ac289720391c76a5dbe -size 42757 +oid sha256:f1c8b6967229f087d45f2bd749f98384c626091c215f27559e014dc150b9a416 +size 42705 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryScreenTest.kt new file mode 100644 index 0000000000..d40dc3fbbf --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryScreenTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.totpentry + +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 +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.contains +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.ui.CoreUiDependenciesRule +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.registration.test.TestTags + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class TotpEntryScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) + + private val events = mutableListOf() + + @Test + fun `screen displays title and subtitle`() { + setContent(TotpEntryState()) + + composeTestRule.onNodeWithText("Two-factor authentication").assertIsDisplayed() + composeTestRule.onNodeWithText("To continue enter the 6-digit code from your authenticator app.").assertIsDisplayed() + } + + @Test + fun `screen displays all six digit fields`() { + setContent(TotpEntryState()) + + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_1).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_2).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_3).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_4).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_5).assertIsDisplayed() + } + + @Test + fun `entering a digit emits DigitChanged for that field`() { + setContent(TotpEntryState()) + + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).performTextInput("4") + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_1).performTextInput("1") + composeTestRule.waitForIdle() + + assertThat(events).contains(TotpEntryScreenEvents.DigitChanged(0, "4")) + assertThat(events).contains(TotpEntryScreenEvents.DigitChanged(1, "1")) + } + + @Test + fun `pasting into a field emits DigitChanged with the raw text`() { + setContent(TotpEntryState()) + + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).performTextInput("418-372") + composeTestRule.waitForIdle() + + assertThat(events).contains(TotpEntryScreenEvents.DigitChanged(0, "418-372")) + } + + @Test + fun `screen renders the digits from state`() { + setContent(TotpEntryState(digits = listOf("4", "1", "8", "3", "7", "2"))) + + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).assertTextEquals("4") + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_5).assertTextEquals("2") + } + + @Test + fun `clicking cancel emits CancelClicked`() { + setContent(TotpEntryState()) + + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_CANCEL_BUTTON).performClick() + + assertThat(events).contains(TotpEntryScreenEvents.CancelClicked) + } + + private fun setContent(state: TotpEntryState) { + composeTestRule.setContent { + SignalTheme { + TotpEntryScreen( + state = state, + onEvent = { events += it } + ) + } + } + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryViewModelTest.kt new file mode 100644 index 0000000000..f15591d3c4 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryViewModelTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.totpentry + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TotpEntryViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state is empty with focus on the first digit`() = runTest(testDispatcher) { + val viewModel = TotpEntryViewModel() + + assertThat(viewModel.state.value.digits).isEqualTo(TotpEntryState.emptyDigits()) + assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(0) + assertThat(viewModel.state.value.isComplete).isFalse() + } + + @Test + fun `entering a digit records it and advances focus`() = runTest(testDispatcher) { + val viewModel = TotpEntryViewModel() + + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "4")) + + assertThat(viewModel.state.value.digits[0]).isEqualTo("4") + assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(1) + } + + @Test + fun `entering the final digit emits CodeEntered`() = runTest(testDispatcher) { + val viewModel = TotpEntryViewModel() + val actions = collectActions(viewModel.actions) + + "41837".forEachIndexed { index, digit -> + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(index, digit.toString())) + } + assertThat(actions).isEmpty() + + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(5, "2")) + + assertThat(viewModel.state.value.isComplete).isTrue() + assertThat(actions).containsExactly(TotpEntryAction.CodeEntered("418372")) + } + + @Test + fun `pasting a full code populates every field and emits CodeEntered`() = runTest(testDispatcher) { + val viewModel = TotpEntryViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "418372")) + + assertThat(viewModel.state.value.digits).isEqualTo(listOf("4", "1", "8", "3", "7", "2")) + assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(5) + assertThat(actions).containsExactly(TotpEntryAction.CodeEntered("418372")) + } + + @Test + fun `pasting an incomplete code is ignored`() = runTest(testDispatcher) { + val viewModel = TotpEntryViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "4183")) + + assertThat(viewModel.state.value.digits).isEqualTo(TotpEntryState.emptyDigits()) + assertThat(actions).isEmpty() + } + + @Test + fun `a backspace deletes the digit and shifts the following ones left`() = runTest(testDispatcher) { + val viewModel = TotpEntryViewModel() + + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "4")) + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(1, "1")) + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(2, "8")) + + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(1, "")) + + assertThat(viewModel.state.value.digits).isEqualTo(listOf("4", "8", "", "", "", "")) + assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(0) + } + + @Test + fun `a backspace on an empty field deletes the previous digit`() = runTest(testDispatcher) { + val viewModel = TotpEntryViewModel() + + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "4")) + viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(1, "")) + + assertThat(viewModel.state.value.digits).isEqualTo(TotpEntryState.emptyDigits()) + assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(0) + } + + @Test + fun `CancelClicked emits NavigateBack`() = runTest(testDispatcher) { + val viewModel = TotpEntryViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(TotpEntryScreenEvents.CancelClicked) + + assertThat(actions).containsExactly(TotpEntryAction.NavigateBack) + } + + private fun TestScope.collectActions(actions: Flow): List { + val collected = mutableListOf() + backgroundScope.launch { actions.toList(collected) } + return collected + } +} 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 3e678b13e1..5b4c819533 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 @@ -71,7 +71,7 @@ class VerificationCodeScreenTest { composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).assertIsDisplayed() composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).assertIsDisplayed() composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_4).assertIsDisplayed() - composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).fetchSemanticsNode() + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).assertIsDisplayed() } @Test