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