Handle additional error cases in regV5.

This commit is contained in:
Greyson Parrelli
2026-07-07 16:56:37 -04:00
parent d8e77d8827
commit 355332d604
11 changed files with 215 additions and 33 deletions
@@ -185,7 +185,11 @@ fun PhoneNumberScreen(
onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent)
when (state.oneTimeEvent) {
OneTimeEvent.NetworkError -> simpleErrorMessage = resources.getString(R.string.VerificationCodeScreen__network_error)
is OneTimeEvent.RateLimited -> simpleErrorMessage = resources.getString(R.string.VerificationCodeScreen__too_many_attempts_try_again_in_s, state.oneTimeEvent.retryAfter.toString())
is OneTimeEvent.RateLimited -> simpleErrorMessage = if (state.oneTimeEvent.retryAfter.isPositive()) {
resources.getString(R.string.VerificationCodeScreen__too_many_attempts_try_again_in_s, state.oneTimeEvent.retryAfter.toString())
} else {
resources.getString(R.string.VerificationCodeScreen__too_many_attempts)
}
OneTimeEvent.UnknownError -> simpleErrorMessage = resources.getString(R.string.VerificationCodeScreen__an_unexpected_error_occurred)
OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> simpleErrorMessage = resources.getString(R.string.VerificationCodeScreen__could_not_send_code_via_selected_method)
OneTimeEvent.UnableToSendSms -> simpleErrorMessage = resources.getString(R.string.VerificationCodeScreen__unable_to_send_sms)
@@ -51,6 +51,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -92,11 +93,38 @@ fun PinCreationScreen(
) {
val activePin = remember { mutableStateOf("") }
val canSubmitPin = activePin.value.length >= 4
val resources = LocalResources.current
var errorMessage: String? by remember { mutableStateOf(null) }
BackHandler(enabled = state.isConfirmEnabled) {
onEvent(PinCreationScreenEvents.BackToPinEntry)
}
LaunchedEffect(state.oneTimeEvent) {
val event = state.oneTimeEvent ?: return@LaunchedEffect
onEvent(PinCreationScreenEvents.ConsumeOneTimeEvent)
errorMessage = when (event) {
is PinCreationState.OneTimeEvent.ServiceError -> {
resources.getString(R.string.PinCreationScreen__service_error)
}
is PinCreationState.OneTimeEvent.NetworkError -> {
if (event.retryAfter != null) {
resources.getString(R.string.PinCreationScreen__network_error_try_again_in_s, event.retryAfter.toString())
} else {
resources.getString(R.string.PinCreationScreen__network_error)
}
}
}
}
errorMessage?.let { message ->
Dialogs.SimpleMessageDialog(
message = message,
dismiss = stringResource(android.R.string.ok),
onDismiss = { errorMessage = null }
)
}
when (val params = RegistrationScaffold.rememberLayoutParams()) {
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(
params = params,
@@ -13,4 +13,5 @@ sealed class PinCreationScreenEvents {
data object LearnMore : PinCreationScreenEvents()
data object OptOut : PinCreationScreenEvents()
data object BackToPinEntry : PinCreationScreenEvents()
data object ConsumeOneTimeEvent : PinCreationScreenEvents()
}
@@ -7,6 +7,7 @@ package org.signal.registration.screens.pincreation
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.censor
import kotlin.time.Duration
data class PinCreationState(
val isAlphanumericKeyboard: Boolean = false,
@@ -14,9 +15,15 @@ data class PinCreationState(
val pinMismatch: Boolean = false,
val loading: Boolean = false,
val firstPin: String? = null,
val accountEntropyPool: AccountEntropyPool? = null
val accountEntropyPool: AccountEntropyPool? = null,
val oneTimeEvent: OneTimeEvent? = null
) {
override fun toString(): String {
return "PinCreationState(isAlphanumericKeyboard=$isAlphanumericKeyboard, isConfirmEnabled=$isConfirmEnabled, pinMismatch=$pinMismatch, loading=$loading, firstPin=${firstPin?.let { "${it.length} chars" }}, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()})"
return "PinCreationState(isAlphanumericKeyboard=$isAlphanumericKeyboard, isConfirmEnabled=$isConfirmEnabled, pinMismatch=$pinMismatch, loading=$loading, firstPin=${firstPin?.let { "${it.length} chars" }}, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()}, oneTimeEvent=$oneTimeEvent)"
}
sealed interface OneTimeEvent {
data object ServiceError : OneTimeEvent
data class NetworkError(val retryAfter: Duration?) : OneTimeEvent
}
}
@@ -23,6 +23,7 @@ import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RestoreDecision
import org.signal.registration.screens.EventDrivenViewModel
import kotlin.time.toKotlinDuration
/**
* ViewModel for the PIN creation screen.
@@ -91,6 +92,9 @@ class PinCreationViewModel(
_state.value = state.copy(isConfirmEnabled = false)
applyOptOut()
}
is PinCreationScreenEvents.ConsumeOneTimeEvent -> {
_state.value = state.copy(oneTimeEvent = null)
}
}
}
@@ -129,8 +133,7 @@ class PinCreationViewModel(
when (val error = result.error) {
is NetworkController.BackupMasterKeyError.EnclaveNotFound -> {
Log.w(TAG, "[PinSubmitted] SVR enclave not found.")
// TODO [registration] - Report to UI and indicate to library user that pin could not be created
throw NotImplementedError("Report to UI and indicate to library user that pin could not be created")
state.copy(loading = false, oneTimeEvent = PinCreationState.OneTimeEvent.ServiceError)
}
is NetworkController.BackupMasterKeyError.NotRegistered -> {
@@ -143,14 +146,12 @@ class PinCreationViewModel(
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[PinSubmitted] Network error when backing up master key.", result.networkError)
// TODO [registration] - Report to UI and indicate to library user that pin could not be created
throw NotImplementedError("Report to UI and indicate to library user that pin could not be created")
state.copy(loading = false, oneTimeEvent = PinCreationState.OneTimeEvent.NetworkError(result.retryAfter?.toKotlinDuration()))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[PinSubmitted] Application error when backing up master key.", result.cause)
// TODO [registration] - Report to UI and indicate to library user that pin could not be created
throw NotImplementedError("Report to UI and indicate to library user that pin could not be created")
state.copy(loading = false, oneTimeEvent = PinCreationState.OneTimeEvent.ServiceError)
}
}
}
@@ -23,6 +23,7 @@ import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.EventDrivenViewModel
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
/**
@@ -90,7 +91,13 @@ class PinEntryForRegistrationLockViewModel(
return when (val error = restoreResult.error) {
is NetworkController.RestoreMasterKeyError.WrongPin -> {
Log.w(TAG, "[PinEntered] Wrong PIN. Tries remaining: ${error.triesRemaining}")
state.copy(loading = false, triesRemaining = error.triesRemaining)
if (error.triesRemaining <= 0) {
Log.w(TAG, "[PinEntered] Out of PIN attempts. Account is locked.")
parentEventEmitter.navigateTo(RegistrationRoute.AccountLocked(timeRemainingMs = timeRemaining))
state
} else {
state.copy(loading = false, triesRemaining = error.triesRemaining)
}
}
is NetworkController.RestoreMasterKeyError.NoDataFound -> {
Log.w(TAG, "[PinEntered] No SVR data found. Account is locked.")
@@ -144,9 +151,9 @@ class PinEntryForRegistrationLockViewModel(
is RequestResult.NonSuccess -> {
when (val error = registerResult.error) {
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
Log.w(TAG, "[PinEntered] Session not found or verified: ${error.message}")
// TODO [registration] - Handle session not found or verified.
throw NotImplementedError("Handle session not found or verified")
Log.w(TAG, "[PinEntered] Session not found or verified: ${error.message}. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
}
is NetworkController.RegisterAccountError.RegistrationLock -> {
Log.w(TAG, "[PinEntered] Still getting registration lock error after providing token. This shouldn't happen. Resetting state.")
@@ -166,9 +173,10 @@ class PinEntryForRegistrationLockViewModel(
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
}
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
Log.w(TAG, "[PinEntered] Registration recovery password incorrect: ${error.message}")
// TODO [registration] - Handle incorrect password
throw NotImplementedError("Handle incorrect password")
Log.w(TAG, "[PinEntered] Registration recovery password incorrect: ${error.message}. Marking recovery password invalid and navigating back.")
parentEventEmitter(RegistrationFlowEvent.RecoveryPasswordInvalid)
parentEventEmitter.navigateBack()
state
}
}
}
@@ -307,9 +307,8 @@ class VerificationCodeViewModel(
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts, digits = VerificationCodeState.emptyDigits(), focusedDigitIndex = 0)
}
is NetworkController.SubmitVerificationCodeError.SessionNotFound -> {
Log.w(TAG, "[SubmitCode] Session not found: ${error.message}")
// TODO don't start over, go back to phone number entry
parentEventEmitter(RegistrationFlowEvent.ResetState)
Log.w(TAG, "[SubmitCode] Session not found: ${error.message}. Navigating back to phone number entry.")
parentEventEmitter.navigateBack()
return state
}
is NetworkController.SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested -> {
@@ -365,8 +364,9 @@ class VerificationCodeViewModel(
is RequestResult.NonSuccess -> {
when (val error = registerResult.error) {
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
// TODO [registration] Handle session not found or not verified case.
throw NotImplementedError("Handle session not found or not verified case.")
Log.w(TAG, "[Register] Session not found or not verified: ${error.message}. Navigating back to phone number entry.")
parentEventEmitter.navigateBack()
state
}
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
error("[Register] Got told a device transfer is possible. We should never get into this state. Resetting.")
@@ -454,9 +454,8 @@ class VerificationCodeViewModel(
)
}
is NetworkController.RequestVerificationCodeError.InvalidSessionId -> {
Log.w(TAG, "[RequestCode][$transport] Invalid session ID: ${error.message}")
// TODO don't start over, go back to phone number entry
parentEventEmitter(RegistrationFlowEvent.ResetState)
Log.w(TAG, "[RequestCode][$transport] Invalid session ID: ${error.message}. Navigating back to phone number entry.")
parentEventEmitter.navigateBack()
state
}
is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> {
@@ -469,9 +468,8 @@ class VerificationCodeViewModel(
)
}
is NetworkController.RequestVerificationCodeError.SessionNotFound -> {
Log.w(TAG, "[RequestCode][$transport] Session not found: ${error.message}")
// TODO don't start over, go back to phone number entry
parentEventEmitter(RegistrationFlowEvent.ResetState)
Log.w(TAG, "[RequestCode][$transport] Session not found: ${error.message}. Navigating back to phone number entry.")
parentEventEmitter.navigateBack()
state
}
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
@@ -91,6 +91,8 @@
<string name="VerificationCodeScreen__network_error">Unable to connect. Please check your network and try again.</string>
<!-- Snackbar shown when rate limited. Placeholder is the retry duration -->
<string name="VerificationCodeScreen__too_many_attempts_try_again_in_s">Too many attempts. Try again in %s.</string>
<!-- Snackbar shown when rate limited without a specific retry duration -->
<string name="VerificationCodeScreen__too_many_attempts">Too many attempts. Please try again later.</string>
<!-- Snackbar shown for generic/unknown errors -->
<string name="VerificationCodeScreen__an_unexpected_error_occurred">An unexpected error occurred. Please try again.</string>
<!-- Snackbar shown when we are unable to send an SMS to the user\'s number -->
@@ -361,6 +363,12 @@
<string name="PinCreationScreen__disable_pin">Disable PIN</string>
<!-- Labels the button that cancels disabling the Signal PIN. -->
<string name="PinCreationScreen__cancel">Cancel</string>
<!-- Dialog message shown when the PIN could not be created because of a problem reaching the service. -->
<string name="PinCreationScreen__service_error">There was an issue connecting to the service.</string>
<!-- Dialog message shown when the PIN could not be created because of a network error. -->
<string name="PinCreationScreen__network_error">Encountered a network error.</string>
<!-- Dialog message shown when the PIN could not be created because of a network error, and the user may retry after a delay. Placeholder is the wait duration. -->
<string name="PinCreationScreen__network_error_try_again_in_s">Encountered a network error. You can try again in %1$s.</string>
<!-- PIN entry screen title when registration lock is active. -->
<string name="PinEntryScreen__registration_lock">Registration Lock</string>
@@ -9,8 +9,11 @@ import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
@@ -34,6 +37,9 @@ import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RestoreDecision
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@OptIn(ExperimentalCoroutinesApi::class)
class PinCreationViewModelTest {
@@ -167,6 +173,68 @@ class PinCreationViewModelTest {
assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
}
@Test
fun `PinSubmitted with EnclaveNotFound error surfaces a service error and stops loading`() = runTest(testDispatcher) {
val states = collectStates()
val aep = AccountEntropyPool.generate()
val confirmState = PinCreationState(accountEntropyPool = aep, isConfirmEnabled = true, firstPin = "123456")
coEvery { mockRepository.setNewlyCreatedPin(any(), any(), any<MasterKey>()) } returns
RequestResult.NonSuccess(NetworkController.BackupMasterKeyError.EnclaveNotFound)
viewModel.applyEvent(confirmState, PinCreationScreenEvents.PinSubmitted("123456"))
assertThat(emittedParentEvents).hasSize(0)
assertThat(states.last().oneTimeEvent).isEqualTo(PinCreationState.OneTimeEvent.ServiceError)
assertThat(states.last().loading).isFalse()
}
@Test
fun `PinSubmitted with application error surfaces a service error and stops loading`() = runTest(testDispatcher) {
val states = collectStates()
val aep = AccountEntropyPool.generate()
val confirmState = PinCreationState(accountEntropyPool = aep, isConfirmEnabled = true, firstPin = "123456")
coEvery { mockRepository.setNewlyCreatedPin(any(), any(), any<MasterKey>()) } returns
RequestResult.ApplicationError(RuntimeException("Unexpected"))
viewModel.applyEvent(confirmState, PinCreationScreenEvents.PinSubmitted("123456"))
assertThat(emittedParentEvents).hasSize(0)
assertThat(states.last().oneTimeEvent).isEqualTo(PinCreationState.OneTimeEvent.ServiceError)
assertThat(states.last().loading).isFalse()
}
@Test
fun `PinSubmitted with retryable network error surfaces a network error with retryAfter`() = runTest(testDispatcher) {
val states = collectStates()
val aep = AccountEntropyPool.generate()
val confirmState = PinCreationState(accountEntropyPool = aep, isConfirmEnabled = true, firstPin = "123456")
val retryAfter = 30.seconds
coEvery { mockRepository.setNewlyCreatedPin(any(), any(), any<MasterKey>()) } returns
RequestResult.RetryableNetworkError(IOException("Network error"), retryAfter.toJavaDuration())
viewModel.applyEvent(confirmState, PinCreationScreenEvents.PinSubmitted("123456"))
assertThat(emittedParentEvents).hasSize(0)
assertThat(states.last().oneTimeEvent).isNotNull()
.isInstanceOf<PinCreationState.OneTimeEvent.NetworkError>()
.prop(PinCreationState.OneTimeEvent.NetworkError::retryAfter)
.isEqualTo(retryAfter)
assertThat(states.last().loading).isFalse()
}
@Test
fun `ConsumeOneTimeEvent clears the one-time event`() = runTest(testDispatcher) {
val states = collectStates()
val stateWithEvent = PinCreationState(oneTimeEvent = PinCreationState.OneTimeEvent.ServiceError)
viewModel.applyEvent(stateWithEvent, PinCreationScreenEvents.ConsumeOneTimeEvent)
assertThat(states.last().oneTimeEvent).isNull()
}
// ==================== OptOut Tests ====================
@Test
@@ -106,6 +106,26 @@ class PinEntryForRegistrationLockViewModelTest {
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@Test
fun `PinEntered with wrong PIN and no tries remaining navigates to AccountLocked`() = runTest {
val initialState = PinEntryState(mode = PinEntryState.Mode.RegistrationLock)
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), forRegistrationLock = true) } returns
RequestResult.NonSuccess(
NetworkController.RestoreMasterKeyError.WrongPin(0)
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("wrong-pin"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.AccountLocked>()
.prop(RegistrationRoute.AccountLocked::timeRemainingMs)
.isEqualTo(testTimeRemaining)
}
@Test
fun `PinEntered with no SVR data navigates to AccountLocked`() = runTest {
val initialState = PinEntryState(mode = PinEntryState.Mode.RegistrationLock)
@@ -197,6 +217,45 @@ class PinEntryForRegistrationLockViewModelTest {
// ==================== Registration Error Tests ====================
@Test
fun `PinEntered with session not found during registration emits ResetState`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.RegistrationLock)
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), forRegistrationLock = true) } returns
RequestResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithSession(any(), any(), any(), any()) } returns
RequestResult.NonSuccess(
NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified("Session not found")
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.ResetState)
}
@Test
fun `PinEntered with recovery password incorrect during registration marks it invalid and navigates back`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.RegistrationLock)
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), forRegistrationLock = true) } returns
RequestResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithSession(any(), any(), any(), any()) } returns
RequestResult.NonSuccess(
NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect("Wrong password")
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(3)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid)
assertThat(emittedParentEvents[2]).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `PinEntered with registration lock error during registration emits ResetState`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
@@ -579,7 +579,7 @@ class VerificationCodeViewModelTest {
}
@Test
fun `CodeEntered with session not found emits ResetState`() = runTest {
fun `CodeEntered with session not found navigates back to phone number entry`() = runTest {
val sessionMetadata = createSessionMetadata()
val initialState = VerificationCodeState(
sessionMetadata = sessionMetadata,
@@ -594,7 +594,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"), stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
@Test
@@ -953,7 +953,7 @@ class VerificationCodeViewModelTest {
}
@Test
fun `ResendSms with InvalidSessionId emits ResetState`() = runTest {
fun `ResendSms with InvalidSessionId navigates back to phone number entry`() = runTest {
val sessionMetadata = createSessionMetadata()
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
@@ -965,11 +965,11 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `ResendSms with SessionNotFound emits ResetState`() = runTest {
fun `ResendSms with SessionNotFound navigates back to phone number entry`() = runTest {
val sessionMetadata = createSessionMetadata()
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
@@ -981,7 +981,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
@Test