From 1e35403c87dcf6190c8a668586d5e89bf1737a14 Mon Sep 17 00:00:00 2001 From: Nicholas Tinsley Date: Fri, 31 May 2024 16:57:38 -0400 Subject: [PATCH] Change Number V2. --- app/src/main/AndroidManifest.xml | 6 + .../securesms/PassphraseRequiredActivity.java | 9 +- .../settings/app/AppSettingsActivity.kt | 7 +- .../ChangeNumberEnterPhoneNumberFragment.kt | 2 +- .../v2/ChangeNumberAccountLockedV2Fragment.kt | 74 ++ .../v2/ChangeNumberCaptchaV2Fragment.kt | 31 + .../v2/ChangeNumberConfirmV2Fragment.kt | 61 ++ .../v2/ChangeNumberEnterCodeV2Fragment.kt | 303 ++++++ .../ChangeNumberEnterPhoneNumberV2Fragment.kt | 167 +++ .../v2/ChangeNumberLockV2Activity.kt | 99 ++ .../v2/ChangeNumberPinDiffersV2Fragment.kt | 55 + .../ChangeNumberRegistrationLockV2Fragment.kt | 328 ++++++ .../app/changenumber/v2/ChangeNumberResult.kt | 72 ++ .../app/changenumber/v2/ChangeNumberState.kt | 42 + .../changenumber/v2/ChangeNumberV2Fragment.kt | 38 + .../v2/ChangeNumberV2Repository.kt | 384 +++++++ .../v2/ChangeNumberV2ViewModel.kt | 537 ++++++++++ .../v2/ChangeNumberVerifyV2Fragment.kt | 144 +++ .../fragment_change_number_enter_code.xml | 5 +- ...nt_change_number_enter_phone_number_v2.xml | 166 +++ .../fragment_change_phone_number_v2.xml | 84 ++ .../app_settings_change_number_v2.xml | 184 ++++ .../app_settings_with_change_number_v2.xml | 951 ++++++++++++++++++ 23 files changed, 3741 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberAccountLockedV2Fragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberCaptchaV2Fragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberConfirmV2Fragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberEnterCodeV2Fragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberEnterPhoneNumberV2Fragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberLockV2Activity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberPinDiffersV2Fragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberRegistrationLockV2Fragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2Fragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2Repository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2ViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberVerifyV2Fragment.kt create mode 100644 app/src/main/res/layout/fragment_change_number_enter_phone_number_v2.xml create mode 100644 app/src/main/res/layout/fragment_change_phone_number_v2.xml create mode 100644 app/src/main/res/navigation/app_settings_change_number_v2.xml create mode 100644 app/src/main/res/navigation/app_settings_with_change_number_v2.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 745ce113b3..06ec3e43c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -783,6 +783,12 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:exported="false"/> + + + parentFragmentManager.setFragmentResultListener(OLD_NUMBER_COUNTRY_SELECT, this) { _: String, bundle: Bundle -> viewModel.setOldCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberAccountLockedV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberAccountLockedV2Fragment.kt new file mode 100644 index 0000000000..4c84b3a30a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberAccountLockedV2Fragment.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView +import java.util.concurrent.TimeUnit + +/** + * Screen visible to the user when they are registration locked and have no SVR data. + */ +class ChangeNumberAccountLockedV2Fragment : LoggingFragment(R.layout.fragment_change_number_account_locked) { + + companion object { + private val TAG = Log.tag(ChangeNumberAccountLockedV2Fragment::class.java) + } + + private val viewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setDebugLogSubmitMultiTapView(view.findViewById(R.id.account_locked_title)) + + val description = view.findViewById(R.id.account_locked_description) + + viewModel.liveLockedTimeRemaining.observe(viewLifecycleOwner) { t: Long -> + description.text = getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t)) + } + + view.findViewById(R.id.account_locked_next).setOnClickListener { onNext() } + view.findViewById(R.id.account_locked_learn_more).setOnClickListener { learnMore() } + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onNext() + } + } + ) + } + + private fun learnMore() { + val intent = Intent(Intent.ACTION_VIEW) + intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url))) + startActivity(intent) + } + + private fun durationToDays(duration: Long): Long { + return if (duration != 0L) getLockoutDays(duration).toLong() else 7 + } + + private fun getLockoutDays(timeRemainingMs: Long): Int { + return TimeUnit.MILLISECONDS.toDays(timeRemainingMs).toInt() + 1 + } + + fun onNext() { + findNavController().navigateUp() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberCaptchaV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberCaptchaV2Fragment.kt new file mode 100644 index 0000000000..f73b412cf6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberCaptchaV2Fragment.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import org.thoughtcrime.securesms.registration.v2.data.network.Challenge +import org.thoughtcrime.securesms.registration.v2.ui.captcha.CaptchaV2Fragment + +/** + * Screen visible to the user when they are to solve a captcha. @see [CaptchaV2Fragment] + */ +class ChangeNumberCaptchaV2Fragment : CaptchaV2Fragment() { + private val viewModel by activityViewModels() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.addPresentedChallenge(Challenge.CAPTCHA) + } + + override fun handleCaptchaToken(token: String) { + viewModel.setCaptchaResponse(token) + } + + override fun handleUserExit() { + viewModel.removePresentedChallenge(Challenge.CAPTCHA) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberConfirmV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberConfirmV2Fragment.kt new file mode 100644 index 0000000000..eb2c1a8ecd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberConfirmV2Fragment.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Screen visible to the user for them to confirm their new phone number was entered correctly. + */ +class ChangeNumberConfirmV2Fragment : LoggingFragment(R.layout.fragment_change_number_confirm) { + + companion object { + private val TAG = Log.tag(ChangeNumberConfirmV2Fragment::class.java) + } + + private val viewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val toolbar: Toolbar = view.findViewById(R.id.toolbar) + toolbar.setTitle(R.string.ChangeNumberEnterPhoneNumberFragment__change_number) + toolbar.setNavigationOnClickListener { findNavController().navigateUp() } + + val confirmMessage: TextView = view.findViewById(R.id.change_number_confirm_new_number_message) + confirmMessage.text = getString(R.string.ChangeNumberConfirmFragment__you_are_about_to_change_your_phone_number_from_s_to_s, viewModel.oldNumberState.fullFormattedNumber, viewModel.number.fullFormattedNumber) + + val newNumber: TextView = view.findViewById(R.id.change_number_confirm_new_number) + newNumber.text = viewModel.number.fullFormattedNumber + + val editNumber: View = view.findViewById(R.id.change_number_confirm_edit_number) + editNumber.setOnClickListener { findNavController().navigateUp() } + + val changeNumber: View = view.findViewById(R.id.change_number_confirm_change_number) + changeNumber.setOnClickListener { + viewModel.registerSmsListenerWithCompletionListener(requireContext()) { + navigateToVerify(it) + } + } + } + + private fun navigateToVerify(smsListenerEnabled: Boolean = false) { + findNavController().safeNavigate( + R.id.action_changePhoneNumberConfirmFragment_to_changePhoneNumberVerifyFragment, + ChangeNumberVerifyV2FragmentArgs.Builder() + .setSmsListenerEnabled(smsListenerEnabled) + .build() + .toBundle() + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberEnterCodeV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberEnterCodeV2Fragment.kt new file mode 100644 index 0000000000..fdc59c524e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberEnterCodeV2Fragment.kt @@ -0,0 +1,303 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +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.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess +import org.thoughtcrime.securesms.databinding.FragmentChangeNumberEnterCodeBinding +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registration.ReceivedSmsEvent +import org.thoughtcrime.securesms.registration.fragments.ContactSupportBottomSheetFragment +import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate +import org.thoughtcrime.securesms.registration.fragments.SignalStrengthPhoneStateListener +import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository +import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationResult +import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener +import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.thoughtcrime.securesms.util.visible + +/** + * Screen used to enter the registration code provided by the service. + */ +class ChangeNumberEnterCodeV2Fragment : LoggingFragment(R.layout.fragment_change_number_enter_code) { + + companion object { + private val TAG: String = Log.tag(ChangeNumberEnterCodeV2Fragment::class.java) + } + + private val viewModel by activityViewModels() + private val binding: FragmentChangeNumberEnterCodeBinding by ViewBinderDelegate(FragmentChangeNumberEnterCodeBinding::bind) + private lateinit var phoneStateListener: SignalStrengthPhoneStateListener + + private var autopilotCodeEntryActive = false + + private val bottomSheet = ContactSupportBottomSheetFragment() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val toolbar: Toolbar = view.findViewById(R.id.toolbar) + toolbar.title = viewModel.number.fullFormattedNumber + toolbar.setNavigationOnClickListener { + Log.d(TAG, "Toolbar navigation clicked.") + navigateUp() + } + + binding.codeEntryLayout.verifyHeader.setOnClickListener(null) + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + Log.d(TAG, "onBackPressed") + navigateUp() + } + } + ) + + RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.codeEntryLayout.verifyHeader) + + phoneStateListener = SignalStrengthPhoneStateListener(this, PhoneStateCallback()) + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + navigateUp() + } + } + ) + + binding.codeEntryLayout.wrongNumber.setOnClickListener { + navigateUp() + } + + binding.codeEntryLayout.code.setOnCompleteListener { + viewModel.verifyCodeWithoutRegistrationLock(requireContext(), it, ::handleSessionErrorResponse, ::handleChangeNumberErrorResponse) + } + + binding.codeEntryLayout.havingTroubleButton.setOnClickListener { + bottomSheet.show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + + binding.codeEntryLayout.callMeCountDown.apply { + setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in) + setOnClickListener { + viewModel.initiateChangeNumberSession(requireContext(), RegistrationRepository.Mode.PHONE_CALL) + } + } + + binding.codeEntryLayout.resendSmsCountDown.apply { + setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in) + setOnClickListener { + viewModel.initiateChangeNumberSession(requireContext(), RegistrationRepository.Mode.SMS_WITHOUT_LISTENER) + } + } + + binding.codeEntryLayout.keyboard.setOnKeyPressListener { key -> + if (!autopilotCodeEntryActive) { + if (key >= 0) { + binding.codeEntryLayout.code.append(key) + } else { + binding.codeEntryLayout.code.delete() + } + } + } + + viewModel.incorrectCodeAttempts.observe(viewLifecycleOwner) { attempts: Int -> + if (attempts >= 3) { + binding.codeEntryLayout.havingTroubleButton.visible = true + } + } + + viewModel.uiState.observe(viewLifecycleOwner, ::onStateUpdate) + } + + private fun onStateUpdate(state: ChangeNumberState) { + binding.codeEntryLayout.resendSmsCountDown.startCountDownTo(state.nextSmsTimestamp) + binding.codeEntryLayout.callMeCountDown.startCountDownTo(state.nextCallTimestamp) + when (val outcome = state.changeNumberOutcome) { + is ChangeNumberOutcome.RecoveryPasswordWorked, + is ChangeNumberOutcome.VerificationCodeWorked -> changeNumberSuccess() + + is ChangeNumberOutcome.ChangeNumberRequestOutcome -> if (!state.inProgress && !outcome.result.isSuccess()) { + presentGenericError(outcome.result) + } + + null -> Unit + } + if (state.inProgress) { + binding.codeEntryLayout.keyboard.displayProgress() + } else { + binding.codeEntryLayout.keyboard.displayKeyboard() + } + } + + private fun navigateUp() { + if (SignalStore.misc().isChangeNumberLocked) { + Log.d(TAG, "Change number locked, navigateUp") + startActivity(ChangeNumberLockV2Activity.createIntent(requireContext())) + } else { + Log.d(TAG, "navigateUp") + findNavController().navigateUp() + } + } + + private fun handleSessionErrorResponse(result: VerificationCodeRequestResult) { + when (result) { + is VerificationCodeRequestResult.Success -> binding.codeEntryLayout.keyboard.displaySuccess() + is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog() + is VerificationCodeRequestResult.AttemptsExhausted -> presentAccountLocked() + is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining) + else -> presentGenericError(result) + } + } + + private fun handleChangeNumberErrorResponse(result: ChangeNumberResult) { + when (result) { + is ChangeNumberResult.Success -> binding.codeEntryLayout.keyboard.displaySuccess() + is ChangeNumberResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining) + is ChangeNumberResult.AuthorizationFailed -> presentIncorrectCodeDialog() + is ChangeNumberResult.AttemptsExhausted -> presentAccountLocked() + is ChangeNumberResult.RateLimited -> presentRateLimitedDialog() + + else -> presentGenericError(result) + } + } + + private fun presentAccountLocked() { + binding.codeEntryLayout.keyboard.displayLocked().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + findNavController().safeNavigate(ChangeNumberEnterCodeV2FragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked()) + } + } + ) + } + + private fun presentRegistrationLocked(timeRemaining: Long) { + binding.codeEntryLayout.keyboard.displayLocked().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + Log.i(TAG, "Account is registration locked, cannot register.") + findNavController().safeNavigate(ChangeNumberEnterCodeV2FragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining)) + } + } + ) + } + + private fun presentRateLimitedDialog() { + binding.codeEntryLayout.keyboard.displayFailure().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(R.string.RegistrationActivity_too_many_attempts) + setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later) + setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + binding.codeEntryLayout.callMeCountDown.visibility = View.VISIBLE + binding.codeEntryLayout.resendSmsCountDown.visibility = View.VISIBLE + binding.codeEntryLayout.wrongNumber.visibility = View.VISIBLE + binding.codeEntryLayout.code.clear() + binding.codeEntryLayout.keyboard.displayKeyboard() + } + show() + } + } + } + ) + } + + private fun presentIncorrectCodeDialog() { + viewModel.incrementIncorrectCodeAttempts() + + Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_code, Toast.LENGTH_LONG).show() + binding.codeEntryLayout.keyboard.displayFailure().addListener(object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + binding.codeEntryLayout.callMeCountDown.setVisibility(View.VISIBLE) + binding.codeEntryLayout.resendSmsCountDown.setVisibility(View.VISIBLE) + binding.codeEntryLayout.wrongNumber.setVisibility(View.VISIBLE) + binding.codeEntryLayout.code.clear() + binding.codeEntryLayout.keyboard.displayKeyboard() + } + }) + } + + private fun presentGenericError(requestResult: RegistrationResult) { + binding.codeEntryLayout.keyboard.displayFailure().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + Log.w(TAG, "Encountered unexpected error!", requestResult.getCause()) + MaterialAlertDialogBuilder(requireContext()).apply { + null?.let { + setTitle(it) + } + setMessage(getString(R.string.RegistrationActivity_error_connecting_to_service)) + setPositiveButton(android.R.string.ok) { _, _ -> + navigateUp() + viewModel.resetLocalSessionState() + } + show() + } + } + } + ) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onVerificationCodeReceived(event: ReceivedSmsEvent) { + binding.codeEntryLayout.code.clear() + + if (event.code.isBlank() || event.code.length != ReceivedSmsEvent.CODE_LENGTH) { + Log.i(TAG, "Received invalid code of length ${event.code.length}. Ignoring.") + return + } + + val finalIndex = ReceivedSmsEvent.CODE_LENGTH - 1 + autopilotCodeEntryActive = true + try { + event.code + .map { it.digitToInt() } + .forEachIndexed { i, digit -> + binding.codeEntryLayout.code.postDelayed({ + binding.codeEntryLayout.code.append(digit) + if (i == finalIndex) { + autopilotCodeEntryActive = false + } + }, i * 200L) + } + } catch (notADigit: IllegalArgumentException) { + Log.w(TAG, "Failed to convert code into digits.", notADigit) + autopilotCodeEntryActive = false + } + } + + private inner class PhoneStateCallback : SignalStrengthPhoneStateListener.Callback { + override fun onNoCellSignalPresent() { + bottomSheet.show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + + override fun onCellSignalPresent() { + if (bottomSheet.isResumed) { + bottomSheet.dismiss() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberEnterPhoneNumberV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberEnterPhoneNumberV2Fragment.kt new file mode 100644 index 0000000000..804bc162db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberEnterPhoneNumberV2Fragment.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.os.Bundle +import android.text.TextUtils +import android.view.View +import android.widget.Toast +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.activityViewModels +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.FragmentChangeNumberEnterPhoneNumberV2Binding +import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragment +import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragmentArgs +import org.thoughtcrime.securesms.registration.util.ChangeNumberInputController +import org.thoughtcrime.securesms.util.Dialogs +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Screen for the user to enter their old and new phone numbers. + */ +class ChangeNumberEnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_change_number_enter_phone_number_v2) { + + companion object { + private val TAG: String = Log.tag(ChangeNumberEnterPhoneNumberV2Fragment::class.java) + + private const val OLD_NUMBER_COUNTRY_SELECT = "old_number_country" + private const val NEW_NUMBER_COUNTRY_SELECT = "new_number_country" + } + + private val binding: FragmentChangeNumberEnterPhoneNumberV2Binding by ViewBinderDelegate(FragmentChangeNumberEnterPhoneNumberV2Binding::bind) + private val viewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val toolbar: Toolbar = view.findViewById(R.id.toolbar) + toolbar.setTitle(R.string.ChangeNumberEnterPhoneNumberFragment__change_number) + toolbar.setNavigationOnClickListener { findNavController().navigateUp() } + + binding.changeNumberEnterPhoneNumberContinue.setOnClickListener { + onContinue() + } + + val oldController = ChangeNumberInputController( + requireContext(), + binding.changeNumberEnterPhoneNumberOldNumberCountryCode, + binding.changeNumberEnterPhoneNumberOldNumberNumber, + binding.changeNumberEnterPhoneNumberOldNumberSpinner, + false, + object : ChangeNumberInputController.Callbacks { + override fun onNumberFocused() { + binding.changeNumberEnterPhoneNumberScroll.postDelayed({ binding.changeNumberEnterPhoneNumberScroll.smoothScrollTo(0, binding.changeNumberEnterPhoneNumberOldNumberNumber.bottom) }, 250) + } + + override fun onNumberInputNext(view: View) { + binding.changeNumberEnterPhoneNumberNewNumberCountryCode.requestFocus() + } + + override fun onNumberInputDone(view: View) = Unit + + override fun onPickCountry(view: View) { + val arguments: CountryPickerFragmentArgs = CountryPickerFragmentArgs.Builder().setResultKey(OLD_NUMBER_COUNTRY_SELECT).build() + + findNavController().safeNavigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle()) + } + + override fun setNationalNumber(number: String) { + viewModel.setOldNationalNumber(number) + } + + override fun setCountry(countryCode: Int) { + viewModel.setOldCountry(countryCode) + } + } + ) + + val newController = ChangeNumberInputController( + requireContext(), + binding.changeNumberEnterPhoneNumberNewNumberCountryCode, + binding.changeNumberEnterPhoneNumberNewNumberNumber, + binding.changeNumberEnterPhoneNumberNewNumberSpinner, + true, + object : ChangeNumberInputController.Callbacks { + override fun onNumberFocused() { + binding.changeNumberEnterPhoneNumberScroll.postDelayed({ binding.changeNumberEnterPhoneNumberScroll.smoothScrollTo(0, binding.changeNumberEnterPhoneNumberNewNumberNumber.bottom) }, 250) + } + + override fun onNumberInputNext(view: View) = Unit + + override fun onNumberInputDone(view: View) { + onContinue() + } + + override fun onPickCountry(view: View) { + val arguments: CountryPickerFragmentArgs = CountryPickerFragmentArgs.Builder().setResultKey(NEW_NUMBER_COUNTRY_SELECT).build() + + findNavController().safeNavigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle()) + } + + override fun setNationalNumber(number: String) { + viewModel.setNewNationalNumber(number) + } + + override fun setCountry(countryCode: Int) { + viewModel.setNewCountry(countryCode) + } + } + ) + + parentFragmentManager.setFragmentResultListener(OLD_NUMBER_COUNTRY_SELECT, this) { _: String, bundle: Bundle -> + viewModel.setOldCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY)) + } + + parentFragmentManager.setFragmentResultListener(NEW_NUMBER_COUNTRY_SELECT, this) { _: String, bundle: Bundle -> + viewModel.setNewCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY)) + } + + viewModel.liveOldNumberState.observe(viewLifecycleOwner, oldController::updateNumber) + viewModel.liveNewNumberState.observe(viewLifecycleOwner, newController::updateNumber) + } + + private fun onContinue() { + if (TextUtils.isEmpty(binding.changeNumberEnterPhoneNumberOldNumberCountryCode.text)) { + Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_old_number_country_code), Toast.LENGTH_LONG).show() + return + } + + if (TextUtils.isEmpty(binding.changeNumberEnterPhoneNumberOldNumberNumber.text)) { + Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_old_phone_number), Toast.LENGTH_LONG).show() + return + } + + if (TextUtils.isEmpty(binding.changeNumberEnterPhoneNumberNewNumberCountryCode.text)) { + Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_new_number_country_code), Toast.LENGTH_LONG).show() + return + } + + if (TextUtils.isEmpty(binding.changeNumberEnterPhoneNumberNewNumberNumber.text)) { + Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_new_phone_number), Toast.LENGTH_LONG).show() + return + } + + when (viewModel.canContinue()) { + ChangeNumberV2ViewModel.ContinueStatus.CAN_CONTINUE -> findNavController().safeNavigate(R.id.action_enterPhoneNumberChangeFragment_to_changePhoneNumberConfirmFragment) + ChangeNumberV2ViewModel.ContinueStatus.INVALID_NUMBER -> { + Dialogs.showAlertDialog( + context, + getString(R.string.RegistrationActivity_invalid_number), + String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), viewModel.number.e164Number) + ) + } + ChangeNumberV2ViewModel.ContinueStatus.OLD_NUMBER_DOESNT_MATCH -> { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.ChangeNumberEnterPhoneNumberFragment__the_phone_number_you_entered_doesnt_match_your_accounts) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberLockV2Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberLockV2Activity.kt new file mode 100644 index 0000000000..b27b600358 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberLockV2Activity.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.PassphraseRequiredActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme +import org.thoughtcrime.securesms.util.DynamicTheme + +/** + * A captive activity that can determine if an interrupted/erred change number request + * caused a disparity between the server and our locally stored number. + */ +class ChangeNumberLockV2Activity : PassphraseRequiredActivity() { + + companion object { + private val TAG: String = Log.tag(ChangeNumberLockV2Activity::class.java) + + @JvmStatic + fun createIntent(context: Context): Intent { + return Intent(context, ChangeNumberLockV2Activity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + } + } + + private val viewModel: ChangeNumberV2ViewModel by viewModels() + private val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme() + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + dynamicTheme.onCreate(this) + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + Log.d(TAG, "Back button press swallowed.") + } + } + ) + + setContentView(R.layout.activity_change_number_lock) + + checkWhoAmI() + } + + override fun onResume() { + super.onResume() + dynamicTheme.onResume(this) + } + + private fun checkWhoAmI() { + viewModel.checkWhoAmI(::onChangeStatusConfirmed, ::onFailedToGetChangeNumberStatus) + } + + private fun onChangeStatusConfirmed() { + SignalStore.misc().clearPendingChangeNumberMetadata() + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.ChangeNumberLockActivity__change_status_confirmed) + .setMessage(getString(R.string.ChangeNumberLockActivity__your_number_has_been_confirmed_as_s, PhoneNumberFormatter.prettyPrint(SignalStore.account().e164!!))) + .setPositiveButton(android.R.string.ok) { _, _ -> + startActivity(MainActivity.clearTop(this)) + finish() + } + .setCancelable(false) + .show() + } + + private fun onFailedToGetChangeNumberStatus(error: Throwable) { + Log.w(TAG, "Unable to determine status of change number", error) + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.ChangeNumberLockActivity__change_status_unconfirmed) + .setMessage(getString(R.string.ChangeNumberLockActivity__we_could_not_determine_the_status_of_your_change_number_request, error.javaClass.simpleName)) + .setPositiveButton(R.string.ChangeNumberLockActivity__retry) { _, _ -> checkWhoAmI() } + .setNegativeButton(R.string.ChangeNumberLockActivity__leave) { _, _ -> finish() } + .setNeutralButton(R.string.ChangeNumberLockActivity__submit_debug_log) { _, _ -> + startActivity(Intent(this, SubmitDebugLogActivity::class.java)) + finish() + } + .setCancelable(false) + .show() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberPinDiffersV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberPinDiffersV2Fragment.kt new file mode 100644 index 0000000000..e7eca9116a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberPinDiffersV2Fragment.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.os.Bundle +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +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.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess +import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity + +/** + * A screen to educate the user if their PIN differs from old number to new number. + */ +class ChangeNumberPinDiffersV2Fragment : LoggingFragment(R.layout.fragment_change_number_pin_differs) { + + companion object { + private val TAG = Log.tag(ChangeNumberPinDiffersV2Fragment::class.java) + } + + private val confirmCancelDialog = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.ChangeNumberPinDiffersFragment__keep_old_pin_question) + .setPositiveButton(android.R.string.ok) { _, _ -> changeNumberSuccess() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.change_number_pin_differs_keep_old_pin).setOnClickListener { + changeNumberSuccess() + } + + val changePin = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == CreateSvrPinActivity.RESULT_OK) { + changeNumberSuccess() + } + } + + view.findViewById(R.id.change_number_pin_differs_update_pin).setOnClickListener { + changePin.launch(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext())) + } + + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, confirmCancelDialog) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberRegistrationLockV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberRegistrationLockV2Fragment.kt new file mode 100644 index 0000000000..9878d0a3c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberRegistrationLockV2Fragment.kt @@ -0,0 +1,328 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.os.Bundle +import android.text.InputType +import android.view.KeyEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.activityViewModels +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.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess +import org.thoughtcrime.securesms.databinding.FragmentRegistrationLockBinding +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.lock.v2.PinKeyboardType +import org.thoughtcrime.securesms.lock.v2.SvrConstants +import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate +import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.registration.v2.ui.registrationlock.RegistrationLockV2Fragment +import org.thoughtcrime.securesms.registration.v2.ui.registrationlock.RegistrationLockV2FragmentArgs +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.SupportEmailUtil +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.whispersystems.signalservice.api.kbs.PinHashUtil +import java.util.concurrent.TimeUnit + +/** + * Screen presented to the user if the new account is registration locked, and allows them to enter their PIN. + */ +class ChangeNumberRegistrationLockV2Fragment : LoggingFragment(R.layout.fragment_change_number_registration_lock) { + + companion object { + private val TAG = Log.tag(RegistrationLockV2Fragment::class.java) + } + + private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind) + + private val viewModel by activityViewModels() + + private var timeRemaining: Long = 0 + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + RegistrationViewDelegate.setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title)) + val toolbar: Toolbar = view.findViewById(R.id.toolbar) + toolbar.setNavigationOnClickListener { navigateUp() } + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + navigateUp() + } + } + ) + + val args: RegistrationLockV2FragmentArgs = RegistrationLockV2FragmentArgs.fromBundle(requireArguments()) + + timeRemaining = args.getTimeRemaining() + + binding.kbsLockForgotPin.visibility = View.GONE + binding.kbsLockForgotPin.setOnClickListener { handleForgottenPin(timeRemaining) } + + binding.kbsLockPinInput.setImeOptions(EditorInfo.IME_ACTION_DONE) + binding.kbsLockPinInput.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + ViewUtil.hideKeyboard(requireContext(), v!!) + handlePinEntry() + return@setOnEditorActionListener true + } + false + } + + enableAndFocusPinEntry() + + binding.kbsLockPinConfirm.setOnClickListener { + ViewUtil.hideKeyboard(requireContext(), binding.kbsLockPinInput) + handlePinEntry() + } + + binding.kbsLockKeyboardToggle.setOnClickListener { + val keyboardType: PinKeyboardType = getPinEntryKeyboardType() + updateKeyboard(keyboardType.other) + binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource) + } + + val keyboardType: PinKeyboardType = getPinEntryKeyboardType().getOther() + binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource) + + viewModel.liveLockedTimeRemaining.observe(viewLifecycleOwner) { t: Long -> timeRemaining = t } + + val triesRemaining: Int = viewModel.svrTriesRemaining + + if (triesRemaining <= 3) { + val daysRemaining = getLockoutDays(timeRemaining) + + MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.RegistrationLockFragment__not_many_tries_left) + .setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining)) + .setPositiveButton(android.R.string.ok, null) + .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() } + .show() + } + + if (triesRemaining < 5) { + binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining) + } + + viewModel.uiState.observe(viewLifecycleOwner, ::onStateUpdate) + } + + private fun onStateUpdate(state: ChangeNumberState) { + if (state.changeNumberOutcome == ChangeNumberOutcome.VerificationCodeWorked) { + handleSuccessfulPinEntry(state.enteredPin) + } + } + + private fun handlePinEntry() { + binding.kbsLockPinInput.setEnabled(false) + + val pin: String = binding.kbsLockPinInput.getText().toString() + + val trimmedLength = pin.replace(" ", "").length + if (trimmedLength == 0) { + Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show() + enableAndFocusPinEntry() + return + } + + if (trimmedLength < SvrConstants.MINIMUM_PIN_LENGTH) { + Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show() + enableAndFocusPinEntry() + return + } + + viewModel.setEnteredPin(pin) + + binding.kbsLockPinConfirm.setSpinning() + viewModel.verifyCodeAndRegisterAccountWithRegistrationLock(requireContext(), pin, ::handleSessionErrorResponse, ::handleChangeNumberErrorResponse) + } + + private fun handleSessionErrorResponse(requestResult: VerificationCodeRequestResult) { + when (requestResult) { + is VerificationCodeRequestResult.Success -> Unit + is VerificationCodeRequestResult.RateLimited -> onRateLimited() + is VerificationCodeRequestResult.AttemptsExhausted, + is VerificationCodeRequestResult.RegistrationLocked -> { + navigateToAccountLocked() + } + + is VerificationCodeRequestResult.AlreadyVerified, + is VerificationCodeRequestResult.ChallengeRequired, + is VerificationCodeRequestResult.ExternalServiceFailure, + is VerificationCodeRequestResult.ImpossibleNumber, + is VerificationCodeRequestResult.InvalidTransportModeFailure, + is VerificationCodeRequestResult.MalformedRequest, + is VerificationCodeRequestResult.MustRetry, + is VerificationCodeRequestResult.NoSuchSession, + is VerificationCodeRequestResult.NonNormalizedNumber, + is VerificationCodeRequestResult.TokenNotAccepted, + is VerificationCodeRequestResult.UnknownError -> { + Log.w(TAG, "Unable to verify code with registration lock", requestResult.getCause()) + onError() + } + } + } + + private fun handleChangeNumberErrorResponse(result: ChangeNumberResult) { + when (result) { + is ChangeNumberResult.Success -> Unit + is ChangeNumberResult.RateLimited -> onRateLimited() + is ChangeNumberResult.AttemptsExhausted -> navigateToAccountLocked() + + is ChangeNumberResult.SvrWrongPin -> { + Log.i(TAG, "SVR returned a WrongPinException.") + onIncorrectKbsRegistrationLockPin(result.triesRemaining) + } + + is ChangeNumberResult.SvrNoData -> { + Log.i(TAG, "SVR returned a NoDataException.") + navigateToAccountLocked() + } + + is ChangeNumberResult.AuthorizationFailed, + is ChangeNumberResult.IncorrectRecoveryPassword, + is ChangeNumberResult.MalformedRequest, + is ChangeNumberResult.RegistrationLocked, + is ChangeNumberResult.UnknownError, + is ChangeNumberResult.ValidationError -> { + Log.w(TAG, "Unable to register account with registration lock", result.getCause()) + onError() + } + } + } + + private fun onIncorrectKbsRegistrationLockPin(svrTriesRemaining: Int) { + binding.kbsLockPinConfirm.cancelSpinning() + binding.kbsLockPinInput.getText().clear() + enableAndFocusPinEntry() + + if (svrTriesRemaining == 0) { + Log.w(TAG, "Account locked. User out of attempts on KBS.") + navigateToAccountLocked() + return + } + + if (svrTriesRemaining == 3) { + val daysRemaining = getLockoutDays(timeRemaining) + + MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.RegistrationLockFragment__incorrect_pin) + .setMessage(getTriesRemainingDialogMessage(svrTriesRemaining, daysRemaining)) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + if (svrTriesRemaining > 5) { + binding.kbsLockPinInputLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again) + } else { + binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, svrTriesRemaining, svrTriesRemaining) + binding.kbsLockForgotPin.visibility = View.VISIBLE + } + } + + private fun onRateLimited() { + binding.kbsLockPinConfirm.cancelSpinning() + enableAndFocusPinEntry() + + MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.RegistrationActivity_too_many_attempts) + .setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + fun onError() { + binding.kbsLockPinConfirm.cancelSpinning() + enableAndFocusPinEntry() + + Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show() + } + + private fun handleForgottenPin(timeRemainingMs: Long) { + val lockoutDays = getLockoutDays(timeRemainingMs) + MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.RegistrationLockFragment__forgot_your_pin) + .setMessage(requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays)) + .setPositiveButton(android.R.string.ok, null) + .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, which -> sendEmailToSupport() } + .show() + } + + private fun getLockoutDays(timeRemainingMs: Long): Int { + return TimeUnit.MILLISECONDS.toDays(timeRemainingMs).toInt() + 1 + } + + private fun getTriesRemainingDialogMessage(triesRemaining: Int, daysRemaining: Int): String { + val resources = requireContext().resources + val tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining) + val days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining) + + return "$tries $days" + } + + private fun enableAndFocusPinEntry() { + binding.kbsLockPinInput.setEnabled(true) + binding.kbsLockPinInput.setFocusable(true) + ViewUtil.focusAndShowKeyboard(binding.kbsLockPinInput) + } + + private fun getPinEntryKeyboardType(): PinKeyboardType { + val isNumeric = (binding.kbsLockPinInput.inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER + + return if (isNumeric) PinKeyboardType.NUMERIC else PinKeyboardType.ALPHA_NUMERIC + } + + private fun updateKeyboard(keyboard: PinKeyboardType) { + val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC + + binding.kbsLockPinInput.setInputType( + if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD + ) + + binding.kbsLockPinInput.getText().clear() + } + + private fun navigateToAccountLocked() { + findNavController().safeNavigate(ChangeNumberRegistrationLockV2FragmentDirections.actionChangeNumberRegistrationLockToChangeNumberAccountLocked()) + } + + private fun handleSuccessfulPinEntry(pin: String) { + val pinsDiffer: Boolean = SignalStore.svr().localPinHash?.let { !PinHashUtil.verifyLocalPinHash(it, pin) } ?: false + + binding.kbsLockPinConfirm.cancelSpinning() + + if (pinsDiffer) { + findNavController().safeNavigate(ChangeNumberRegistrationLockV2FragmentDirections.actionChangeNumberRegistrationLockToChangeNumberPinDiffers()) + } else { + changeNumberSuccess() + } + } + + private fun sendEmailToSupport() { + val subject = R.string.ChangeNumberRegistrationLockFragment__signal_change_number_need_help_with_pin_for_android_v2_pin + + val body: String = SupportEmailUtil.generateSupportEmailBody(requireContext(), subject, null, null) + + CommunicationActions.openEmail(requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), getString(subject), body) + } + + private fun navigateUp() { + if (SignalStore.misc().isChangeNumberLocked) { + startActivity(ChangeNumberLockV2Activity.createIntent(requireContext())) + } else { + findNavController().navigateUp() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberResult.kt new file mode 100644 index 0000000000..8edb51a798 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberResult.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import org.thoughtcrime.securesms.pin.SvrWrongPinException +import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationResult +import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.SvrNoDataException +import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException +import org.whispersystems.signalservice.api.push.exceptions.IncorrectRegistrationRecoveryPasswordException +import org.whispersystems.signalservice.api.push.exceptions.MalformedRequestException +import org.whispersystems.signalservice.api.push.exceptions.RateLimitException +import org.whispersystems.signalservice.internal.push.AuthCredentials +import org.whispersystems.signalservice.internal.push.LockedException +import org.whispersystems.signalservice.internal.push.VerifyAccountResponse + +/** + * This is a processor to map a [VerifyAccountResponse] to all the known outcomes. + */ +sealed class ChangeNumberResult(cause: Throwable?) : RegistrationResult(cause) { + companion object { + fun from(networkResult: NetworkResult): ChangeNumberResult { + return when (networkResult) { + is NetworkResult.Success -> Success(networkResult.result) + is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable) + is NetworkResult.NetworkError -> UnknownError(networkResult.exception) + is NetworkResult.StatusCodeError -> { + when (val cause = networkResult.exception) { + is IncorrectRegistrationRecoveryPasswordException -> IncorrectRecoveryPassword(cause) + is AuthorizationFailedException -> AuthorizationFailed(cause) + is MalformedRequestException -> MalformedRequest(cause) + is RateLimitException -> createRateLimitProcessor(cause) + is LockedException -> RegistrationLocked(cause = cause, timeRemaining = cause.timeRemaining, svr2Credentials = cause.svr2Credentials) + else -> { + if (networkResult.code == 422) { + ValidationError(cause) + } else { + UnknownError(cause) + } + } + } + } + } + } + + private fun createRateLimitProcessor(exception: RateLimitException): ChangeNumberResult { + return if (exception.retryAfterMilliseconds.isPresent) { + RateLimited(exception, exception.retryAfterMilliseconds.get()) + } else { + AttemptsExhausted(exception) + } + } + } + + class Success(val numberChangeResult: ChangeNumberV2Repository.NumberChangeResult) : ChangeNumberResult(null) + class IncorrectRecoveryPassword(cause: Throwable) : ChangeNumberResult(cause) + class AuthorizationFailed(cause: Throwable) : ChangeNumberResult(cause) + class MalformedRequest(cause: Throwable) : ChangeNumberResult(cause) + class ValidationError(cause: Throwable) : ChangeNumberResult(cause) + class RateLimited(cause: Throwable, val timeRemaining: Long) : ChangeNumberResult(cause) + class AttemptsExhausted(cause: Throwable) : ChangeNumberResult(cause) + class RegistrationLocked(cause: Throwable, val timeRemaining: Long, val svr2Credentials: AuthCredentials?) : ChangeNumberResult(cause) + class UnknownError(cause: Throwable) : ChangeNumberResult(cause) + + class SvrNoData(cause: SvrNoDataException) : ChangeNumberResult(cause) + class SvrWrongPin(cause: SvrWrongPinException) : ChangeNumberResult(cause) { + val triesRemaining = cause.triesRemaining + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberState.kt new file mode 100644 index 0000000000..459f773b5a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberState.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import org.thoughtcrime.securesms.registration.v2.data.network.Challenge +import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState +import org.whispersystems.signalservice.internal.push.AuthCredentials + +/** + * State holder for [ChangeNumberV2ViewModel] + */ +data class ChangeNumberState( + val number: NumberViewState = NumberViewState.INITIAL, + val enteredCode: String? = null, + val enteredPin: String = "", + val oldPhoneNumber: NumberViewState = NumberViewState.INITIAL, + val sessionId: String? = null, + val changeNumberOutcome: ChangeNumberOutcome? = null, + val lockedTimeRemaining: Long = 0L, + val svrCredentials: AuthCredentials? = null, + val svrTriesRemaining: Int = 10, + val incorrectCodeAttempts: Int = 0, + val nextSmsTimestamp: Long = 0L, + val nextCallTimestamp: Long = 0L, + val inProgress: Boolean = false, + val captchaToken: String? = null, + val challengesRequested: List = emptyList(), + val challengesPresented: Set = emptySet(), + val allowedToRequestCode: Boolean = false +) { + val challengesRemaining: List = challengesRequested.filterNot { it in challengesPresented } +} + +sealed interface ChangeNumberOutcome { + data object RecoveryPasswordWorked : ChangeNumberOutcome + data object VerificationCodeWorked : ChangeNumberOutcome + class ChangeNumberRequestOutcome(val result: VerificationCodeRequestResult) : ChangeNumberOutcome +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2Fragment.kt new file mode 100644 index 0000000000..8de0647674 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2Fragment.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.os.Bundle +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.navigation.fragment.findNavController +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.FragmentChangePhoneNumberV2Binding +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Screen used to educate the user about what they're about to do (change their phone number) + */ +class ChangeNumberV2Fragment : LoggingFragment(R.layout.fragment_change_phone_number_v2) { + + companion object { + private val TAG = Log.tag(ChangeNumberV2Fragment::class.java) + } + + private val binding: FragmentChangePhoneNumberV2Binding by ViewBinderDelegate(FragmentChangePhoneNumberV2Binding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val toolbar: Toolbar = view.findViewById(R.id.toolbar) + toolbar.setNavigationOnClickListener { findNavController().navigateUp() } + + binding.changePhoneNumberContinue.setOnClickListener { + findNavController().safeNavigate(R.id.action_changePhoneNumberFragment_to_enterPhoneNumberChangeFragment) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2Repository.kt new file mode 100644 index 0000000000..845947d538 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2Repository.kt @@ -0,0 +1,384 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import androidx.annotation.WorkerThread +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import okio.ByteString.Companion.toByteString +import org.signal.core.util.logging.Log +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.SignalProtocolAddress +import org.signal.libsignal.protocol.state.KyberPreKeyRecord +import org.signal.libsignal.protocol.state.SignalProtocolStore +import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import org.signal.libsignal.protocol.util.KeyHelper +import org.signal.libsignal.protocol.util.Medium +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.crypto.PreKeyUtil +import org.thoughtcrime.securesms.database.IdentityTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob +import org.thoughtcrime.securesms.keyvalue.CertificateType +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.pin.SvrRepository +import org.thoughtcrime.securesms.pin.SvrWrongPinException +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet +import org.thoughtcrime.securesms.storage.StorageSyncHelper +import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.SignalServiceAccountManager +import org.whispersystems.signalservice.api.SignalServiceMessageSender +import org.whispersystems.signalservice.api.SvrNoDataException +import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest +import org.whispersystems.signalservice.api.account.PreKeyUpload +import org.whispersystems.signalservice.api.kbs.MasterKey +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.api.push.ServiceIdType +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.api.push.SignedPreKeyEntity +import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity +import org.whispersystems.signalservice.internal.push.OutgoingPushMessage +import org.whispersystems.signalservice.internal.push.SyncMessage +import org.whispersystems.signalservice.internal.push.VerifyAccountResponse +import org.whispersystems.signalservice.internal.push.WhoAmIResponse +import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException +import java.io.IOException +import java.security.MessageDigest +import java.security.SecureRandom +import kotlin.coroutines.resume +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Repository to perform data operations during change number. + * + * @see [org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository] + */ +class ChangeNumberV2Repository( + private val accountManager: SignalServiceAccountManager = AppDependencies.signalServiceAccountManager, + private val messageSender: SignalServiceMessageSender = AppDependencies.signalServiceMessageSender +) { + + companion object { + private val TAG = Log.tag(ChangeNumberV2Repository::class.java) + } + + fun whoAmI(): WhoAmIResponse { + return accountManager.whoAmI + } + + suspend fun ensureDecryptionsDrained(timeout: Duration = 15.seconds) = + withTimeoutOrNull(timeout) { + suspendCancellableCoroutine { + val drainedListener = object : Runnable { + override fun run() { + AppDependencies + .incomingMessageObserver + .removeDecryptionDrainedListener(this) + Log.d(TAG, "Decryptions drained.") + it.resume(true) + } + } + + it.invokeOnCancellation { + AppDependencies + .incomingMessageObserver + .removeDecryptionDrainedListener(drainedListener) + Log.d(TAG, "Decryptions draining canceled.") + } + + AppDependencies + .incomingMessageObserver + .addDecryptionDrainedListener(drainedListener) + Log.d(TAG, "Waiting for decryption drain.") + } + } + + @WorkerThread + fun changeLocalNumber(e164: String, pni: ServiceId.PNI) { + val oldStorageId: ByteArray? = Recipient.self().storageId + SignalDatabase.recipients.updateSelfE164(e164, pni) + val newStorageId: ByteArray? = Recipient.self().storageId + + if (e164 != SignalStore.account().requireE164() && MessageDigest.isEqual(oldStorageId, newStorageId)) { + Log.w(TAG, "Self storage id was not rotated, attempting to rotate again") + SignalDatabase.recipients.rotateStorageId(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() + val secondAttemptStorageId: ByteArray? = Recipient.self().storageId + if (MessageDigest.isEqual(oldStorageId, secondAttemptStorageId)) { + Log.w(TAG, "Second attempt also failed to rotate storage id") + } + } + + AppDependencies.recipientCache.clear() + + SignalStore.account().setE164(e164) + SignalStore.account().setPni(pni) + AppDependencies.resetProtocolStores() + + AppDependencies.groupsV2Authorization.clear() + + val metadata: PendingChangeNumberMetadata? = SignalStore.misc().pendingChangeNumberMetadata + if (metadata == null) { + Log.w(TAG, "No change number metadata, this shouldn't happen") + throw AssertionError("No change number metadata") + } + + val originalPni = ServiceId.PNI.parseOrThrow(metadata.previousPni) + + if (originalPni == pni) { + Log.i(TAG, "No change has occurred, PNI is unchanged: $pni") + } else { + val pniIdentityKeyPair = IdentityKeyPair(metadata.pniIdentityKeyPair.toByteArray()) + val pniRegistrationId = metadata.pniRegistrationId + val pniSignedPreyKeyId = metadata.pniSignedPreKeyId + val pniLastResortKyberPreKeyId = metadata.pniLastResortKyberPreKeyId + + val pniProtocolStore = AppDependencies.protocolStore.pni() + val pniMetadataStore = SignalStore.account().pniPreKeys + + SignalStore.account().pniRegistrationId = pniRegistrationId + SignalStore.account().setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair) + + val signedPreKey = pniProtocolStore.loadSignedPreKey(pniSignedPreyKeyId) + val oneTimeEcPreKeys = PreKeyUtil.generateAndStoreOneTimeEcPreKeys(pniProtocolStore, pniMetadataStore) + val lastResortKyberPreKey = pniProtocolStore.loadLastResortKyberPreKeys().firstOrNull { it.id == pniLastResortKyberPreKeyId } + val oneTimeKyberPreKeys = PreKeyUtil.generateAndStoreOneTimeKyberPreKeys(pniProtocolStore, pniMetadataStore) + + if (lastResortKyberPreKey == null) { + Log.w(TAG, "Last-resort kyber prekey is missing!") + } + + pniMetadataStore.activeSignedPreKeyId = signedPreKey.id + Log.i(TAG, "Submitting prekeys with PNI identity key: ${pniIdentityKeyPair.publicKey.fingerprint}") + + accountManager.setPreKeys( + PreKeyUpload( + serviceIdType = ServiceIdType.PNI, + signedPreKey = signedPreKey, + oneTimeEcPreKeys = oneTimeEcPreKeys, + lastResortKyberPreKey = lastResortKyberPreKey, + oneTimeKyberPreKeys = oneTimeKyberPreKeys + ) + ) + pniMetadataStore.isSignedPreKeyRegistered = true + pniMetadataStore.lastResortKyberPreKeyId = pniLastResortKyberPreKeyId + + pniProtocolStore.identities().saveIdentityWithoutSideEffects( + Recipient.self().id, + pni, + pniProtocolStore.identityKeyPair.publicKey, + IdentityTable.VerifiedStatus.VERIFIED, + true, + System.currentTimeMillis(), + true + ) + + SignalStore.misc().hasPniInitializedDevices = true + AppDependencies.groupsV2Authorization.clear() + } + + Recipient.self().live().refresh() + StorageSyncHelper.scheduleSyncForDataChange() + + AppDependencies.resetNetwork() + AppDependencies.incomingMessageObserver + + AppDependencies.jobManager.add(RefreshAttributesJob()) + + rotateCertificates() + } + + @WorkerThread + private fun rotateCertificates() { + val certificateTypes = SignalStore.phoneNumberPrivacy().allCertificateTypes + + Log.i(TAG, "Rotating these certificates $certificateTypes") + + for (certificateType in certificateTypes) { + val certificate: ByteArray? = when (certificateType) { + CertificateType.ACI_AND_E164 -> accountManager.senderCertificate + CertificateType.ACI_ONLY -> accountManager.senderCertificateForPhoneNumberPrivacy + else -> throw AssertionError() + } + + Log.i(TAG, "Successfully got $certificateType certificate") + + SignalStore.certificateValues().setUnidentifiedAccessCertificate(certificateType, certificate) + } + } + + suspend fun changeNumberWithRecoveryPassword(recoveryPassword: String, newE164: String): ChangeNumberResult { + return changeNumberInternal(recoveryPassword = recoveryPassword, newE164 = newE164) + } + + suspend fun changeNumberWithoutRegistrationLock(sessionId: String, newE164: String): ChangeNumberResult { + return changeNumberInternal(sessionId = sessionId, newE164 = newE164) + } + + suspend fun changeNumberWithRegistrationLock( + sessionId: String, + newE164: String, + pin: String, + svrAuthCredentials: SvrAuthCredentialSet + ): ChangeNumberResult { + val masterKey: MasterKey + + try { + masterKey = SvrRepository.restoreMasterKeyPreRegistration(svrAuthCredentials, pin) + } catch (e: SvrWrongPinException) { + return ChangeNumberResult.SvrWrongPin(e) + } catch (e: SvrNoDataException) { + return ChangeNumberResult.SvrNoData(e) + } catch (e: IOException) { + return ChangeNumberResult.UnknownError(e) + } + + val registrationLock = masterKey.deriveRegistrationLock() + return changeNumberInternal(sessionId = sessionId, registrationLock = registrationLock, newE164 = newE164) + } + + /** + * Sends a request to the service to change the phone number associated with this account. + */ + private suspend fun changeNumberInternal(sessionId: String? = null, recoveryPassword: String? = null, registrationLock: String? = null, newE164: String): ChangeNumberResult { + check((sessionId != null && recoveryPassword == null) || (sessionId == null && recoveryPassword != null)) + var completed = false + var attempts = 0 + lateinit var result: NetworkResult + + while (!completed && attempts < 5) { + Log.i(TAG, "Attempt #$attempts") + val (request: ChangePhoneNumberRequest, metadata: PendingChangeNumberMetadata) = createChangeNumberRequest( + sessionId = sessionId, + recoveryPassword = recoveryPassword, + newE164 = newE164, + registrationLock = registrationLock + ) + + SignalStore.misc().setPendingChangeNumberMetadata(metadata) + withContext(Dispatchers.IO) { + result = accountManager.registrationApi.changeNumber(request) + } + + val possibleError = result.getCause() as? MismatchedDevicesException + if (possibleError != null) { + messageSender.handleChangeNumberMismatchDevices(possibleError.mismatchedDevices) + attempts++ + } else { + completed = true + } + } + Log.i(TAG, "Returning change number network result.") + return ChangeNumberResult.from( + result.map { accountRegistrationResponse: VerifyAccountResponse -> + NumberChangeResult( + uuid = accountRegistrationResponse.uuid, + pni = accountRegistrationResponse.pni, + storageCapable = accountRegistrationResponse.storageCapable, + number = accountRegistrationResponse.number + ) + } + ) + } + + @WorkerThread + private fun createChangeNumberRequest( + sessionId: String? = null, + recoveryPassword: String? = null, + newE164: String, + registrationLock: String? = null + ): ChangeNumberRequestData { + val selfIdentifier: String = SignalStore.account().requireAci().toString() + val aciProtocolStore: SignalProtocolStore = AppDependencies.protocolStore.aci() + + val pniIdentity: IdentityKeyPair = IdentityKeyUtil.generateIdentityKeyPair() + val deviceMessages = mutableListOf() + val devicePniSignedPreKeys = mutableMapOf() + val devicePniLastResortKyberPreKeys = mutableMapOf() + val pniRegistrationIds = mutableMapOf() + val primaryDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID + + val devices: List = listOf(primaryDeviceId) + aciProtocolStore.getSubDeviceSessions(selfIdentifier) + + devices + .filter { it == primaryDeviceId || aciProtocolStore.containsSession(SignalProtocolAddress(selfIdentifier, it)) } + .forEach { deviceId -> + // Signed Prekeys + val signedPreKeyRecord: SignedPreKeyRecord = if (deviceId == primaryDeviceId) { + PreKeyUtil.generateAndStoreSignedPreKey(AppDependencies.protocolStore.pni(), SignalStore.account().pniPreKeys, pniIdentity.privateKey) + } else { + PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey) + } + devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature) + + // Last-resort kyber prekeys + val lastResortKyberPreKeyRecord: KyberPreKeyRecord = if (deviceId == primaryDeviceId) { + PreKeyUtil.generateAndStoreLastResortKyberPreKey(AppDependencies.protocolStore.pni(), SignalStore.account().pniPreKeys, pniIdentity.privateKey) + } else { + PreKeyUtil.generateLastResortKyberPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey) + } + devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id, lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature) + + // Registration Ids + var pniRegistrationId = -1 + + while (pniRegistrationId < 0 || pniRegistrationIds.values.contains(pniRegistrationId)) { + pniRegistrationId = KeyHelper.generateRegistrationId(false) + } + pniRegistrationIds[deviceId] = pniRegistrationId + + // Device Messages + if (deviceId != primaryDeviceId) { + val pniChangeNumber = SyncMessage.PniChangeNumber( + identityKeyPair = pniIdentity.serialize().toByteString(), + signedPreKey = signedPreKeyRecord.serialize().toByteString(), + lastResortKyberPreKey = lastResortKyberPreKeyRecord.serialize().toByteString(), + registrationId = pniRegistrationId, + newE164 = newE164 + ) + + deviceMessages += messageSender.getEncryptedSyncPniInitializeDeviceMessage(deviceId, pniChangeNumber) + } + } + + val request = ChangePhoneNumberRequest( + sessionId, + recoveryPassword, + newE164, + registrationLock, + pniIdentity.publicKey, + deviceMessages, + devicePniSignedPreKeys.mapKeys { it.key.toString() }, + devicePniLastResortKyberPreKeys.mapKeys { it.key.toString() }, + pniRegistrationIds.mapKeys { it.key.toString() } + ) + + val metadata = PendingChangeNumberMetadata( + previousPni = SignalStore.account().pni!!.toByteString(), + pniIdentityKeyPair = pniIdentity.serialize().toByteString(), + pniRegistrationId = pniRegistrationIds[primaryDeviceId]!!, + pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId, + pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId + ) + + return ChangeNumberRequestData(request, metadata) + } + + private data class ChangeNumberRequestData(val changeNumberRequest: ChangePhoneNumberRequest, val pendingChangeNumberMetadata: PendingChangeNumberMetadata) + + data class NumberChangeResult( + val uuid: String, + val pni: String, + val storageCapable: Boolean, + val number: String + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2ViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2ViewModel.kt new file mode 100644 index 0000000000..c35383296a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberV2ViewModel.kt @@ -0,0 +1,537 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registration.RegistrationData +import org.thoughtcrime.securesms.registration.SmsRetrieverReceiver +import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository +import org.thoughtcrime.securesms.registration.v2.data.network.Challenge +import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationSessionCreationResult +import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel +import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState +import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet +import org.thoughtcrime.securesms.util.dualsim.MccMncProducer +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse +import java.io.IOException +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** + * [ViewModel] for the change number flow. + * + * @see [RegistrationV2ViewModel], from which this is derived. + */ +class ChangeNumberV2ViewModel : ViewModel() { + + companion object { + private val TAG = Log.tag(ChangeNumberV2ViewModel::class.java) + + val CHANGE_NUMBER_LOCK = ReentrantLock() + } + + private val repository = ChangeNumberV2Repository() + private val store = MutableStateFlow(ChangeNumberState()) + private val serialContext = SignalExecutors.SERIAL.asCoroutineDispatcher() + private val smsRetrieverReceiver: SmsRetrieverReceiver = SmsRetrieverReceiver(AppDependencies.application) + + private val initialLocalNumber = SignalStore.account().e164 + private val password = SignalStore.account().servicePassword!! + + val uiState = store.asLiveData() + val liveOldNumberState = store.map { it.oldPhoneNumber }.asLiveData() + val liveNewNumberState = store.map { it.number }.asLiveData() + val liveLockedTimeRemaining = store.map { it.lockedTimeRemaining }.asLiveData() + val incorrectCodeAttempts = store.map { it.incorrectCodeAttempts }.asLiveData() + + init { + try { + val countryCode: Int = PhoneNumberUtil.getInstance() + .parse(SignalStore.account().e164!!, null) + .countryCode + + store.update { + it.copy( + number = it.number.toBuilder().countryCode(countryCode).build(), + oldPhoneNumber = it.oldPhoneNumber.toBuilder().countryCode(countryCode).build() + ) + } + } catch (e: NumberParseException) { + Log.i(TAG, "Unable to parse number for default country code") + } + + smsRetrieverReceiver.registerReceiver() + } + + override fun onCleared() { + super.onCleared() + smsRetrieverReceiver.unregisterReceiver() + } + + // region Public Getters and Setters + + val number: NumberViewState + get() = store.value.number + + val oldNumberState: NumberViewState + get() = store.value.oldPhoneNumber + + val svrTriesRemaining: Int + get() = store.value.svrTriesRemaining + + fun setOldNationalNumber(updatedNumber: String) { + store.update { + it.copy(oldPhoneNumber = oldNumberState.toBuilder().nationalNumber(updatedNumber).build()) + } + } + + fun setOldCountry(countryCode: Int, country: String? = null) { + store.update { + it.copy(oldPhoneNumber = oldNumberState.toBuilder().selectedCountryDisplayName(country).countryCode(countryCode).build()) + } + } + + fun setNewNationalNumber(updatedNumber: String) { + store.update { + it.copy(number = number.toBuilder().nationalNumber(updatedNumber).build()) + } + } + + fun setNewCountry(countryCode: Int, country: String? = null) { + store.update { + it.copy(number = number.toBuilder().selectedCountryDisplayName(country).countryCode(countryCode).build()) + } + } + + fun setCaptchaResponse(token: String) { + Log.v(TAG, "setCaptchaResponse()") + store.update { + it.copy(captchaToken = token) + } + } + + fun setEnteredPin(pin: String) { + store.update { + it.copy(enteredPin = pin) + } + } + + fun incrementIncorrectCodeAttempts() { + store.update { + it.copy(incorrectCodeAttempts = it.incorrectCodeAttempts + 1) + } + } + + fun addPresentedChallenge(challenge: Challenge) { + Log.v(TAG, "addPresentedChallenge()") + store.update { + it.copy(challengesPresented = it.challengesPresented.plus(challenge)) + } + } + + fun removePresentedChallenge(challenge: Challenge) { + Log.v(TAG, "addPresentedChallenge()") + store.update { + it.copy(challengesPresented = it.challengesPresented.minus(challenge)) + } + } + + fun resetLocalSessionState() { + Log.v(TAG, "resetLocalSessionState()") + store.update { + it.copy(inProgress = false, changeNumberOutcome = null, captchaToken = null, challengesRequested = emptyList(), allowedToRequestCode = false) + } + } + + fun canContinue(): ContinueStatus { + return if (oldNumberState.e164Number == initialLocalNumber) { + if (number.isValid) { + ContinueStatus.CAN_CONTINUE + } else { + ContinueStatus.INVALID_NUMBER + } + } else { + ContinueStatus.OLD_NUMBER_DOESNT_MATCH + } + } + + // endregion + + // region Public actions + + fun checkWhoAmI(onSuccess: () -> Unit, onError: (Throwable) -> Unit) { + Log.v(TAG, "checkWhoAmI()") + viewModelScope.launch(Dispatchers.IO) { + try { + val whoAmI = repository.whoAmI() + + if (whoAmI.number == SignalStore.account().e164) { + return@launch bail { Log.i(TAG, "Local and remote numbers match, nothing needs to be done.") } + } + + Log.i(TAG, "Local (${SignalStore.account().e164}) and remote (${whoAmI.number}) numbers do not match, updating local.") + + withLockOnSerialExecutor { + repository.changeLocalNumber(whoAmI.number, ServiceId.PNI.parseOrThrow(whoAmI.pni)) + } + + withContext(Dispatchers.Main) { + onSuccess() + } + } catch (ioException: IOException) { + Log.w(TAG, "Encountered an exception when requesting whoAmI()", ioException) + withContext(Dispatchers.Main) { + onError(ioException) + } + } + } + } + + fun registerSmsListenerWithCompletionListener(context: Context, onComplete: (Boolean) -> Unit) { + Log.v(TAG, "registerSmsListenerWithCompletionListener()") + viewModelScope.launch { + val listenerRegistered = RegistrationRepository.registerSmsListener(context) + onComplete(listenerRegistered) + } + } + + fun verifyCodeWithoutRegistrationLock(context: Context, code: String, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) { + Log.v(TAG, "verifyCodeWithoutRegistrationLock()") + store.update { + it.copy( + inProgress = true, + enteredCode = code + ) + } + + viewModelScope.launch { + verifyCodeInternal(context = context, pin = null, verificationErrorHandler = verificationErrorHandler, numberChangeErrorHandler = numberChangeErrorHandler) + } + } + + fun verifyCodeAndRegisterAccountWithRegistrationLock(context: Context, pin: String, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) { + Log.v(TAG, "verifyCodeAndRegisterAccountWithRegistrationLock()") + store.update { it.copy(inProgress = true) } + + viewModelScope.launch { + verifyCodeInternal(context = context, pin = pin, verificationErrorHandler = verificationErrorHandler, numberChangeErrorHandler = numberChangeErrorHandler) + } + } + + private suspend fun verifyCodeInternal(context: Context, pin: String?, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) { + val sessionId = getOrCreateValidSession(context)?.body?.id ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") } + val registrationData = getRegistrationData(context) + + val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData) + + if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) { + handleVerificationError(verificationResponse, verificationErrorHandler) + return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") } + } + + val result: ChangeNumberResult = if (pin == null) { + repository.changeNumberWithoutRegistrationLock(sessionId = sessionId, newE164 = number.e164Number) + } else { + repository.changeNumberWithRegistrationLock(sessionId = sessionId, newE164 = number.e164Number, pin, SvrAuthCredentialSet(null, store.value.svrCredentials)) + } + + if (result is ChangeNumberResult.Success) { + handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked) + } else { + handleChangeNumberError(result, numberChangeErrorHandler) + } + } + + fun submitCaptchaToken(context: Context) { + Log.v(TAG, "submitCaptchaToken()") + val e164 = number.e164Number + val captchaToken = store.value.captchaToken ?: throw IllegalStateException("Can't submit captcha token if no captcha token is set!") + + viewModelScope.launch { + Log.d(TAG, "Getting session in order to submit captcha token…") + val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Bailing captcha token submission due to invalid session.") } + if (!Challenge.parse(session.body.requestedInformation).contains(Challenge.CAPTCHA)) { + Log.d(TAG, "Captcha submission no longer necessary, bailing.") + store.update { + it.copy( + captchaToken = null, + inProgress = false, + changeNumberOutcome = null + ) + } + 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.") + store.update { + it.copy(captchaToken = null, inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(captchaSubmissionResult)) + } + } + } + + fun requestAndSubmitPushToken(context: Context) { + Log.v(TAG, "validatePushToken()") + + addPresentedChallenge(Challenge.PUSH) + + val e164 = number.e164Number + + viewModelScope.launch { + Log.d(TAG, "Getting session in order to perform push token verification…") + val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Bailing from push token verification due to invalid session.") } + + if (!Challenge.parse(session.body.requestedInformation).contains(Challenge.PUSH)) { + Log.d(TAG, "Push submission no longer necessary, bailing.") + store.update { + it.copy( + inProgress = false, + changeNumberOutcome = null + ) + } + return@launch + } + + Log.d(TAG, "Requesting push challenge token…") + val pushSubmissionResult = RegistrationRepository.requestAndVerifyPushToken(context, session.body.id, e164, password) + Log.d(TAG, "Push challenge token submitted.") + store.update { + it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(pushSubmissionResult)) + } + } + } + + fun initiateChangeNumberSession(context: Context, mode: RegistrationRepository.Mode) { + Log.v(TAG, "changeNumber()") + store.update { it.copy(inProgress = true) } + viewModelScope.launch { + val encryptionDrained = repository.ensureDecryptionsDrained() ?: false + + if (!encryptionDrained) { + return@launch bail { Log.i(TAG, "Failed to drain encryption.") } + } + + val changed = changeNumberWithRecoveryPassword() + + if (changed) { + Log.d(TAG, "Successfully changed number using recovery password, which cleaned up after itself.") + return@launch + } + + requestVerificationCode(context, mode) + } + } + + // endregion + + // region Private actions + + private fun updateLocalStateFromSession(response: RegistrationSessionMetadataResponse) { + Log.v(TAG, "updateLocalStateFromSession()") + store.update { + it.copy(sessionId = response.body.id, challengesRequested = Challenge.parse(response.body.requestedInformation), allowedToRequestCode = response.body.allowedToRequestCode) + } + } + + private suspend fun getOrCreateValidSession(context: Context): RegistrationSessionMetadataResponse? { + Log.v(TAG, "getOrCreateValidSession()") + val e164 = number.e164Number + val mccMncProducer = MccMncProducer(context) + val existingSessionId = store.value.sessionId + return RegistrationV2ViewModel.getOrCreateValidSession(context = context, existingSessionId = existingSessionId, e164 = e164, password = password, mcc = mccMncProducer.mcc, mnc = mccMncProducer.mnc, successListener = { freshMetadata -> + Log.v(TAG, "Valid session received, updating local state.") + updateLocalStateFromSession(freshMetadata) + }, errorHandler = { result -> + val requestCode: VerificationCodeRequestResult = when (result) { + is RegistrationSessionCreationResult.RateLimited -> VerificationCodeRequestResult.RateLimited(result.getCause(), result.timeRemaining) + is RegistrationSessionCreationResult.MalformedRequest -> VerificationCodeRequestResult.MalformedRequest(result.getCause()) + else -> VerificationCodeRequestResult.UnknownError(result.getCause()) + } + + store.update { + it.copy(changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(requestCode)) + } + }) + } + + private suspend fun changeNumberWithRecoveryPassword(): Boolean { + Log.v(TAG, "changeNumberWithRecoveryPassword()") + SignalStore.svr().recoveryPassword?.let { recoveryPassword -> + if (SignalStore.svr().hasPin()) { + val result = repository.changeNumberWithRecoveryPassword(recoveryPassword = recoveryPassword, newE164 = number.e164Number) + + if (result is ChangeNumberResult.Success) { + handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked) + return true + } + + Log.d(TAG, "Encountered error while trying to change number with recovery password.", result.getCause()) + } + } + return false + } + + private suspend fun handleSuccessfulChangedRemoteNumber(e164: String, pni: ServiceId.PNI, changeNumberOutcome: ChangeNumberOutcome) { + var result = changeNumberOutcome + Log.v(TAG, "handleSuccessfulChangedRemoteNumber(${result.javaClass.simpleName}") + try { + withLockOnSerialExecutor { + repository.changeLocalNumber(e164, pni) + } + } catch (ioException: IOException) { + Log.w(TAG, "Failed to change local number!", ioException) + result = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(ioException)) + } + + store.update { + it.copy(inProgress = false, changeNumberOutcome = result) + } + } + + private fun handleVerificationError(result: VerificationCodeRequestResult, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit) { + Log.v(TAG, "handleVerificationError(${result.javaClass.simpleName}") + when (result) { + is VerificationCodeRequestResult.Success -> Unit + is VerificationCodeRequestResult.RegistrationLocked -> + store.update { + it.copy( + svrCredentials = result.svr2Credentials + ) + } + else -> Log.i(TAG, "Received exception during verification.", result.getCause()) + } + + verificationErrorHandler(result) + } + + private fun handleChangeNumberError(result: ChangeNumberResult, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) { + Log.v(TAG, "handleChangeNumberError(${result.javaClass.simpleName}") + when (result) { + is ChangeNumberResult.Success -> Unit + is ChangeNumberResult.RegistrationLocked -> + store.update { + it.copy( + svrCredentials = result.svr2Credentials + ) + } + is ChangeNumberResult.SvrWrongPin -> { + store.update { + it.copy( + svrTriesRemaining = result.triesRemaining + ) + } + } + else -> Log.i(TAG, "Received exception during change number.", result.getCause()) + } + + numberChangeErrorHandler(result) + } + + private suspend fun requestVerificationCode(context: Context, mode: RegistrationRepository.Mode) { + Log.v(TAG, "requestVerificationCode()") + val e164 = number.e164Number + + val validSession = getOrCreateValidSession(context) + + if (validSession == null) { + Log.w(TAG, "Bailing on requesting verification code because could not create a session!") + resetLocalSessionState() + return + } + + val result = if (!validSession.body.allowedToRequestCode) { + val challenges = validSession.body.requestedInformation.joinToString() + Log.i(TAG, "Not allowed to request code! Remaining challenges: $challenges") + VerificationCodeRequestResult.ChallengeRequired(Challenge.parse(validSession.body.requestedInformation)) + } else { + store.update { + it.copy(changeNumberOutcome = null, challengesRequested = emptyList()) + } + val response = RegistrationRepository.requestSmsCode(context = context, sessionId = validSession.body.id, e164 = e164, password = password, mode = mode) + Log.d(TAG, "SMS code request submitted") + response + } + + val challengesRequested = if (result is VerificationCodeRequestResult.ChallengeRequired) { + result.challenges + } else { + emptyList() + } + + Log.d(TAG, "Received result: ${result.javaClass.canonicalName}\nwith challenges: ${challengesRequested.joinToString { it.key }}") + + store.update { + it.copy(changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result), challengesRequested = challengesRequested, inProgress = false) + } + } + + private suspend fun getRegistrationData(context: Context): RegistrationData { + val currentState = store.value + val code = currentState.enteredCode ?: throw IllegalStateException("Can't construct registration data without entered code!") + val e164: String = number.e164Number ?: throw IllegalStateException("Can't construct registration data without E164!") + val recoveryPassword = if (currentState.sessionId == null) SignalStore.svr().getRecoveryPassword() else null + val fcmToken = RegistrationRepository.getFcmToken(context) + return RegistrationData(code, e164, password, RegistrationRepository.getRegistrationId(), RegistrationRepository.getProfileKey(e164), fcmToken, RegistrationRepository.getPniRegistrationId(), recoveryPassword) + } + + // endregion + + // region Utility Functions + + /** + * Used for early returns in order to end the in-progress visual state, as well as print a log message explaining what happened. + * + * @param logMessage Logging code is wrapped in lambda so that our automated tools detect the various [Log] calls with their accompanying messages. + */ + private fun bail(logMessage: () -> Unit) { + logMessage() + store.update { + it.copy(inProgress = false) + } + } + + /** + * Anything that runs through this will be run serially, with locks. + */ + private suspend fun withLockOnSerialExecutor(action: () -> T): T = withContext(serialContext) { + Log.v(TAG, "withLock()") + val result = CHANGE_NUMBER_LOCK.withLock { + SignalStore.misc().lockChangeNumber() + Log.v(TAG, "Change number lock acquired.") + try { + action() + } finally { + SignalStore.misc().unlockChangeNumber() + } + } + Log.v(TAG, "Change number lock released.") + return@withContext result + } + + // endregion + + enum class ContinueStatus { + CAN_CONTINUE, INVALID_NUMBER, OLD_NUMBER_DOESNT_MATCH + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberVerifyV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberVerifyV2Fragment.kt new file mode 100644 index 0000000000..84af2f8ae6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/v2/ChangeNumberVerifyV2Fragment.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber.v2 + +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.signal.core.util.isNotNullOrBlank +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess +import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberVerifyFragmentArgs +import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository +import org.thoughtcrime.securesms.registration.v2.data.network.Challenge +import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Screen to show while the change number is in-progress. + */ +class ChangeNumberVerifyV2Fragment : LoggingFragment(R.layout.fragment_change_phone_number_verify) { + + companion object { + private val TAG: String = Log.tag(ChangeNumberVerifyV2Fragment::class.java) + } + + private val viewModel by activityViewModels() + private var dialogVisible: Boolean = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val toolbar: Toolbar = view.findViewById(R.id.toolbar) + toolbar.setTitle(R.string.ChangeNumberVerifyFragment__change_number) + toolbar.setNavigationOnClickListener { + findNavController().navigateUp() + viewModel.resetLocalSessionState() + } + + val status: TextView = view.findViewById(R.id.change_phone_number_verify_status) + status.text = getString(R.string.ChangeNumberVerifyFragment__verifying_s, viewModel.number.fullFormattedNumber) + + viewModel.uiState.observe(viewLifecycleOwner, ::onStateUpdate) + + requestCode() + } + + private fun onStateUpdate(state: ChangeNumberState) { + if (state.challengesRequested.contains(Challenge.CAPTCHA) && state.captchaToken.isNotNullOrBlank()) { + viewModel.submitCaptchaToken(requireContext()) + } else if (state.challengesRemaining.isNotEmpty()) { + handleChallenges(state.challengesRemaining) + } else if (state.changeNumberOutcome != null) { + handleRequestCodeResult(state.changeNumberOutcome) + } else if (!state.inProgress) { + Log.d(TAG, "Not in progress, navigating up.") + if (state.allowedToRequestCode) { + requestCode() + } else if (!dialogVisible) { + showErrorDialog(R.string.RegistrationActivity_unable_to_request_verification_code) + } + } + } + + private fun requestCode() { + val mode = if (ChangeNumberVerifyFragmentArgs.fromBundle(requireArguments()).smsListenerEnabled) RegistrationRepository.Mode.SMS_WITH_LISTENER else RegistrationRepository.Mode.SMS_WITHOUT_LISTENER + viewModel.initiateChangeNumberSession(requireContext(), mode) + } + + private fun handleRequestCodeResult(changeNumberOutcome: ChangeNumberOutcome) { + Log.d(TAG, "Handling request code result: ${changeNumberOutcome.javaClass.name}") + when (changeNumberOutcome) { + is ChangeNumberOutcome.RecoveryPasswordWorked -> { + Log.i(TAG, "Successfully changed number with recovery password.") + changeNumberSuccess() + } + + is ChangeNumberOutcome.ChangeNumberRequestOutcome -> { + when (val castResult = changeNumberOutcome.result) { + is VerificationCodeRequestResult.Success -> { + Log.i(TAG, "Successfully requested SMS code.") + findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment) + } + + is VerificationCodeRequestResult.ChallengeRequired -> { + Log.i(TAG, "Unable to request sms code due to challenges required: ${castResult.challenges.joinToString { it.key }}") + } + + is VerificationCodeRequestResult.RateLimited -> { + Log.i(TAG, "Unable to request sms code due to rate limit") + showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service) + } + + else -> { + Log.w(TAG, "Unable to request sms code", castResult.getCause()) + showErrorDialog(R.string.RegistrationActivity_unable_to_request_verification_code) + } + } + } + + is ChangeNumberOutcome.VerificationCodeWorked -> { + Log.i(TAG, "Successfully changed number with verification code.") + changeNumberSuccess() + } + } + } + + private fun handleChallenges(remainingChallenges: List) { + Log.i(TAG, "Handling challenge(s): ${remainingChallenges.joinToString { it.key }}") + when (remainingChallenges.first()) { + Challenge.CAPTCHA -> { + findNavController().safeNavigate(ChangeNumberVerifyV2FragmentDirections.actionChangePhoneNumberVerifyFragmentToCaptchaFragment()) + } + + Challenge.PUSH -> { + viewModel.requestAndSubmitPushToken(requireContext()) + } + } + } + + private fun showErrorDialog(@StringRes message: Int) { + if (dialogVisible) { + Log.i(TAG, "Dialog already being shown, failed to display dialog with message ${getString(message)}") + return + } + + MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(message) + setPositiveButton(android.R.string.ok) { _, _ -> + findNavController().navigateUp() + viewModel.resetLocalSessionState() + } + show() + dialogVisible = true + } + } +} diff --git a/app/src/main/res/layout/fragment_change_number_enter_code.xml b/app/src/main/res/layout/fragment_change_number_enter_code.xml index ec6f8f7174..20e4aa889d 100644 --- a/app/src/main/res/layout/fragment_change_number_enter_code.xml +++ b/app/src/main/res/layout/fragment_change_number_enter_code.xml @@ -1,7 +1,5 @@ @@ -9,7 +7,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_change_phone_number_v2.xml b/app/src/main/res/layout/fragment_change_phone_number_v2.xml new file mode 100644 index 0000000000..559404fa1e --- /dev/null +++ b/app/src/main/res/layout/fragment_change_phone_number_v2.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/app_settings_change_number_v2.xml b/app/src/main/res/navigation/app_settings_change_number_v2.xml new file mode 100644 index 0000000000..998dda76dd --- /dev/null +++ b/app/src/main/res/navigation/app_settings_change_number_v2.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/app_settings_with_change_number_v2.xml b/app/src/main/res/navigation/app_settings_with_change_number_v2.xml new file mode 100644 index 0000000000..452505d312 --- /dev/null +++ b/app/src/main/res/navigation/app_settings_with_change_number_v2.xml @@ -0,0 +1,951 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file