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