diff --git a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreen.kt index 9a36a791e7..6cffcb8078 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreen.kt @@ -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) { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreenEvents.kt index 121f3b6aeb..764190b61e 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreenEvents.kt @@ -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() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileState.kt b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileState.kt index 1fd249f979..594e680058 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileState.kt @@ -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 - } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileViewModel.kt index 8187fdb218..a8d9ddbc21 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileViewModel.kt @@ -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)) } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreenEvents.kt index 9725236173..d0aa87efe1 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreenEvents.kt @@ -7,5 +7,4 @@ package org.signal.registration.screens.devicetransfer.complete sealed class DeviceTransferCompleteScreenEvents { data object ContinueClicked : DeviceTransferCompleteScreenEvents() - data object ConsumeOneTimeEvent : DeviceTransferCompleteScreenEvents() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteState.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteState.kt index fb4cb46d59..f627fadf19 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteState.kt @@ -5,8 +5,4 @@ package org.signal.registration.screens.devicetransfer.complete -data class DeviceTransferCompleteState( - val oneTimeEvent: OneTimeEvent? = null -) { - sealed interface OneTimeEvent -} +class DeviceTransferCompleteState diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt index 4850ac7259..fefbdad67d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt @@ -55,9 +55,6 @@ class DeviceTransferCompleteViewModel( repository.restoreAccountRecord() parentEventEmitter(RegistrationFlowEvent.RegistrationComplete) } - DeviceTransferCompleteScreenEvents.ConsumeOneTimeEvent -> { - stateEmitter(state.copy(oneTimeEvent = null)) - } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreenEvents.kt index 234eb66473..6aaa70a937 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreenEvents.kt @@ -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() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsState.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsState.kt index 302bc87404..88e2f41a8c 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsState.kt @@ -5,8 +5,4 @@ package org.signal.registration.screens.devicetransfer.instructions -data class DeviceTransferInstructionsState( - val oneTimeEvent: OneTimeEvent? = null -) { - sealed interface OneTimeEvent -} +class DeviceTransferInstructionsState diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModel.kt index bdb7ab4e2c..c0bca225d1 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModel.kt @@ -55,9 +55,6 @@ class DeviceTransferInstructionsViewModel( DeviceTransferInstructionsScreenEvents.BackClicked -> { parentEventEmitter.navigateBack() } - DeviceTransferInstructionsScreenEvents.ConsumeOneTimeEvent -> { - stateEmitter(state.copy(oneTimeEvent = null)) - } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreenEvents.kt index da1cae4fb7..8d6baf1798 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreenEvents.kt @@ -10,5 +10,4 @@ sealed class DeviceTransferProgressScreenEvents { data object CancelConfirmed : DeviceTransferProgressScreenEvents() data object CancelDismissed : DeviceTransferProgressScreenEvents() data object TryAgainClicked : DeviceTransferProgressScreenEvents() - data object ConsumeOneTimeEvent : DeviceTransferProgressScreenEvents() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressState.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressState.kt index ebc825119a..5c9dabfaf8 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressState.kt @@ -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 - } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModel.kt index 6343cb1a1d..697a1ab13f 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModel.kt @@ -98,9 +98,6 @@ class DeviceTransferProgressViewModel( stopService() parentEventEmitter.navigateTo(RegistrationRoute.DeviceTransferInstructions) } - DeviceTransferProgressScreenEvents.ConsumeOneTimeEvent -> { - stateEmitter(state.copy(oneTimeEvent = null)) - } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt index 4c28781ea8..ad8609b0d9 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt @@ -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) } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreenEvents.kt index 41e4a49e38..3558558826 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreenEvents.kt @@ -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() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupState.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupState.kt index 426dc4a344..bb20f8295d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupState.kt @@ -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 + ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModel.kt index 990a0bb205..06268d52f6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModel.kt @@ -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))) } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt index 7126fc59f8..85b4a1e0f0 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt @@ -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? = 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) } ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt index 4526bab64f..54c1bd2a2d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt @@ -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() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt index 64b703c644..4311ae62a6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt @@ -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 = 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 + ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index 6a82d98399..3d563fb255 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -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)) } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt index add1a16262..a52727831e 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt @@ -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? = 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) } ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt index 0b1c9f056a..8e12ecfbca 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt @@ -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() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt index a817211fe8..93e8e00e05 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt @@ -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?) } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt index e9fed94ada..aad3ae72ab 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt @@ -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)) } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt index 8cafbdb7bb..914a2e547b 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt @@ -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)) } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt index c618faf2e9..7dc7d86f91 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt @@ -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) { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt index 8bacfeda22..0392845a31 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt @@ -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)) } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt index d82f09fadf..53a09f86a4 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt @@ -121,6 +121,28 @@ fun PinEntryScreen( ) } + val errorDialog: Pair? = 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 = "", diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEventHandler.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEventHandler.kt index e596370cae..32104921a3 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEventHandler.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEventHandler.kt @@ -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!") } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt index 7b1b94dfdf..051e68bd46 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt @@ -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() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt index 9394da2722..5acac619d4 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt @@ -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 + ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt index 36915429e3..284289a548 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt @@ -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) { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt index e3c4aee3fd..ef65032ab0 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt @@ -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). diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt index e6351847e8..eb9d4b15f4 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt @@ -19,9 +19,9 @@ data class VerificationCodeState( val digits: List = 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 = 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) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt index 409729d9ac..b7d324f0dd 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt @@ -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)) } } } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt index 066c3f3a2f..b1e0b3047b 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt @@ -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) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModelTest.kt index b45f9d0f3c..08eb87a460 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModelTest.kt @@ -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) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModelTest.kt index c9bb08c1fb..2857e53ceb 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModelTest.kt @@ -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) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModelTest.kt index ac4355f6f8..e5c6c907dc 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModelTest.kt @@ -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 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt index 1709708005..63c816253f 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt @@ -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() - .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() + 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() - .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() - .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 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt index 86ac2f60a4..f90a047029 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt @@ -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() - .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 ==================== diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt index 6c5853e9c7..9c7a48cf22 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt @@ -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() - assertThat(emittedStates.last().oneTimeEvent).isNotNull() - .isInstanceOf() - .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() - 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() - 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() - 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() - assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError) + assertThat(emittedStates.last().dialogs.unknownError).isTrue() assertThat(emittedStates.last().loading).isEqualTo(false) } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt index cbe987330c..2b53799ff0 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt @@ -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() - 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() - 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() - assertThat(emittedStates.last().oneTimeEvent).isNotNull() - .isInstanceOf() - .prop(PinEntryState.OneTimeEvent.RateLimited::retryAfter) - .isEqualTo(retryAfter) + assertThat(emittedStates.last().dialogs.rateLimitedRetryAfter).isEqualTo(retryAfter) assertThat(emittedStates.last().loading).isEqualTo(false) } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt index 4ca1e4a3a0..56e773bd85 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt @@ -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) } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt index 4caa44e849..8d3a0d906a 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt @@ -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() - .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() - .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() - .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() - .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 ====================