mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Use a shared text entry field for TOTP entry.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+179
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
@@ -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()"
|
||||
}
|
||||
}
|
||||
+24
@@ -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()})"
|
||||
}
|
||||
}
|
||||
+133
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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) { "" }
|
||||
}
|
||||
}
|
||||
+139
@@ -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
|
||||
}
|
||||
}
|
||||
+88
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user