Add UI scaffolding for TotpEntryScreen.

This commit is contained in:
Greyson Parrelli
2026-09-02 16:11:27 -03:00
committed by Alex Hart
parent 2109ada5cf
commit 01fae0aaf9
14 changed files with 888 additions and 5 deletions
@@ -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()})"
}
}
@@ -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<FocusRequester>,
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<FocusRequester>,
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<FocusRequester>,
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 = {}
)
}
}
@@ -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()
}
@@ -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<String> = 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<String> = List(CODE_LENGTH) { "" }
}
}
@@ -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<TotpEntryScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(TotpEntryViewModel::class)
}
private val _state = MutableStateFlow(TotpEntryState())
private val _actions = Channel<TotpEntryAction>(Channel.BUFFERED)
val state: StateFlow<TotpEntryState> = _state.asStateFlow()
val actions: Flow<TotpEntryAction> = _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))
}
}
}
@@ -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
)
}
@@ -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"
@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="41dp"
android:height="77dp"
android:viewportWidth="40.5"
android:viewportHeight="77">
<path
android:fillColor="#FFD9E0EE"
android:strokeColor="#FF4A5775"
android:strokeWidth="2"
android:pathData="M10,1h20.5c4.97,0 9,4.03 9,9v57c0,4.97 -4.03,9 -9,9h-20.5c-4.97,0 -9,-4.03 -9,-9v-57c0,-4.97 4.03,-9 9,-9z"/>
<path
android:fillColor="#FFEAEEF6"
android:pathData="M22.19,2C20.48,12.81 12.45,21.51 2,24.2V10C2,5.58 5.58,2 10,2H22.19Z"/>
<path
android:fillColor="#FFC0CBE2"
android:pathData="M34.69,4.15C34.69,3.72 35.16,3.49 35.5,3.76C37.33,5.22 38.5,7.47 38.5,10V67C38.5,71.42 34.92,75 30.5,75H10C6.97,75 6.53,71.01 9.48,70.35C10.05,70.22 10.64,70.15 11.25,70.15H26.69C31.11,70.15 34.69,66.57 34.69,62.15V4.15Z"/>
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M19.04,52.3C20.18,53.23 21.82,53.23 22.96,52.3L27.24,48.77C29.67,46.78 31.18,43.9 31.45,40.78L31.99,34.64C32.11,33.2 31.22,31.87 29.84,31.43L23.22,29.34C21.78,28.89 20.22,28.89 18.78,29.34L12.16,31.43C10.78,31.87 9.89,33.2 10.01,34.64L10.55,40.78C10.82,43.9 12.33,46.78 14.76,48.77L19.04,52.3Z"/>
<path
android:fillColor="#FF4A5775"
android:fillType="evenOdd"
android:pathData="M19.04,52.3C20.18,53.23 21.82,53.23 22.96,52.3L27.24,48.77C29.67,46.78 31.18,43.9 31.45,40.78L31.99,34.64C32.11,33.2 31.22,31.87 29.84,31.43L23.22,29.34C21.78,28.89 20.22,28.89 18.78,29.34L12.16,31.43C10.78,31.87 9.89,33.2 10.01,34.64L10.55,40.78C10.82,43.9 12.33,46.78 14.76,48.77L19.04,52.3ZM21.77,50.85C21.32,51.21 20.68,51.21 20.23,50.85L15.95,47.32C13.92,45.65 12.65,43.23 12.42,40.61L11.88,34.48C11.83,33.91 12.18,33.39 12.72,33.22L19.34,31.13C20.42,30.79 21.58,30.79 22.66,31.13L29.28,33.22C29.82,33.39 30.17,33.91 30.12,34.48L29.58,40.61C29.35,43.23 28.08,45.65 26.05,47.32L21.77,50.85Z"/>
<path
android:fillColor="#FF4A5775"
android:pathData="M18,38a3,3 0,1 0,6 0a3,3 0,1 0,-6 0z"/>
<path
android:fillColor="#FF4A5775"
android:pathData="M18,45L20.25,38H21.75L24,45H18Z"/>
<path
android:fillColor="#FF4A5775"
android:pathData="M16.74,4.81h7.02a1.4,1.4 0,0 1,0 2.81h-7.02a1.4,1.4 0,0 1,0 -2.81z"/>
</vector>
@@ -727,4 +727,12 @@
<string name="ContactSupportDialog__submit_with_debug_log">Submit with debug log</string>
<!-- Button that contacts support without attaching a debug log -->
<string name="ContactSupportDialog__submit_without_debug_log">Submit without debug log</string>
<!-- TotpEntryScreen -->
<!-- Title of the two-factor authentication code entry screen -->
<string name="TotpEntryScreen__two_factor_authentication">Two-factor authentication</string>
<!-- Subtitle explaining where to find the code -->
<string name="TotpEntryScreen__to_continue_enter_the_code">To continue enter the 6-digit code from your authenticator app.</string>
<!-- Button text to cancel entering a code -->
<string name="TotpEntryScreen__cancel">Cancel</string>
</resources>
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:742f21af788eaa066a52d6beadca83ea5c699e374df74a6f609f271a1436f4cd
size 42484
oid sha256:cde64bac51945a95f1f1867c578969b83c7c7efee5098a3a5437cce76cf6e41d
size 42431
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e9f54e1a65f728ee817e1472b646afb824e315e3ff566ac289720391c76a5dbe
size 42757
oid sha256:f1c8b6967229f087d45f2bd749f98384c626091c215f27559e014dc150b9a416
size 42705
@@ -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<TotpEntryScreenEvents>()
@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 }
)
}
}
}
}
@@ -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<TotpEntryAction>): List<TotpEntryAction> {
val collected = mutableListOf<TotpEntryAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}
@@ -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