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:
+20
-4
@@ -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
|
||||
}
|
||||
|
||||
+12
-14
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+9
-5
@@ -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
|
||||
}
|
||||
|
||||
+31
-38
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
+9
-8
@@ -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 -->
|
||||
|
||||
+35
-10
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-128
@@ -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 = {}
|
||||
)
|
||||
|
||||
+12
-8
@@ -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()
|
||||
|
||||
+4
-19
@@ -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()
|
||||
)
|
||||
|
||||
+29
-89
@@ -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
|
||||
|
||||
+3
-2
@@ -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
-31
@@ -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
-68
@@ -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()
|
||||
|
||||
@@ -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