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
@@ -5,11 +5,14 @@
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import org.signal.appsettings.totpcodeentry.TotpCodeEntryAction
@@ -18,6 +21,7 @@ import org.signal.appsettings.totpcodeentry.TotpCodeEntryState
import org.signal.appsettings.totpcodeentry.TotpCodeEntryState.Error
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.uicomponents.codeentryfield.CodeEntryFieldPresenter
/**
* Drives the screen that collects a code from the user's authenticator app, which is how the service learns the user
@@ -37,16 +41,28 @@ class TotpCodeEntryViewModel(
val state: StateFlow<TotpCodeEntryState> = _state.asStateFlow()
val actions: Flow<TotpCodeEntryAction> = _actions.receiveAsFlow()
private val codeEntryPresenter = CodeEntryFieldPresenter(viewModelScope)
init {
codeEntryPresenter
.state
.onEach { onEvent(TotpCodeEntryEvent.CodeEntryStateChanged(it)) }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: TotpCodeEntryEvent) {
when (event) {
TotpCodeEntryEvent.NavigateBackClicked -> {
_actions.send(TotpCodeEntryAction.NavigateBack)
}
is TotpCodeEntryEvent.CodeChanged -> {
val digits = event.code.filter { it.isDigit() }.take(TotpCodeEntryState.CODE_LENGTH)
_state.update { it.copy(code = digits, error = Error.None) }
is TotpCodeEntryEvent.CodeEntryEvent -> {
_state.update { it.copy(error = Error.None) }
codeEntryPresenter.onEvent(event.event)
}
TotpCodeEntryEvent.DoneClicked -> {
is TotpCodeEntryEvent.CodeEntryStateChanged -> {
_state.update { it.copy(codeEntry = event.codeEntryState) }
}
TotpCodeEntryEvent.NextClicked -> {
if (!_state.value.canSubmit) {
return
}
@@ -28,6 +28,7 @@ import org.junit.Test
import org.signal.appsettings.totpcodeentry.TotpCodeEntryAction
import org.signal.appsettings.totpcodeentry.TotpCodeEntryEvent
import org.signal.appsettings.totpcodeentry.TotpCodeEntryState.Error
import org.signal.uicomponents.codeentryfield.CodeEntryFieldEvents
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@OptIn(ExperimentalCoroutinesApi::class)
@@ -57,25 +58,16 @@ class TotpCodeEntryViewModelTest {
Dispatchers.resetMain()
}
@Test
fun `non-digits are dropped and the code is capped at six digits`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("12a34 5678"))
assertThat(viewModel.state.value.code).isEqualTo(FULL_CODE)
}
@Test
fun `a partial code can't be submitted`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("123"))
enterCode(viewModel, "123")
assertThat(viewModel.state.value.canSubmit).isFalse()
viewModel.onEvent(TotpCodeEntryEvent.DoneClicked)
viewModel.onEvent(TotpCodeEntryEvent.NextClicked)
assertThat(actions).isEmpty()
}
@@ -124,7 +116,7 @@ class TotpCodeEntryViewModelTest {
val viewModel = createViewModel()
submit(viewModel)
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("1"))
enterCode(viewModel, "1")
assertThat(viewModel.state.value.error).isEqualTo(Error.None)
}
@@ -140,8 +132,14 @@ class TotpCodeEntryViewModelTest {
}
private fun submit(viewModel: TotpCodeEntryViewModel) {
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged(FULL_CODE))
viewModel.onEvent(TotpCodeEntryEvent.DoneClicked)
enterCode(viewModel, FULL_CODE)
viewModel.onEvent(TotpCodeEntryEvent.NextClicked)
}
private fun enterCode(viewModel: TotpCodeEntryViewModel, code: String) {
code.forEachIndexed { index, digit ->
viewModel.onEvent(TotpCodeEntryEvent.CodeEntryEvent(CodeEntryFieldEvents.DigitChanged(index, digit.toString())))
}
}
private fun createViewModel() = TotpCodeEntryViewModel(repository = repository)