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 f6059e2e0d..61c03a1cc3 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 @@ -53,7 +53,9 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import kotlinx.coroutines.delay import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews +import org.signal.network.api.RegistrationApiV2.VerificationCodeTransport import org.signal.registration.R import org.signal.registration.screens.OnePaneRegistrationScaffold import org.signal.registration.screens.RegistrationScaffold @@ -100,8 +102,6 @@ fun VerificationCodeScreen( 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 @@ -111,6 +111,8 @@ fun VerificationCodeScreen( onEvent(dismissedEvent) } + RequestCodeErrorDialogs(state.dialogs, onEvent) + LaunchedEffect(state.focusedDigitIndex) { focusRequesters[state.focusedDigitIndex].requestFocus() } @@ -149,6 +151,50 @@ fun VerificationCodeScreen( } } +/** + * Modal dialogs for failures that occur while requesting a verification code (resend SMS / call me). Unlike the + * inline snackbars used for code submission, these block until acknowledged so the user can't miss them. + */ +@Composable +private fun RequestCodeErrorDialogs(dialogs: VerificationCodeState.Dialogs, onEvent: (VerificationCodeScreenEvents) -> Unit) { + dialogs.providerRejectedTransport?.let { transport -> + val message = when (transport) { + VerificationCodeTransport.VOICE -> stringResource(R.string.VerificationCodeScreen__could_not_call_provider_rejected) + VerificationCodeTransport.SMS -> stringResource(R.string.VerificationCodeScreen__could_not_sms_provider_rejected) + } + Dialogs.SimpleMessageDialog( + message = message, + dismiss = stringResource(android.R.string.ok), + onDismiss = { onEvent(VerificationCodeScreenEvents.ProviderRejectedDialogDismissed) } + ) + return + } + + val simpleError: Pair? = when { + dialogs.networkError -> stringResource(R.string.VerificationCodeScreen__network_error) to VerificationCodeScreenEvents.NetworkErrorDialogDismissed + dialogs.rateLimitedRetryAfter != null -> { + val message = if (dialogs.rateLimitedRetryAfter.isPositive()) { + stringResource(R.string.VerificationCodeScreen__too_many_attempts_try_again_in_s, dialogs.rateLimitedRetryAfter.toString()) + } else { + stringResource(R.string.VerificationCodeScreen__too_many_attempts) + } + message to VerificationCodeScreenEvents.RateLimitedDialogDismissed + } + dialogs.couldNotRequestCodeWithSelectedTransport -> stringResource(R.string.VerificationCodeScreen__could_not_send_code_via_selected_method) to VerificationCodeScreenEvents.CouldNotRequestCodeWithSelectedTransportDialogDismissed + dialogs.unableToSendSms -> stringResource(R.string.VerificationCodeScreen__unable_to_send_sms) to VerificationCodeScreenEvents.UnableToSendSmsDialogDismissed + dialogs.unknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to VerificationCodeScreenEvents.UnknownErrorDialogDismissed + else -> null + } + + simpleError?.let { (message, dismissedEvent) -> + Dialogs.SimpleMessageDialog( + message = message, + dismiss = stringResource(android.R.string.ok), + onDismiss = { onEvent(dismissedEvent) } + ) + } +} + @Composable private fun OnePaneLayout( params: RegistrationScaffold.Params.OnePane, 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 ef65032ab0..5317a0234b 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 @@ -54,11 +54,23 @@ sealed class 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 network error dialog from requesting a code was dismissed. */ + data object NetworkErrorDialogDismissed : VerificationCodeScreenEvents() - /** The could-not-request-code-with-selected-transport snackbar was shown and dismissed. */ - data object CouldNotRequestCodeWithSelectedTransportSnackbarDismissed : VerificationCodeScreenEvents() + /** The unknown error dialog from requesting a code was dismissed. */ + data object UnknownErrorDialogDismissed : VerificationCodeScreenEvents() + + /** The rate limited dialog from requesting a code was dismissed. */ + data object RateLimitedDialogDismissed : VerificationCodeScreenEvents() + + /** The unable-to-send-SMS dialog was dismissed. */ + data object UnableToSendSmsDialogDismissed : VerificationCodeScreenEvents() + + /** The could-not-request-code-with-selected-transport dialog was dismissed. */ + data object CouldNotRequestCodeWithSelectedTransportDialogDismissed : VerificationCodeScreenEvents() + + /** The delivery-provider-rejected dialog was dismissed. */ + data object ProviderRejectedDialogDismissed : VerificationCodeScreenEvents() /** The incorrect verification code snackbar was shown and dismissed. */ data object IncorrectVerificationCodeSnackbarDismissed : VerificationCodeScreenEvents() 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 8a8f9c8228..824266dd58 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 @@ -6,6 +6,7 @@ package org.signal.registration.screens.verificationcode import org.signal.network.api.RegistrationApiV2.SessionMetadata +import org.signal.network.api.RegistrationApiV2.VerificationCodeTransport import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -19,9 +20,10 @@ data class VerificationCodeState( val digits: List = List(CODE_LENGTH) { "" }, val focusedDigitIndex: Int = 0, val showContactSupportSheet: Boolean = false, - val snackbars: Snackbars = Snackbars() + val snackbars: Snackbars = Snackbars(), + val dialogs: Dialogs = Dialogs() ) { - override fun toString(): String = "VerificationCodeState(sessionMetadata=$sessionMetadata, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, digitsEntered=${digits.count { it.isNotEmpty() }}, focusedDigitIndex=$focusedDigitIndex, showContactSupportSheet=$showContactSupportSheet, snackbars=$snackbars)" + override fun toString(): String = "VerificationCodeState(sessionMetadata=$sessionMetadata, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, digitsEntered=${digits.count { it.isNotEmpty() }}, focusedDigitIndex=$focusedDigitIndex, showContactSupportSheet=$showContactSupportSheet, snackbars=$snackbars, dialogs=$dialogs)" /** * The full code as currently entered. Only meaningful when [isComplete] is true. @@ -42,15 +44,24 @@ data class VerificationCodeState( fun emptyDigits(): List = List(CODE_LENGTH) { "" } } - /** Transient error messages shown as snackbars. Cleared once the snackbar has been shown and dismissed. */ + /** Transient errors from submitting a code or registering, shown as snackbars. Cleared once shown and dismissed. */ data class Snackbars( + val networkError: Boolean = false, + val unknownError: Boolean = false, + val rateLimitedRetryAfter: Duration? = null, + val incorrectVerificationCode: Boolean = false, + val registrationError: Boolean = false + ) + + /** Errors from requesting a verification code (resend SMS / call me), shown as modal dialogs so they aren't missed. */ + data class Dialogs( 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 + /** Nonnull when the delivery provider rejected the request. Carries the failed transport for accurate wording. */ + val providerRejectedTransport: VerificationCodeTransport? = null ) /** 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 80dab4128b..16d355da5b 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 @@ -139,8 +139,12 @@ class VerificationCodeViewModel( 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.NetworkErrorDialogDismissed -> state.copy(dialogs = state.dialogs.copy(networkError = false)) + is VerificationCodeScreenEvents.UnknownErrorDialogDismissed -> state.copy(dialogs = state.dialogs.copy(unknownError = false)) + is VerificationCodeScreenEvents.RateLimitedDialogDismissed -> state.copy(dialogs = state.dialogs.copy(rateLimitedRetryAfter = null)) + is VerificationCodeScreenEvents.UnableToSendSmsDialogDismissed -> state.copy(dialogs = state.dialogs.copy(unableToSendSms = false)) + is VerificationCodeScreenEvents.CouldNotRequestCodeWithSelectedTransportDialogDismissed -> state.copy(dialogs = state.dialogs.copy(couldNotRequestCodeWithSelectedTransport = false)) + is VerificationCodeScreenEvents.ProviderRejectedDialogDismissed -> state.copy(dialogs = state.dialogs.copy(providerRejectedTransport = null)) 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) @@ -467,13 +471,13 @@ class VerificationCodeViewModel( when (val error = result.error) { is RequestVerificationCodeError.InvalidRequest -> { Log.w(TAG, "[RequestCode][$transport] Invalid request: ${error.message}") - state.copy(snackbars = state.snackbars.copy(unknownError = true)) + state.copy(dialogs = state.dialogs.copy(unknownError = true)) } is RequestVerificationCodeError.RateLimited -> { Log.w(TAG, "[RequestCode][$transport] Rate limited (retryAfter: ${error.retryAfter}).") parentEventEmitter(RegistrationFlowEvent.SessionUpdated(error.session)) state.copy( - snackbars = state.snackbars.copy(rateLimitedRetryAfter = error.retryAfter), + dialogs = state.dialogs.copy(rateLimitedRetryAfter = error.retryAfter), sessionMetadata = error.session, rateLimits = computeRateLimits(error.session) ) @@ -482,7 +486,7 @@ class VerificationCodeViewModel( Log.w(TAG, "[RequestCode][$transport] Could not fulfill with requested transport.") parentEventEmitter(RegistrationFlowEvent.SessionUpdated(error.session)) state.copy( - snackbars = state.snackbars.copy(couldNotRequestCodeWithSelectedTransport = true), + dialogs = state.dialogs.copy(couldNotRequestCodeWithSelectedTransport = true), sessionMetadata = error.session, rateLimits = computeRateLimits(error.session) ) @@ -496,7 +500,7 @@ class VerificationCodeViewModel( Log.w(TAG, "[RequestCode][$transport] Missing request information or already verified.") parentEventEmitter(RegistrationFlowEvent.SessionUpdated(error.session)) state.copy( - snackbars = state.snackbars.copy(unableToSendSms = true), + dialogs = state.dialogs.copy(unableToSendSms = true), sessionMetadata = error.session, rateLimits = computeRateLimits(error.session) ) @@ -508,17 +512,17 @@ class VerificationCodeViewModel( } is RequestVerificationCodeError.ThirdPartyServiceError -> { Log.w(TAG, "[RequestCode][$transport] Third party service error. ${error.data}") - state.copy(snackbars = state.snackbars.copy(unableToSendSms = true)) + state.copy(dialogs = state.dialogs.copy(providerRejectedTransport = transport)) } } } is RequestResult.RetryableNetworkError -> { Log.w(TAG, "[RequestCode][$transport] Network error.", result.networkError) - state.copy(snackbars = state.snackbars.copy(networkError = true)) + state.copy(dialogs = state.dialogs.copy(networkError = true)) } is RequestResult.ApplicationError -> { Log.w(TAG, "[RequestCode][$transport] Unknown application error.", result.cause) - state.copy(snackbars = state.snackbars.copy(unknownError = true)) + state.copy(dialogs = state.dialogs.copy(unknownError = true)) } } } diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 33c815d600..4219c48937 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -105,6 +105,10 @@ We are unable to send an SMS to your number. Please try again in several hours. Could not send code via the selected method. Please try another option. + + We couldn\'t call you with a verification code. Please try again, or request the code via SMS instead. + + We couldn\'t send you an SMS with a verification code. Please try again, or request the code via a phone call instead. Registration failed. Please try again. 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 bca40c9e10..7b2439a2a9 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 @@ -1145,7 +1145,7 @@ class VerificationCodeViewModelTest { viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter) - assertThat(emittedStates.last().snackbars.rateLimitedRetryAfter).isEqualTo(45.seconds) + assertThat(emittedStates.last().dialogs.rateLimitedRetryAfter).isEqualTo(45.seconds) } @Test @@ -1160,7 +1160,7 @@ class VerificationCodeViewModelTest { viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter) - assertThat(emittedStates.last().snackbars.unknownError).isTrue() + assertThat(emittedStates.last().dialogs.unknownError).isTrue() } @Test @@ -1175,7 +1175,7 @@ class VerificationCodeViewModelTest { viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter) - assertThat(emittedStates.last().snackbars.couldNotRequestCodeWithSelectedTransport).isTrue() + assertThat(emittedStates.last().dialogs.couldNotRequestCodeWithSelectedTransport).isTrue() } @Test @@ -1222,11 +1222,11 @@ class VerificationCodeViewModelTest { viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter) - assertThat(emittedStates.last().snackbars.unableToSendSms).isTrue() + assertThat(emittedStates.last().dialogs.unableToSendSms).isTrue() } @Test - fun `ResendSms with ThirdPartyServiceError returns UnableToSendSms event`() = runTest { + fun `ResendSms with ThirdPartyServiceError shows provider rejected dialog for SMS`() = runTest { val sessionMetadata = createSessionMetadata() val initialState = VerificationCodeState(sessionMetadata = sessionMetadata) @@ -1239,7 +1239,7 @@ class VerificationCodeViewModelTest { viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter) - assertThat(emittedStates.last().snackbars.unableToSendSms).isTrue() + assertThat(emittedStates.last().dialogs.providerRejectedTransport).isEqualTo(VerificationCodeTransport.SMS) } @Test @@ -1252,7 +1252,7 @@ class VerificationCodeViewModelTest { viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter) - assertThat(emittedStates.last().snackbars.networkError).isTrue() + assertThat(emittedStates.last().dialogs.networkError).isTrue() } @Test @@ -1265,7 +1265,7 @@ class VerificationCodeViewModelTest { viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter) - assertThat(emittedStates.last().snackbars.unknownError).isTrue() + assertThat(emittedStates.last().dialogs.unknownError).isTrue() } // ==================== applyEvent: CallMe Tests ==================== @@ -1307,7 +1307,7 @@ class VerificationCodeViewModelTest { viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter) - assertThat(emittedStates.last().snackbars.rateLimitedRetryAfter).isEqualTo(90.seconds) + assertThat(emittedStates.last().dialogs.rateLimitedRetryAfter).isEqualTo(90.seconds) } @Test @@ -1322,11 +1322,11 @@ class VerificationCodeViewModelTest { viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter) - assertThat(emittedStates.last().snackbars.couldNotRequestCodeWithSelectedTransport).isTrue() + assertThat(emittedStates.last().dialogs.couldNotRequestCodeWithSelectedTransport).isTrue() } @Test - fun `CallMe with ThirdPartyServiceError returns UnableToSendSms event`() = runTest { + fun `CallMe with ThirdPartyServiceError shows provider rejected dialog for VOICE`() = runTest { val sessionMetadata = createSessionMetadata() val initialState = VerificationCodeState(sessionMetadata = sessionMetadata) @@ -1339,7 +1339,7 @@ class VerificationCodeViewModelTest { viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe, stateEmitter) - assertThat(emittedStates.last().snackbars.unableToSendSms).isTrue() + assertThat(emittedStates.last().dialogs.providerRejectedTransport).isEqualTo(VerificationCodeTransport.VOICE) } // ==================== applyEvent: Foregrounded Tests ====================