Use a shared text entry field for TOTP entry.

This commit is contained in:
Greyson Parrelli
2026-09-11 13:48:17 -04:00
parent 7e569e02c3
commit af920d71bb
25 changed files with 826 additions and 434 deletions
+1
View File
@@ -21,6 +21,7 @@ dependencies {
lintChecks(project(":lintchecks"))
// Project dependencies
api(project(":lib:ui-components"))
implementation(project(":core:ui"))
implementation(project(":core:util"))
implementation(project(":lib:signal-login"))
@@ -5,6 +5,9 @@
package org.signal.appsettings.totpcodeentry
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
/**
* Reminder that these events are logged, so don't include anything sensitive in the toString.
*/
@@ -13,11 +16,12 @@ sealed interface TotpCodeEntryEvent {
/** The user tapped the navigation (back) icon. */
data object NavigateBackClicked : TotpCodeEntryEvent
/** The user typed in the code field. */
data class CodeChanged(val code: String) : TotpCodeEntryEvent {
override fun toString(): String = "CodeChanged(length=${code.length})"
}
/** Received an event from the code field that we want to forward. */
data class CodeEntryEvent(val event: CodeEntryFieldEvents) : TotpCodeEntryEvent
/** The code field's presenter emitted new state for us to mirror. */
data class CodeEntryStateChanged(val codeEntryState: CodeEntryFieldState) : TotpCodeEntryEvent
/** The user submitted the code they entered. */
data object DoneClicked : TotpCodeEntryEvent
data object NextClicked : TotpCodeEntryEvent
}
@@ -10,25 +10,17 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import org.signal.appsettings.R
import org.signal.core.ui.compose.Buttons
@@ -36,11 +28,12 @@ import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.uicomponents.codeentryfield.CodeEntryField
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
@VisibleForTesting
object TotpCodeEntryTestTags {
const val CODE_INPUT = "code-input"
const val BUTTON_DONE = "button-done"
const val BUTTON_NEXT = "button-next"
const val ERROR = "error"
}
@@ -52,12 +45,6 @@ fun TotpCodeEntryScreen(
state: TotpCodeEntryState,
onEvent: (TotpCodeEntryEvent) -> Unit
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Scaffolds.Settings(
title = stringResource(R.string.TotpCodeEntryScreen__enter_your_code),
onNavigationClick = { onEvent(TotpCodeEntryEvent.NavigateBackClicked) },
@@ -76,34 +63,37 @@ fun TotpCodeEntryScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
.padding(start = 24.dp, end = 24.dp, top = 12.dp)
)
Spacer(modifier = Modifier.height(24.dp))
val errorMessage = state.error.message()
TextField(
value = state.code,
onValueChange = { onEvent(TotpCodeEntryEvent.CodeChanged(it)) },
label = { Text(text = stringResource(R.string.TotpCodeEntryScreen__code)) },
singleLine = true,
CodeEntryField(
state = state.codeEntry,
onEvent = { onEvent(TotpCodeEntryEvent.CodeEntryEvent(it)) },
enabled = !state.submitting,
isError = errorMessage != null,
supportingText = errorMessage?.let { message ->
{ Text(text = message, modifier = Modifier.testTag(TotpCodeEntryTestTags.ERROR)) }
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (state.canSubmit) onEvent(TotpCodeEntryEvent.DoneClicked) }),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.focusRequester(focusRequester)
.testTag(TotpCodeEntryTestTags.CODE_INPUT)
modifier = Modifier.padding(horizontal = 24.dp)
)
if (errorMessage != null) {
Text(
text = errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
.testTag(TotpCodeEntryTestTags.ERROR)
)
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
onClick = { onEvent(TotpCodeEntryEvent.DoneClicked) },
onClick = { onEvent(TotpCodeEntryEvent.NextClicked) },
enabled = state.canSubmit,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
@@ -111,9 +101,9 @@ fun TotpCodeEntryScreen(
),
modifier = Modifier
.padding(horizontal = 24.dp, vertical = 24.dp)
.testTag(TotpCodeEntryTestTags.BUTTON_DONE)
.testTag(TotpCodeEntryTestTags.BUTTON_NEXT)
) {
Text(text = stringResource(R.string.TotpCodeEntryScreen__done))
Text(text = stringResource(R.string.TotpCodeEntryScreen__next))
}
}
}
@@ -134,7 +124,7 @@ private fun TotpCodeEntryState.Error.message(): String? = when (this) {
private fun TotpCodeEntryScreenPreview() {
Previews.Preview {
TotpCodeEntryScreen(
state = TotpCodeEntryState(code = "123456"),
state = TotpCodeEntryState(codeEntry = CodeEntryFieldState(digits = listOf("1", "2", "3", "4", "5", "6"))),
onEvent = {}
)
}
@@ -145,7 +135,10 @@ private fun TotpCodeEntryScreenPreview() {
private fun TotpCodeEntryScreenErrorPreview() {
Previews.Preview {
TotpCodeEntryScreen(
state = TotpCodeEntryState(code = "123456", error = TotpCodeEntryState.Error.IncorrectCode),
state = TotpCodeEntryState(
codeEntry = CodeEntryFieldState(digits = listOf("1", "2", "3", "4", "5", "6")),
error = TotpCodeEntryState.Error.IncorrectCode
),
onEvent = {}
)
}
@@ -5,17 +5,22 @@
package org.signal.appsettings.totpcodeentry
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
data class TotpCodeEntryState(
val code: String = "",
val codeEntry: CodeEntryFieldState = CodeEntryFieldState(),
val submitting: Boolean = false,
/** Why the last submission didn't work, shown under the code field and cleared as soon as the user types. */
val error: Error = Error.None
) {
val canSubmit: Boolean
get() = code.length == CODE_LENGTH && !submitting
val code: String
get() = codeEntry.code
override fun toString(): String = "TotpCodeEntryState(codeLength=${code.length}, submitting=$submitting, error=$error)"
val canSubmit: Boolean
get() = codeEntry.isComplete && !submitting
override fun toString(): String = "TotpCodeEntryState(codeEntry=$codeEntry, submitting=$submitting, error=$error)"
sealed interface Error {
data object None : Error
@@ -25,8 +30,4 @@ data class TotpCodeEntryState(
data object NetworkFailure : Error
}
companion object {
const val CODE_LENGTH = 6
}
}
@@ -138,10 +138,8 @@
<string name="TotpCodeEntryScreen__enter_your_code">Enter your code</string>
<!-- Instructions shown above the code entry field -->
<string name="TotpCodeEntryScreen__enter_the_6_digit_code">Enter the 6-digit code from your authenticator app.</string>
<!-- Label of the code entry field -->
<string name="TotpCodeEntryScreen__code">Code</string>
<!-- Button that submits the entered code -->
<string name="TotpCodeEntryScreen__done">Done</string>
<string name="TotpCodeEntryScreen__next">Next</string>
<!-- Error shown under the code field when the code the user entered was rejected -->
<string name="TotpCodeEntryScreen__incorrect_code">Incorrect code. Enter the code showing in your authenticator app now.</string>
<!-- Error shown under the code field when we couldn\'t reach the service to check the code -->
@@ -6,6 +6,7 @@
package org.signal.appsettings.totpcodeentry
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
@@ -19,41 +20,65 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
import org.signal.uicomponents.codeentryfield.CodeEntryFieldTestTags
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class TotpCodeEntryScreenTest {
companion object {
private val FULL_CODE = CodeEntryFieldState(digits = listOf("1", "2", "3", "4", "5", "6"))
private val PARTIAL_CODE = CodeEntryFieldState(digits = listOf("1", "2", "3", "", "", ""))
}
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<TotpCodeEntryEvent>()
@Test
fun givenAPartialCode_whenScreenDisplayed_thenDoneIsDisabled() {
setContent(TotpCodeEntryState(code = "123"))
fun givenAPartialCode_whenScreenDisplayed_thenNextIsDisabled() {
setContent(TotpCodeEntryState(codeEntry = PARTIAL_CODE))
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_DONE).assertIsNotEnabled()
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_NEXT).assertIsNotEnabled()
}
@Test
fun givenAFullCode_whenIClickDone_thenIExpectDoneEvent() {
setContent(TotpCodeEntryState(code = "123456"))
fun givenAFullCode_whenIClickNext_thenIExpectNextEvent() {
setContent(TotpCodeEntryState(codeEntry = FULL_CODE))
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_DONE)
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_NEXT)
.assertIsEnabled()
.performClick()
assertThat(events).contains(TotpCodeEntryEvent.DoneClicked)
assertThat(events).contains(TotpCodeEntryEvent.NextClicked)
}
@Test
fun whenITypeInTheCodeField_thenIExpectCodeChangedEvent() {
fun whenITypeInTheCodeField_thenIExpectAForwardedCodeEntryEvent() {
setContent(TotpCodeEntryState())
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.CODE_INPUT).performTextInput("123456")
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).performTextInput("1")
composeTestRule.waitForIdle()
assertThat(events).contains(TotpCodeEntryEvent.CodeChanged("123456"))
assertThat(events).contains(TotpCodeEntryEvent.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(0, "1")))
}
@Test
fun givenAnIncorrectCode_whenScreenDisplayed_thenIExpectAnError() {
setContent(TotpCodeEntryState(codeEntry = FULL_CODE, error = TotpCodeEntryState.Error.IncorrectCode))
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.ERROR).assertIsDisplayed()
}
@Test
fun givenASubmissionInFlight_whenScreenDisplayed_thenTheCodeFieldIsDisabled() {
setContent(TotpCodeEntryState(codeEntry = FULL_CODE, submitting = true))
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).assertIsNotEnabled()
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_NEXT).assertIsNotEnabled()
}
private fun setContent(state: TotpCodeEntryState) {
+1
View File
@@ -65,6 +65,7 @@ dependencies {
implementation(project(":lib:device-transfer"))
implementation(project(":lib:password-manager"))
implementation(project(":lib:signal-login"))
implementation(project(":lib:ui-components"))
implementation(libs.libsignal.android)
// Compose BOM
@@ -15,33 +15,19 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
@@ -51,6 +37,8 @@ import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.TwoPaneRegistrationScaffold
import org.signal.registration.screens.attachDebugLogHelper
import org.signal.registration.test.TestTags
import org.signal.uicomponents.codeentryfield.CodeEntryField
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
/**
* Two-factor authentication code entry screen. Displays a 6-digit code input in XXX-XXX format for a code from the
@@ -62,24 +50,16 @@ fun TotpEntryScreen(
onEvent: (TotpEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val focusRequesters = remember { List(TotpEntryState.CODE_LENGTH) { FocusRequester() } }
LaunchedEffect(state.focusedDigitIndex) {
focusRequesters[state.focusedDigitIndex].requestFocus()
}
Surface(modifier = modifier.testTag(TestTags.TOTP_ENTRY_SCREEN)) {
when (val layoutParams = RegistrationScaffold.rememberLayoutParams()) {
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(
params = layoutParams,
focusRequesters = focusRequesters,
state = state,
onEvent = onEvent
)
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(
params = layoutParams,
focusRequesters = focusRequesters,
state = state,
onEvent = onEvent
)
@@ -90,7 +70,6 @@ fun TotpEntryScreen(
@Composable
private fun OnePaneLayout(
params: RegistrationScaffold.Params.OnePane,
focusRequesters: List<FocusRequester>,
state: TotpEntryState,
onEvent: (TotpEntryScreenEvents) -> Unit
) {
@@ -114,10 +93,9 @@ private fun OnePaneLayout(
Spacer(modifier = Modifier.height(32.dp))
CodeField(
focusRequesters = focusRequesters,
state = state,
onEvent = onEvent
CodeEntryField(
state = state.codeEntry,
onEvent = { onEvent(TotpEntryScreenEvents.CodeEntryEvent(it)) }
)
}
},
@@ -141,7 +119,6 @@ private fun OnePaneLayout(
@Composable
private fun TwoPaneLayout(
params: RegistrationScaffold.Params.TwoPane,
focusRequesters: List<FocusRequester>,
state: TotpEntryState,
onEvent: (TotpEntryScreenEvents) -> Unit
) {
@@ -172,10 +149,9 @@ private fun TwoPaneLayout(
.verticalScroll(secondPaneScrollState)
.padding(paddingValues)
) {
CodeField(
focusRequesters = focusRequesters,
state = state,
onEvent = onEvent
CodeEntryField(
state = state.codeEntry,
onEvent = { onEvent(TotpEntryScreenEvents.CodeEntryEvent(it)) }
)
}
},
@@ -227,100 +203,6 @@ private fun Description(twoPane: Boolean = false) {
)
}
@Composable
private fun CodeField(
focusRequesters: List<FocusRequester>,
state: TotpEntryState,
onEvent: (TotpEntryScreenEvents) -> Unit
) {
val digits = state.digits
Row(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.TOTP_ENTRY_INPUT),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
for (i in 0..2) {
DigitField(
value = digits[i],
onValueChange = { newValue -> onEvent(TotpEntryScreenEvents.DigitChanged(i, newValue)) },
focusRequester = focusRequesters[i],
testTag = when (i) {
0 -> TestTags.TOTP_ENTRY_DIGIT_0
1 -> TestTags.TOTP_ENTRY_DIGIT_1
else -> TestTags.TOTP_ENTRY_DIGIT_2
},
modifier = Modifier.weight(1f, fill = false)
)
if (i < 2) {
Spacer(modifier = Modifier.width(4.dp))
}
}
Text(
text = "-",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.onSurface
)
for (i in 3..5) {
if (i > 3) {
Spacer(modifier = Modifier.width(4.dp))
}
DigitField(
value = digits[i],
onValueChange = { newValue -> onEvent(TotpEntryScreenEvents.DigitChanged(i, newValue)) },
focusRequester = focusRequesters[i],
testTag = when (i) {
3 -> TestTags.TOTP_ENTRY_DIGIT_3
4 -> TestTags.TOTP_ENTRY_DIGIT_4
else -> TestTags.TOTP_ENTRY_DIGIT_5
},
modifier = Modifier.weight(1f, fill = false)
)
}
}
}
@Composable
private fun DigitField(
value: String,
onValueChange: (String) -> Unit,
focusRequester: FocusRequester,
testTag: String,
modifier: Modifier = Modifier
) {
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier
.width(48.dp)
.focusRequester(focusRequester)
.testTag(testTag)
.onKeyEvent { keyEvent ->
if ((keyEvent.key == Key.Backspace || keyEvent.key == Key.Delete) && value.isEmpty()) {
onValueChange("")
true
} else {
false
}
},
textStyle = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center),
singleLine = true,
shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = MaterialTheme.colorScheme.primary,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline
)
)
}
@Composable
private fun CancelButton(onEvent: (TotpEntryScreenEvents) -> Unit) {
TextButton(
@@ -352,8 +234,10 @@ private fun TotpEntryScreenPartiallyFilledPreview() {
Previews.Preview {
TotpEntryScreen(
state = TotpEntryState(
digits = listOf("4", "1", "8", "3", "7", ""),
focusedDigitIndex = 5
codeEntry = CodeEntryFieldState(
digits = listOf("4", "1", "8", "3", "7", ""),
focusedDigitIndex = 5
)
),
onEvent = {}
)
@@ -5,19 +5,23 @@
package org.signal.registration.screens.totpentry
import org.signal.uicomponents.codeentryfield.CodeEntryFieldAction
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
/**
* Reminder that these events are logged, so don't include anything sensitive in the toString.
*/
sealed class TotpEntryScreenEvents {
/**
* The raw [value] of the digit field at [index] changed. The view model interprets it: a single digit is recorded,
* an empty [value] is a backspace (deleting a digit and shifting the following ones left), and multi-character
* input (e.g. a pasted code) populates every field at once.
*/
data class DigitChanged(val index: Int, val value: String) : TotpEntryScreenEvents() {
override fun toString(): String = "DigitChanged(index=$index)"
}
/** Received an event from the code field that we want to forward. */
data class CodeEntryEvent(val event: CodeEntryFieldEvents) : TotpEntryScreenEvents()
/** The code field's presenter emitted new state for us to mirror. */
data class CodeEntryStateChanged(val codeEntryState: CodeEntryFieldState) : TotpEntryScreenEvents()
/** The code field's presenter decided something needs doing that only this screen can do. */
data class CodeEntryAction(val action: CodeEntryFieldAction) : TotpEntryScreenEvents()
/** The user tapped the cancel button. */
data object CancelClicked : TotpEntryScreenEvents()
@@ -5,26 +5,11 @@
package org.signal.registration.screens.totpentry
import org.signal.uicomponents.codeentryfield.CodeEntryFieldState
/**
* Everything [TotpEntryScreen] needs to render.
*/
data class TotpEntryState(
val digits: List<String> = emptyDigits(),
val focusedDigitIndex: Int = 0
) {
override fun toString(): String = "TotpEntryState(digitsEntered=${digits.count { it.isNotEmpty() }}, focusedDigitIndex=$focusedDigitIndex)"
/**
* The full code as currently entered. Only meaningful when [isComplete] is true.
*/
val code: String get() = digits.joinToString("")
val isComplete: Boolean get() = digits.size == CODE_LENGTH && digits.all { it.isNotEmpty() }
companion object {
const val CODE_LENGTH = 6
fun emptyDigits(): List<String> = List(CODE_LENGTH) { "" }
}
}
val codeEntry: CodeEntryFieldState = CodeEntryFieldState()
)
@@ -17,12 +17,13 @@ import org.signal.core.ui.navigation.ResultEventBus
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.totpentry.TotpEntryState.Companion.CODE_LENGTH
import org.signal.registration.screens.util.navigateBack
import org.signal.uicomponents.codeentryfield.CodeEntryFieldAction
import org.signal.uicomponents.codeentryfield.CodeEntryFieldPresenter
/**
* Drives [TotpEntryScreen]. Interprets raw digit-field input into a six-digit code. The completed code is emitted
* via [ResultEventBus], then the two-factor screens are popped so the login screen that bounced here can retry.
* Drives [TotpEntryScreen]. The six-digit code is collected by a [CodeEntryFieldPresenter], and once it's complete it's
* emitted via [ResultEventBus], then the two-factor screens are popped so the login screen that bounced here can retry.
*/
class TotpEntryViewModel(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
@@ -38,16 +39,36 @@ class TotpEntryViewModel(
val state: StateFlow<TotpEntryState> = _state.asStateFlow()
private val codeEntryPresenter = CodeEntryFieldPresenter(viewModelScope)
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
.launchIn(viewModelScope)
codeEntryPresenter
.state
.onEach { onEvent(TotpEntryScreenEvents.CodeEntryStateChanged(it)) }
.launchIn(viewModelScope)
codeEntryPresenter
.actions
.onEach { onEvent(TotpEntryScreenEvents.CodeEntryAction(it)) }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: TotpEntryScreenEvents) {
when (event) {
is TotpEntryScreenEvents.DigitChanged -> {
applyDigitChanged(event.index, event.value)
is TotpEntryScreenEvents.CodeEntryEvent -> {
codeEntryPresenter.onEvent(event.event)
}
is TotpEntryScreenEvents.CodeEntryStateChanged -> {
_state.update { it.copy(codeEntry = event.codeEntryState) }
}
is TotpEntryScreenEvents.CodeEntryAction -> {
when (val action = event.action) {
is CodeEntryFieldAction.CodeEntered -> emitCode(action.code)
}
}
TotpEntryScreenEvents.CancelClicked -> {
parentEventEmitter.navigateBack()
@@ -55,89 +76,8 @@ class TotpEntryViewModel(
}
}
/**
* Interprets the raw [value] reported by the digit field at [index] and updates the digits and focus accordingly:
*
* - an empty [value] is a backspace, deleting a digit and moving focus back
* - a single digit is recorded and focus advances
* - multi-character input (e.g. a pasted code) populates every field at once
*
* Once every field has a value, the completed code is emitted.
*/
private fun applyDigitChanged(index: Int, value: String) {
check(index in _state.value.digits.indices) { "[DigitChanged] Out of bounds index $index." }
if (value.isEmpty()) {
deleteDigit(index)
return
}
val currentValue = _state.value.digits[index]
val remainder = if (currentValue.isNotEmpty()) value.replaceFirst(currentValue, "") else value
val addedDigits = remainder.filter { it.isDigit() }
when {
addedDigits.isEmpty() -> Unit
addedDigits.length == 1 -> {
_state.update {
it.copy(
digits = it.digits.toMutableList().also { digits -> digits[index] = addedDigits },
focusedDigitIndex = (index + 1).coerceAtMost(CODE_LENGTH - 1)
)
}
emitCodeIfComplete()
}
else -> applyFullCode(addedDigits)
}
}
/**
* Populates every digit field from a full pasted [code] at once. Multi-character input that isn't a complete code
* is ignored.
*/
private fun applyFullCode(code: String) {
if (code.length != CODE_LENGTH) {
Log.w(TAG, "[DigitChanged] Ignoring multi-character input containing ${code.length} digits.")
return
}
_state.update {
it.copy(
digits = code.map { digit -> digit.toString() },
focusedDigitIndex = CODE_LENGTH - 1
)
}
emitCodeIfComplete()
}
/**
* Deletes the digit at [index] (or the previous one, if [index] is already empty), shifts any following digits left
* to fill the gap, and moves focus back.
*/
private fun deleteDigit(index: Int) {
val digits = _state.value.digits
val deleteAt = if (digits[index].isNotEmpty()) index else index - 1
if (deleteAt < 0) {
return
}
val newDigits = digits.toMutableList().apply {
for (j in deleteAt until CODE_LENGTH - 1) {
this[j] = this[j + 1]
}
this[CODE_LENGTH - 1] = ""
}
_state.update { it.copy(digits = newDigits, focusedDigitIndex = (index - 1).coerceAtLeast(0)) }
}
private fun emitCodeIfComplete() {
val state = _state.value
if (state.isComplete) {
resultBus.sendResult(resultKey, state.code)
parentEventEmitter(RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.SignalLoginCredentialEntry()))
}
private fun emitCode(code: String) {
resultBus.sendResult(resultKey, code)
parentEventEmitter(RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.SignalLoginCredentialEntry()))
}
}
@@ -204,13 +204,6 @@ object TestTags {
// Totp Entry Screen
const val TOTP_ENTRY_SCREEN = "totp_entry_screen"
const val TOTP_ENTRY_INPUT = "totp_entry_input"
const val TOTP_ENTRY_DIGIT_0 = "totp_entry_digit_0"
const val TOTP_ENTRY_DIGIT_1 = "totp_entry_digit_1"
const val TOTP_ENTRY_DIGIT_2 = "totp_entry_digit_2"
const val TOTP_ENTRY_DIGIT_3 = "totp_entry_digit_3"
const val TOTP_ENTRY_DIGIT_4 = "totp_entry_digit_4"
const val TOTP_ENTRY_DIGIT_5 = "totp_entry_digit_5"
const val TOTP_ENTRY_CANCEL_BUTTON = "totp_entry_cancel_button"
// Two Factor Selection Screen
@@ -88,6 +88,7 @@ import org.signal.registration.screens.signalloginpayment.PaymentAvailability
import org.signal.registration.screens.util.MockMultiplePermissionsState
import org.signal.registration.screens.util.MockPermissionsState
import org.signal.registration.test.TestTags
import org.signal.uicomponents.codeentryfield.CodeEntryFieldTestTags
import java.time.Duration
import java.util.UUID
import kotlin.time.Duration.Companion.days
@@ -1908,8 +1909,8 @@ class RegistrationEndToEndTest {
// The service wants a second factor, so the user picks one and enters a code from it
waitForTag(TestTags.TWO_FACTOR_SELECTION_AUTHENTICATOR_APP_OPTION)
composeTestRule.onNodeWithTag(TestTags.TWO_FACTOR_SELECTION_AUTHENTICATOR_APP_OPTION).performClick()
waitForTag(TestTags.TOTP_ENTRY_DIGIT_0)
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).performTextInput(totp)
waitForTag(CodeEntryFieldTestTags.digit(0))
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).performTextInput(totp)
waitFor("registration to complete") { registrationComplete }
@@ -7,7 +7,6 @@ package org.signal.registration.screens.totpentry
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
@@ -24,6 +23,8 @@ import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.test.TestTags
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.signal.uicomponents.codeentryfield.CodeEntryFieldTestTags
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
@@ -46,45 +47,20 @@ class TotpEntryScreenTest {
}
@Test
fun `screen displays all six digit fields`() {
fun `screen displays the code field`() {
setContent(TotpEntryState())
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_1).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_2).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_3).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_4).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_5).assertIsDisplayed()
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.ROOT).assertIsDisplayed()
}
@Test
fun `entering a digit emits DigitChanged for that field`() {
fun `entering a digit forwards a code field event`() {
setContent(TotpEntryState())
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).performTextInput("4")
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_1).performTextInput("1")
composeTestRule.onNodeWithTag(CodeEntryFieldTestTags.digit(0)).performTextInput("4")
composeTestRule.waitForIdle()
assertThat(events).contains(TotpEntryScreenEvents.DigitChanged(0, "4"))
assertThat(events).contains(TotpEntryScreenEvents.DigitChanged(1, "1"))
}
@Test
fun `pasting into a field emits DigitChanged with the raw text`() {
setContent(TotpEntryState())
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).performTextInput("418-372")
composeTestRule.waitForIdle()
assertThat(events).contains(TotpEntryScreenEvents.DigitChanged(0, "418-372"))
}
@Test
fun `screen renders the digits from state`() {
setContent(TotpEntryState(digits = listOf("4", "1", "8", "3", "7", "2")))
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).assertTextEquals("4")
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_5).assertTextEquals("2")
assertThat(events).contains(TotpEntryScreenEvents.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(0, "4")))
}
@Test
@@ -9,9 +9,7 @@ import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -24,6 +22,7 @@ import org.junit.Test
import org.signal.core.ui.navigation.ResultEventBus
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRoute
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
@OptIn(ExperimentalCoroutinesApi::class)
class TotpEntryViewModelTest {
@@ -48,89 +47,31 @@ class TotpEntryViewModelTest {
}
@Test
fun `initial state is empty with focus on the first digit`() = runTest(testDispatcher) {
fun `code field state is mirrored into screen state`() = runTest(testDispatcher) {
val viewModel = createViewModel()
assertThat(viewModel.state.value.digits).isEqualTo(TotpEntryState.emptyDigits())
assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(0)
assertThat(viewModel.state.value.isComplete).isFalse()
viewModel.onEvent(TotpEntryScreenEvents.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(0, "4")))
assertThat(viewModel.state.value.codeEntry.digits[0]).isEqualTo("4")
assertThat(viewModel.state.value.codeEntry.focusedDigitIndex).isEqualTo(1)
}
@Test
fun `entering a digit records it and advances focus`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "4"))
assertThat(viewModel.state.value.digits[0]).isEqualTo("4")
assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(1)
}
@Test
fun `entering the final digit emits the code and pops back to the login screen`() = runTest(testDispatcher) {
fun `a completed code is emitted and pops back to the login screen`() = runTest(testDispatcher) {
val viewModel = createViewModel()
"41837".forEachIndexed { index, digit ->
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(index, digit.toString()))
viewModel.onEvent(TotpEntryScreenEvents.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(index, digit.toString())))
}
assertThat(sentCode()).isNull()
assertThat(emittedParentEvents).isEmpty()
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(5, "2"))
viewModel.onEvent(TotpEntryScreenEvents.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(5, "2")))
assertThat(viewModel.state.value.isComplete).isTrue()
assertThat(sentCode()).isEqualTo("418372")
assertThat(emittedParentEvents).containsExactly(RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.SignalLoginCredentialEntry()))
}
@Test
fun `pasting a full code populates every field and emits the code`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "418372"))
assertThat(viewModel.state.value.digits).isEqualTo(listOf("4", "1", "8", "3", "7", "2"))
assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(5)
assertThat(sentCode()).isEqualTo("418372")
assertThat(emittedParentEvents).containsExactly(RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.SignalLoginCredentialEntry()))
}
@Test
fun `pasting an incomplete code is ignored`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "4183"))
assertThat(viewModel.state.value.digits).isEqualTo(TotpEntryState.emptyDigits())
assertThat(sentCode()).isNull()
assertThat(emittedParentEvents).isEmpty()
}
@Test
fun `a backspace deletes the digit and shifts the following ones left`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "4"))
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(1, "1"))
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(2, "8"))
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(1, ""))
assertThat(viewModel.state.value.digits).isEqualTo(listOf("4", "8", "", "", "", ""))
assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(0)
}
@Test
fun `a backspace on an empty field deletes the previous digit`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "4"))
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(1, ""))
assertThat(viewModel.state.value.digits).isEqualTo(TotpEntryState.emptyDigits())
assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(0)
}
@Test
fun `CancelClicked navigates back`() = runTest(testDispatcher) {
val viewModel = createViewModel()