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
+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
)
}
}
}