Remove OneTimeEvent pattern from regV5.

This commit is contained in:
Greyson Parrelli
2026-07-15 10:52:59 -04:00
parent bdfe06ed74
commit edc629e7fa
46 changed files with 437 additions and 449 deletions
@@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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
@@ -48,6 +47,7 @@ import androidx.compose.ui.unit.dp
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.rememberWindowBreakpoint
@@ -84,10 +84,12 @@ fun CreateProfileScreen(
pickAvatarLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
LaunchedEffect(state.oneTimeEvent) {
if (state.oneTimeEvent != null) {
onEvent(CreateProfileScreenEvents.ConsumeOneTimeEvent)
}
if (state.showUploadFailedDialog) {
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred),
dismiss = stringResource(android.R.string.ok),
onDismiss = { onEvent(CreateProfileScreenEvents.UploadFailedDialogDismissed) }
)
}
if (state.isLoading) {
@@ -25,5 +25,5 @@ sealed class CreateProfileScreenEvents {
data object WhoCanFindMeClicked : CreateProfileScreenEvents()
data class DiscoverabilityChanged(val discoverable: Boolean) : CreateProfileScreenEvents()
data object NextClicked : CreateProfileScreenEvents()
data object ConsumeOneTimeEvent : CreateProfileScreenEvents()
data object UploadFailedDialogDismissed : CreateProfileScreenEvents()
}
@@ -12,14 +12,14 @@ data class CreateProfileState(
val discoverableByPhoneNumber: Boolean = true,
val isLoading: Boolean = true,
val isSubmitting: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null
val showUploadFailedDialog: Boolean = false
) {
val isFormValid: Boolean
get() = givenName.trim().isNotEmpty()
override fun toString(): String {
return "CreateProfileState(givenName=${givenName.length} chars, familyName=${familyName.length} chars, avatar=${avatar?.size ?: 0} bytes, discoverableByPhoneNumber=$discoverableByPhoneNumber, isLoading=$isLoading, isSubmitting=$isSubmitting, oneTimeEvent=$oneTimeEvent)"
return "CreateProfileState(givenName=${givenName.length} chars, familyName=${familyName.length} chars, avatar=${avatar?.size ?: 0} bytes, discoverableByPhoneNumber=$discoverableByPhoneNumber, isLoading=$isLoading, isSubmitting=$isSubmitting, showUploadFailedDialog=$showUploadFailedDialog)"
}
override fun equals(other: Any?): Boolean {
@@ -36,7 +36,7 @@ data class CreateProfileState(
if (discoverableByPhoneNumber != other.discoverableByPhoneNumber) return false
if (isLoading != other.isLoading) return false
if (isSubmitting != other.isSubmitting) return false
if (oneTimeEvent != other.oneTimeEvent) return false
if (showUploadFailedDialog != other.showUploadFailedDialog) return false
return true
}
@@ -47,11 +47,7 @@ data class CreateProfileState(
result = 31 * result + discoverableByPhoneNumber.hashCode()
result = 31 * result + isLoading.hashCode()
result = 31 * result + isSubmitting.hashCode()
result = 31 * result + (oneTimeEvent?.hashCode() ?: 0)
result = 31 * result + showUploadFailedDialog.hashCode()
return result
}
sealed interface OneTimeEvent {
data object UploadFailed : OneTimeEvent
}
}
@@ -97,8 +97,8 @@ class CreateProfileViewModel(
stateEmitter(state.copy(isSubmitting = true))
submitProfile(state, parentEventEmitter, repository, stateEmitter)
}
CreateProfileScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
CreateProfileScreenEvents.UploadFailedDialogDismissed -> {
stateEmitter(state.copy(showUploadFailedDialog = false))
}
}
}
@@ -126,15 +126,15 @@ class CreateProfileViewModel(
}
is RequestResult.NonSuccess -> {
Log.w(TAG, "[submitProfile] Profile save failed: ${result.error}")
stateEmitter(state.copy(isSubmitting = false, oneTimeEvent = CreateProfileState.OneTimeEvent.UploadFailed))
stateEmitter(state.copy(isSubmitting = false, showUploadFailedDialog = true))
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[submitProfile] Network error saving profile.", result.networkError)
stateEmitter(state.copy(isSubmitting = false, oneTimeEvent = CreateProfileState.OneTimeEvent.UploadFailed))
stateEmitter(state.copy(isSubmitting = false, showUploadFailedDialog = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[submitProfile] Application error saving profile.", result.cause)
stateEmitter(state.copy(isSubmitting = false, oneTimeEvent = CreateProfileState.OneTimeEvent.UploadFailed))
stateEmitter(state.copy(isSubmitting = false, showUploadFailedDialog = true))
}
}
}
@@ -7,5 +7,4 @@ package org.signal.registration.screens.devicetransfer.complete
sealed class DeviceTransferCompleteScreenEvents {
data object ContinueClicked : DeviceTransferCompleteScreenEvents()
data object ConsumeOneTimeEvent : DeviceTransferCompleteScreenEvents()
}
@@ -5,8 +5,4 @@
package org.signal.registration.screens.devicetransfer.complete
data class DeviceTransferCompleteState(
val oneTimeEvent: OneTimeEvent? = null
) {
sealed interface OneTimeEvent
}
class DeviceTransferCompleteState
@@ -55,9 +55,6 @@ class DeviceTransferCompleteViewModel(
repository.restoreAccountRecord()
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
DeviceTransferCompleteScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
@@ -8,5 +8,4 @@ package org.signal.registration.screens.devicetransfer.instructions
sealed class DeviceTransferInstructionsScreenEvents {
data object ContinueClicked : DeviceTransferInstructionsScreenEvents()
data object BackClicked : DeviceTransferInstructionsScreenEvents()
data object ConsumeOneTimeEvent : DeviceTransferInstructionsScreenEvents()
}
@@ -5,8 +5,4 @@
package org.signal.registration.screens.devicetransfer.instructions
data class DeviceTransferInstructionsState(
val oneTimeEvent: OneTimeEvent? = null
) {
sealed interface OneTimeEvent
}
class DeviceTransferInstructionsState
@@ -55,9 +55,6 @@ class DeviceTransferInstructionsViewModel(
DeviceTransferInstructionsScreenEvents.BackClicked -> {
parentEventEmitter.navigateBack()
}
DeviceTransferInstructionsScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
@@ -10,5 +10,4 @@ sealed class DeviceTransferProgressScreenEvents {
data object CancelConfirmed : DeviceTransferProgressScreenEvents()
data object CancelDismissed : DeviceTransferProgressScreenEvents()
data object TryAgainClicked : DeviceTransferProgressScreenEvents()
data object ConsumeOneTimeEvent : DeviceTransferProgressScreenEvents()
}
@@ -8,8 +8,7 @@ package org.signal.registration.screens.devicetransfer.progress
data class DeviceTransferProgressState(
val messageCount: Long = 0,
val status: Status = Status.RECEIVING,
val errorReason: ErrorReason? = null,
val oneTimeEvent: OneTimeEvent? = null
val errorReason: ErrorReason? = null
) {
enum class Status {
@@ -24,8 +23,4 @@ data class DeviceTransferProgressState(
FOREIGN_KEY,
UNKNOWN
}
sealed interface OneTimeEvent {
data object TransferCanceled : OneTimeEvent
}
}
@@ -98,9 +98,6 @@ class DeviceTransferProgressViewModel(
stopService()
parentEventEmitter.navigateTo(RegistrationRoute.DeviceTransferInstructions)
}
DeviceTransferProgressScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
@@ -82,25 +82,26 @@ internal fun DeviceTransferSetupScreen(
onEvent(DeviceTransferSetupScreenEvents.BackClicked)
}
LaunchedEffect(state.oneTimeEvent) {
val event = state.oneTimeEvent ?: return@LaunchedEffect
onEvent(DeviceTransferSetupScreenEvents.ConsumeOneTimeEvent)
when (event) {
DeviceTransferSetupState.OneTimeEvent.RequestLocationPermission -> {
LaunchedEffect(state.pendingActions) {
when {
state.pendingActions.requestLocationPermission -> {
when (permissionState.status) {
is PermissionStatus.Granted -> onEvent(DeviceTransferSetupScreenEvents.PermissionsGranted)
is PermissionStatus.Denied -> permissionState.launchPermissionRequest()
}
onEvent(DeviceTransferSetupScreenEvents.RequestLocationPermissionHandled)
}
DeviceTransferSetupState.OneTimeEvent.OpenLocationSettings -> {
state.pendingActions.openLocationSettings -> {
runCatching { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }
.onFailure { runCatching { context.startActivity(Intent(Settings.ACTION_SETTINGS)) } }
onEvent(DeviceTransferSetupScreenEvents.OpenLocationSettingsHandled)
}
DeviceTransferSetupState.OneTimeEvent.OpenWifiSettings -> {
state.pendingActions.openWifiSettings -> {
runCatching { context.startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) }
.onFailure { runCatching { context.startActivity(Intent(Settings.ACTION_SETTINGS)) } }
onEvent(DeviceTransferSetupScreenEvents.OpenWifiSettingsHandled)
}
DeviceTransferSetupState.OneTimeEvent.OpenAppSettings -> {
state.pendingActions.openAppSettings -> {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -110,10 +111,7 @@ internal fun DeviceTransferSetupScreen(
} catch (_: ActivityNotFoundException) {
// nothing we can do
}
}
DeviceTransferSetupState.OneTimeEvent.NavigateToProgress,
DeviceTransferSetupState.OneTimeEvent.NavigateAway -> {
// Navigation is handled by the ViewModel via parentEventEmitter.
onEvent(DeviceTransferSetupScreenEvents.OpenAppSettingsHandled)
}
}
}
@@ -38,5 +38,15 @@ sealed class DeviceTransferSetupScreenEvents {
/** Back / close. Stops the service and pops the nav stack. */
data object BackClicked : DeviceTransferSetupScreenEvents()
data object ConsumeOneTimeEvent : DeviceTransferSetupScreenEvents()
/** The screen has launched the pending location permission request. */
data object RequestLocationPermissionHandled : DeviceTransferSetupScreenEvents()
/** The screen has launched the pending open-location-settings action. */
data object OpenLocationSettingsHandled : DeviceTransferSetupScreenEvents()
/** The screen has launched the pending open-wifi-settings action. */
data object OpenWifiSettingsHandled : DeviceTransferSetupScreenEvents()
/** The screen has launched the pending open-app-settings action. */
data object OpenAppSettingsHandled : DeviceTransferSetupScreenEvents()
}
@@ -11,28 +11,23 @@ data class DeviceTransferSetupState(
val takingTooLong: Boolean = false,
val showVerifyRejectDialog: Boolean = false,
val showErrorDialog: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null
val pendingActions: PendingActions = PendingActions()
) {
override fun toString(): String = "DeviceTransferSetupState(step=$step, authenticationCode=${authenticationCode?.let { "present" }}, takingTooLong=$takingTooLong, showVerifyRejectDialog=$showVerifyRejectDialog, showErrorDialog=$showErrorDialog, oneTimeEvent=$oneTimeEvent)"
override fun toString(): String = "DeviceTransferSetupState(step=$step, authenticationCode=${authenticationCode?.let { "present" }}, takingTooLong=$takingTooLong, showVerifyRejectDialog=$showVerifyRejectDialog, showErrorDialog=$showErrorDialog, pendingActions=$pendingActions)"
sealed interface OneTimeEvent {
/** One-shot actions the screen should launch. The screen clears these once launched. */
data class PendingActions(
/** The screen should launch a runtime permission request. */
data object RequestLocationPermission : OneTimeEvent
val requestLocationPermission: Boolean = false,
/** The screen should launch the system Location settings. */
data object OpenLocationSettings : OneTimeEvent
val openLocationSettings: Boolean = false,
/** The screen should launch the system Wi-Fi settings. */
data object OpenWifiSettings : OneTimeEvent
val openWifiSettings: Boolean = false,
/** The screen should launch this app's system settings (for permanent-denial recovery). */
data object OpenAppSettings : OneTimeEvent
/** Both devices verified successfully; navigate to the Progress screen. */
data object NavigateToProgress : OneTimeEvent
/** Unrecoverable setup path (e.g. Wi-Fi Direct unavailable); navigate back. */
data object NavigateAway : OneTimeEvent
}
val openAppSettings: Boolean = false
)
}
@@ -105,7 +105,7 @@ class DeviceTransferSetupViewModel(
stateEmitter(state.copy(step = SetupStep.LOCATION_CHECK, takingTooLong = false))
checkLocation(parentEventEmitter, stateEmitter)
} else {
stateEmitter(state.copy(step = SetupStep.PERMISSIONS_CHECK, takingTooLong = false, oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.RequestLocationPermission))
stateEmitter(state.copy(step = SetupStep.PERMISSIONS_CHECK, takingTooLong = false, pendingActions = state.pendingActions.copy(requestLocationPermission = true)))
}
}
@@ -119,19 +119,19 @@ class DeviceTransferSetupViewModel(
}
DeviceTransferSetupScreenEvents.RequestPermissionClicked -> {
stateEmitter(state.copy(oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.RequestLocationPermission))
stateEmitter(state.copy(pendingActions = state.pendingActions.copy(requestLocationPermission = true)))
}
DeviceTransferSetupScreenEvents.OpenLocationSettingsClicked -> {
stateEmitter(state.copy(oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.OpenLocationSettings))
stateEmitter(state.copy(pendingActions = state.pendingActions.copy(openLocationSettings = true)))
}
DeviceTransferSetupScreenEvents.OpenWifiSettingsClicked -> {
stateEmitter(state.copy(oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.OpenWifiSettings))
stateEmitter(state.copy(pendingActions = state.pendingActions.copy(openWifiSettings = true)))
}
DeviceTransferSetupScreenEvents.OpenAppSettingsClicked -> {
stateEmitter(state.copy(oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.OpenAppSettings))
stateEmitter(state.copy(pendingActions = state.pendingActions.copy(openAppSettings = true)))
}
DeviceTransferSetupScreenEvents.OnResume -> {
@@ -181,8 +181,20 @@ class DeviceTransferSetupViewModel(
parentEventEmitter.navigateBack()
}
DeviceTransferSetupScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
DeviceTransferSetupScreenEvents.RequestLocationPermissionHandled -> {
stateEmitter(state.copy(pendingActions = state.pendingActions.copy(requestLocationPermission = false)))
}
DeviceTransferSetupScreenEvents.OpenLocationSettingsHandled -> {
stateEmitter(state.copy(pendingActions = state.pendingActions.copy(openLocationSettings = false)))
}
DeviceTransferSetupScreenEvents.OpenWifiSettingsHandled -> {
stateEmitter(state.copy(pendingActions = state.pendingActions.copy(openWifiSettings = false)))
}
DeviceTransferSetupScreenEvents.OpenAppSettingsHandled -> {
stateEmitter(state.copy(pendingActions = state.pendingActions.copy(openAppSettings = false)))
}
}
}
@@ -54,7 +54,6 @@ 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.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -83,7 +82,6 @@ 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.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
import org.signal.registration.test.TestTags
import org.signal.core.ui.R as CoreR
@@ -116,10 +114,7 @@ fun PhoneNumberScreen(
onEvent: (PhoneNumberEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val resources = LocalResources.current
val context = LocalContext.current
var simpleErrorMessage: String? by remember { mutableStateOf(null) }
var showInvalidNumberDialog by remember { mutableStateOf(false) }
var hasRequestedPhoneNumberHint by rememberSaveable { mutableStateOf(false) }
val currentNationalNumber by rememberUpdatedState(state.nationalNumber)
@@ -171,7 +166,7 @@ fun PhoneNumberScreen(
}
}
if (state.showDialog) {
if (state.dialogs.confirmNumber) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RegistrationActivity_is_the_phone_number),
body = "+${state.countryCode} ${state.formattedNumber}\n\n${stringResource(R.string.RegistrationActivity_a_verification_code)}",
@@ -182,37 +177,36 @@ fun PhoneNumberScreen(
)
}
LaunchedEffect(state.oneTimeEvent) {
onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent)
when (state.oneTimeEvent) {
OneTimeEvent.NetworkError -> simpleErrorMessage = resources.getString(R.string.VerificationCodeScreen__network_error)
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())
val simpleError: Pair<String, PhoneNumberEntryScreenEvents>? = when {
state.dialogs.networkError -> stringResource(R.string.VerificationCodeScreen__network_error) to PhoneNumberEntryScreenEvents.NetworkErrorDialogDismissed
state.dialogs.rateLimitedRetryAfter != null -> {
val message = if (state.dialogs.rateLimitedRetryAfter.isPositive()) {
stringResource(R.string.VerificationCodeScreen__too_many_attempts_try_again_in_s, state.dialogs.rateLimitedRetryAfter.toString())
} else {
resources.getString(R.string.VerificationCodeScreen__too_many_attempts)
stringResource(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)
OneTimeEvent.InvalidPhoneNumber -> showInvalidNumberDialog = true
null -> Unit
message to PhoneNumberEntryScreenEvents.RateLimitedDialogDismissed
}
state.dialogs.unknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to PhoneNumberEntryScreenEvents.UnknownErrorDialogDismissed
state.dialogs.couldNotRequestCodeWithSelectedTransport -> stringResource(R.string.VerificationCodeScreen__could_not_send_code_via_selected_method) to PhoneNumberEntryScreenEvents.CouldNotRequestCodeWithSelectedTransportDialogDismissed
state.dialogs.unableToSendSms -> stringResource(R.string.VerificationCodeScreen__unable_to_send_sms) to PhoneNumberEntryScreenEvents.UnableToSendSmsDialogDismissed
else -> null
}
simpleErrorMessage?.let { message ->
simpleError?.let { (message, dismissedEvent) ->
Dialogs.SimpleMessageDialog(
message = message,
dismiss = stringResource(android.R.string.ok),
onDismiss = { simpleErrorMessage = null }
onDismiss = { onEvent(dismissedEvent) }
)
}
if (showInvalidNumberDialog) {
if (state.dialogs.invalidPhoneNumber) {
Dialogs.SimpleMessageDialog(
title = stringResource(R.string.RegistrationActivity_invalid_phone_number),
message = stringResource(R.string.RegistrationActivity_the_number_you_entered_is_not_valid),
dismiss = stringResource(android.R.string.ok),
onDismiss = { showInvalidNumberDialog = false }
onDismiss = { onEvent(PhoneNumberEntryScreenEvents.InvalidPhoneNumberDialogDismissed) }
)
}
@@ -59,5 +59,22 @@ sealed class PhoneNumberEntryScreenEvents {
data class LocalBackupRestoreCompleted(val result: LocalBackupRestoreResult) : PhoneNumberEntryScreenEvents() {
override fun toString(): String = "LocalBackupRestoreCompleted(result=***)"
}
data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents()
/** The user dismissed the network error dialog. */
data object NetworkErrorDialogDismissed : PhoneNumberEntryScreenEvents()
/** The user dismissed the unknown error dialog. */
data object UnknownErrorDialogDismissed : PhoneNumberEntryScreenEvents()
/** The user dismissed the rate limited dialog. */
data object RateLimitedDialogDismissed : PhoneNumberEntryScreenEvents()
/** The user dismissed the unable-to-send-SMS dialog. */
data object UnableToSendSmsDialogDismissed : PhoneNumberEntryScreenEvents()
/** The user dismissed the could-not-request-code-with-selected-transport dialog. */
data object CouldNotRequestCodeWithSelectedTransportDialogDismissed : PhoneNumberEntryScreenEvents()
/** The user dismissed the invalid phone number dialog. */
data object InvalidPhoneNumberDialogDismissed : PhoneNumberEntryScreenEvents()
}
@@ -21,8 +21,7 @@ data class PhoneNumberEntryState(
val sessionE164: String? = null,
val sessionMetadata: SessionMetadata? = null,
val showSpinner: Boolean = false,
val showDialog: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null,
val dialogs: Dialogs = Dialogs(),
val preExistingRegistrationData: PreExistingRegistrationData? = null,
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList(),
val pendingRestoreOption: PendingRestoreOption? = null,
@@ -32,14 +31,17 @@ data class PhoneNumberEntryState(
/** Whether the entered number is definitively invalid. A still-too-short number is not considered invalid, since the user may simply be mid-entry. */
val isNumberInvalid: Boolean = false
) {
override fun toString(): String = "PhoneNumberEntryState(regionCode=$regionCode, countryCode=$countryCode, countryName=$countryName, countryEmoji=$countryEmoji, nationalNumber=$nationalNumber, formattedNumber=$formattedNumber, sessionE164=$sessionE164, sessionMetadata=${sessionMetadata?.let { "present" }}, showSpinner=$showSpinner, showDialog=$showDialog, oneTimeEvent=$oneTimeEvent, preExistingRegistrationData=${preExistingRegistrationData?.let { "present" }}, restoredSvrCredentials=${restoredSvrCredentials.size} items, pendingRestoreOption=$pendingRestoreOption, initialized=$initialized, isNumberPossible=$isNumberPossible, isNumberInvalid=$isNumberInvalid)"
override fun toString(): String = "PhoneNumberEntryState(regionCode=$regionCode, countryCode=$countryCode, countryName=$countryName, countryEmoji=$countryEmoji, nationalNumber=$nationalNumber, formattedNumber=$formattedNumber, sessionE164=$sessionE164, sessionMetadata=${sessionMetadata?.let { "present" }}, showSpinner=$showSpinner, dialogs=$dialogs, preExistingRegistrationData=${preExistingRegistrationData?.let { "present" }}, restoredSvrCredentials=${restoredSvrCredentials.size} items, pendingRestoreOption=$pendingRestoreOption, initialized=$initialized, isNumberPossible=$isNumberPossible, isNumberInvalid=$isNumberInvalid)"
sealed interface OneTimeEvent {
data object NetworkError : OneTimeEvent
data object UnknownError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
data object UnableToSendSms : OneTimeEvent
data object CouldNotRequestCodeWithSelectedTransport : OneTimeEvent
data object InvalidPhoneNumber : OneTimeEvent
}
data class Dialogs(
/** Asks the user to confirm the number they entered before submitting it. */
val confirmNumber: Boolean = false,
val networkError: Boolean = false,
val unknownError: Boolean = false,
/** When non-null, shows a rate limit error dialog. A non-positive duration indicates the server didn't say how long to wait. */
val rateLimitedRetryAfter: Duration? = null,
val unableToSendSms: Boolean = false,
val couldNotRequestCodeWithSelectedTransport: Boolean = false,
val invalidPhoneNumber: Boolean = false
)
}
@@ -33,7 +33,6 @@ import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.countrycode.Country
import org.signal.registration.screens.countrycode.CountryUtils
import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreResult
import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
import org.signal.registration.screens.util.navigateTo
class PhoneNumberEntryViewModel(
@@ -103,22 +102,22 @@ class PhoneNumberEntryViewModel(
}
is PhoneNumberEntryScreenEvents.FullPhoneNumberEntered -> {
val populatedState = applyFullPhoneNumberEntered(state, event.e164)
stateEmitter(populatedState.copy(showDialog = event.autoConfirm && populatedState.isNumberPossible))
stateEmitter(populatedState.copy(dialogs = populatedState.dialogs.copy(confirmNumber = event.autoConfirm && populatedState.isNumberPossible)))
}
is PhoneNumberEntryScreenEvents.NationalNumberChanged -> {
stateEmitter(applyPhoneNumberChanged(state, event.oldValue, event.newValue))
}
is PhoneNumberEntryScreenEvents.NextClicked -> {
stateEmitter(state.copy(showDialog = true))
stateEmitter(state.copy(dialogs = state.dialogs.copy(confirmNumber = true)))
}
is PhoneNumberEntryScreenEvents.PhoneNumberCancelled -> {
stateEmitter(state.copy(showDialog = false))
stateEmitter(state.copy(dialogs = state.dialogs.copy(confirmNumber = false)))
}
is PhoneNumberEntryScreenEvents.PhoneNumberConfirmed -> {
var localState = state.copy(showSpinner = true, showDialog = false)
var localState = state.copy(showSpinner = true, dialogs = state.dialogs.copy(confirmNumber = false))
stateEmitter(localState)
localState = applyPhoneNumberSubmitted(localState, parentEventEmitter)
stateEmitter(localState.copy(showSpinner = false, showDialog = false))
stateEmitter(localState.copy(showSpinner = false))
}
is PhoneNumberEntryScreenEvents.CountryPicker -> {
state.also {
@@ -148,8 +147,23 @@ class PhoneNumberEntryViewModel(
}
}
}
is PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
is PhoneNumberEntryScreenEvents.NetworkErrorDialogDismissed -> {
stateEmitter(state.copy(dialogs = state.dialogs.copy(networkError = false)))
}
is PhoneNumberEntryScreenEvents.UnknownErrorDialogDismissed -> {
stateEmitter(state.copy(dialogs = state.dialogs.copy(unknownError = false)))
}
is PhoneNumberEntryScreenEvents.RateLimitedDialogDismissed -> {
stateEmitter(state.copy(dialogs = state.dialogs.copy(rateLimitedRetryAfter = null)))
}
is PhoneNumberEntryScreenEvents.UnableToSendSmsDialogDismissed -> {
stateEmitter(state.copy(dialogs = state.dialogs.copy(unableToSendSms = false)))
}
is PhoneNumberEntryScreenEvents.CouldNotRequestCodeWithSelectedTransportDialogDismissed -> {
stateEmitter(state.copy(dialogs = state.dialogs.copy(couldNotRequestCodeWithSelectedTransport = false)))
}
is PhoneNumberEntryScreenEvents.InvalidPhoneNumberDialogDismissed -> {
stateEmitter(state.copy(dialogs = state.dialogs.copy(invalidPhoneNumber = false)))
}
}
}
@@ -301,7 +315,7 @@ class PhoneNumberEntryViewModel(
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Register] Rate limited (retryAfter: ${error.retryAfter}).")
return state.copy(oneTimeEvent = OneTimeEvent.RateLimited(error.retryAfter))
return state.copy(dialogs = state.dialogs.copy(rateLimitedRetryAfter = error.retryAfter))
}
is NetworkController.RegisterAccountError.InvalidRequest -> {
Log.w(TAG, "[Register] Invalid request when registering account with RRP. Ditching pre-existing data and continuing with session creation. Message: ${error.message}")
@@ -317,11 +331,11 @@ class PhoneNumberEntryViewModel(
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[Register] Network error.", registerResult.networkError)
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
return state.copy(dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[Register] Unknown error when registering account.", registerResult.cause)
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
return state.copy(dialogs = state.dialogs.copy(unknownError = true))
}
}
}
@@ -408,7 +422,7 @@ class PhoneNumberEntryViewModel(
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[LocalRestore] Rate limited (retryAfter: ${error.retryAfter}).")
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(error.retryAfter))
state.copy(dialogs = state.dialogs.copy(rateLimitedRetryAfter = error.retryAfter))
}
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
Log.w(TAG, "[LocalRestore] Session not found. Falling back to session-based registration.")
@@ -422,11 +436,11 @@ class PhoneNumberEntryViewModel(
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[LocalRestore] Network error.", result.networkError)
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
state.copy(dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[LocalRestore] Application error.", result.cause)
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
state.copy(dialogs = state.dialogs.copy(unknownError = true))
}
}
}
@@ -488,21 +502,21 @@ class PhoneNumberEntryViewModel(
return when (val error = response.error) {
is NetworkController.CreateSessionError.InvalidRequest -> {
Log.w(TAG, "[CreateSession] Invalid request when creating session, likely an invalid phone number. Message: ${error.message}")
state.copy(oneTimeEvent = OneTimeEvent.InvalidPhoneNumber)
state.copy(dialogs = state.dialogs.copy(invalidPhoneNumber = true))
}
is NetworkController.CreateSessionError.RateLimited -> {
Log.w(TAG, "[CreateSession] Rate limited (retryAfter: ${error.retryAfter}).")
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(error.retryAfter))
state.copy(dialogs = state.dialogs.copy(rateLimitedRetryAfter = error.retryAfter))
}
}
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[CreateSession] Network error.", response.networkError)
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
return state.copy(dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Unknown error when creating session.", response.cause)
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
return state.copy(dialogs = state.dialogs.copy(unknownError = true))
}
}
@@ -550,7 +564,7 @@ class PhoneNumberEntryViewModel(
if (!sessionMetadata.allowedToRequestCode && sessionMetadata.requestedInformation.isEmpty()) {
Log.w(TAG, "Not allowed to request code and no challenges requested. Unable to send SMS.")
return state.copy(oneTimeEvent = OneTimeEvent.UnableToSendSms)
return state.copy(dialogs = state.dialogs.copy(unableToSendSms = true))
}
val verificationCodeResponse = this@PhoneNumberEntryViewModel.repository.requestVerificationCode(
@@ -568,15 +582,15 @@ class PhoneNumberEntryViewModel(
return when (val error = verificationCodeResponse.error) {
is NetworkController.RequestVerificationCodeError.InvalidRequest -> {
Log.w(TAG, "[RequestVerificationCode] Invalid request when requesting verification code. Message: ${error.message}")
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
state.copy(dialogs = state.dialogs.copy(unknownError = true))
}
is NetworkController.RequestVerificationCodeError.RateLimited -> {
Log.w(TAG, "[RequestVerificationCode] Rate limited (retryAfter: ${error.retryAfter}).")
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(error.retryAfter))
state.copy(dialogs = state.dialogs.copy(rateLimitedRetryAfter = error.retryAfter))
}
is NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport -> {
Log.w(TAG, "[RequestVerificationCode] Could not fulfill with requested transport.")
state.copy(oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
state.copy(dialogs = state.dialogs.copy(couldNotRequestCodeWithSelectedTransport = true))
}
is NetworkController.RequestVerificationCodeError.InvalidSessionId -> {
Log.w(TAG, "[RequestVerificationCode] Invalid session ID when requesting verification code.")
@@ -585,7 +599,7 @@ class PhoneNumberEntryViewModel(
}
is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> {
Log.w(TAG, "[RequestVerificationCode] Missing request information or already verified.")
state.copy(oneTimeEvent = OneTimeEvent.UnableToSendSms)
state.copy(dialogs = state.dialogs.copy(unableToSendSms = true))
}
is NetworkController.RequestVerificationCodeError.SessionNotFound -> {
Log.w(TAG, "[RequestVerificationCode] Session not found when requesting verification code.")
@@ -594,17 +608,17 @@ class PhoneNumberEntryViewModel(
}
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
Log.w(TAG, "[RequestVerificationCode] Third party service error.")
state.copy(oneTimeEvent = OneTimeEvent.UnableToSendSms)
state.copy(dialogs = state.dialogs.copy(unableToSendSms = true))
}
}
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[RequestVerificationCode] Network error.", verificationCodeResponse.networkError)
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
return state.copy(dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[RequestVerificationCode] Unknown error when creating session.", verificationCodeResponse.cause)
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
return state.copy(dialogs = state.dialogs.copy(unknownError = true))
}
}
@@ -623,7 +637,7 @@ class PhoneNumberEntryViewModel(
private suspend fun applyCaptchaCompleted(inputState: PhoneNumberEntryState, token: String, parentEventEmitter: (RegistrationFlowEvent) -> Unit): PhoneNumberEntryState {
var state = inputState.copy()
var sessionMetadata = state.sessionMetadata ?: return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
var sessionMetadata = state.sessionMetadata ?: return state.copy(dialogs = state.dialogs.copy(unknownError = true))
val updateResult = this@PhoneNumberEntryViewModel.repository.submitCaptchaToken(sessionMetadata.id, token)
@@ -632,22 +646,22 @@ class PhoneNumberEntryViewModel(
is RequestResult.NonSuccess -> {
return when (val error = updateResult.error) {
is NetworkController.UpdateSessionError.InvalidRequest -> {
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
state.copy(dialogs = state.dialogs.copy(unknownError = true))
}
is NetworkController.UpdateSessionError.RejectedUpdate -> {
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
state.copy(dialogs = state.dialogs.copy(unknownError = true))
}
is NetworkController.UpdateSessionError.RateLimited -> {
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(error.retryAfter))
state.copy(dialogs = state.dialogs.copy(rateLimitedRetryAfter = error.retryAfter))
}
}
}
is RequestResult.RetryableNetworkError -> {
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
return state.copy(dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Unknown error when submitting captcha.", updateResult.cause)
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
return state.copy(dialogs = state.dialogs.copy(unknownError = true))
}
}
@@ -660,7 +674,7 @@ class PhoneNumberEntryViewModel(
if (!sessionMetadata.allowedToRequestCode && sessionMetadata.requestedInformation.isEmpty()) {
Log.w(TAG, "Not allowed to request code and no challenges requested after captcha. Unable to send SMS.")
return state.copy(oneTimeEvent = OneTimeEvent.UnableToSendSms)
return state.copy(dialogs = state.dialogs.copy(unableToSendSms = true))
}
val verificationCodeResponse = this@PhoneNumberEntryViewModel.repository.requestVerificationCode(
@@ -674,13 +688,13 @@ class PhoneNumberEntryViewModel(
is RequestResult.NonSuccess -> {
return when (val error = verificationCodeResponse.error) {
is NetworkController.RequestVerificationCodeError.InvalidRequest -> {
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
state.copy(dialogs = state.dialogs.copy(unknownError = true))
}
is NetworkController.RequestVerificationCodeError.RateLimited -> {
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(error.retryAfter))
state.copy(dialogs = state.dialogs.copy(rateLimitedRetryAfter = error.retryAfter))
}
is NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport -> {
state.copy(oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
state.copy(dialogs = state.dialogs.copy(couldNotRequestCodeWithSelectedTransport = true))
}
is NetworkController.RequestVerificationCodeError.InvalidSessionId -> {
parentEventEmitter(RegistrationFlowEvent.ResetState)
@@ -688,23 +702,23 @@ class PhoneNumberEntryViewModel(
}
is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> {
Log.w(TAG, "When requesting verification code after captcha, missing request information or already verified.")
state.copy(oneTimeEvent = OneTimeEvent.UnableToSendSms)
state.copy(dialogs = state.dialogs.copy(unableToSendSms = true))
}
is NetworkController.RequestVerificationCodeError.SessionNotFound -> {
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
}
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
state.copy(oneTimeEvent = OneTimeEvent.UnableToSendSms)
state.copy(dialogs = state.dialogs.copy(unableToSendSms = true))
}
}
}
is RequestResult.RetryableNetworkError -> {
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
return state.copy(dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Unknown error when requesting verification code.", verificationCodeResponse.cause)
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
return state.copy(dialogs = state.dialogs.copy(unknownError = true))
}
}
@@ -51,7 +51,6 @@ 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
@@ -93,35 +92,30 @@ 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)
}
val errorDialog: Pair<String, PinCreationScreenEvents>? = when {
state.dialogs.serviceError -> stringResource(R.string.PinCreationScreen__service_error) to PinCreationScreenEvents.ServiceErrorDialogDismissed
state.dialogs.networkError != null -> {
val retryAfter = state.dialogs.networkError.retryAfter
val message = if (retryAfter != null) {
stringResource(R.string.PinCreationScreen__network_error_try_again_in_s, retryAfter.toString())
} else {
stringResource(R.string.PinCreationScreen__network_error)
}
message to PinCreationScreenEvents.NetworkErrorDialogDismissed
}
else -> null
}
errorMessage?.let { message ->
errorDialog?.let { (message, dismissedEvent) ->
Dialogs.SimpleMessageDialog(
message = message,
dismiss = stringResource(android.R.string.ok),
onDismiss = { errorMessage = null }
onDismiss = { onEvent(dismissedEvent) }
)
}
@@ -18,5 +18,6 @@ sealed class PinCreationScreenEvents {
data object LearnMore : PinCreationScreenEvents()
data object OptOut : PinCreationScreenEvents()
data object BackToPinEntry : PinCreationScreenEvents()
data object ConsumeOneTimeEvent : PinCreationScreenEvents()
data object ServiceErrorDialogDismissed : PinCreationScreenEvents()
data object NetworkErrorDialogDismissed : PinCreationScreenEvents()
}
@@ -16,14 +16,16 @@ data class PinCreationState(
val loading: Boolean = false,
val firstPin: String? = null,
val accountEntropyPool: AccountEntropyPool? = null,
val oneTimeEvent: OneTimeEvent? = null
val dialogs: Dialogs = Dialogs()
) {
override fun toString(): String {
return "PinCreationState(isAlphanumericKeyboard=$isAlphanumericKeyboard, isConfirmEnabled=$isConfirmEnabled, pinMismatch=$pinMismatch, loading=$loading, firstPin=${firstPin?.let { "${it.length} chars" }}, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()}, oneTimeEvent=$oneTimeEvent)"
return "PinCreationState(isAlphanumericKeyboard=$isAlphanumericKeyboard, isConfirmEnabled=$isConfirmEnabled, pinMismatch=$pinMismatch, loading=$loading, firstPin=${firstPin?.let { "${it.length} chars" }}, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()}, dialogs=$dialogs)"
}
sealed interface OneTimeEvent {
data object ServiceError : OneTimeEvent
data class NetworkError(val retryAfter: Duration?) : OneTimeEvent
data class Dialogs(
val serviceError: Boolean = false,
val networkError: NetworkError? = null
) {
data class NetworkError(val retryAfter: Duration?)
}
}
@@ -100,8 +100,11 @@ class PinCreationViewModel(
_state.value = state.copy(isConfirmEnabled = false)
applyOptOut()
}
is PinCreationScreenEvents.ConsumeOneTimeEvent -> {
_state.value = state.copy(oneTimeEvent = null)
is PinCreationScreenEvents.ServiceErrorDialogDismissed -> {
_state.value = state.copy(dialogs = state.dialogs.copy(serviceError = false))
}
is PinCreationScreenEvents.NetworkErrorDialogDismissed -> {
_state.value = state.copy(dialogs = state.dialogs.copy(networkError = null))
}
}
}
@@ -141,7 +144,7 @@ class PinCreationViewModel(
when (val error = result.error) {
is NetworkController.BackupMasterKeyError.EnclaveNotFound -> {
Log.w(TAG, "[PinSubmitted] SVR enclave not found.")
state.copy(loading = false, oneTimeEvent = PinCreationState.OneTimeEvent.ServiceError)
state.copy(loading = false, dialogs = state.dialogs.copy(serviceError = true))
}
is NetworkController.BackupMasterKeyError.NotRegistered -> {
@@ -154,12 +157,12 @@ class PinCreationViewModel(
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[PinSubmitted] Network error when backing up master key.", result.networkError)
state.copy(loading = false, oneTimeEvent = PinCreationState.OneTimeEvent.NetworkError(result.retryAfter?.toKotlinDuration()))
state.copy(loading = false, dialogs = state.dialogs.copy(networkError = PinCreationState.Dialogs.NetworkError(result.retryAfter?.toKotlinDuration())))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[PinSubmitted] Application error when backing up master key.", result.cause)
state.copy(loading = false, oneTimeEvent = PinCreationState.OneTimeEvent.ServiceError)
state.copy(loading = false, dialogs = state.dialogs.copy(serviceError = true))
}
}
}
@@ -77,7 +77,10 @@ class PinEntryForRegistrationLockViewModel(
is PinEntryScreenEvents.CreateNewPin,
is PinEntryScreenEvents.ContactSupport,
is PinEntryScreenEvents.ParentStateChanged -> Unit
is PinEntryScreenEvents.ToggleKeyboard -> {
is PinEntryScreenEvents.ToggleKeyboard,
is PinEntryScreenEvents.NetworkErrorDialogDismissed,
is PinEntryScreenEvents.RateLimitedDialogDismissed,
is PinEntryScreenEvents.UnknownErrorDialogDismissed -> {
stateEmitter(PinEntryScreenEventHandler.applyEvent(state, event))
}
}
@@ -114,11 +117,11 @@ class PinEntryForRegistrationLockViewModel(
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[PinEntered] Network error when restoring master key.", restoreResult.networkError)
return state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
return state.copy(loading = false, dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[PinEntered] Application error when restoring master key.", restoreResult.cause)
return state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
return state.copy(loading = false, dialogs = state.dialogs.copy(unknownError = true))
}
}
@@ -180,15 +183,15 @@ class PinEntryForRegistrationLockViewModel(
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[PinEntered] Rate limited when registering. Retry After: ${error.retryAfter}")
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.RateLimited(error.retryAfter))
state.copy(loading = false, dialogs = state.dialogs.copy(rateLimitedRetryAfter = error.retryAfter))
}
is NetworkController.RegisterAccountError.InvalidRequest -> {
Log.w(TAG, "[PinEntered] Invalid request when registering: ${error.message}")
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
state.copy(loading = false, dialogs = state.dialogs.copy(unknownError = true))
}
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
Log.w(TAG, "[PinEntered] Device transfer possible. This shouldn't happen when skipDeviceTransfer is true.")
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
state.copy(loading = false, dialogs = state.dialogs.copy(unknownError = true))
}
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
Log.w(TAG, "[PinEntered] Registration recovery password incorrect: ${error.message}. Marking recovery password invalid and navigating back.")
@@ -200,11 +203,11 @@ class PinEntryForRegistrationLockViewModel(
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[PinEntered] Network error when registering.", registerResult.networkError)
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
state.copy(loading = false, dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[PinEntered] Application error when registering.", registerResult.cause)
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
state.copy(loading = false, dialogs = state.dialogs.copy(unknownError = true))
}
}
}
@@ -86,7 +86,10 @@ class PinEntryForSmsBypassViewModel(
}
is PinEntryScreenEvents.CreateNewPin,
is PinEntryScreenEvents.ContactSupport -> Unit
is PinEntryScreenEvents.ToggleKeyboard -> {
is PinEntryScreenEvents.ToggleKeyboard,
is PinEntryScreenEvents.NetworkErrorDialogDismissed,
is PinEntryScreenEvents.RateLimitedDialogDismissed,
is PinEntryScreenEvents.UnknownErrorDialogDismissed -> {
stateEmitter(PinEntryScreenEventHandler.applyEvent(state, event))
}
}
@@ -131,11 +134,11 @@ class PinEntryForSmsBypassViewModel(
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[PinEntered] Network error when restoring master key (sms-bypass).", result.networkError)
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
state.copy(loading = false, dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[PinEntered] Application error when restoring master key (sms-bypass).", result.cause)
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
state.copy(loading = false, dialogs = state.dialogs.copy(unknownError = true))
}
}
}
@@ -165,10 +168,10 @@ class PinEntryForSmsBypassViewModel(
state
}
is RequestResult.RetryableNetworkError -> {
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
state.copy(loading = false, dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
state.copy(loading = false, dialogs = state.dialogs.copy(unknownError = true))
}
is RequestResult.NonSuccess -> {
when (val error = result.error) {
@@ -185,7 +188,7 @@ class PinEntryForSmsBypassViewModel(
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Register] Rate limited (retryAfter: ${error.retryAfter}).")
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.RateLimited(error.retryAfter))
state.copy(loading = false, dialogs = state.dialogs.copy(rateLimitedRetryAfter = error.retryAfter))
}
is NetworkController.RegisterAccountError.RegistrationLock -> {
if (provideRegistrationLock) {
@@ -84,7 +84,10 @@ class PinEntryForSvrRestoreViewModel(
Log.i(TAG, "[ContactSupport] User opted to contact support after no data was found.")
stateEmitter(state.copy(showNoDataToRestoreDialog = false))
}
is PinEntryScreenEvents.ToggleKeyboard -> {
is PinEntryScreenEvents.ToggleKeyboard,
is PinEntryScreenEvents.NetworkErrorDialogDismissed,
is PinEntryScreenEvents.RateLimitedDialogDismissed,
is PinEntryScreenEvents.UnknownErrorDialogDismissed -> {
stateEmitter(PinEntryScreenEventHandler.applyEvent(state, event))
}
is PinEntryScreenEvents.ParentStateChanged -> Unit
@@ -117,10 +120,10 @@ class PinEntryForSvrRestoreViewModel(
}
}
is RequestResult.RetryableNetworkError -> {
return state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
return state.copy(loading = false, dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
return state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
return state.copy(loading = false, dialogs = state.dialogs.copy(unknownError = true))
}
}
@@ -148,11 +151,11 @@ class PinEntryForSvrRestoreViewModel(
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[PinEntered] Network error when restoring master key.", result.networkError)
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
state.copy(loading = false, dialogs = state.dialogs.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[PinEntered] Application error when restoring master key.", result.cause)
state.copy(loading = false, oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
state.copy(loading = false, dialogs = state.dialogs.copy(unknownError = true))
}
}
}
@@ -121,6 +121,28 @@ fun PinEntryScreen(
)
}
val errorDialog: Pair<String, PinEntryScreenEvents>? = when {
state.dialogs.networkError -> stringResource(R.string.VerificationCodeScreen__network_error) to PinEntryScreenEvents.NetworkErrorDialogDismissed
state.dialogs.rateLimitedRetryAfter != null -> {
val message = if (state.dialogs.rateLimitedRetryAfter.isPositive()) {
stringResource(R.string.VerificationCodeScreen__too_many_attempts_try_again_in_s, state.dialogs.rateLimitedRetryAfter.toString())
} else {
stringResource(R.string.VerificationCodeScreen__too_many_attempts)
}
message to PinEntryScreenEvents.RateLimitedDialogDismissed
}
state.dialogs.unknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to PinEntryScreenEvents.UnknownErrorDialogDismissed
else -> null
}
errorDialog?.let { (message, dismissedEvent) ->
Dialogs.SimpleMessageDialog(
message = message,
dismiss = stringResource(android.R.string.ok),
onDismiss = { onEvent(dismissedEvent) }
)
}
if (state.showNoDataToRestoreDialog) {
Dialogs.SimpleAlertDialog(
title = "",
@@ -10,6 +10,9 @@ object PinEntryScreenEventHandler {
fun applyEvent(state: PinEntryState, event: PinEntryScreenEvents): PinEntryState {
return when (event) {
PinEntryScreenEvents.ToggleKeyboard -> state.copy(isAlphanumericKeyboard = !state.isAlphanumericKeyboard)
PinEntryScreenEvents.NetworkErrorDialogDismissed -> state.copy(dialogs = state.dialogs.copy(networkError = false))
PinEntryScreenEvents.RateLimitedDialogDismissed -> state.copy(dialogs = state.dialogs.copy(rateLimitedRetryAfter = null))
PinEntryScreenEvents.UnknownErrorDialogDismissed -> state.copy(dialogs = state.dialogs.copy(unknownError = false))
else -> throw UnsupportedOperationException("This even is not handled generically!")
}
}
@@ -15,6 +15,15 @@ sealed class PinEntryScreenEvents {
override fun toString(): String = "PinEntered(pin=${pin.length} chars)"
}
data object ToggleKeyboard : PinEntryScreenEvents()
/** The user dismissed the network error dialog. */
data object NetworkErrorDialogDismissed : PinEntryScreenEvents()
/** The user dismissed the rate limited dialog. */
data object RateLimitedDialogDismissed : PinEntryScreenEvents()
/** The user dismissed the unknown error dialog. */
data object UnknownErrorDialogDismissed : PinEntryScreenEvents()
data object Skip : PinEntryScreenEvents()
data object CreateNewPin : PinEntryScreenEvents()
data object ContactSupport : PinEntryScreenEvents()
@@ -14,7 +14,7 @@ data class PinEntryState(
val showNoDataToRestoreDialog: Boolean = false,
val triesRemaining: Int? = null,
val mode: Mode = Mode.SvrRestore,
val oneTimeEvent: OneTimeEvent? = null,
val dialogs: Dialogs = Dialogs(),
val e164: String? = null
) {
enum class Mode {
@@ -23,10 +23,10 @@ data class PinEntryState(
SvrRestore
}
sealed interface OneTimeEvent {
data object NetworkError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
data object SvrDataMissing : OneTimeEvent
data object UnknownError : OneTimeEvent
}
data class Dialogs(
val networkError: Boolean = false,
/** When non-null, shows a rate limit error dialog. A non-positive duration indicates the server didn't say how long to wait. */
val rateLimitedRetryAfter: Duration? = null,
val unknownError: Boolean = false
)
}
@@ -94,39 +94,20 @@ fun VerificationCodeScreen(
onEvent(VerificationCodeScreenEvents.ConsumeAutoFillCode)
}
LaunchedEffect(state.oneTimeEvent) {
val event = state.oneTimeEvent ?: return@LaunchedEffect
when (event) {
VerificationCodeState.OneTimeEvent.IncorrectVerificationCode -> {
snackbarHostState.showSnackbar(resources.getString(R.string.VerificationCodeScreen__incorrect_code))
}
VerificationCodeState.OneTimeEvent.NetworkError -> {
snackbarHostState.showSnackbar(resources.getString(R.string.VerificationCodeScreen__network_error))
}
is VerificationCodeState.OneTimeEvent.RateLimited -> {
snackbarHostState.showSnackbar(resources.getString(R.string.VerificationCodeScreen__too_many_attempts_try_again_in_s, event.retryAfter.toString()))
}
VerificationCodeState.OneTimeEvent.UnableToSendSms -> {
snackbarHostState.showSnackbar(resources.getString(R.string.VerificationCodeScreen__unable_to_send_sms))
}
VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> {
snackbarHostState.showSnackbar(resources.getString(R.string.VerificationCodeScreen__could_not_send_code_via_selected_method))
}
VerificationCodeState.OneTimeEvent.UnknownError -> {
snackbarHostState.showSnackbar(resources.getString(R.string.VerificationCodeScreen__an_unexpected_error_occurred))
}
VerificationCodeState.OneTimeEvent.RegistrationError -> {
snackbarHostState.showSnackbar(resources.getString(R.string.VerificationCodeScreen__registration_error))
}
LaunchedEffect(state.snackbars) {
val (message, dismissedEvent) = when {
state.snackbars.incorrectVerificationCode -> resources.getString(R.string.VerificationCodeScreen__incorrect_code) to VerificationCodeScreenEvents.IncorrectVerificationCodeSnackbarDismissed
state.snackbars.networkError -> resources.getString(R.string.VerificationCodeScreen__network_error) to VerificationCodeScreenEvents.NetworkErrorSnackbarDismissed
state.snackbars.rateLimitedRetryAfter != null -> resources.getString(R.string.VerificationCodeScreen__too_many_attempts_try_again_in_s, state.snackbars.rateLimitedRetryAfter.toString()) to VerificationCodeScreenEvents.RateLimitedSnackbarDismissed
state.snackbars.unableToSendSms -> resources.getString(R.string.VerificationCodeScreen__unable_to_send_sms) to VerificationCodeScreenEvents.UnableToSendSmsSnackbarDismissed
state.snackbars.couldNotRequestCodeWithSelectedTransport -> resources.getString(R.string.VerificationCodeScreen__could_not_send_code_via_selected_method) to VerificationCodeScreenEvents.CouldNotRequestCodeWithSelectedTransportSnackbarDismissed
state.snackbars.unknownError -> resources.getString(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to VerificationCodeScreenEvents.UnknownErrorSnackbarDismissed
state.snackbars.registrationError -> resources.getString(R.string.VerificationCodeScreen__registration_error) to VerificationCodeScreenEvents.RegistrationErrorSnackbarDismissed
else -> return@LaunchedEffect
}
onEvent(VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent)
snackbarHostState.showSnackbar(message)
onEvent(dismissedEvent)
}
LaunchedEffect(state.focusedDigitIndex) {
@@ -45,7 +45,26 @@ sealed class VerificationCodeScreenEvents {
data object DismissContactSupport : VerificationCodeScreenEvents()
data object ConsumeInnerOneTimeEvent : VerificationCodeScreenEvents()
/** The network error snackbar was shown and dismissed. */
data object NetworkErrorSnackbarDismissed : VerificationCodeScreenEvents()
/** The unknown error snackbar was shown and dismissed. */
data object UnknownErrorSnackbarDismissed : VerificationCodeScreenEvents()
/** The rate limited snackbar was shown and dismissed. */
data object RateLimitedSnackbarDismissed : VerificationCodeScreenEvents()
/** The unable-to-send-SMS snackbar was shown and dismissed. */
data object UnableToSendSmsSnackbarDismissed : VerificationCodeScreenEvents()
/** The could-not-request-code-with-selected-transport snackbar was shown and dismissed. */
data object CouldNotRequestCodeWithSelectedTransportSnackbarDismissed : VerificationCodeScreenEvents()
/** The incorrect verification code snackbar was shown and dismissed. */
data object IncorrectVerificationCodeSnackbarDismissed : VerificationCodeScreenEvents()
/** The registration error snackbar was shown and dismissed. */
data object RegistrationErrorSnackbarDismissed : VerificationCodeScreenEvents()
/**
* Event to update countdown timers. Should be triggered periodically (e.g., every second).
@@ -19,9 +19,9 @@ data class VerificationCodeState(
val digits: List<String> = List(CODE_LENGTH) { "" },
val focusedDigitIndex: Int = 0,
val showContactSupportSheet: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null
val snackbars: Snackbars = Snackbars()
) {
override fun toString(): String = "VerificationCodeState(sessionMetadata=${sessionMetadata?.let { "present" }}, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, digitsEntered=${digits.count { it.isNotEmpty() }}, focusedDigitIndex=$focusedDigitIndex, showContactSupportSheet=$showContactSupportSheet, oneTimeEvent=$oneTimeEvent)"
override fun toString(): String = "VerificationCodeState(sessionMetadata=${sessionMetadata?.let { "present" }}, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, digitsEntered=${digits.count { it.isNotEmpty() }}, focusedDigitIndex=$focusedDigitIndex, showContactSupportSheet=$showContactSupportSheet, snackbars=$snackbars)"
/**
* The full code as currently entered. Only meaningful when [isComplete] is true.
@@ -42,21 +42,16 @@ data class VerificationCodeState(
fun emptyDigits(): List<String> = List(CODE_LENGTH) { "" }
}
sealed interface OneTimeEvent {
data object NetworkError : OneTimeEvent
data object UnknownError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
data object UnableToSendSms : OneTimeEvent
data object CouldNotRequestCodeWithSelectedTransport : OneTimeEvent
data object IncorrectVerificationCode : OneTimeEvent
data object RegistrationError : OneTimeEvent
}
/** Transient error messages shown as snackbars. Cleared once the snackbar has been shown and dismissed. */
data class Snackbars(
val networkError: Boolean = false,
val unknownError: Boolean = false,
val rateLimitedRetryAfter: Duration? = null,
val unableToSendSms: Boolean = false,
val couldNotRequestCodeWithSelectedTransport: Boolean = false,
val incorrectVerificationCode: Boolean = false,
val registrationError: Boolean = false
)
/**
* Returns true if the user can resend SMS (timer has expired)
@@ -37,7 +37,6 @@ import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
import org.signal.registration.screens.verificationcode.VerificationCodeState.OneTimeEvent
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
@@ -135,7 +134,13 @@ class VerificationCodeViewModel(
is VerificationCodeScreenEvents.CallMe -> applyResendCode(state, NetworkController.VerificationCodeTransport.VOICE)
is VerificationCodeScreenEvents.HavingTrouble -> state.copy(showContactSupportSheet = true)
is VerificationCodeScreenEvents.DismissContactSupport -> state.copy(showContactSupportSheet = false)
is VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent -> state.copy(oneTimeEvent = null)
is VerificationCodeScreenEvents.NetworkErrorSnackbarDismissed -> state.copy(snackbars = state.snackbars.copy(networkError = false))
is VerificationCodeScreenEvents.UnknownErrorSnackbarDismissed -> state.copy(snackbars = state.snackbars.copy(unknownError = false))
is VerificationCodeScreenEvents.RateLimitedSnackbarDismissed -> state.copy(snackbars = state.snackbars.copy(rateLimitedRetryAfter = null))
is VerificationCodeScreenEvents.UnableToSendSmsSnackbarDismissed -> state.copy(snackbars = state.snackbars.copy(unableToSendSms = false))
is VerificationCodeScreenEvents.CouldNotRequestCodeWithSelectedTransportSnackbarDismissed -> state.copy(snackbars = state.snackbars.copy(couldNotRequestCodeWithSelectedTransport = false))
is VerificationCodeScreenEvents.IncorrectVerificationCodeSnackbarDismissed -> state.copy(snackbars = state.snackbars.copy(incorrectVerificationCode = false))
is VerificationCodeScreenEvents.RegistrationErrorSnackbarDismissed -> state.copy(snackbars = state.snackbars.copy(registrationError = false))
is VerificationCodeScreenEvents.CountdownTick -> applyCountdownTick(state)
is VerificationCodeScreenEvents.Foregrounded -> applyForegrounded(state)
}
@@ -309,7 +314,7 @@ class VerificationCodeViewModel(
is NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode -> {
Log.w(TAG, "[SubmitCode] Invalid sessionId or verification code entered. This is distinct from an *incorrect* verification code. Body: ${error.message}")
val newAttempts = state.incorrectCodeAttempts + 1
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts, digits = VerificationCodeState.emptyDigits(), focusedDigitIndex = 0)
return state.copy(snackbars = state.snackbars.copy(incorrectVerificationCode = true), incorrectCodeAttempts = newAttempts, digits = VerificationCodeState.emptyDigits(), focusedDigitIndex = 0)
}
is NetworkController.SubmitVerificationCodeError.SessionNotFound -> {
Log.w(TAG, "[SubmitCode] Session not found: ${error.message}. Navigating back to phone number entry.")
@@ -328,17 +333,17 @@ class VerificationCodeViewModel(
}
is NetworkController.SubmitVerificationCodeError.RateLimited -> {
Log.w(TAG, "[SubmitCode] Rate limited (retryAfter: ${error.retryAfter}).")
return state.copy(oneTimeEvent = OneTimeEvent.RateLimited(error.retryAfter))
return state.copy(snackbars = state.snackbars.copy(rateLimitedRetryAfter = error.retryAfter))
}
}
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[SubmitCode] Network error.", result.networkError)
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
return state.copy(snackbars = state.snackbars.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[SubmitCode] Unknown error when submitting verification code.", result.cause)
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
return state.copy(snackbars = state.snackbars.copy(unknownError = true))
}
}
@@ -347,7 +352,7 @@ class VerificationCodeViewModel(
if (!sessionMetadata.verified) {
Log.w(TAG, "[SubmitCode] Verification code was incorrect.")
val newAttempts = state.incorrectCodeAttempts + 1
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts, digits = VerificationCodeState.emptyDigits(), focusedDigitIndex = 0)
return state.copy(snackbars = state.snackbars.copy(incorrectVerificationCode = true), incorrectCodeAttempts = newAttempts, digits = VerificationCodeState.emptyDigits(), focusedDigitIndex = 0)
}
// Attempt to register
@@ -388,11 +393,11 @@ class VerificationCodeViewModel(
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Register] Rate limited (retryAfter: ${error.retryAfter}).")
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(error.retryAfter))
state.copy(snackbars = state.snackbars.copy(rateLimitedRetryAfter = error.retryAfter))
}
is NetworkController.RegisterAccountError.InvalidRequest -> {
Log.w(TAG, "[Register] Invalid request when registering account: ${error.message}")
state.copy(oneTimeEvent = OneTimeEvent.RegistrationError)
state.copy(snackbars = state.snackbars.copy(registrationError = true))
}
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
error("[Register] Got told the registration recovery password incorrect. We don't use the RRP in this flow, and should never get this error. Resetting. Message: ${error.message}")
@@ -401,11 +406,11 @@ class VerificationCodeViewModel(
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[Register] Network error.", registerResult.networkError)
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
state.copy(snackbars = state.snackbars.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[Register] Unknown error when registering account.", registerResult.cause)
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
state.copy(snackbars = state.snackbars.copy(unknownError = true))
}
}
}
@@ -438,13 +443,13 @@ class VerificationCodeViewModel(
when (val error = result.error) {
is NetworkController.RequestVerificationCodeError.InvalidRequest -> {
Log.w(TAG, "[RequestCode][$transport] Invalid request: ${error.message}")
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
state.copy(snackbars = state.snackbars.copy(unknownError = true))
}
is NetworkController.RequestVerificationCodeError.RateLimited -> {
Log.w(TAG, "[RequestCode][$transport] Rate limited (retryAfter: ${error.retryAfter}).")
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(error.session))
state.copy(
oneTimeEvent = OneTimeEvent.RateLimited(error.retryAfter),
snackbars = state.snackbars.copy(rateLimitedRetryAfter = error.retryAfter),
sessionMetadata = error.session,
rateLimits = computeRateLimits(error.session)
)
@@ -453,7 +458,7 @@ class VerificationCodeViewModel(
Log.w(TAG, "[RequestCode][$transport] Could not fulfill with requested transport.")
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(error.session))
state.copy(
oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport,
snackbars = state.snackbars.copy(couldNotRequestCodeWithSelectedTransport = true),
sessionMetadata = error.session,
rateLimits = computeRateLimits(error.session)
)
@@ -467,7 +472,7 @@ class VerificationCodeViewModel(
Log.w(TAG, "[RequestCode][$transport] Missing request information or already verified.")
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(error.session))
state.copy(
oneTimeEvent = OneTimeEvent.UnableToSendSms,
snackbars = state.snackbars.copy(unableToSendSms = true),
sessionMetadata = error.session,
rateLimits = computeRateLimits(error.session)
)
@@ -479,17 +484,17 @@ class VerificationCodeViewModel(
}
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
Log.w(TAG, "[RequestCode][$transport] Third party service error. ${error.data}")
state.copy(oneTimeEvent = OneTimeEvent.UnableToSendSms)
state.copy(snackbars = state.snackbars.copy(unableToSendSms = true))
}
}
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[RequestCode][$transport] Network error.", result.networkError)
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
state.copy(snackbars = state.snackbars.copy(networkError = true))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[RequestCode][$transport] Unknown application error.", result.cause)
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
state.copy(snackbars = state.snackbars.copy(unknownError = true))
}
}
}
@@ -7,9 +7,7 @@ package org.signal.registration.screens.devicetransfer.complete
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isNull
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
@@ -98,22 +96,6 @@ class DeviceTransferCompleteViewModelTest {
assertThat(emittedStates).isEmpty()
}
@Test
fun `ConsumeOneTimeEvent clears the one-time event without touching the repository`() = runTest {
viewModel.applyEvent(
DeviceTransferCompleteState(),
DeviceTransferCompleteScreenEvents.ConsumeOneTimeEvent,
parentEventEmitter,
mockRepository,
stateEmitter
)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isNull()
coVerify(exactly = 0) { mockRepository.setRestoreDecision(any()) }
coVerify(exactly = 0) { mockRepository.restoreAccountRecord(any()) }
}
@Test
fun `ContinueClicked through the real event channel hands off to the repository`() = runTest {
viewModel.onEvent(DeviceTransferCompleteScreenEvents.ContinueClicked)
@@ -7,9 +7,7 @@ package org.signal.registration.screens.devicetransfer.instructions
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -74,20 +72,6 @@ class DeviceTransferInstructionsViewModelTest {
assertThat(emittedStates).isEmpty()
}
@Test
fun `ConsumeOneTimeEvent clears the one-time event and emits no navigation`() = runTest {
viewModel.applyEvent(
DeviceTransferInstructionsState(),
DeviceTransferInstructionsScreenEvents.ConsumeOneTimeEvent,
parentEventEmitter,
stateEmitter
)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isNull()
assertThat(emittedEvents).isEmpty()
}
@Test
fun `ContinueClicked through the real event channel navigates to Setup`() = runTest {
viewModel.onEvent(DeviceTransferInstructionsScreenEvents.ContinueClicked)
@@ -212,18 +212,6 @@ class DeviceTransferProgressViewModelTest {
assertThat(emittedEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.DeviceTransferInstructions))
}
@Test
fun `ConsumeOneTimeEvent clears the one-time event`() = runTest {
viewModel.applyEvent(
DeviceTransferProgressState(oneTimeEvent = DeviceTransferProgressState.OneTimeEvent.TransferCanceled),
DeviceTransferProgressScreenEvents.ConsumeOneTimeEvent,
parentEventEmitter,
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isNull()
}
@Test
fun `CancelConfirmed through the real event channel navigates back`() = runTest {
viewModel.onEvent(DeviceTransferProgressScreenEvents.CancelConfirmed)
@@ -14,7 +14,6 @@ import assertk.assertions.doesNotContain
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
@@ -235,11 +234,11 @@ class DeviceTransferSetupViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(DeviceTransferSetupState.OneTimeEvent.RequestLocationPermission)
assertThat(emittedStates.last().pendingActions.requestLocationPermission).isTrue()
}
@Test
fun `OpenLocationSettingsClicked emits the open-location one-time event`() = runTest {
fun `OpenLocationSettingsClicked sets the open-location pending action`() = runTest {
viewModel.applyEvent(
DeviceTransferSetupState(),
DeviceTransferSetupScreenEvents.OpenLocationSettingsClicked,
@@ -247,11 +246,11 @@ class DeviceTransferSetupViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(DeviceTransferSetupState.OneTimeEvent.OpenLocationSettings)
assertThat(emittedStates.last().pendingActions.openLocationSettings).isTrue()
}
@Test
fun `OpenWifiSettingsClicked emits the open-wifi one-time event`() = runTest {
fun `OpenWifiSettingsClicked sets the open-wifi pending action`() = runTest {
viewModel.applyEvent(
DeviceTransferSetupState(),
DeviceTransferSetupScreenEvents.OpenWifiSettingsClicked,
@@ -259,11 +258,11 @@ class DeviceTransferSetupViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(DeviceTransferSetupState.OneTimeEvent.OpenWifiSettings)
assertThat(emittedStates.last().pendingActions.openWifiSettings).isTrue()
}
@Test
fun `OpenAppSettingsClicked emits the open-app-settings one-time event`() = runTest {
fun `OpenAppSettingsClicked sets the open-app-settings pending action`() = runTest {
viewModel.applyEvent(
DeviceTransferSetupState(),
DeviceTransferSetupScreenEvents.OpenAppSettingsClicked,
@@ -271,7 +270,7 @@ class DeviceTransferSetupViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(DeviceTransferSetupState.OneTimeEvent.OpenAppSettings)
assertThat(emittedStates.last().pendingActions.openAppSettings).isTrue()
}
@Test
@@ -338,15 +337,15 @@ class DeviceTransferSetupViewModelTest {
}
@Test
fun `ConsumeOneTimeEvent clears the one-time event`() = runTest {
fun `OpenWifiSettingsHandled clears only the open-wifi pending action`() = runTest {
viewModel.applyEvent(
DeviceTransferSetupState(oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.OpenWifiSettings),
DeviceTransferSetupScreenEvents.ConsumeOneTimeEvent,
DeviceTransferSetupState(pendingActions = DeviceTransferSetupState.PendingActions(openWifiSettings = true, requestLocationPermission = true)),
DeviceTransferSetupScreenEvents.OpenWifiSettingsHandled,
parentEventEmitter,
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isNull()
assertThat(emittedStates.last().pendingActions).isEqualTo(DeviceTransferSetupState.PendingActions(requestLocationPermission = true))
}
// endregion
@@ -433,20 +433,20 @@ class PhoneNumberEntryViewModelTest {
}
@Test
fun `ConsumeInnerOneTimeEvent clears inner event`() = runTest {
fun `NetworkErrorDialogDismissed clears only the network error dialog`() = runTest {
val initialState = PhoneNumberEntryState(
oneTimeEvent = PhoneNumberEntryState.OneTimeEvent.NetworkError
dialogs = PhoneNumberEntryState.Dialogs(networkError = true, unknownError = true)
)
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent,
PhoneNumberEntryScreenEvents.NetworkErrorDialogDismissed,
parentEventEmitter,
stateEmitter
)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isNull()
assertThat(emittedStates.last().dialogs).isEqualTo(PhoneNumberEntryState.Dialogs(unknownError = true))
}
@Test
@@ -526,7 +526,7 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates).hasSize(1)
val result = emittedStates.last()
assertThat(result.nationalNumber).isEqualTo("5551234567")
assertThat(result.showDialog).isTrue()
assertThat(result.dialogs.confirmNumber).isTrue()
// We only open the dialog; we do not submit on our own.
assertThat(emittedEvents).isEmpty()
@@ -543,7 +543,7 @@ class PhoneNumberEntryViewModelTest {
)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().showDialog).isFalse()
assertThat(emittedStates.last().dialogs.confirmNumber).isFalse()
assertThat(emittedEvents).isEmpty()
}
@@ -558,7 +558,7 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
assertThat(emittedStates.last().showDialog).isFalse()
assertThat(emittedStates.last().dialogs.confirmNumber).isFalse()
assertThat(emittedEvents).isEmpty()
}
@@ -722,10 +722,7 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
.prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(60.seconds)
assertThat(emittedStates.last().dialogs.rateLimitedRetryAfter).isEqualTo(60.seconds)
}
@Test
@@ -746,7 +743,7 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.InvalidPhoneNumber)
assertThat(emittedStates.last().dialogs.invalidPhoneNumber).isTrue()
}
@Test
@@ -765,7 +762,7 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().dialogs.networkError).isTrue()
}
@Test
@@ -784,7 +781,7 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
}
@Test
@@ -837,7 +834,7 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isNotNull().isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
assertThat(emittedStates.last().dialogs.rateLimitedRetryAfter).isNotNull()
}
@Test
@@ -888,7 +885,7 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
assertThat(emittedStates.last().dialogs.couldNotRequestCodeWithSelectedTransport).isTrue()
}
@Test
@@ -915,7 +912,7 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnableToSendSms)
assertThat(emittedStates.last().dialogs.unableToSendSms).isTrue()
}
// ==================== Push Challenge Tests ====================
@@ -1154,7 +1151,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), parentEventEmitter, stateEmitter)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
}
@Test
@@ -1187,10 +1184,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), parentEventEmitter, stateEmitter)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
.prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(45.seconds)
assertThat(emittedStates.last().dialogs.rateLimitedRetryAfter).isEqualTo(45.seconds)
}
@Test
@@ -1206,7 +1200,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), parentEventEmitter, stateEmitter)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
}
@Test
@@ -1220,7 +1214,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), parentEventEmitter, stateEmitter)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().dialogs.networkError).isTrue()
}
// ==================== ParentStateChanged Tests ====================
@@ -1482,10 +1476,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
.prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(30.seconds)
assertThat(emittedStates.last().dialogs.rateLimitedRetryAfter).isEqualTo(30.seconds)
}
@Test
@@ -1566,7 +1557,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().dialogs.networkError).isTrue()
}
@Test
@@ -1587,7 +1578,7 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberConfirmed, parentEventEmitter, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
}
@Test
@@ -10,11 +10,8 @@ import assertk.assertions.contains
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
@@ -187,7 +184,7 @@ class PinCreationViewModelTest {
viewModel.applyEvent(confirmState, PinCreationScreenEvents.PinSubmitted("123456"))
assertThat(emittedParentEvents).hasSize(0)
assertThat(states.last().oneTimeEvent).isEqualTo(PinCreationState.OneTimeEvent.ServiceError)
assertThat(states.last().dialogs.serviceError).isTrue()
assertThat(states.last().loading).isFalse()
}
@@ -203,7 +200,7 @@ class PinCreationViewModelTest {
viewModel.applyEvent(confirmState, PinCreationScreenEvents.PinSubmitted("123456"))
assertThat(emittedParentEvents).hasSize(0)
assertThat(states.last().oneTimeEvent).isEqualTo(PinCreationState.OneTimeEvent.ServiceError)
assertThat(states.last().dialogs.serviceError).isTrue()
assertThat(states.last().loading).isFalse()
}
@@ -220,21 +217,29 @@ class PinCreationViewModelTest {
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().dialogs.networkError).isEqualTo(PinCreationState.Dialogs.NetworkError(retryAfter))
assertThat(states.last().loading).isFalse()
}
@Test
fun `ConsumeOneTimeEvent clears the one-time event`() = runTest(testDispatcher) {
fun `ServiceErrorDialogDismissed clears only the service error dialog`() = runTest(testDispatcher) {
val states = collectStates()
val stateWithEvent = PinCreationState(oneTimeEvent = PinCreationState.OneTimeEvent.ServiceError)
val networkError = PinCreationState.Dialogs.NetworkError(retryAfter = null)
val stateWithEvent = PinCreationState(dialogs = PinCreationState.Dialogs(serviceError = true, networkError = networkError))
viewModel.applyEvent(stateWithEvent, PinCreationScreenEvents.ConsumeOneTimeEvent)
viewModel.applyEvent(stateWithEvent, PinCreationScreenEvents.ServiceErrorDialogDismissed)
assertThat(states.last().oneTimeEvent).isNull()
assertThat(states.last().dialogs).isEqualTo(PinCreationState.Dialogs(networkError = networkError))
}
@Test
fun `NetworkErrorDialogDismissed clears only the network error dialog`() = runTest(testDispatcher) {
val states = collectStates()
val stateWithEvent = PinCreationState(dialogs = PinCreationState.Dialogs(serviceError = true, networkError = PinCreationState.Dialogs.NetworkError(retryAfter = null)))
viewModel.applyEvent(stateWithEvent, PinCreationScreenEvents.NetworkErrorDialogDismissed)
assertThat(states.last().dialogs).isEqualTo(PinCreationState.Dialogs(serviceError = true))
}
// ==================== OptOut Tests ====================
@@ -10,7 +10,7 @@ import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.coVerify
@@ -184,7 +184,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().dialogs.networkError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -198,7 +198,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -338,10 +338,7 @@ class PinEntryForRegistrationLockViewModelTest {
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PinEntryState.OneTimeEvent.RateLimited>()
.prop(PinEntryState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(retryAfter)
assertThat(emittedStates.last().dialogs.rateLimitedRetryAfter).isEqualTo(retryAfter)
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -361,7 +358,7 @@ class PinEntryForRegistrationLockViewModelTest {
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -381,7 +378,7 @@ class PinEntryForRegistrationLockViewModelTest {
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -399,7 +396,7 @@ class PinEntryForRegistrationLockViewModelTest {
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().dialogs.networkError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -417,7 +414,7 @@ class PinEntryForRegistrationLockViewModelTest {
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -9,8 +9,7 @@ import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.prop
import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
@@ -142,7 +141,7 @@ class PinEntryForSmsBypassViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().dialogs.networkError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -156,7 +155,7 @@ class PinEntryForSmsBypassViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -187,7 +186,7 @@ class PinEntryForSmsBypassViewModelTest {
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().dialogs.networkError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -205,7 +204,7 @@ class PinEntryForSmsBypassViewModelTest {
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -266,10 +265,7 @@ class PinEntryForSmsBypassViewModelTest {
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PinEntryState.OneTimeEvent.RateLimited>()
.prop(PinEntryState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(retryAfter)
assertThat(emittedStates.last().dialogs.rateLimitedRetryAfter).isEqualTo(retryAfter)
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -9,6 +9,7 @@ import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isTrue
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.coVerify
@@ -126,7 +127,7 @@ class PinEntryForSvrRestoreViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().dialogs.networkError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -140,7 +141,7 @@ class PinEntryForSvrRestoreViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -233,7 +234,7 @@ class PinEntryForSvrRestoreViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().dialogs.networkError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -253,7 +254,7 @@ class PinEntryForSvrRestoreViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().dialogs.unknownError).isTrue()
assertThat(emittedStates.last().loading).isEqualTo(false)
}
@@ -9,7 +9,6 @@ import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
@@ -139,8 +138,8 @@ class VerificationCodeViewModelTest {
}
@Test
fun `ParentStateChanged preserves existing oneTimeEvent`() = runTest {
val state = VerificationCodeState(oneTimeEvent = VerificationCodeState.OneTimeEvent.NetworkError)
fun `ParentStateChanged preserves existing snackbars`() = runTest {
val state = VerificationCodeState(snackbars = VerificationCodeState.Snackbars(networkError = true))
val sessionMetadata = createSessionMetadata()
val parentFlowState = RegistrationFlowState(
sessionMetadata = sessionMetadata,
@@ -149,37 +148,37 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(state, VerificationCodeScreenEvents.ParentStateChanged(parentFlowState), stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().snackbars.networkError).isTrue()
}
// ==================== applyEvent: ConsumeInnerOneTimeEvent Tests ====================
// ==================== applyEvent: Snackbar Dismissal Tests ====================
@Test
fun `ConsumeInnerOneTimeEvent clears oneTimeEvent`() = runTest {
fun `NetworkErrorSnackbarDismissed clears only the network error snackbar`() = runTest {
val initialState = VerificationCodeState(
oneTimeEvent = VerificationCodeState.OneTimeEvent.NetworkError
snackbars = VerificationCodeState.Snackbars(networkError = true, incorrectVerificationCode = true)
)
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent,
VerificationCodeScreenEvents.NetworkErrorSnackbarDismissed,
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isNull()
assertThat(emittedStates.last().snackbars).isEqualTo(VerificationCodeState.Snackbars(incorrectVerificationCode = true))
}
@Test
fun `ConsumeInnerOneTimeEvent with null event returns state with null event`() = runTest {
val initialState = VerificationCodeState(oneTimeEvent = null)
fun `NetworkErrorSnackbarDismissed with no snackbars showing leaves snackbars cleared`() = runTest {
val initialState = VerificationCodeState()
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent,
VerificationCodeScreenEvents.NetworkErrorSnackbarDismissed,
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isNull()
assertThat(emittedStates.last().snackbars).isEqualTo(VerificationCodeState.Snackbars())
}
// ==================== applyEvent: SMS Auto-Fill Tests ====================
@@ -431,7 +430,7 @@ class VerificationCodeViewModelTest {
)
assertThat(emittedStates.last().digits).isEqualTo(listOf("", "", "", "", "", ""))
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.IncorrectVerificationCode)
assertThat(emittedStates.last().snackbars.incorrectVerificationCode).isTrue()
}
@Test
@@ -626,7 +625,7 @@ class VerificationCodeViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.IncorrectVerificationCode)
assertThat(emittedStates.last().snackbars.incorrectVerificationCode).isTrue()
}
@Test
@@ -714,10 +713,7 @@ class VerificationCodeViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(60.seconds)
assertThat(emittedStates.last().snackbars.rateLimitedRetryAfter).isEqualTo(60.seconds)
}
@Test
@@ -737,7 +733,7 @@ class VerificationCodeViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().snackbars.networkError).isTrue()
}
@Test
@@ -757,7 +753,7 @@ class VerificationCodeViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().snackbars.unknownError).isTrue()
}
// ==================== applyEvent: CodeEntered - Registration Errors ====================
@@ -806,10 +802,7 @@ class VerificationCodeViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(30.seconds)
assertThat(emittedStates.last().snackbars.rateLimitedRetryAfter).isEqualTo(30.seconds)
}
@Ignore
@@ -834,7 +827,7 @@ class VerificationCodeViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
assertThat(emittedStates.last().snackbars.registrationError).isTrue()
}
@Ignore
@@ -859,7 +852,7 @@ class VerificationCodeViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
assertThat(emittedStates.last().snackbars.registrationError).isTrue()
}
@Ignore
@@ -882,7 +875,7 @@ class VerificationCodeViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().snackbars.networkError).isTrue()
}
@Ignore
@@ -905,7 +898,7 @@ class VerificationCodeViewModelTest {
stateEmitter
)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().snackbars.unknownError).isTrue()
}
// ==================== applyEvent: ResendSms Tests ====================
@@ -967,10 +960,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(45.seconds)
assertThat(emittedStates.last().snackbars.rateLimitedRetryAfter).isEqualTo(45.seconds)
}
@Test
@@ -985,7 +975,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().snackbars.unknownError).isTrue()
}
@Test
@@ -1000,7 +990,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
assertThat(emittedStates.last().snackbars.couldNotRequestCodeWithSelectedTransport).isTrue()
}
@Test
@@ -1047,7 +1037,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnableToSendSms)
assertThat(emittedStates.last().snackbars.unableToSendSms).isTrue()
}
@Test
@@ -1064,7 +1054,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnableToSendSms)
assertThat(emittedStates.last().snackbars.unableToSendSms).isTrue()
}
@Test
@@ -1077,7 +1067,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
assertThat(emittedStates.last().snackbars.networkError).isTrue()
}
@Test
@@ -1090,7 +1080,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
assertThat(emittedStates.last().snackbars.unknownError).isTrue()
}
// ==================== applyEvent: CallMe Tests ====================
@@ -1132,10 +1122,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(90.seconds)
assertThat(emittedStates.last().snackbars.rateLimitedRetryAfter).isEqualTo(90.seconds)
}
@Test
@@ -1150,7 +1137,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
assertThat(emittedStates.last().snackbars.couldNotRequestCodeWithSelectedTransport).isTrue()
}
@Test
@@ -1167,7 +1154,7 @@ class VerificationCodeViewModelTest {
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnableToSendSms)
assertThat(emittedStates.last().snackbars.unableToSendSms).isTrue()
}
// ==================== applyEvent: Foregrounded Tests ====================