Use a shared text entry field for TOTP entry.

This commit is contained in:
Greyson Parrelli
2026-09-11 13:48:17 -04:00
parent 7e569e02c3
commit af920d71bb
25 changed files with 826 additions and 434 deletions
@@ -5,11 +5,14 @@
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
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.appsettings.totpcodeentry.TotpCodeEntryAction
@@ -18,6 +21,7 @@ import org.signal.appsettings.totpcodeentry.TotpCodeEntryState
import org.signal.appsettings.totpcodeentry.TotpCodeEntryState.Error
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.uicomponents.codeentryfield.CodeEntryFieldPresenter
/**
* Drives the screen that collects a code from the user's authenticator app, which is how the service learns the user
@@ -37,16 +41,28 @@ class TotpCodeEntryViewModel(
val state: StateFlow<TotpCodeEntryState> = _state.asStateFlow()
val actions: Flow<TotpCodeEntryAction> = _actions.receiveAsFlow()
private val codeEntryPresenter = CodeEntryFieldPresenter(viewModelScope)
init {
codeEntryPresenter
.state
.onEach { onEvent(TotpCodeEntryEvent.CodeEntryStateChanged(it)) }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: TotpCodeEntryEvent) {
when (event) {
TotpCodeEntryEvent.NavigateBackClicked -> {
_actions.send(TotpCodeEntryAction.NavigateBack)
}
is TotpCodeEntryEvent.CodeChanged -> {
val digits = event.code.filter { it.isDigit() }.take(TotpCodeEntryState.CODE_LENGTH)
_state.update { it.copy(code = digits, error = Error.None) }
is TotpCodeEntryEvent.CodeEntryEvent -> {
_state.update { it.copy(error = Error.None) }
codeEntryPresenter.onEvent(event.event)
}
TotpCodeEntryEvent.DoneClicked -> {
is TotpCodeEntryEvent.CodeEntryStateChanged -> {
_state.update { it.copy(codeEntry = event.codeEntryState) }
}
TotpCodeEntryEvent.NextClicked -> {
if (!_state.value.canSubmit) {
return
}
@@ -28,6 +28,7 @@ import org.junit.Test
import org.signal.appsettings.totpcodeentry.TotpCodeEntryAction
import org.signal.appsettings.totpcodeentry.TotpCodeEntryEvent
import org.signal.appsettings.totpcodeentry.TotpCodeEntryState.Error
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@OptIn(ExperimentalCoroutinesApi::class)
@@ -57,25 +58,16 @@ class TotpCodeEntryViewModelTest {
Dispatchers.resetMain()
}
@Test
fun `non-digits are dropped and the code is capped at six digits`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("12a34 5678"))
assertThat(viewModel.state.value.code).isEqualTo(FULL_CODE)
}
@Test
fun `a partial code can't be submitted`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("123"))
enterCode(viewModel, "123")
assertThat(viewModel.state.value.canSubmit).isFalse()
viewModel.onEvent(TotpCodeEntryEvent.DoneClicked)
viewModel.onEvent(TotpCodeEntryEvent.NextClicked)
assertThat(actions).isEmpty()
}
@@ -124,7 +116,7 @@ class TotpCodeEntryViewModelTest {
val viewModel = createViewModel()
submit(viewModel)
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("1"))
enterCode(viewModel, "1")
assertThat(viewModel.state.value.error).isEqualTo(Error.None)
}
@@ -140,8 +132,14 @@ class TotpCodeEntryViewModelTest {
}
private fun submit(viewModel: TotpCodeEntryViewModel) {
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged(FULL_CODE))
viewModel.onEvent(TotpCodeEntryEvent.DoneClicked)
enterCode(viewModel, FULL_CODE)
viewModel.onEvent(TotpCodeEntryEvent.NextClicked)
}
private fun enterCode(viewModel: TotpCodeEntryViewModel, code: String) {
code.forEachIndexed { index, digit ->
viewModel.onEvent(TotpCodeEntryEvent.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(index, digit.toString())))
}
}
private fun createViewModel() = TotpCodeEntryViewModel(repository = repository)
+1
View File
@@ -21,6 +21,7 @@ dependencies {
lintChecks(project(":lintchecks"))
// Project dependencies
api(project(":lib:ui-components"))
implementation(project(":core:ui"))
implementation(project(":core:util"))
implementation(project(":lib:signal-login"))
@@ -5,6 +5,9 @@
package org.signal.appsettings.totpcodeentry
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
/**
* Reminder that these events are logged, so don't include anything sensitive in the toString.
*/
@@ -13,11 +16,12 @@ sealed interface TotpCodeEntryEvent {
/** The user tapped the navigation (back) icon. */
data object NavigateBackClicked : TotpCodeEntryEvent
/** The user typed in the code field. */
data class CodeChanged(val code: String) : TotpCodeEntryEvent {
override fun toString(): String = "CodeChanged(length=${code.length})"
}
/** Received an event from the code field that we want to forward. */
data class CodeEntryEvent(val event: CodeEntryFieldEvents) : TotpCodeEntryEvent
/** The code field's presenter emitted new state for us to mirror. */
data class CodeEntryStateChanged(val codeEntryState: CodeEntryFieldState) : TotpCodeEntryEvent
/** The user submitted the code they entered. */
data object DoneClicked : TotpCodeEntryEvent
data object NextClicked : TotpCodeEntryEvent
}
@@ -10,25 +10,17 @@ import androidx.compose.foundation.layout.Column
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
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.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import org.signal.appsettings.R
import org.signal.core.ui.compose.Buttons
@@ -36,11 +28,12 @@ import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.uicomponents.codeentryfield.CodeEntryField
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
@VisibleForTesting
object TotpCodeEntryTestTags {
const val CODE_INPUT = "code-input"
const val BUTTON_DONE = "button-done"
const val BUTTON_NEXT = "button-next"
const val ERROR = "error"
}
@@ -52,12 +45,6 @@ fun TotpCodeEntryScreen(
state: TotpCodeEntryState,
onEvent: (TotpCodeEntryEvent) -> Unit
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Scaffolds.Settings(
title = stringResource(R.string.TotpCodeEntryScreen__enter_your_code),
onNavigationClick = { onEvent(TotpCodeEntryEvent.NavigateBackClicked) },
@@ -76,34 +63,37 @@ fun TotpCodeEntryScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
.padding(start = 24.dp, end = 24.dp, top = 12.dp)
)
Spacer(modifier = Modifier.height(24.dp))
val errorMessage = state.error.message()
TextField(
value = state.code,
onValueChange = { onEvent(TotpCodeEntryEvent.CodeChanged(it)) },
label = { Text(text = stringResource(R.string.TotpCodeEntryScreen__code)) },
singleLine = true,
CodeEntryField(
state = state.codeEntry,
onEvent = { onEvent(TotpCodeEntryEvent.CodeEntryEvent(it)) },
enabled = !state.submitting,
isError = errorMessage != null,
supportingText = errorMessage?.let { message ->
{ Text(text = message, modifier = Modifier.testTag(TotpCodeEntryTestTags.ERROR)) }
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (state.canSubmit) onEvent(TotpCodeEntryEvent.DoneClicked) }),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.focusRequester(focusRequester)
.testTag(TotpCodeEntryTestTags.CODE_INPUT)
modifier = Modifier.padding(horizontal = 24.dp)
)
if (errorMessage != null) {
Text(
text = errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
.testTag(TotpCodeEntryTestTags.ERROR)
)
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
onClick = { onEvent(TotpCodeEntryEvent.DoneClicked) },
onClick = { onEvent(TotpCodeEntryEvent.NextClicked) },
enabled = state.canSubmit,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
@@ -111,9 +101,9 @@ fun TotpCodeEntryScreen(
),
modifier = Modifier
.padding(horizontal = 24.dp, vertical = 24.dp)
.testTag(TotpCodeEntryTestTags.BUTTON_DONE)
.testTag(TotpCodeEntryTestTags.BUTTON_NEXT)
) {
Text(text = stringResource(R.string.TotpCodeEntryScreen__done))
Text(text = stringResource(R.string.TotpCodeEntryScreen__next))
}
}
}
@@ -134,7 +124,7 @@ private fun TotpCodeEntryState.Error.message(): String? = when (this) {
private fun TotpCodeEntryScreenPreview() {
Previews.Preview {
TotpCodeEntryScreen(
state = TotpCodeEntryState(code = "123456"),
state = TotpCodeEntryState(codeEntry = CodeEntryFieldState(digits = listOf("1", "2", "3", "4", "5", "6"))),
onEvent = {}
)
}
@@ -145,7 +135,10 @@ private fun TotpCodeEntryScreenPreview() {
private fun TotpCodeEntryScreenErrorPreview() {
Previews.Preview {
TotpCodeEntryScreen(
state = TotpCodeEntryState(code = "123456", error = TotpCodeEntryState.Error.IncorrectCode),
state = TotpCodeEntryState(
codeEntry = CodeEntryFieldState(digits = listOf("1", "2", "3", "4", "5", "6")),
error = TotpCodeEntryState.Error.IncorrectCode
),
onEvent = {}
)
}
@@ -5,17 +5,22 @@
package org.signal.appsettings.totpcodeentry
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
data class TotpCodeEntryState(
val code: String = "",
val codeEntry: CodeEntryFieldState = CodeEntryFieldState(),
val submitting: Boolean = false,
/** Why the last submission didn't work, shown under the code field and cleared as soon as the user types. */
val error: Error = Error.None
) {
val canSubmit: Boolean
get() = code.length == CODE_LENGTH && !submitting
val code: String
get() = codeEntry.code
override fun toString(): String = "TotpCodeEntryState(codeLength=${code.length}, submitting=$submitting, error=$error)"
val canSubmit: Boolean
get() = codeEntry.isComplete && !submitting
override fun toString(): String = "TotpCodeEntryState(codeEntry=$codeEntry, submitting=$submitting, error=$error)"
sealed interface Error {
data object None : Error
@@ -25,8 +30,4 @@ data class TotpCodeEntryState(
data object NetworkFailure : Error
}
companion object {
const val CODE_LENGTH = 6
}
}
@@ -138,10 +138,8 @@
<string name="TotpCodeEntryScreen__enter_your_code">Enter your code</string>
<!-- Instructions shown above the code entry field -->
<string name="TotpCodeEntryScreen__enter_the_6_digit_code">Enter the 6-digit code from your authenticator app.</string>
<!-- Label of the code entry field -->
<string name="TotpCodeEntryScreen__code">Code</string>
<!-- Button that submits the entered code -->
<string name="TotpCodeEntryScreen__done">Done</string>
<string name="TotpCodeEntryScreen__next">Next</string>
<!-- Error shown under the code field when the code the user entered was rejected -->
<string name="TotpCodeEntryScreen__incorrect_code">Incorrect code. Enter the code showing in your authenticator app now.</string>
<!-- Error shown under the code field when we couldn\'t reach the service to check the code -->
@@ -6,6 +6,7 @@
package org.signal.appsettings.totpcodeentry
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
@@ -19,41 +20,65 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
import org.signal.uicomponents.codeentryfield.CodeEntryFieldTestTags
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class TotpCodeEntryScreenTest {
companion object {
private val FULL_CODE = CodeEntryFieldState(digits = listOf("1", "2", "3", "4", "5", "6"))
private val PARTIAL_CODE = CodeEntryFieldState(digits = listOf("1", "2", "3", "", "", ""))
}
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<TotpCodeEntryEvent>()
@Test
fun givenAPartialCode_whenScreenDisplayed_thenDoneIsDisabled() {
setContent(TotpCodeEntryState(code = "123"))
fun givenAPartialCode_whenScreenDisplayed_thenNextIsDisabled() {
setContent(TotpCodeEntryState(codeEntry = PARTIAL_CODE))
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_DONE).assertIsNotEnabled()
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_NEXT).assertIsNotEnabled()
}
@Test
fun givenAFullCode_whenIClickDone_thenIExpectDoneEvent() {
setContent(TotpCodeEntryState(code = "123456"))
fun givenAFullCode_whenIClickNext_thenIExpectNextEvent() {
setContent(TotpCodeEntryState(codeEntry = FULL_CODE))
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_DONE)
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_NEXT)
.assertIsEnabled()
.performClick()
assertThat(events).contains(TotpCodeEntryEvent.DoneClicked)
assertThat(events).contains(TotpCodeEntryEvent.NextClicked)
}
@Test
fun whenITypeInTheCodeField_thenIExpectCodeChangedEvent() {
fun whenITypeInTheCodeField_thenIExpectAForwardedCodeEntryEvent() {
setContent(TotpCodeEntryState())
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.CODE_INPUT).performTextInput("123456")
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).performTextInput("1")
composeTestRule.waitForIdle()
assertThat(events).contains(TotpCodeEntryEvent.CodeChanged("123456"))
assertThat(events).contains(TotpCodeEntryEvent.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(0, "1")))
}
@Test
fun givenAnIncorrectCode_whenScreenDisplayed_thenIExpectAnError() {
setContent(TotpCodeEntryState(codeEntry = FULL_CODE, error = TotpCodeEntryState.Error.IncorrectCode))
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.ERROR).assertIsDisplayed()
}
@Test
fun givenASubmissionInFlight_whenScreenDisplayed_thenTheCodeFieldIsDisabled() {
setContent(TotpCodeEntryState(codeEntry = FULL_CODE, submitting = true))
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).assertIsNotEnabled()
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_NEXT).assertIsNotEnabled()
}
private fun setContent(state: TotpCodeEntryState) {
+1
View File
@@ -65,6 +65,7 @@ dependencies {
implementation(project(":lib:device-transfer"))
implementation(project(":lib:password-manager"))
implementation(project(":lib:signal-login"))
implementation(project(":lib:ui-components"))
implementation(libs.libsignal.android)
// Compose BOM
@@ -15,33 +15,19 @@ 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
@@ -51,6 +37,8 @@ import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.TwoPaneRegistrationScaffold
import org.signal.registration.screens.attachDebugLogHelper
import org.signal.registration.test.TestTags
import org.signal.uicomponents.codeentryfield.CodeEntryField
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
/**
* Two-factor authentication code entry screen. Displays a 6-digit code input in XXX-XXX format for a code from the
@@ -62,24 +50,16 @@ fun TotpEntryScreen(
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
)
@@ -90,7 +70,6 @@ fun TotpEntryScreen(
@Composable
private fun OnePaneLayout(
params: RegistrationScaffold.Params.OnePane,
focusRequesters: List<FocusRequester>,
state: TotpEntryState,
onEvent: (TotpEntryScreenEvents) -> Unit
) {
@@ -114,10 +93,9 @@ private fun OnePaneLayout(
Spacer(modifier = Modifier.height(32.dp))
CodeField(
focusRequesters = focusRequesters,
state = state,
onEvent = onEvent
CodeEntryField(
state = state.codeEntry,
onEvent = { onEvent(TotpEntryScreenEvents.CodeEntryEvent(it)) }
)
}
},
@@ -141,7 +119,6 @@ private fun OnePaneLayout(
@Composable
private fun TwoPaneLayout(
params: RegistrationScaffold.Params.TwoPane,
focusRequesters: List<FocusRequester>,
state: TotpEntryState,
onEvent: (TotpEntryScreenEvents) -> Unit
) {
@@ -172,10 +149,9 @@ private fun TwoPaneLayout(
.verticalScroll(secondPaneScrollState)
.padding(paddingValues)
) {
CodeField(
focusRequesters = focusRequesters,
state = state,
onEvent = onEvent
CodeEntryField(
state = state.codeEntry,
onEvent = { onEvent(TotpEntryScreenEvents.CodeEntryEvent(it)) }
)
}
},
@@ -227,100 +203,6 @@ private fun Description(twoPane: Boolean = false) {
)
}
@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(
@@ -352,8 +234,10 @@ private fun TotpEntryScreenPartiallyFilledPreview() {
Previews.Preview {
TotpEntryScreen(
state = TotpEntryState(
digits = listOf("4", "1", "8", "3", "7", ""),
focusedDigitIndex = 5
codeEntry = CodeEntryFieldState(
digits = listOf("4", "1", "8", "3", "7", ""),
focusedDigitIndex = 5
)
),
onEvent = {}
)
@@ -5,19 +5,23 @@
package org.signal.registration.screens.totpentry
import org.signal.uicomponents.codeentryfield.CodeEntryFieldAction
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
/**
* 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)"
}
/** Received an event from the code field that we want to forward. */
data class CodeEntryEvent(val event: CodeEntryFieldEvents) : TotpEntryScreenEvents()
/** The code field's presenter emitted new state for us to mirror. */
data class CodeEntryStateChanged(val codeEntryState: CodeEntryFieldState) : TotpEntryScreenEvents()
/** The code field's presenter decided something needs doing that only this screen can do. */
data class CodeEntryAction(val action: CodeEntryFieldAction) : TotpEntryScreenEvents()
/** The user tapped the cancel button. */
data object CancelClicked : TotpEntryScreenEvents()
@@ -5,26 +5,11 @@
package org.signal.registration.screens.totpentry
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
/**
* 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) { "" }
}
}
val codeEntry: CodeEntryFieldState = CodeEntryFieldState()
)
@@ -17,12 +17,13 @@ import org.signal.core.ui.navigation.ResultEventBus
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.totpentry.TotpEntryState.Companion.CODE_LENGTH
import org.signal.registration.screens.util.navigateBack
import org.signal.uicomponents.codeentryfield.CodeEntryFieldAction
import org.signal.uicomponents.codeentryfield.CodeEntryFieldPresenter
/**
* Drives [TotpEntryScreen]. Interprets raw digit-field input into a six-digit code. The completed code is emitted
* via [ResultEventBus], then the two-factor screens are popped so the login screen that bounced here can retry.
* Drives [TotpEntryScreen]. The six-digit code is collected by a [CodeEntryFieldPresenter], and once it's complete it's
* emitted via [ResultEventBus], then the two-factor screens are popped so the login screen that bounced here can retry.
*/
class TotpEntryViewModel(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
@@ -38,16 +39,36 @@ class TotpEntryViewModel(
val state: StateFlow<TotpEntryState> = _state.asStateFlow()
private val codeEntryPresenter = CodeEntryFieldPresenter(viewModelScope)
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
.launchIn(viewModelScope)
codeEntryPresenter
.state
.onEach { onEvent(TotpEntryScreenEvents.CodeEntryStateChanged(it)) }
.launchIn(viewModelScope)
codeEntryPresenter
.actions
.onEach { onEvent(TotpEntryScreenEvents.CodeEntryAction(it)) }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: TotpEntryScreenEvents) {
when (event) {
is TotpEntryScreenEvents.DigitChanged -> {
applyDigitChanged(event.index, event.value)
is TotpEntryScreenEvents.CodeEntryEvent -> {
codeEntryPresenter.onEvent(event.event)
}
is TotpEntryScreenEvents.CodeEntryStateChanged -> {
_state.update { it.copy(codeEntry = event.codeEntryState) }
}
is TotpEntryScreenEvents.CodeEntryAction -> {
when (val action = event.action) {
is CodeEntryFieldAction.CodeEntered -> emitCode(action.code)
}
}
TotpEntryScreenEvents.CancelClicked -> {
parentEventEmitter.navigateBack()
@@ -55,89 +76,8 @@ class TotpEntryViewModel(
}
}
/**
* 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.
*/
private 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 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 fun emitCodeIfComplete() {
val state = _state.value
if (state.isComplete) {
resultBus.sendResult(resultKey, state.code)
parentEventEmitter(RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.SignalLoginCredentialEntry()))
}
private fun emitCode(code: String) {
resultBus.sendResult(resultKey, code)
parentEventEmitter(RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.SignalLoginCredentialEntry()))
}
}
@@ -204,13 +204,6 @@ object TestTags {
// 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"
// Two Factor Selection Screen
@@ -88,6 +88,7 @@ import org.signal.registration.screens.signalloginpayment.PaymentAvailability
import org.signal.registration.screens.util.MockMultiplePermissionsState
import org.signal.registration.screens.util.MockPermissionsState
import org.signal.registration.test.TestTags
import org.signal.uicomponents.codeentryfield.CodeEntryFieldTestTags
import java.time.Duration
import java.util.UUID
import kotlin.time.Duration.Companion.days
@@ -1908,8 +1909,8 @@ class RegistrationEndToEndTest {
// The service wants a second factor, so the user picks one and enters a code from it
waitForTag(TestTags.TWO_FACTOR_SELECTION_AUTHENTICATOR_APP_OPTION)
composeTestRule.onNodeWithTag(TestTags.TWO_FACTOR_SELECTION_AUTHENTICATOR_APP_OPTION).performClick()
waitForTag(TestTags.TOTP_ENTRY_DIGIT_0)
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).performTextInput(totp)
waitForTag(CodeEntryFieldTestTags.digit(0))
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).performTextInput(totp)
waitFor("registration to complete") { registrationComplete }
@@ -7,7 +7,6 @@ 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
@@ -24,6 +23,8 @@ 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
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.signal.uicomponents.codeentryfield.CodeEntryFieldTestTags
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
@@ -46,45 +47,20 @@ class TotpEntryScreenTest {
}
@Test
fun `screen displays all six digit fields`() {
fun `screen displays the code field`() {
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()
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.ROOT).assertIsDisplayed()
}
@Test
fun `entering a digit emits DigitChanged for that field`() {
fun `entering a digit forwards a code field event`() {
setContent(TotpEntryState())
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).performTextInput("4")
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_1).performTextInput("1")
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).performTextInput("4")
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")
assertThat(events).contains(TotpEntryScreenEvents.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(0, "4")))
}
@Test
@@ -9,9 +9,7 @@ import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -24,6 +22,7 @@ import org.junit.Test
import org.signal.core.ui.navigation.ResultEventBus
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRoute
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
@OptIn(ExperimentalCoroutinesApi::class)
class TotpEntryViewModelTest {
@@ -48,89 +47,31 @@ class TotpEntryViewModelTest {
}
@Test
fun `initial state is empty with focus on the first digit`() = runTest(testDispatcher) {
fun `code field state is mirrored into screen state`() = runTest(testDispatcher) {
val viewModel = createViewModel()
assertThat(viewModel.state.value.digits).isEqualTo(TotpEntryState.emptyDigits())
assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(0)
assertThat(viewModel.state.value.isComplete).isFalse()
viewModel.onEvent(TotpEntryScreenEvents.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(0, "4")))
assertThat(viewModel.state.value.codeEntry.digits[0]).isEqualTo("4")
assertThat(viewModel.state.value.codeEntry.focusedDigitIndex).isEqualTo(1)
}
@Test
fun `entering a digit records it and advances focus`() = runTest(testDispatcher) {
val viewModel = createViewModel()
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 the code and pops back to the login screen`() = runTest(testDispatcher) {
fun `a completed code is emitted and pops back to the login screen`() = runTest(testDispatcher) {
val viewModel = createViewModel()
"41837".forEachIndexed { index, digit ->
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(index, digit.toString()))
viewModel.onEvent(TotpEntryScreenEvents.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(index, digit.toString())))
}
assertThat(sentCode()).isNull()
assertThat(emittedParentEvents).isEmpty()
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(5, "2"))
viewModel.onEvent(TotpEntryScreenEvents.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(5, "2")))
assertThat(viewModel.state.value.isComplete).isTrue()
assertThat(sentCode()).isEqualTo("418372")
assertThat(emittedParentEvents).containsExactly(RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.SignalLoginCredentialEntry()))
}
@Test
fun `pasting a full code populates every field and emits the code`() = runTest(testDispatcher) {
val viewModel = createViewModel()
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(sentCode()).isEqualTo("418372")
assertThat(emittedParentEvents).containsExactly(RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.SignalLoginCredentialEntry()))
}
@Test
fun `pasting an incomplete code is ignored`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "4183"))
assertThat(viewModel.state.value.digits).isEqualTo(TotpEntryState.emptyDigits())
assertThat(sentCode()).isNull()
assertThat(emittedParentEvents).isEmpty()
}
@Test
fun `a backspace deletes the digit and shifts the following ones left`() = runTest(testDispatcher) {
val viewModel = createViewModel()
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 = createViewModel()
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 navigates back`() = runTest(testDispatcher) {
val viewModel = createViewModel()
+16
View File
@@ -9,6 +9,12 @@ android {
buildFeatures {
compose = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
dependencies {
@@ -20,4 +26,14 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
// Testing
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.assertk)
testImplementation(testLibs.kotlinx.coroutines.test)
testImplementation(testLibs.robolectric.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit4)
// Supplies the ComponentActivity that createComposeRule() launches the screen into
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
@@ -0,0 +1,179 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.uicomponents.codeentryfield
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState.Companion.CODE_LENGTH
private val DIGIT_WIDTH = 48.dp
private val DIGIT_SPACING = 8.dp
private val SEPARATOR_PADDING = 18.dp
private val DIGIT_SHAPE = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)
@VisibleForTesting
object CodeEntryFieldTestTags {
const val ROOT = "code-entry-field"
fun digit(index: Int): String = "code-entry-field-digit-$index"
}
/**
* A [CODE_LENGTH]-digit code input, laid out as one box per digit in XXX-XXX format. Focus follows along as the user
* types, and a pasted code fills every box at once.
*
* Driven entirely by a [CodeEntryFieldPresenter], which owns the state handed in here and decides what the events sent
* back out of here actually do.
*/
@Composable
fun CodeEntryField(
state: CodeEntryFieldState,
onEvent: (CodeEntryFieldEvents) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isError: Boolean = false
) {
val focusRequesters = remember { List(CODE_LENGTH) { FocusRequester() } }
LaunchedEffect(state.focusedDigitIndex, enabled) {
if (enabled) {
focusRequesters[state.focusedDigitIndex.coerceIn(0, CODE_LENGTH - 1)].requestFocus()
}
}
Row(
modifier = modifier
.fillMaxWidth()
.testTag(CodeEntryFieldTestTags.ROOT),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
for (index in 0 until CODE_LENGTH) {
if (index == CODE_LENGTH / 2) {
Text(
text = "-",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = SEPARATOR_PADDING)
)
} else if (index > 0) {
Spacer(modifier = Modifier.width(DIGIT_SPACING))
}
DigitField(
value = state.digits.getOrElse(index) { "" },
onValueChange = { onEvent(CodeEntryFieldEvents.DigitChanged(index, it)) },
focusRequester = focusRequesters[index],
enabled = enabled,
isError = isError,
modifier = Modifier
.weight(1f, fill = false)
.testTag(CodeEntryFieldTestTags.digit(index))
)
}
}
}
@Composable
private fun DigitField(
value: String,
onValueChange: (String) -> Unit,
focusRequester: FocusRequester,
enabled: Boolean,
isError: Boolean,
modifier: Modifier = Modifier
) {
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier
.width(DIGIT_WIDTH)
.focusRequester(focusRequester)
.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),
enabled = enabled,
isError = isError,
singleLine = true,
shape = DIGIT_SHAPE,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = MaterialTheme.colorScheme.primary,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline
)
)
}
@DayNightPreviews
@Composable
private fun CodeEntryFieldPreview() {
Previews.Preview {
CodeEntryField(
state = CodeEntryFieldState(digits = listOf("4", "1", "8", "3", "7", "2")),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun CodeEntryFieldPartiallyFilledPreview() {
Previews.Preview {
CodeEntryField(
state = CodeEntryFieldState(digits = listOf("4", "1", "8", "", "", ""), focusedDigitIndex = 3),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun CodeEntryFieldErrorPreview() {
Previews.Preview {
CodeEntryField(
state = CodeEntryFieldState(digits = listOf("4", "1", "8", "3", "7", "2")),
onEvent = {},
isError = true
)
}
}
@@ -0,0 +1,19 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.uicomponents.codeentryfield
/**
* Side effects that can be emitted by a [CodeEntryFieldPresenter] that need to be handled by the user of the component.
*
* Actions are logged, so be sure `toString()` contains nothing sensitive.
*/
sealed interface CodeEntryFieldAction {
/** Every digit has a value, so here's the [code] the user entered. */
data class CodeEntered(val code: String) : CodeEntryFieldAction {
override fun toString(): String = "CodeEntered()"
}
}
@@ -0,0 +1,24 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.uicomponents.codeentryfield
import org.signal.core.util.censor
/**
* Everything a [CodeEntryFieldPresenter] can be told, whether it came from the field itself or from the screen hosting
* it.
*
* Reminder that these events are logged, so don't include anything sensitive in the toString.
*/
sealed interface CodeEntryFieldEvents {
/**
* The raw [value] of the digit field at [index] changed.
*/
data class DigitChanged(val index: Int, val value: String) : CodeEntryFieldEvents {
override fun toString(): String = "DigitChanged(index=$index, value=${value.censor()})"
}
}
@@ -0,0 +1,133 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.uicomponents.codeentryfield
import kotlinx.coroutines.CoroutineScope
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.receiveAsFlow
import kotlinx.coroutines.flow.update
import org.signal.core.ui.compose.EventDrivenPresenter
import org.signal.core.util.logging.Log
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState.Companion.CODE_LENGTH
/**
* All of the logic behind a [CodeEntryField]: turning the raw text each digit field reports into a code, moving focus
* along as the user types, and saying when the code is finished.
*
* Meant to be held by the view model of whichever screen shows the field, which feeds it events, mirrors [state] into
* its own state, and carries out [actions].
*/
class CodeEntryFieldPresenter(
coroutineScope: CoroutineScope
) : EventDrivenPresenter<CodeEntryFieldEvents>(TAG, coroutineScope) {
companion object {
private val TAG = Log.tag(CodeEntryFieldPresenter::class)
}
private val _state = MutableStateFlow(CodeEntryFieldState())
private val _actions = Channel<CodeEntryFieldAction>(Channel.BUFFERED)
val state: StateFlow<CodeEntryFieldState> = _state.asStateFlow()
val actions: Flow<CodeEntryFieldAction> = _actions.receiveAsFlow()
override suspend fun processEvent(event: CodeEntryFieldEvents) {
when (event) {
is CodeEntryFieldEvents.DigitChanged -> {
applyDigitChanged(event.index, event.value)
}
}
}
/**
* 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.
*/
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(CodeEntryFieldAction.CodeEntered(state.code))
}
}
}
@@ -0,0 +1,33 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.uicomponents.codeentryfield
/**
* State of a [CodeEntryField]. Owned by a [CodeEntryFieldPresenter] and expected to be mirrored into the state of
* whatever screen the field sits in.
*
* Reminder that this is logged, so don't put the code itself in the toString.
*/
data class CodeEntryFieldState(
val digits: List<String> = emptyDigits(),
val focusedDigitIndex: Int = 0
) {
override fun toString(): String = "CodeEntryFieldState(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.uicomponents.codeentryfield
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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class CodeEntryFieldPresenterTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val emittedActions = mutableListOf<CodeEntryFieldAction>()
@Test
fun `initial state is empty with focus on the first digit`() = runTest(testDispatcher) {
val presenter = createPresenter()
assertThat(presenter.state.value.digits).isEqualTo(CodeEntryFieldState.emptyDigits())
assertThat(presenter.state.value.focusedDigitIndex).isEqualTo(0)
assertThat(presenter.state.value.isComplete).isFalse()
}
@Test
fun `entering a digit records it and advances focus`() = runTest(testDispatcher) {
val presenter = createPresenter()
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(0, "4"))
assertThat(presenter.state.value.digits[0]).isEqualTo("4")
assertThat(presenter.state.value.focusedDigitIndex).isEqualTo(1)
}
@Test
fun `entering the final digit emits the code`() = runTest(testDispatcher) {
val presenter = createPresenter()
"41837".forEachIndexed { index, digit ->
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(index, digit.toString()))
}
assertThat(emittedActions).isEmpty()
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(5, "2"))
assertThat(presenter.state.value.isComplete).isTrue()
assertThat(emittedActions).containsExactly(CodeEntryFieldAction.CodeEntered("418372"))
}
@Test
fun `pasting a full code populates every field and emits the code`() = runTest(testDispatcher) {
val presenter = createPresenter()
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(0, "418372"))
assertThat(presenter.state.value.digits).isEqualTo(listOf("4", "1", "8", "3", "7", "2"))
assertThat(presenter.state.value.focusedDigitIndex).isEqualTo(5)
assertThat(emittedActions).containsExactly(CodeEntryFieldAction.CodeEntered("418372"))
}
@Test
fun `pasting an incomplete code is ignored`() = runTest(testDispatcher) {
val presenter = createPresenter()
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(0, "4183"))
assertThat(presenter.state.value.digits).isEqualTo(CodeEntryFieldState.emptyDigits())
assertThat(emittedActions).isEmpty()
}
@Test
fun `non-digit input is ignored`() = runTest(testDispatcher) {
val presenter = createPresenter()
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(0, "a"))
assertThat(presenter.state.value.digits).isEqualTo(CodeEntryFieldState.emptyDigits())
assertThat(presenter.state.value.focusedDigitIndex).isEqualTo(0)
}
@Test
fun `a backspace deletes the digit and shifts the following ones left`() = runTest(testDispatcher) {
val presenter = createPresenter()
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(0, "4"))
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(1, "1"))
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(2, "8"))
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(1, ""))
assertThat(presenter.state.value.digits).isEqualTo(listOf("4", "8", "", "", "", ""))
assertThat(presenter.state.value.focusedDigitIndex).isEqualTo(0)
}
@Test
fun `a backspace on an empty field deletes the previous digit`() = runTest(testDispatcher) {
val presenter = createPresenter()
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(0, "4"))
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(1, ""))
assertThat(presenter.state.value.digits).isEqualTo(CodeEntryFieldState.emptyDigits())
assertThat(presenter.state.value.focusedDigitIndex).isEqualTo(0)
}
@Test
fun `a backspace on the first empty field does nothing`() = runTest(testDispatcher) {
val presenter = createPresenter()
presenter.onEvent(CodeEntryFieldEvents.DigitChanged(0, ""))
assertThat(presenter.state.value.digits).isEqualTo(CodeEntryFieldState.emptyDigits())
assertThat(presenter.state.value.focusedDigitIndex).isEqualTo(0)
}
private fun TestScope.createPresenter(): CodeEntryFieldPresenter {
val presenter = CodeEntryFieldPresenter(backgroundScope)
presenter
.actions
.onEach { emittedActions += it }
.launchIn(backgroundScope)
return presenter
}
}
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.uicomponents.codeentryfield
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput
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.uicomponents.codeentryfield.CodeEntryFieldState.Companion.CODE_LENGTH
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class CodeEntryFieldTest {
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<CodeEntryFieldEvents>()
@Test
fun `field displays one box per digit`() {
setContent(CodeEntryFieldState())
for (index in 0 until CODE_LENGTH) {
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(index)).assertIsDisplayed()
}
}
@Test
fun `field renders the digits from state`() {
setContent(CodeEntryFieldState(digits = listOf("4", "1", "8", "3", "7", "2")))
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).assertTextEquals("4")
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(5)).assertTextEquals("2")
}
@Test
fun `entering a digit emits DigitChanged for that box`() {
setContent(CodeEntryFieldState())
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).performTextInput("4")
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(1)).performTextInput("1")
composeTestRule.waitForIdle()
assertThat(events).contains(CodeEntryFieldEvents.DigitChanged(0, "4"))
assertThat(events).contains(CodeEntryFieldEvents.DigitChanged(1, "1"))
}
@Test
fun `pasting into a box emits DigitChanged with the raw text`() {
setContent(CodeEntryFieldState())
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).performTextInput("418-372")
composeTestRule.waitForIdle()
assertThat(events).contains(CodeEntryFieldEvents.DigitChanged(0, "418-372"))
}
@Test
fun `a disabled field cannot be typed in`() {
setContent(CodeEntryFieldState(), enabled = false)
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).assertIsNotEnabled()
}
private fun setContent(state: CodeEntryFieldState, enabled: Boolean = true) {
composeTestRule.setContent {
CodeEntryField(
state = state,
onEvent = { events += it },
enabled = enabled
)
}
}
}