mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 08:45:50 +01:00
Add UI scaffolding for TotpEntryScreen.
This commit is contained in:
committed by
Alex Hart
parent
2109ada5cf
commit
01fae0aaf9
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.totpentry
|
||||
|
||||
import org.signal.core.util.censor
|
||||
|
||||
/**
|
||||
* One-shot side effects that have to be carried out by the host rather than the screen itself.
|
||||
*
|
||||
* Actions are logged, so be sure `toString()` contains nothing sensitive.
|
||||
*/
|
||||
sealed interface TotpEntryAction {
|
||||
|
||||
/** The user cancelled code entry. Leave the screen. */
|
||||
data object NavigateBack : TotpEntryAction
|
||||
|
||||
/** All six digits have been entered. The host should verify [code]. */
|
||||
data class CodeEntered(val code: String) : TotpEntryAction {
|
||||
override fun toString(): String = "CodeEntered(code=${code.censor()})"
|
||||
}
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.totpentry
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.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
|
||||
import org.signal.registration.R
|
||||
import org.signal.registration.screens.OnePaneRegistrationScaffold
|
||||
import org.signal.registration.screens.RegistrationScaffold
|
||||
import org.signal.registration.screens.TwoPaneRegistrationScaffold
|
||||
import org.signal.registration.screens.attachDebugLogHelper
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Two-factor authentication code entry screen. Displays a 6-digit code input in XXX-XXX format for a code from the
|
||||
* user's authenticator app.
|
||||
*/
|
||||
@Composable
|
||||
fun TotpEntryScreen(
|
||||
state: TotpEntryState,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnePaneLayout(
|
||||
params: RegistrationScaffold.Params.OnePane,
|
||||
focusRequesters: List<FocusRequester>,
|
||||
state: TotpEntryState,
|
||||
onEvent: (TotpEntryScreenEvents) -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
OnePaneRegistrationScaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
params = params,
|
||||
content = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Illustration()
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Description()
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
CodeField(
|
||||
focusRequesters = focusRequesters,
|
||||
state = state,
|
||||
onEvent = onEvent
|
||||
)
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
RegistrationScaffold.FooterSurface(
|
||||
isElevated = scrollState.canScrollForward
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(params.footerPadding),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CancelButton(onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TwoPaneLayout(
|
||||
params: RegistrationScaffold.Params.TwoPane,
|
||||
focusRequesters: List<FocusRequester>,
|
||||
state: TotpEntryState,
|
||||
onEvent: (TotpEntryScreenEvents) -> Unit
|
||||
) {
|
||||
val firstPaneScrollState = rememberScrollState()
|
||||
val secondPaneScrollState = rememberScrollState()
|
||||
|
||||
TwoPaneRegistrationScaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
params = params,
|
||||
firstPane = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(firstPaneScrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Illustration()
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Description(twoPane = true)
|
||||
}
|
||||
},
|
||||
secondPane = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(secondPaneScrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
CodeField(
|
||||
focusRequesters = focusRequesters,
|
||||
state = state,
|
||||
onEvent = onEvent
|
||||
)
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
RegistrationScaffold.FooterSurface(
|
||||
isElevated = firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(params.footerPadding),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
CancelButton(onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Illustration() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_totp_phone),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Description(twoPane: Boolean = false) {
|
||||
Text(
|
||||
text = stringResource(R.string.TotpEntryScreen__two_factor_authentication),
|
||||
style = if (twoPane) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.attachDebugLogHelper()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.TotpEntryScreen__to_continue_enter_the_code),
|
||||
style = if (twoPane) MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) else MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@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(
|
||||
onClick = { onEvent(TotpEntryScreenEvents.CancelClicked) },
|
||||
modifier = Modifier.testTag(TestTags.TOTP_ENTRY_CANCEL_BUTTON)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.TotpEntryScreen__cancel),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun TotpEntryScreenPreview() {
|
||||
Previews.Preview {
|
||||
TotpEntryScreen(
|
||||
state = TotpEntryState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun TotpEntryScreenPartiallyFilledPreview() {
|
||||
Previews.Preview {
|
||||
TotpEntryScreen(
|
||||
state = TotpEntryState(
|
||||
digits = listOf("4", "1", "8", "3", "7", ""),
|
||||
focusedDigitIndex = 5
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.totpentry
|
||||
|
||||
/**
|
||||
* 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)"
|
||||
}
|
||||
|
||||
/** The user tapped the cancel button. */
|
||||
data object CancelClicked : TotpEntryScreenEvents()
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.totpentry
|
||||
|
||||
/**
|
||||
* 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) { "" }
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.totpentry
|
||||
|
||||
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.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.screens.totpentry.TotpEntryState.Companion.CODE_LENGTH
|
||||
|
||||
/**
|
||||
* Drives [TotpEntryScreen]. Interprets raw digit-field input into a six-digit code, surfacing the completed code
|
||||
* (and cancellation) to the host as a [TotpEntryAction].
|
||||
*/
|
||||
class TotpEntryViewModel : EventDrivenViewModel<TotpEntryScreenEvents>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(TotpEntryViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(TotpEntryState())
|
||||
private val _actions = Channel<TotpEntryAction>(Channel.BUFFERED)
|
||||
|
||||
val state: StateFlow<TotpEntryState> = _state.asStateFlow()
|
||||
val actions: Flow<TotpEntryAction> = _actions.receiveAsFlow()
|
||||
|
||||
init {
|
||||
_state
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: TotpEntryScreenEvents) {
|
||||
when (event) {
|
||||
is TotpEntryScreenEvents.DigitChanged -> {
|
||||
applyDigitChanged(event.index, event.value)
|
||||
}
|
||||
TotpEntryScreenEvents.CancelClicked -> {
|
||||
_actions.send(TotpEntryAction.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 as [TotpEntryAction.CodeEntered].
|
||||
*/
|
||||
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(TotpEntryAction.CodeEntered(state.code))
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -372,6 +372,7 @@ private fun CodeField(
|
||||
1 -> TestTags.VERIFICATION_CODE_DIGIT_1
|
||||
else -> TestTags.VERIFICATION_CODE_DIGIT_2
|
||||
},
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
enabled = !state.isSubmittingCode
|
||||
)
|
||||
if (i < 2) {
|
||||
@@ -399,6 +400,7 @@ private fun CodeField(
|
||||
4 -> TestTags.VERIFICATION_CODE_DIGIT_4
|
||||
else -> TestTags.VERIFICATION_CODE_DIGIT_5
|
||||
},
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
enabled = !state.isSubmittingCode
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,6 +187,17 @@ object TestTags {
|
||||
const val QUICK_RESTORE_QR_RETRY_BUTTON = "quick_restore_qr_retry_button"
|
||||
const val QUICK_RESTORE_QR_CANCEL_BUTTON = "quick_restore_qr_cancel_button"
|
||||
|
||||
// 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"
|
||||
|
||||
// Remote Backup Restore Screen
|
||||
const val REMOTE_BACKUP_RESTORE_SCREEN = "remote_backup_restore_screen"
|
||||
const val REMOTE_BACKUP_RESTORE_RESTORE_BUTTON = "remote_backup_restore_restore_button"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="41dp"
|
||||
android:height="77dp"
|
||||
android:viewportWidth="40.5"
|
||||
android:viewportHeight="77">
|
||||
<path
|
||||
android:fillColor="#FFD9E0EE"
|
||||
android:strokeColor="#FF4A5775"
|
||||
android:strokeWidth="2"
|
||||
android:pathData="M10,1h20.5c4.97,0 9,4.03 9,9v57c0,4.97 -4.03,9 -9,9h-20.5c-4.97,0 -9,-4.03 -9,-9v-57c0,-4.97 4.03,-9 9,-9z"/>
|
||||
<path
|
||||
android:fillColor="#FFEAEEF6"
|
||||
android:pathData="M22.19,2C20.48,12.81 12.45,21.51 2,24.2V10C2,5.58 5.58,2 10,2H22.19Z"/>
|
||||
<path
|
||||
android:fillColor="#FFC0CBE2"
|
||||
android:pathData="M34.69,4.15C34.69,3.72 35.16,3.49 35.5,3.76C37.33,5.22 38.5,7.47 38.5,10V67C38.5,71.42 34.92,75 30.5,75H10C6.97,75 6.53,71.01 9.48,70.35C10.05,70.22 10.64,70.15 11.25,70.15H26.69C31.11,70.15 34.69,66.57 34.69,62.15V4.15Z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M19.04,52.3C20.18,53.23 21.82,53.23 22.96,52.3L27.24,48.77C29.67,46.78 31.18,43.9 31.45,40.78L31.99,34.64C32.11,33.2 31.22,31.87 29.84,31.43L23.22,29.34C21.78,28.89 20.22,28.89 18.78,29.34L12.16,31.43C10.78,31.87 9.89,33.2 10.01,34.64L10.55,40.78C10.82,43.9 12.33,46.78 14.76,48.77L19.04,52.3Z"/>
|
||||
<path
|
||||
android:fillColor="#FF4A5775"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M19.04,52.3C20.18,53.23 21.82,53.23 22.96,52.3L27.24,48.77C29.67,46.78 31.18,43.9 31.45,40.78L31.99,34.64C32.11,33.2 31.22,31.87 29.84,31.43L23.22,29.34C21.78,28.89 20.22,28.89 18.78,29.34L12.16,31.43C10.78,31.87 9.89,33.2 10.01,34.64L10.55,40.78C10.82,43.9 12.33,46.78 14.76,48.77L19.04,52.3ZM21.77,50.85C21.32,51.21 20.68,51.21 20.23,50.85L15.95,47.32C13.92,45.65 12.65,43.23 12.42,40.61L11.88,34.48C11.83,33.91 12.18,33.39 12.72,33.22L19.34,31.13C20.42,30.79 21.58,30.79 22.66,31.13L29.28,33.22C29.82,33.39 30.17,33.91 30.12,34.48L29.58,40.61C29.35,43.23 28.08,45.65 26.05,47.32L21.77,50.85Z"/>
|
||||
<path
|
||||
android:fillColor="#FF4A5775"
|
||||
android:pathData="M18,38a3,3 0,1 0,6 0a3,3 0,1 0,-6 0z"/>
|
||||
<path
|
||||
android:fillColor="#FF4A5775"
|
||||
android:pathData="M18,45L20.25,38H21.75L24,45H18Z"/>
|
||||
<path
|
||||
android:fillColor="#FF4A5775"
|
||||
android:pathData="M16.74,4.81h7.02a1.4,1.4 0,0 1,0 2.81h-7.02a1.4,1.4 0,0 1,0 -2.81z"/>
|
||||
</vector>
|
||||
@@ -727,4 +727,12 @@
|
||||
<string name="ContactSupportDialog__submit_with_debug_log">Submit with debug log</string>
|
||||
<!-- Button that contacts support without attaching a debug log -->
|
||||
<string name="ContactSupportDialog__submit_without_debug_log">Submit without debug log</string>
|
||||
|
||||
<!-- TotpEntryScreen -->
|
||||
<!-- Title of the two-factor authentication code entry screen -->
|
||||
<string name="TotpEntryScreen__two_factor_authentication">Two-factor authentication</string>
|
||||
<!-- Subtitle explaining where to find the code -->
|
||||
<string name="TotpEntryScreen__to_continue_enter_the_code">To continue enter the 6-digit code from your authenticator app.</string>
|
||||
<!-- Button text to cancel entering a code -->
|
||||
<string name="TotpEntryScreen__cancel">Cancel</string>
|
||||
</resources>
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:742f21af788eaa066a52d6beadca83ea5c699e374df74a6f609f271a1436f4cd
|
||||
size 42484
|
||||
oid sha256:cde64bac51945a95f1f1867c578969b83c7c7efee5098a3a5437cce76cf6e41d
|
||||
size 42431
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e9f54e1a65f728ee817e1472b646afb824e315e3ff566ac289720391c76a5dbe
|
||||
size 42757
|
||||
oid sha256:f1c8b6967229f087d45f2bd749f98384c626091c215f27559e014dc150b9a416
|
||||
size 42705
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
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
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
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.core.ui.CoreUiDependenciesRule
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class TotpEntryScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
|
||||
|
||||
private val events = mutableListOf<TotpEntryScreenEvents>()
|
||||
|
||||
@Test
|
||||
fun `screen displays title and subtitle`() {
|
||||
setContent(TotpEntryState())
|
||||
|
||||
composeTestRule.onNodeWithText("Two-factor authentication").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("To continue enter the 6-digit code from your authenticator app.").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screen displays all six digit fields`() {
|
||||
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()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entering a digit emits DigitChanged for that field`() {
|
||||
setContent(TotpEntryState())
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).performTextInput("4")
|
||||
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_1).performTextInput("1")
|
||||
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")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking cancel emits CancelClicked`() {
|
||||
setContent(TotpEntryState())
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_CANCEL_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(TotpEntryScreenEvents.CancelClicked)
|
||||
}
|
||||
|
||||
private fun setContent(state: TotpEntryState) {
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
TotpEntryScreen(
|
||||
state = state,
|
||||
onEvent = { events += it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.totpentry
|
||||
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TotpEntryViewModelTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is empty with focus on the first digit`() = runTest(testDispatcher) {
|
||||
val viewModel = TotpEntryViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.digits).isEqualTo(TotpEntryState.emptyDigits())
|
||||
assertThat(viewModel.state.value.focusedDigitIndex).isEqualTo(0)
|
||||
assertThat(viewModel.state.value.isComplete).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entering a digit records it and advances focus`() = runTest(testDispatcher) {
|
||||
val viewModel = TotpEntryViewModel()
|
||||
|
||||
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 CodeEntered`() = runTest(testDispatcher) {
|
||||
val viewModel = TotpEntryViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
"41837".forEachIndexed { index, digit ->
|
||||
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(index, digit.toString()))
|
||||
}
|
||||
assertThat(actions).isEmpty()
|
||||
|
||||
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(5, "2"))
|
||||
|
||||
assertThat(viewModel.state.value.isComplete).isTrue()
|
||||
assertThat(actions).containsExactly(TotpEntryAction.CodeEntered("418372"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pasting a full code populates every field and emits CodeEntered`() = runTest(testDispatcher) {
|
||||
val viewModel = TotpEntryViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
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(actions).containsExactly(TotpEntryAction.CodeEntered("418372"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pasting an incomplete code is ignored`() = runTest(testDispatcher) {
|
||||
val viewModel = TotpEntryViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpEntryScreenEvents.DigitChanged(0, "4183"))
|
||||
|
||||
assertThat(viewModel.state.value.digits).isEqualTo(TotpEntryState.emptyDigits())
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a backspace deletes the digit and shifts the following ones left`() = runTest(testDispatcher) {
|
||||
val viewModel = TotpEntryViewModel()
|
||||
|
||||
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 = TotpEntryViewModel()
|
||||
|
||||
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 emits NavigateBack`() = runTest(testDispatcher) {
|
||||
val viewModel = TotpEntryViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpEntryScreenEvents.CancelClicked)
|
||||
|
||||
assertThat(actions).containsExactly(TotpEntryAction.NavigateBack)
|
||||
}
|
||||
|
||||
private fun TestScope.collectActions(actions: Flow<TotpEntryAction>): List<TotpEntryAction> {
|
||||
val collected = mutableListOf<TotpEntryAction>()
|
||||
backgroundScope.launch { actions.toList(collected) }
|
||||
return collected
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -71,7 +71,7 @@ class VerificationCodeScreenTest {
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_4).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).fetchSemanticsNode()
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user