From af920d71bb427cf2ed2a024ca998f4406c834ae5 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 11 Sep 2026 12:42:18 -0400 Subject: [PATCH] Use a shared text entry field for TOTP entry. --- .../authenticator/TotpCodeEntryViewModel.kt | 24 ++- .../TotpCodeEntryViewModelTest.kt | 26 ++- feature/app-settings/build.gradle.kts | 1 + .../totpcodeentry/TotpCodeEntryEvent.kt | 14 +- .../totpcodeentry/TotpCodeEntryScreen.kt | 69 +++---- .../totpcodeentry/TotpCodeEntryState.kt | 17 +- .../src/main/res/values/strings.xml | 4 +- .../totpcodeentry/TotpCodeEntryScreenTest.kt | 45 ++++- feature/registration/build.gradle.kts | 1 + .../screens/totpentry/TotpEntryScreen.kt | 140 ++------------ .../totpentry/TotpEntryScreenEvents.kt | 20 +- .../screens/totpentry/TotpEntryState.kt | 23 +-- .../screens/totpentry/TotpEntryViewModel.kt | 118 +++--------- .../org/signal/registration/test/TestTags.kt | 7 - .../registration/RegistrationEndToEndTest.kt | 5 +- .../screens/totpentry/TotpEntryScreenTest.kt | 38 +--- .../totpentry/TotpEntryViewModelTest.kt | 77 +------- lib/ui-components/build.gradle.kts | 16 ++ .../codeentryfield/CodeEntryField.kt | 179 ++++++++++++++++++ .../codeentryfield/CodeEntryFieldAction.kt | 19 ++ .../codeentryfield/CodeEntryFieldEvents.kt | 24 +++ .../codeentryfield/CodeEntryFieldPresenter.kt | 133 +++++++++++++ .../codeentryfield/CodeEntryFieldState.kt | 33 ++++ .../CodeEntryFieldPresenterTest.kt | 139 ++++++++++++++ .../codeentryfield/CodeEntryFieldTest.kt | 88 +++++++++ 25 files changed, 826 insertions(+), 434 deletions(-) create mode 100644 lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryField.kt create mode 100644 lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldAction.kt create mode 100644 lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldEvents.kt create mode 100644 lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldPresenter.kt create mode 100644 lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldState.kt create mode 100644 lib/ui-components/src/test/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldPresenterTest.kt create mode 100644 lib/ui-components/src/test/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpCodeEntryViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpCodeEntryViewModel.kt index 3d47f1bd39..10738357e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpCodeEntryViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpCodeEntryViewModel.kt @@ -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 = _state.asStateFlow() val actions: Flow = _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 } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpCodeEntryViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpCodeEntryViewModelTest.kt index 1ea9ae36fa..f0a2bc7005 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpCodeEntryViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpCodeEntryViewModelTest.kt @@ -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) diff --git a/feature/app-settings/build.gradle.kts b/feature/app-settings/build.gradle.kts index f13880148e..82b08914d9 100644 --- a/feature/app-settings/build.gradle.kts +++ b/feature/app-settings/build.gradle.kts @@ -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")) diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryEvent.kt index d56605a06f..96c00c9dd7 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryEvent.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryEvent.kt @@ -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 } diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryScreen.kt index 3a0d09a7c4..5332db0129 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryScreen.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryScreen.kt @@ -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 = {} ) } diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryState.kt index a07ae4d451..049f508e9f 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryState.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryState.kt @@ -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 - } } diff --git a/feature/app-settings/src/main/res/values/strings.xml b/feature/app-settings/src/main/res/values/strings.xml index 49a0f5ae33..4e9369f1ab 100644 --- a/feature/app-settings/src/main/res/values/strings.xml +++ b/feature/app-settings/src/main/res/values/strings.xml @@ -138,10 +138,8 @@ Enter your code Enter the 6-digit code from your authenticator app. - - Code - Done + Next Incorrect code. Enter the code showing in your authenticator app now. diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryScreenTest.kt index 0dc8441b15..04c705e69f 100644 --- a/feature/app-settings/src/test/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryScreenTest.kt +++ b/feature/app-settings/src/test/java/org/signal/appsettings/totpcodeentry/TotpCodeEntryScreenTest.kt @@ -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() @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) { diff --git a/feature/registration/build.gradle.kts b/feature/registration/build.gradle.kts index defbc4592d..de2d820025 100644 --- a/feature/registration/build.gradle.kts +++ b/feature/registration/build.gradle.kts @@ -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 diff --git a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreen.kt index 7d226f77c5..98d3d09405 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreen.kt @@ -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, 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, 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, - 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 = {} ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreenEvents.kt index 301273babd..12c05db028 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryScreenEvents.kt @@ -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() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryState.kt b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryState.kt index ceee40aae0..462d9aa805 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryState.kt @@ -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 = 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 = List(CODE_LENGTH) { "" } - } -} + val codeEntry: CodeEntryFieldState = CodeEntryFieldState() +) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryViewModel.kt index b87dec8b80..e2dfcf954d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/totpentry/TotpEntryViewModel.kt @@ -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 = _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())) } } diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index bb6d96843f..10b534f9bb 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -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 diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt index b9aa331790..8a67620d2f 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt @@ -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 } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryScreenTest.kt index e7cd01dcd9..c03324a308 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryScreenTest.kt @@ -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 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryViewModelTest.kt index 1aed79c947..ec98cb8e46 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/totpentry/TotpEntryViewModelTest.kt @@ -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() diff --git a/lib/ui-components/build.gradle.kts b/lib/ui-components/build.gradle.kts index 9a443e4f41..a997197db0 100644 --- a/lib/ui-components/build.gradle.kts +++ b/lib/ui-components/build.gradle.kts @@ -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) } diff --git a/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryField.kt b/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryField.kt new file mode 100644 index 0000000000..6e6790c295 --- /dev/null +++ b/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryField.kt @@ -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 + ) + } +} diff --git a/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldAction.kt b/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldAction.kt new file mode 100644 index 0000000000..d89d5cece4 --- /dev/null +++ b/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldAction.kt @@ -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()" + } +} diff --git a/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldEvents.kt b/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldEvents.kt new file mode 100644 index 0000000000..f705a2beb0 --- /dev/null +++ b/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldEvents.kt @@ -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()})" + } +} diff --git a/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldPresenter.kt b/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldPresenter.kt new file mode 100644 index 0000000000..1c05401747 --- /dev/null +++ b/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldPresenter.kt @@ -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(TAG, coroutineScope) { + + companion object { + private val TAG = Log.tag(CodeEntryFieldPresenter::class) + } + + private val _state = MutableStateFlow(CodeEntryFieldState()) + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _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)) + } + } +} diff --git a/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldState.kt b/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldState.kt new file mode 100644 index 0000000000..ce53f01dab --- /dev/null +++ b/lib/ui-components/src/main/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldState.kt @@ -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 = 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 = List(CODE_LENGTH) { "" } + } +} diff --git a/lib/ui-components/src/test/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldPresenterTest.kt b/lib/ui-components/src/test/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldPresenterTest.kt new file mode 100644 index 0000000000..2a8f38013a --- /dev/null +++ b/lib/ui-components/src/test/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldPresenterTest.kt @@ -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() + + @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 + } +} diff --git a/lib/ui-components/src/test/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldTest.kt b/lib/ui-components/src/test/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldTest.kt new file mode 100644 index 0000000000..0e08a0dace --- /dev/null +++ b/lib/ui-components/src/test/java/org/signal/uicomponents/codeentryfield/CodeEntryFieldTest.kt @@ -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() + + @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 + ) + } + } +}