From f37efd7e151398624a29357fa8fd7246a6d1a9c7 Mon Sep 17 00:00:00 2001 From: Nicholas Tinsley Date: Fri, 17 May 2024 19:04:49 -0400 Subject: [PATCH] Add additional error handling for registration v2. --- .../v2/data/RegistrationRepository.kt | 3 +- .../network/VerificationCodeRequestResult.kt | 8 +- .../v2/ui/RegistrationV2ViewModel.kt | 104 ++++++++++----- .../v2/ui/entercode/EnterCodeV2Fragment.kt | 84 ++++++++++++- .../phonenumber/EnterPhoneNumberV2Fragment.kt | 119 +++++++++++++++--- .../ui/phonenumber/EnterPhoneNumberV2State.kt | 2 +- .../api/registration/RegistrationApi.kt | 19 +++ 7 files changed, 281 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt index c09f2bda4a..ef81d76150 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt @@ -525,7 +525,8 @@ object RegistrationRepository { enum class Mode(val isSmsRetrieverSupported: Boolean, val transport: PushServiceSocket.VerificationCodeTransport) { SMS_WITH_LISTENER(true, PushServiceSocket.VerificationCodeTransport.SMS), SMS_WITHOUT_LISTENER(false, PushServiceSocket.VerificationCodeTransport.SMS), - PHONE_CALL(false, PushServiceSocket.VerificationCodeTransport.VOICE) + PHONE_CALL(false, PushServiceSocket.VerificationCodeTransport.VOICE), + NONE(false, PushServiceSocket.VerificationCodeTransport.SMS) } private class PushTokenChallengeSubscriber { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/network/VerificationCodeRequestResult.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/network/VerificationCodeRequestResult.kt index 778d10fe29..1482aaabaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/network/VerificationCodeRequestResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/network/VerificationCodeRequestResult.kt @@ -19,6 +19,7 @@ import org.whispersystems.signalservice.api.push.exceptions.PushChallengeRequire import org.whispersystems.signalservice.api.push.exceptions.RateLimitException import org.whispersystems.signalservice.api.push.exceptions.RegistrationRetryException import org.whispersystems.signalservice.api.push.exceptions.TokenNotAcceptedException +import org.whispersystems.signalservice.internal.push.LockedException import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataJson import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse import org.whispersystems.signalservice.internal.util.JsonUtil @@ -56,12 +57,13 @@ sealed class VerificationCodeRequestResult(cause: Throwable?) : RegistrationResu is CaptchaRequiredException -> createChallengeRequiredProcessor(networkResult) is RateLimitException -> createRateLimitProcessor(cause) is ImpossiblePhoneNumberException -> ImpossibleNumber(cause) - is NonNormalizedPhoneNumberException -> NonNormalizedNumber(cause) + is NonNormalizedPhoneNumberException -> NonNormalizedNumber(cause = cause, originalNumber = cause.originalNumber, normalizedNumber = cause.normalizedNumber) is TokenNotAcceptedException -> TokenNotAccepted(cause) is ExternalServiceFailureException -> ExternalServiceFailure(cause) is InvalidTransportModeException -> InvalidTransportModeFailure(cause) is MalformedRequestException -> MalformedRequest(cause) is RegistrationRetryException -> MustRetry(cause) + is LockedException -> RegistrationLocked(cause = cause, timeRemaining = cause.timeRemaining) else -> UnknownError(cause) } } @@ -102,7 +104,7 @@ sealed class VerificationCodeRequestResult(cause: Throwable?) : RegistrationResu class ImpossibleNumber(cause: Throwable) : VerificationCodeRequestResult(cause) - class NonNormalizedNumber(cause: Throwable) : VerificationCodeRequestResult(cause) + class NonNormalizedNumber(cause: Throwable, val originalNumber: String, val normalizedNumber: String) : VerificationCodeRequestResult(cause) class TokenNotAccepted(cause: Throwable) : VerificationCodeRequestResult(cause) @@ -114,5 +116,7 @@ sealed class VerificationCodeRequestResult(cause: Throwable?) : RegistrationResu class MustRetry(cause: Throwable) : VerificationCodeRequestResult(cause) + class RegistrationLocked(cause: Throwable, val timeRemaining: Long) : VerificationCodeRequestResult(cause) + class UnknownError(cause: Throwable) : VerificationCodeRequestResult(cause) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2ViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2ViewModel.kt index 0e2eaeaf42..4b24df321e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2ViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2ViewModel.kt @@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeR import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.MustRetry import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.NonNormalizedNumber import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.RateLimited +import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.RegistrationLocked import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.Success import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.TokenNotAccepted import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.UnknownError @@ -68,7 +69,10 @@ class RegistrationV2ViewModel : ViewModel() { private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception -> Log.w(TAG, "CoroutineExceptionHandler invoked.", exception) store.update { - it.copy(networkError = exception) + it.copy( + networkError = exception, + inProgress = false + ) } } @@ -141,7 +145,7 @@ class RegistrationV2ViewModel : ViewModel() { } } - fun onUserConfirmedPhoneNumber(context: Context) { + fun onUserConfirmedPhoneNumber(context: Context, errorHandler: (VerificationCodeRequestResult, RegistrationRepository.Mode) -> Unit) { setRegistrationCheckpoint(RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED) val state = store.value if (state.phoneNumber == null) { @@ -153,8 +157,12 @@ class RegistrationV2ViewModel : ViewModel() { val e164 = state.phoneNumber.toE164() if (hasRecoveryPassword() && matchesSavedE164(e164)) { // Re-registration when the local database is intact. + Log.d(TAG, "Has recovery password, and therefore can skip SMS verification.") store.update { - it.copy(canSkipSms = true) + it.copy( + canSkipSms = true, + inProgress = false + ) } return } @@ -171,7 +179,7 @@ class RegistrationV2ViewModel : ViewModel() { is BackupAuthCheckResult.SuccessWithCredentials -> { Log.d(TAG, "Found local valid SVR auth credentials.") store.update { - it.copy(canSkipSms = true, svrAuthCredentials = svrCredentialsResult.authCredentials) + it.copy(canSkipSms = true, svrAuthCredentials = svrCredentialsResult.authCredentials, inProgress = false) } return@launch } @@ -184,15 +192,15 @@ class RegistrationV2ViewModel : ViewModel() { if (!validSession.body.allowedToRequestCode) { val challenges = validSession.body.requestedInformation.joinToString() Log.i(TAG, "Not allowed to request code! Remaining challenges: $challenges") - handleSessionStateResult(context, ChallengeRequired(validSession.body.requestedInformation)) + handleSessionStateResult(context, ChallengeRequired(validSession.body.requestedInformation), RegistrationRepository.Mode.NONE, errorHandler) return@launch } - requestSmsCodeInternal(context, validSession.body.id, e164) + requestSmsCodeInternal(context, validSession.body.id, e164, errorHandler) } } - fun requestSmsCode(context: Context) { + fun requestSmsCode(context: Context, errorHandler: (VerificationCodeRequestResult, RegistrationRepository.Mode) -> Unit) { val e164 = getCurrentE164() if (e164 == null) { @@ -203,11 +211,11 @@ class RegistrationV2ViewModel : ViewModel() { viewModelScope.launch { val validSession = getOrCreateValidSession(context) ?: return@launch - requestSmsCodeInternal(context, validSession.body.id, e164) + requestSmsCodeInternal(context, validSession.body.id, e164, errorHandler) } } - fun requestVerificationCall(context: Context) { + fun requestVerificationCall(context: Context, errorHandler: (VerificationCodeRequestResult, RegistrationRepository.Mode) -> Unit) { val e164 = getCurrentE164() if (e164 == null) { @@ -228,11 +236,11 @@ class RegistrationV2ViewModel : ViewModel() { ) Log.d(TAG, "Voice call code request submitted.") - handleSessionStateResult(context, codeRequestResponse) + handleSessionStateResult(context, codeRequestResponse, RegistrationRepository.Mode.PHONE_CALL, errorHandler) } } - private suspend fun requestSmsCodeInternal(context: Context, sessionId: String, e164: String) { + private suspend fun requestSmsCodeInternal(context: Context, sessionId: String, e164: String, errorHandler: (VerificationCodeRequestResult, RegistrationRepository.Mode) -> Unit) { var smsListenerReady = false Log.d(TAG, "Captcha token submitted.") if (store.value.smsListenerTimeout < System.currentTimeMillis()) { @@ -248,16 +256,17 @@ class RegistrationV2ViewModel : ViewModel() { } Log.d(TAG, "Requesting SMS code…") + val transportMode = if (smsListenerReady) RegistrationRepository.Mode.SMS_WITH_LISTENER else RegistrationRepository.Mode.SMS_WITHOUT_LISTENER val codeRequestResponse = RegistrationRepository.requestSmsCode( context = context, sessionId = sessionId, e164 = e164, password = password, - mode = if (smsListenerReady) RegistrationRepository.Mode.SMS_WITH_LISTENER else RegistrationRepository.Mode.SMS_WITHOUT_LISTENER + mode = transportMode ) Log.d(TAG, "SMS code request submitted.") - handleSessionStateResult(context, codeRequestResponse) + handleSessionStateResult(context, codeRequestResponse, transportMode, errorHandler) } private suspend fun getOrCreateValidSession(context: Context): RegistrationSessionMetadataResponse? { @@ -281,6 +290,7 @@ class RegistrationV2ViewModel : ViewModel() { Log.d(TAG, "Registration session validated.") return metadata } + is RegistrationSessionCreationResult.Success -> { val metadata = sessionResult.getMetadata() val newSessionId = metadata.body.id @@ -294,26 +304,32 @@ class RegistrationV2ViewModel : ViewModel() { Log.d(TAG, "Registration session created.") return metadata } + is RegistrationSessionCheckResult.SessionNotFound -> { Log.w(TAG, "This should be impossible to reach at this stage; it should have been handled in RegistrationRepository.", sessionResult.getCause()) handleGenericError(sessionResult.getCause()) } + is RegistrationSessionCheckResult.UnknownError -> { Log.i(TAG, "Unknown error occurred while checking registration session.", sessionResult.getCause()) handleGenericError(sessionResult.getCause()) } + is RegistrationSessionCreationResult.MalformedRequest -> { Log.i(TAG, "Malformed request error occurred while creating registration session.", sessionResult.getCause()) handleGenericError(sessionResult.getCause()) } + is RegistrationSessionCreationResult.RateLimited -> { Log.i(TAG, "Rate limit occurred while creating registration session.", sessionResult.getCause()) handleGenericError(sessionResult.getCause()) } + is RegistrationSessionCreationResult.ServerUnableToParse -> { Log.i(TAG, "Server unable to parse request for creating registration session.", sessionResult.getCause()) handleGenericError(sessionResult.getCause()) } + is RegistrationSessionCreationResult.UnknownError -> { Log.i(TAG, "Unknown error occurred while checking registration session.", sessionResult.getCause()) handleGenericError(sessionResult.getCause()) @@ -322,23 +338,23 @@ class RegistrationV2ViewModel : ViewModel() { return null } - fun submitCaptchaToken(context: Context) { - val e164 = getCurrentE164() ?: throw IllegalStateException("TODO") - val captchaToken = store.value.captchaToken ?: throw IllegalStateException("TODO") + fun submitCaptchaToken(context: Context, errorHandler: (VerificationCodeRequestResult, RegistrationRepository.Mode) -> Unit) { + val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!") + val captchaToken = store.value.captchaToken ?: throw IllegalStateException("Can't submit captcha token if no captcha token is set!") viewModelScope.launch { val session = getOrCreateValidSession(context) ?: return@launch Log.d(TAG, "Submitting captcha token…") val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, session.body.id, captchaToken) Log.d(TAG, "Captcha token submitted.") - handleSessionStateResult(context, captchaSubmissionResult) + handleSessionStateResult(context, captchaSubmissionResult, RegistrationRepository.Mode.NONE, errorHandler) } } /** * @return whether the request was successful and execution should continue */ - private suspend fun handleSessionStateResult(context: Context, sessionResult: VerificationCodeRequestResult): Boolean { + private suspend fun handleSessionStateResult(context: Context, sessionResult: VerificationCodeRequestResult, requestedTransport: RegistrationRepository.Mode, errorHandler: (VerificationCodeRequestResult, RegistrationRepository.Mode) -> Unit): Boolean { when (sessionResult) { is UnknownError -> { handleGenericError(sessionResult.getCause()) @@ -353,30 +369,46 @@ class RegistrationV2ViewModel : ViewModel() { sessionId = sessionResult.sessionId, nextSms = sessionResult.nextSmsTimestamp, nextCall = sessionResult.nextCallTimestamp, - registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED + registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED, + inProgress = false ) } return true } - is AttemptsExhausted -> Log.w(TAG, "TODO") - is ChallengeRequired -> store.update { - // TODO [regv2] handle push challenge required + is ChallengeRequired -> { Log.d(TAG, "[${sessionResult.challenges.joinToString()}] registration challenges received.") - it.copy( - registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_RECEIVED - ) + store.update { + it.copy( + registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_RECEIVED, + inProgress = false + ) + } + return false } - is ImpossibleNumber -> Log.w(TAG, "TODO") - is NonNormalizedNumber -> Log.w(TAG, "TODO") - is RateLimited -> Log.w(TAG, "TODO") - is ExternalServiceFailure -> Log.w(TAG, "TODO") - is InvalidTransportModeFailure -> Log.w(TAG, "TODO") - is MalformedRequest -> Log.w(TAG, "TODO") - is MustRetry -> Log.w(TAG, "TODO") - is TokenNotAccepted -> Log.w(TAG, "TODO") + is AttemptsExhausted -> Log.i(TAG, "Received AttemptsExhausted.", sessionResult.getCause()) + + is ImpossibleNumber -> Log.i(TAG, "Received ImpossibleNumber.", sessionResult.getCause()) + + is NonNormalizedNumber -> Log.i(TAG, "Received NonNormalizedNumber.", sessionResult.getCause()) + + is RateLimited -> Log.i(TAG, "Received RateLimited.", sessionResult.getCause()) + + is ExternalServiceFailure -> Log.i(TAG, "Received ExternalServiceFailure.", sessionResult.getCause()) + + is InvalidTransportModeFailure -> Log.i(TAG, "Received InvalidTransportModeFailure.", sessionResult.getCause()) + + is MalformedRequest -> Log.i(TAG, "Received MalformedRequest.", sessionResult.getCause()) + + is MustRetry -> Log.i(TAG, "Received MustRetry.", sessionResult.getCause()) + + is TokenNotAccepted -> Log.i(TAG, "Received TokenNotAccepted.", sessionResult.getCause()) + + is RegistrationLocked -> Log.i(TAG, "Received RegistrationLocked.", sessionResult.getCause()) } + setInProgress(false) + errorHandler(sessionResult, requestedTransport) return false } @@ -515,6 +547,7 @@ class RegistrationV2ViewModel : ViewModel() { setRegistrationCheckpoint(RegistrationCheckpoint.VERIFICATION_CODE_VALIDATED) val registrationResponse = RegistrationRepository.registerAccount(context, sessionId, registrationData).successOrThrow() + // TODO [regv2]: error handling onSuccessfulRegistration(context, registrationData, registrationResponse, false) } } @@ -525,7 +558,10 @@ class RegistrationV2ViewModel : ViewModel() { refreshFeatureFlags() store.update { - it.copy(registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED) + it.copy( + registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED, + inProgress = false + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt index 5dd792cedc..439afaf155 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt @@ -5,26 +5,38 @@ package org.thoughtcrime.securesms.registration.v2.ui.entercode +import android.content.DialogInterface import android.os.Bundle import android.view.View import androidx.activity.OnBackPressedCallback import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.LoggingFragment import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterCodeV2Binding +import org.thoughtcrime.securesms.registration.fragments.ContactSupportBottomSheetFragment import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView import org.thoughtcrime.securesms.registration.fragments.SignalStrengthPhoneStateListener +import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository +import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult import org.thoughtcrime.securesms.registration.v2.ui.RegistrationCheckpoint import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel +import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener +import org.thoughtcrime.securesms.util.navigation.safeNavigate /** * The final screen of account registration, where the user enters their verification code. */ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter_code_v2) { + companion object { + private const val BOTTOM_SHEET_TAG = "support_bottom_sheet" + } + private val TAG = Log.tag(EnterCodeV2Fragment::class.java) private val sharedViewModel by activityViewModels() @@ -34,6 +46,8 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter private var autopilotCodeEntryActive = false + private val bottomSheet = ContactSupportBottomSheetFragment() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -58,17 +72,21 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter sharedViewModel.verifyCodeWithoutRegistrationLock(requireContext(), it) } + binding.havingTroubleButton.setOnClickListener { + bottomSheet.show(childFragmentManager, BOTTOM_SHEET_TAG) + } + binding.callMeCountDown.apply { setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in) setOnClickListener { - sharedViewModel.requestVerificationCall(requireContext()) + sharedViewModel.requestVerificationCall(requireContext(), ::handleErrorResponse) } } binding.resendSmsCountDown.apply { setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in) setOnClickListener { - sharedViewModel.requestSmsCode(requireContext()) + sharedViewModel.requestSmsCode(requireContext(), ::handleErrorResponse) } } @@ -85,6 +103,60 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter sharedViewModel.uiState.observe(viewLifecycleOwner) { binding.resendSmsCountDown.startCountDownTo(it.nextSms) binding.callMeCountDown.startCountDownTo(it.nextCall) + if (it.inProgress) { + binding.keyboard.displayProgress() + } else { + binding.keyboard.displayKeyboard() + } + } + } + + private fun handleErrorResponse(requestResult: VerificationCodeRequestResult, mode: RegistrationRepository.Mode) { + when (requestResult) { + is VerificationCodeRequestResult.Success -> binding.keyboard.displaySuccess() + is VerificationCodeRequestResult.RateLimited -> { + binding.keyboard.displayFailure().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + presentRemoteErrorDialog(getString(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)) { _, _ -> + binding.code.clear() + } + } + } + ) + } + + is VerificationCodeRequestResult.RegistrationLocked -> { + binding.keyboard.displayLocked().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + findNavController().safeNavigate(EnterCodeV2FragmentDirections.actionRequireKbsLockPin(requestResult.timeRemaining)) + } + } + ) + } + + else -> { + binding.keyboard.displayFailure().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + Log.w(TAG, "Encountered unexpected error!", requestResult.getCause()) + presentRemoteErrorDialog(getString(R.string.RegistrationActivity_error_connecting_to_service)) + } + } + ) + } + } + } + + private fun presentRemoteErrorDialog(message: String, title: String? = null, positiveButtonListener: DialogInterface.OnClickListener? = null) { + MaterialAlertDialogBuilder(requireContext()).apply { + title?.let { + setTitle(it) + } + setMessage(message) + setPositiveButton(android.R.string.ok, positiveButtonListener) + show() } } @@ -93,13 +165,15 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter NavHostFragment.findNavController(this).popBackStack() } - private class PhoneStateCallback : SignalStrengthPhoneStateListener.Callback { + private inner class PhoneStateCallback : SignalStrengthPhoneStateListener.Callback { override fun onNoCellSignalPresent() { - // TODO [regv2]: animate in bottom sheet + bottomSheet.show(childFragmentManager, BOTTOM_SHEET_TAG) } override fun onCellSignalPresent() { - // TODO [regv2]: animate in bottom sheet + if (bottomSheet.isResumed) { + bottomSheet.dismiss() + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2Fragment.kt index a52168657b..79068cd363 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2Fragment.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.registration.v2.ui.phonenumber import android.content.Context +import android.content.DialogInterface import android.os.Bundle import android.text.SpannableStringBuilder import android.text.TextWatcher @@ -30,6 +31,7 @@ import com.google.android.gms.common.GoogleApiAvailability import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.google.android.material.textfield.TextInputEditText +import com.google.i18n.phonenumbers.NumberParseException import com.google.i18n.phonenumbers.PhoneNumberUtil import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber import org.signal.core.util.logging.Log @@ -40,16 +42,22 @@ import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterPhoneNumb import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView import org.thoughtcrime.securesms.registration.util.CountryPrefix +import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository +import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult import org.thoughtcrime.securesms.registration.v2.ui.RegistrationCheckpoint import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2State import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel import org.thoughtcrime.securesms.registration.v2.ui.toE164 +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.Dialogs import org.thoughtcrime.securesms.util.PlayServicesUtil import org.thoughtcrime.securesms.util.SpanUtil +import org.thoughtcrime.securesms.util.SupportEmailUtil import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.livedata.LiveDataObserverCallback import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.visible +import kotlin.time.Duration.Companion.milliseconds /** * Screen in registration where the user enters their phone number. @@ -61,6 +69,8 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio private val fragmentViewModel by viewModels() private val binding: FragmentRegistrationEnterPhoneNumberV2Binding by ViewBinderDelegate(FragmentRegistrationEnterPhoneNumberV2Binding::bind) + private val skipToNextScreen: DialogInterface.OnClickListener = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> moveToVerificationEntryScreen() } + private lateinit var spinnerAdapter: ArrayAdapter private lateinit var phoneNumberInputLayout: TextInputEditText private lateinit var spinnerView: MaterialAutoCompleteTextView @@ -106,9 +116,7 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio } else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) { moveToVerificationEntryScreen() } else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.CHALLENGE_COMPLETED) { - sharedViewModel.submitCaptchaToken(requireContext()) - } else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.CHALLENGE_RECEIVED) { - findNavController().safeNavigate(EnterPhoneNumberV2FragmentDirections.actionRequestCaptcha()) + sharedViewModel.submitCaptchaToken(requireContext(), ::handleErrorResponse) } } @@ -133,7 +141,7 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio } if (fragmentState.error != EnterPhoneNumberV2State.Error.NONE) { - presentError(fragmentState) + presentLocalError(fragmentState) } } @@ -145,10 +153,10 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio fragmentViewModel.phoneNumber()?.let { phoneNumberInputLayout.setText(it.nationalNumber.toString()) } - } else if (spinnerView.editableText.isBlank()) { - spinnerView.setText(fragmentViewModel.countryPrefix().toString()) } + spinnerView.setText(fragmentViewModel.countryPrefix().toString()) + ViewUtil.focusAndShowKeyboard(phoneNumberInputLayout) } @@ -195,15 +203,17 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio } private fun presentRegisterButton(sharedState: RegistrationV2State) { - binding.registerButton.isEnabled = sharedState.phoneNumber != null && PhoneNumberUtil.getInstance().isValidNumber(sharedState.phoneNumber) && !sharedState.inProgress - // TODO [regv2]: always enable the button but display error dialogs if the entered phone number is invalid + binding.registerButton.isEnabled = sharedState.phoneNumber != null && PhoneNumberUtil.getInstance().isValidNumber(sharedState.phoneNumber) + if (sharedState.inProgress) { + binding.registerButton.setSpinning() + } else { + binding.registerButton.cancelSpinning() + } } - private fun presentError(state: EnterPhoneNumberV2State) { + private fun presentLocalError(state: EnterPhoneNumberV2State) { when (state.error) { - EnterPhoneNumberV2State.Error.NONE -> { - Unit - } + EnterPhoneNumberV2State.Error.NONE -> Unit EnterPhoneNumberV2State.Error.INVALID_PHONE_NUMBER -> { MaterialAlertDialogBuilder(requireContext()).apply { @@ -222,7 +232,7 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio } EnterPhoneNumberV2State.Error.PLAY_SERVICES_MISSING -> { - Log.w(TAG, "Not yet implemented!", NotImplementedError()) // TODO [regv2] + handlePromptForNoPlayServices() } EnterPhoneNumberV2State.Error.PLAY_SERVICES_NEEDS_UPDATE -> { @@ -253,6 +263,85 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio } } + private fun handleErrorResponse(result: VerificationCodeRequestResult, mode: RegistrationRepository.Mode) { + when (result) { + is VerificationCodeRequestResult.Success -> Unit + is VerificationCodeRequestResult.AttemptsExhausted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service)) + is VerificationCodeRequestResult.ChallengeRequired -> findNavController().safeNavigate(EnterPhoneNumberV2FragmentDirections.actionRequestCaptcha()) + is VerificationCodeRequestResult.ExternalServiceFailure -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen) + is VerificationCodeRequestResult.ImpossibleNumber -> { + MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber()?.toE164())) + setPositiveButton(android.R.string.ok, null) + show() + } + } + is VerificationCodeRequestResult.InvalidTransportModeFailure -> { + MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(R.string.RegistrationActivity_we_couldnt_send_you_a_verification_code) + setPositiveButton(R.string.RegistrationActivity_voice_call) { _, _ -> + sharedViewModel.requestVerificationCall(requireContext(), ::handleErrorResponse) + } + setNegativeButton(R.string.RegistrationActivity_cancel, null) + show() + } + } + is VerificationCodeRequestResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen) + is VerificationCodeRequestResult.MustRetry -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen) + is VerificationCodeRequestResult.NonNormalizedNumber -> handleNonNormalizedNumberError(result.originalNumber, result.normalizedNumber, mode) + is VerificationCodeRequestResult.RateLimited -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, result.timeRemaining.milliseconds.toString())) + is VerificationCodeRequestResult.TokenNotAccepted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_we_need_to_verify_that_youre_human)) + else -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service)) + } + } + + private fun presentRemoteErrorDialog(message: String, positiveButtonListener: DialogInterface.OnClickListener? = null) { + MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(message) + setPositiveButton(android.R.string.ok, positiveButtonListener) + show() + } + } + + private fun handleNonNormalizedNumberError(originalNumber: String, normalizedNumber: String, mode: RegistrationRepository.Mode) { + try { + val phoneNumber = PhoneNumberUtil.getInstance().parse(normalizedNumber, null) + + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(R.string.RegistrationActivity_non_standard_number_format) + setMessage(getString(R.string.RegistrationActivity_the_number_you_entered_appears_to_be_a_non_standard, originalNumber, normalizedNumber)) + setNegativeButton(android.R.string.no) { d: DialogInterface, i: Int -> d.dismiss() } + setNeutralButton(R.string.RegistrationActivity_contact_signal_support) { dialogInterface, _ -> + val subject = getString(R.string.RegistrationActivity_signal_android_phone_number_format) + val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.RegistrationActivity_signal_android_phone_number_format, null, null) + + CommunicationActions.openEmail(requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), subject, body) + dialogInterface.dismiss() + } + setPositiveButton(R.string.yes) { dialogInterface, _ -> + spinnerView.setText(phoneNumber.countryCode.toString()) + phoneNumberInputLayout.setText(phoneNumber.nationalNumber.toString()) + when (mode) { + RegistrationRepository.Mode.SMS_WITH_LISTENER, + RegistrationRepository.Mode.SMS_WITHOUT_LISTENER -> sharedViewModel.requestSmsCode(requireContext(), ::handleErrorResponse) + RegistrationRepository.Mode.PHONE_CALL -> sharedViewModel.requestVerificationCall(requireContext(), ::handleErrorResponse) + RegistrationRepository.Mode.NONE -> Log.w(TAG, "Somehow got a non normalized number exception even though we didn't request a code.") + } + dialogInterface.dismiss() + } + show() + } + } catch (e: NumberParseException) { + Log.w(TAG, "Failed to parse number!", e) + + Dialogs.showAlertDialog( + requireContext(), + getString(R.string.RegistrationActivity_invalid_number), + getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber()?.toE164()) + ) + } + } + private fun onRegistrationButtonClicked() { ViewUtil.hideKeyboard(requireContext(), phoneNumberInputLayout) sharedViewModel.setInProgress(true) @@ -352,7 +441,7 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio if (missingFcmConsentRequired) { handlePromptForNoPlayServices() } else { - sharedViewModel.onUserConfirmedPhoneNumber(requireContext()) + sharedViewModel.onUserConfirmedPhoneNumber(requireContext(), ::handleErrorResponse) } } setNegativeButton(R.string.RegistrationActivity_edit_number) { _, _ -> onConfirmNumberDialogCanceled() } @@ -367,7 +456,7 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio setMessage(R.string.RegistrationActivity_this_device_is_missing_google_play_services) setPositiveButton(R.string.RegistrationActivity_i_understand) { _, _ -> Log.d(TAG, "User confirmed number.") - sharedViewModel.onUserConfirmedPhoneNumber(requireContext()) + sharedViewModel.onUserConfirmedPhoneNumber(requireContext(), ::handleErrorResponse) } setNegativeButton(android.R.string.cancel, null) setOnCancelListener { fragmentViewModel.clearError() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2State.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2State.kt index d311bcaa6e..58ec8d91af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2State.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2State.kt @@ -14,7 +14,7 @@ data class EnterPhoneNumberV2State(val countryPrefixIndex: Int, val phoneNumber: companion object { @JvmStatic - val INIT = EnterPhoneNumberV2State(0, "") + val INIT = EnterPhoneNumberV2State(1, "") } enum class Error { diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt index 0ecff8ae87..36bc2548f1 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt @@ -23,6 +23,8 @@ class RegistrationApi( /** * Request that the service initialize a new registration session. + * + * `POST /v1/verification/session` */ fun createRegistrationSession(fcmToken: String?, mcc: String?, mnc: String?): NetworkResult { return NetworkResult.fromFetch { @@ -32,6 +34,8 @@ class RegistrationApi( /** * Retrieve current status of a registration session. + * + * `GET /v1/verification/session/{session-id}` */ fun getRegistrationSessionStatus(sessionId: String): NetworkResult { return NetworkResult.fromFetch { @@ -41,6 +45,8 @@ class RegistrationApi( /** * Submit an FCM token to the service as proof that this is an honest user attempting to register. + * + * `PATCH /v1/verification/session/{session-id}` */ fun submitPushChallengeToken(sessionId: String?, pushChallengeToken: String?): NetworkResult { return NetworkResult.fromFetch { @@ -52,6 +58,8 @@ class RegistrationApi( * Request an SMS verification code. On success, the server will send * an SMS verification code to this Signal user. * + * `POST /v1/verification/session/{session-id}/code` + * * @param androidSmsRetrieverSupported whether the system framework will automatically parse the incoming verification message. */ fun requestSmsVerificationCode(sessionId: String?, locale: Locale?, androidSmsRetrieverSupported: Boolean, transport: PushServiceSocket.VerificationCodeTransport): NetworkResult { @@ -62,6 +70,8 @@ class RegistrationApi( /** * Submit a verification code sent by the service via one of the supported channels (SMS, phone call) to prove the registrant's control of the phone number. + * + * `PUT /v1/verification/session/{session-id}/code` */ fun verifyAccount(sessionId: String, verificationCode: String): NetworkResult { return NetworkResult.fromFetch { @@ -71,6 +81,8 @@ class RegistrationApi( /** * Submits the solved captcha token to the service. + * + * `PATCH /v1/verification/session/{session-id}` */ fun submitCaptchaToken(sessionId: String, captchaToken: String): NetworkResult { return NetworkResult.fromFetch { @@ -80,6 +92,8 @@ class RegistrationApi( /** * Submit the cryptographic assets required for an account to use the service. + * + * `POST /v1/registration` */ fun registerAccount(sessionId: String?, recoveryPassword: String?, attributes: AccountAttributes?, aciPreKeys: PreKeyCollection?, pniPreKeys: PreKeyCollection?, fcmToken: String?, skipDeviceTransfer: Boolean): NetworkResult { return NetworkResult.fromFetch { @@ -87,6 +101,11 @@ class RegistrationApi( } } + /** + * Retrieves an SVR auth credential that corresponds with the supplied username and password. + * + * `POST /v2/backup/auth/check` + */ fun getSvrAuthCredential(e164: String, usernamePasswords: List): NetworkResult { return NetworkResult.fromFetch { pushServiceSocket.checkBackupAuthCredentials(e164, usernamePasswords)