Delete registration V1.

This commit is contained in:
Nicholas Tinsley
2024-06-25 10:01:27 -04:00
parent f11028529e
commit d7b5c6bff3
140 changed files with 1658 additions and 9190 deletions

View File

@@ -6,7 +6,6 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class UnauthorizedReminder extends Reminder {

View File

@@ -22,7 +22,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private const val START_LOCATION = "app.settings.start.location"
@@ -40,8 +39,7 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
val navGraphResId = if (RemoteConfig.registrationV2) R.navigation.app_settings_with_change_number_v2 else R.navigation.app_settings
intent?.putExtra(ARG_NAV_GRAPH, navGraphResId)
intent?.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings_with_change_number)
}
super.onCreate(savedInstanceState, ready)
@@ -197,9 +195,8 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
fun usernameRecovery(context: Context): Intent = getIntentForStartLocation(context, StartLocation.RECOVER_USERNAME)
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
val navGraphResId = if (RemoteConfig.registrationV2) R.navigation.app_settings_with_change_number_v2 else R.navigation.app_settings
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, navGraphResId)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings_with_change_number)
.putExtra(START_LOCATION, startLocation.code)
}
}

View File

@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.events.ReminderUpdateEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.RemoteConfig
@@ -114,7 +114,7 @@ class AppSettingsFragment : DSLSettingsFragment(
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
}
R.id.reminder_action_re_register -> {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
startActivity(RegistrationActivity.newIntentForReRegistration(requireContext()))
}
}
}

View File

@@ -32,7 +32,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ViewUtil
@@ -151,7 +151,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.preferences_account_reregister),
onClick = {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
startActivity(RegistrationActivity.newIntentForReRegistration(requireContext()))
}
)
}

View File

@@ -1,27 +1,74 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
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.BaseAccountLockedFragment
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import java.util.concurrent.TimeUnit
class ChangeNumberAccountLockedFragment : BaseAccountLockedFragment(R.layout.fragment_change_number_account_locked) {
/**
* Screen visible to the user when they are registration locked and have no SVR data.
*/
class ChangeNumberAccountLockedFragment : LoggingFragment(R.layout.fragment_change_number_account_locked) {
companion object {
private val TAG = Log.tag(ChangeNumberAccountLockedFragment::class.java)
}
private val viewModel by activityViewModels<ChangeNumberViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
setDebugLogSubmitMultiTapView(view.findViewById(R.id.account_locked_title))
val description = view.findViewById<TextView>(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<View>(R.id.account_locked_next).setOnClickListener { onNext() }
view.findViewById<View>(R.id.account_locked_learn_more).setOnClickListener { learnMore() }
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onNext()
}
}
)
}
override fun getViewModel(): BaseRegistrationViewModel {
return ChangeNumberUtil.getViewModel(this)
private fun learnMore() {
val intent = Intent(Intent.ACTION_VIEW)
intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url)))
startActivity(intent)
}
override fun onNext() {
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()
}
}

View File

@@ -3,19 +3,19 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
package org.thoughtcrime.securesms.components.settings.app.changenumber
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
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.ui.captcha.CaptchaFragment
/**
* Screen visible to the user when they are to solve a captcha. @see [CaptchaV2Fragment]
* Screen visible to the user when they are to solve a captcha. @see [CaptchaFragment]
*/
class ChangeNumberCaptchaV2Fragment : CaptchaV2Fragment() {
private val viewModel by activityViewModels<ChangeNumberV2ViewModel>()
class ChangeNumberCaptchaFragment : CaptchaFragment() {
private val viewModel by activityViewModels<ChangeNumberViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.addPresentedChallenge(Challenge.CAPTCHA)

View File

@@ -1,24 +1,33 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
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 com.google.android.gms.auth.api.phone.SmsRetriever
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.PlayServicesUtil.PlayServicesStatus
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Screen visible to the user for them to confirm their new phone number was entered correctly.
*/
class ChangeNumberConfirmFragment : LoggingFragment(R.layout.fragment_change_number_confirm) {
private lateinit var viewModel: ChangeNumberViewModel
companion object {
private val TAG = Log.tag(ChangeNumberConfirmFragment::class.java)
}
private val viewModel by activityViewModels<ChangeNumberViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel = ChangeNumberUtil.getViewModel(this)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setTitle(R.string.ChangeNumberEnterPhoneNumberFragment__change_number)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
@@ -33,35 +42,14 @@ class ChangeNumberConfirmFragment : LoggingFragment(R.layout.fragment_change_num
editNumber.setOnClickListener { findNavController().navigateUp() }
val changeNumber: View = view.findViewById(R.id.change_number_confirm_change_number)
changeNumber.setOnClickListener { onConfirm() }
}
private fun onConfirm() {
val playServicesAvailable = PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesStatus.SUCCESS
if (playServicesAvailable) {
val client = SmsRetriever.getClient(requireContext())
val task = client.startSmsRetriever()
task.addOnSuccessListener {
Log.i(TAG, "Successfully registered SMS listener.")
navigateToVerify(smsListenerEnabled = true)
changeNumber.setOnClickListener {
viewModel.registerSmsListenerWithCompletionListener(requireContext()) {
navigateToVerify(it)
}
task.addOnFailureListener { e ->
Log.w(TAG, "Failed to register SMS listener.", e)
navigateToVerify()
}
} else {
navigateToVerify()
}
}
private fun navigateToVerify(smsListenerEnabled: Boolean = false) {
findNavController().safeNavigate(R.id.action_changePhoneNumberConfirmFragment_to_changePhoneNumberVerifyFragment, ChangeNumberVerifyFragmentArgs.Builder().setSmsListenerEnabled(smsListenerEnabled).build().toBundle())
}
companion object {
private val TAG = Log.tag(ChangeNumberConfirmFragment::class.java)
findNavController().safeNavigate(ChangeNumberConfirmFragmentDirections.actionChangePhoneNumberConfirmFragmentToChangePhoneNumberVerifyFragment(smsListenerEnabled))
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.content.DialogInterface
import android.os.Bundle
@@ -24,12 +24,12 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
import org.thoughtcrime.securesms.databinding.FragmentChangeNumberEnterCodeBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ReceivedSmsEvent
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
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
@@ -38,13 +38,13 @@ 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) {
class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_number_enter_code) {
companion object {
private val TAG: String = Log.tag(ChangeNumberEnterCodeV2Fragment::class.java)
private val TAG: String = Log.tag(ChangeNumberEnterCodeFragment::class.java)
}
private val viewModel by activityViewModels<ChangeNumberV2ViewModel>()
private val viewModel by activityViewModels<ChangeNumberViewModel>()
private val binding: FragmentChangeNumberEnterCodeBinding by ViewBinderDelegate(FragmentChangeNumberEnterCodeBinding::bind)
private lateinit var phoneStateListener: SignalStrengthPhoneStateListener
@@ -154,7 +154,7 @@ class ChangeNumberEnterCodeV2Fragment : LoggingFragment(R.layout.fragment_change
private fun navigateUp() {
if (SignalStore.misc.isChangeNumberLocked) {
Log.d(TAG, "Change number locked, navigateUp")
startActivity(ChangeNumberLockV2Activity.createIntent(requireContext()))
startActivity(ChangeNumberLockActivity.createIntent(requireContext()))
} else {
Log.d(TAG, "navigateUp")
findNavController().navigateUp()
@@ -187,7 +187,7 @@ class ChangeNumberEnterCodeV2Fragment : LoggingFragment(R.layout.fragment_change
binding.codeEntryLayout.keyboard.displayLocked().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
findNavController().safeNavigate(ChangeNumberEnterCodeV2FragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
findNavController().safeNavigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
}
}
)
@@ -198,7 +198,7 @@ class ChangeNumberEnterCodeV2Fragment : LoggingFragment(R.layout.fragment_change
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
Log.i(TAG, "Account is registration locked, cannot register.")
findNavController().safeNavigate(ChangeNumberEnterCodeV2FragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
findNavController().safeNavigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
}
}
)

View File

@@ -1,86 +1,71 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import android.widget.ScrollView
import android.widget.Spinner
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.LabeledEditText
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberViewModel.ContinueStatus
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentChangeNumberEnterPhoneNumberBinding
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
private const val OLD_NUMBER_COUNTRY_SELECT = "old_number_country"
private const val NEW_NUMBER_COUNTRY_SELECT = "new_number_country"
/**
* Screen for the user to enter their old and new phone numbers.
*/
class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_change_number_enter_phone_number) {
private var binding: FragmentChangeNumberEnterPhoneNumberBinding? = null
companion object {
private val TAG: String = Log.tag(ChangeNumberEnterPhoneNumberFragment::class.java)
private val scrollView: ScrollView
get() = binding!!.changeNumberEnterPhoneNumberScroll
private const val OLD_NUMBER_COUNTRY_SELECT = "old_number_country"
private const val NEW_NUMBER_COUNTRY_SELECT = "new_number_country"
}
private val oldNumberCountrySpinner: Spinner
get() = binding!!.changeNumberEnterPhoneNumberOldNumberSpinner
private val oldNumberCountryCode: LabeledEditText
get() = binding!!.changeNumberEnterPhoneNumberOldNumberCountryCode
private val oldNumber: LabeledEditText
get() = binding!!.changeNumberEnterPhoneNumberOldNumberNumber
private val newNumberCountrySpinner: Spinner
get() = binding!!.changeNumberEnterPhoneNumberNewNumberSpinner
private val newNumberCountryCode: LabeledEditText
get() = binding!!.changeNumberEnterPhoneNumberNewNumberCountryCode
private val newNumber: LabeledEditText
get() = binding!!.changeNumberEnterPhoneNumberNewNumberNumber
private lateinit var viewModel: ChangeNumberViewModel
private val binding: FragmentChangeNumberEnterPhoneNumberBinding by ViewBinderDelegate(FragmentChangeNumberEnterPhoneNumberBinding::bind)
private val viewModel by activityViewModels<ChangeNumberViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentChangeNumberEnterPhoneNumberBinding.bind(view)
viewModel = getViewModel(this)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setTitle(R.string.ChangeNumberEnterPhoneNumberFragment__change_number)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
view.findViewById<View>(R.id.change_number_enter_phone_number_continue).setOnClickListener {
binding.changeNumberEnterPhoneNumberContinue.setOnClickListener {
onContinue()
}
val oldController = ChangeNumberInputController(
requireContext(),
oldNumberCountryCode,
oldNumber,
oldNumberCountrySpinner,
binding.changeNumberEnterPhoneNumberOldNumberCountryCode,
binding.changeNumberEnterPhoneNumberOldNumberNumber,
binding.changeNumberEnterPhoneNumberOldNumberSpinner,
false,
object : ChangeNumberInputController.Callbacks {
override fun onNumberFocused() {
scrollView.postDelayed({ scrollView.smoothScrollTo(0, oldNumber.bottom) }, 250)
binding.changeNumberEnterPhoneNumberScroll.postDelayed({ binding.changeNumberEnterPhoneNumberScroll.smoothScrollTo(0, binding.changeNumberEnterPhoneNumberOldNumberNumber.bottom) }, 250)
}
override fun onNumberInputNext(view: View) {
newNumberCountryCode.requestFocus()
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())
findNavController().safeNavigate(ChangeNumberEnterPhoneNumberFragmentDirections.actionEnterPhoneNumberChangeFragmentToCountryPickerFragment(OLD_NUMBER_COUNTRY_SELECT))
}
override fun setNationalNumber(number: String) {
@@ -95,13 +80,13 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
val newController = ChangeNumberInputController(
requireContext(),
newNumberCountryCode,
newNumber,
newNumberCountrySpinner,
binding.changeNumberEnterPhoneNumberNewNumberCountryCode,
binding.changeNumberEnterPhoneNumberNewNumberNumber,
binding.changeNumberEnterPhoneNumberNewNumberSpinner,
true,
object : ChangeNumberInputController.Callbacks {
override fun onNumberFocused() {
scrollView.postDelayed({ scrollView.smoothScrollTo(0, newNumber.bottom) }, 250)
binding.changeNumberEnterPhoneNumberScroll.postDelayed({ binding.changeNumberEnterPhoneNumberScroll.smoothScrollTo(0, binding.changeNumberEnterPhoneNumberNewNumberNumber.bottom) }, 250)
}
override fun onNumberInputNext(view: View) = Unit
@@ -111,9 +96,7 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
}
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())
findNavController().safeNavigate(ChangeNumberEnterPhoneNumberFragmentDirections.actionEnterPhoneNumberChangeFragmentToCountryPickerFragment(NEW_NUMBER_COUNTRY_SELECT))
}
override fun setNationalNumber(number: String) {
@@ -130,50 +113,45 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
viewModel.setOldCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY))
}
parentFragmentManager.setFragmentResultListener(NEW_NUMBER_COUNTRY_SELECT, this) { _, bundle ->
parentFragmentManager.setFragmentResultListener(NEW_NUMBER_COUNTRY_SELECT, this) { _: String, bundle: Bundle ->
viewModel.setNewCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY))
}
viewModel.getLiveOldNumber().observe(viewLifecycleOwner, oldController::updateNumber)
viewModel.getLiveNewNumber().observe(viewLifecycleOwner, newController::updateNumber)
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
viewModel.liveOldNumberState.observe(viewLifecycleOwner, oldController::updateNumber)
viewModel.liveNewNumberState.observe(viewLifecycleOwner, newController::updateNumber)
}
private fun onContinue() {
if (TextUtils.isEmpty(oldNumberCountryCode.text)) {
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(oldNumber.text)) {
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(newNumberCountryCode.text)) {
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(newNumber.text)) {
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()) {
ContinueStatus.CAN_CONTINUE -> findNavController().safeNavigate(R.id.action_enterPhoneNumberChangeFragment_to_changePhoneNumberConfirmFragment)
ContinueStatus.INVALID_NUMBER -> {
ChangeNumberViewModel.ContinueStatus.CAN_CONTINUE -> findNavController().safeNavigate(ChangeNumberEnterPhoneNumberFragmentDirections.actionEnterPhoneNumberChangeFragmentToChangePhoneNumberConfirmFragment())
ChangeNumberViewModel.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)
)
}
ContinueStatus.OLD_NUMBER_DOESNT_MATCH -> {
ChangeNumberViewModel.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)

View File

@@ -1,77 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getCaptchaArguments
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.fragments.BaseEnterSmsCodeFragment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private val TAG: String = Log.tag(ChangeNumberEnterSmsCodeFragment::class.java)
class ChangeNumberEnterSmsCodeFragment : BaseEnterSmsCodeFragment<ChangeNumberViewModel>(R.layout.fragment_change_number_enter_code) {
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()
}
view.findViewById<View>(R.id.verify_header).setOnClickListener(null)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
Log.d(TAG, "onBackPressed")
navigateUp()
}
}
)
}
private fun navigateUp() {
if (SignalStore.misc.isChangeNumberLocked) {
Log.d(TAG, "Change number locked, navigateUp")
startActivity(ChangeNumberLockActivity.createIntent(requireContext()))
} else {
Log.d(TAG, "navigateUp")
findNavController().navigateUp()
}
}
override fun getViewModel(): ChangeNumberViewModel {
return getViewModel(this)
}
override fun handleSuccessfulVerify() {
Log.d(TAG, "handleSuccessfulVerify")
displaySuccess { changeNumberSuccess() }
}
override fun navigateToCaptcha() {
Log.d(TAG, "navigateToCaptcha")
findNavController().safeNavigate(R.id.action_changeNumberEnterCodeFragment_to_captchaFragment, getCaptchaArguments())
}
override fun navigateToRegistrationLock(timeRemaining: Long) {
Log.d(TAG, "navigateToRegistrationLock")
findNavController().safeNavigate(ChangeNumberEnterSmsCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
}
override fun navigateToKbsAccountLocked() {
Log.d(TAG, "navigateToKbsAccountLocked")
findNavController().safeNavigate(ChangeNumberEnterSmsCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
}
}

View File

@@ -1,20 +1,38 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
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.FragmentChangePhoneNumberBinding
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 ChangeNumberFragment : LoggingFragment(R.layout.fragment_change_phone_number) {
companion object {
private val TAG = Log.tag(ChangeNumberFragment::class.java)
}
private val binding: FragmentChangePhoneNumberBinding by ViewBinderDelegate(FragmentChangePhoneNumberBinding::bind)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
view.findViewById<View>(R.id.change_phone_number_continue).setOnClickListener {
findNavController().safeNavigate(R.id.action_changePhoneNumberFragment_to_enterPhoneNumberChangeFragment)
binding.changePhoneNumberContinue.setOnClickListener {
findNavController().safeNavigate(ChangeNumberFragmentDirections.actionChangePhoneNumberFragmentToEnterPhoneNumberChangeFragment())
}
}
}

View File

@@ -1,14 +1,16 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.annotation.SuppressLint
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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity
@@ -18,10 +20,6 @@ import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.util.Objects
private val TAG: String = Log.tag(ChangeNumberLockActivity::class.java)
/**
* A captive activity that can determine if an interrupted/erred change number request
@@ -29,17 +27,34 @@ private val TAG: String = Log.tag(ChangeNumberLockActivity::class.java)
*/
class ChangeNumberLockActivity : PassphraseRequiredActivity() {
companion object {
private val TAG: String = Log.tag(ChangeNumberLockActivity::class.java)
@JvmStatic
fun createIntent(context: Context): Intent {
return Intent(context, ChangeNumberLockActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
}
}
private val viewModel: ChangeNumberViewModel by viewModels()
private val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
private val disposables: LifecycleDisposable = LifecycleDisposable()
private lateinit var changeNumberRepository: ChangeNumberRepository
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
dynamicTheme.onCreate(this)
disposables.bindTo(lifecycle)
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
Log.d(TAG, "Back button press swallowed.")
}
}
)
setContentView(R.layout.activity_change_number_lock)
changeNumberRepository = ChangeNumberRepository()
checkWhoAmI()
}
@@ -48,31 +63,11 @@ class ChangeNumberLockActivity : PassphraseRequiredActivity() {
dynamicTheme.onResume(this)
}
@SuppressLint("MissingSuperCall")
override fun onBackPressed() = Unit
private fun checkWhoAmI() {
disposables += changeNumberRepository
.whoAmI()
.flatMap { whoAmI ->
if (Objects.equals(whoAmI.number, SignalStore.account.e164)) {
Log.i(TAG, "Local and remote numbers match, nothing needs to be done.")
Single.just(false)
} else {
Log.i(TAG, "Local (${SignalStore.account.e164}) and remote (${whoAmI.number}) numbers do not match, updating local.")
Single
.just(true)
.flatMap { changeNumberRepository.changeLocalNumber(whoAmI.number, PNI.parseOrThrow(whoAmI.pni)) }
.compose(ChangeNumberRepository::acquireReleaseChangeNumberLock)
.map { true }
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onSuccess = { onChangeStatusConfirmed() }, onError = this::onFailedToGetChangeNumberStatus)
viewModel.checkWhoAmI(::onChangeStatusConfirmed, ::onFailedToGetChangeNumberStatus)
}
private fun onChangeStatusConfirmed() {
SignalStore.misc.unlockChangeNumber()
SignalStore.misc.clearPendingChangeNumberMetadata()
MaterialAlertDialogBuilder(this)
@@ -101,13 +96,4 @@ class ChangeNumberLockActivity : PassphraseRequiredActivity() {
.setCancelable(false)
.show()
}
companion object {
@JvmStatic
fun createIntent(context: Context): Intent {
return Intent(context, ChangeNumberLockActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
}
}
}

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
@@ -5,13 +10,31 @@ 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 ChangeNumberPinDiffersFragment : LoggingFragment(R.layout.fragment_change_number_pin_differs) {
companion object {
private val TAG = Log.tag(ChangeNumberPinDiffersFragment::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<View>(R.id.change_number_pin_differs_keep_old_pin).setOnClickListener {
changeNumberSuccess()
@@ -27,17 +50,6 @@ class ChangeNumberPinDiffersFragment : LoggingFragment(R.layout.fragment_change_
changePin.launch(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()))
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
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()
}
}
)
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, confirmCancelDialog)
}
}

View File

@@ -1,25 +1,60 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
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.registration.fragments.BaseRegistrationLockFragment
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
import org.thoughtcrime.securesms.registration.ui.registrationlock.RegistrationLockFragment
import org.thoughtcrime.securesms.registration.ui.registrationlock.RegistrationLockFragmentArgs
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
class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layout.fragment_change_number_registration_lock) {
/**
* Screen presented to the user if the new account is registration locked, and allows them to enter their PIN.
*/
class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_change_number_registration_lock) {
companion object {
private val TAG = Log.tag(RegistrationLockFragment::class.java)
}
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind)
private val viewModel by activityViewModels<ChangeNumberViewModel>()
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() }
@@ -31,20 +66,242 @@ class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layo
}
}
)
val args: RegistrationLockFragmentArgs = RegistrationLockFragmentArgs.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)
}
override fun getViewModel(): BaseRegistrationViewModel {
return ChangeNumberUtil.getViewModel(this)
private fun onStateUpdate(state: ChangeNumberState) {
if (state.changeNumberOutcome == ChangeNumberOutcome.VerificationCodeWorked) {
handleSuccessfulPinEntry(state.enteredPin)
}
}
override fun navigateToAccountLocked() {
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(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberAccountLocked())
}
override fun handleSuccessfulPinEntry(pin: String) {
private fun handleSuccessfulPinEntry(pin: String) {
val pinsDiffer: Boolean = SignalStore.svr.localPinHash?.let { !PinHashUtil.verifyLocalPinHash(it, pin) } ?: false
pinButton.cancelSpinning()
binding.kbsLockPinConfirm.cancelSpinning()
if (pinsDiffer) {
findNavController().safeNavigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberPinDiffers())
@@ -53,22 +310,12 @@ class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layo
}
}
override fun sendEmailToSupport() {
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
)
val body: String = SupportEmailUtil.generateSupportEmailBody(requireContext(), subject, null, null)
CommunicationActions.openEmail(
requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getString(subject),
body
)
CommunicationActions.openEmail(requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), getString(subject), body)
}
private fun navigateUp() {

View File

@@ -1,9 +1,15 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
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
@@ -25,23 +31,21 @@ 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.VerifyResponse
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.PNI
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.ServiceResponse
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import org.whispersystems.signalservice.internal.push.SyncMessage
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
@@ -49,14 +53,14 @@ import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevic
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
private val TAG: String = Log.tag(ChangeNumberRepository::class.java)
import kotlin.coroutines.resume
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Provides various change number operations. All operations must run on [Schedulers.single] to support
* the global "I am changing the number" lock exclusivity.
* Repository to perform data operations during change number.
*
* @see [org.thoughtcrime.securesms.registration.data.RegistrationRepository]
*/
class ChangeNumberRepository(
private val accountManager: SignalServiceAccountManager = AppDependencies.signalServiceAccountManager,
@@ -64,157 +68,42 @@ class ChangeNumberRepository(
) {
companion object {
/**
* This lock should be held by anyone who is performing a change number operation, so that two different parties cannot change the user's number
* at the same time.
*/
val CHANGE_NUMBER_LOCK = ReentrantLock()
private val TAG = Log.tag(ChangeNumberRepository::class.java)
}
/**
* Adds Rx operators to chain to acquire and release the [CHANGE_NUMBER_LOCK] on subscribe and on finish.
*/
fun <T : Any> acquireReleaseChangeNumberLock(upstream: Single<T>): Single<T> {
return upstream.doOnSubscribe {
CHANGE_NUMBER_LOCK.lock()
SignalStore.misc.lockChangeNumber()
}
.subscribeOn(Schedulers.single())
.observeOn(Schedulers.single())
.doFinally {
if (CHANGE_NUMBER_LOCK.isHeldByCurrentThread) {
CHANGE_NUMBER_LOCK.unlock()
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)
}
}
}
}
fun ensureDecryptionsDrained(): Completable {
return Completable.create { emitter ->
val drainedListener = object : Runnable {
override fun run() {
emitter.onComplete()
it.invokeOnCancellation { cancellationCause ->
AppDependencies
.incomingMessageObserver
.removeDecryptionDrainedListener(this)
.removeDecryptionDrainedListener(drainedListener)
Log.d(TAG, "Decryptions draining canceled.", cancellationCause)
}
}
emitter.setCancellable {
AppDependencies
.incomingMessageObserver
.removeDecryptionDrainedListener(drainedListener)
.addDecryptionDrainedListener(drainedListener)
Log.d(TAG, "Waiting for decryption drain.")
}
AppDependencies
.incomingMessageObserver
.addDecryptionDrainedListener(drainedListener)
}.subscribeOn(Schedulers.single())
.timeout(15, TimeUnit.SECONDS)
}
fun changeNumber(sessionId: String? = null, recoveryPassword: String? = null, newE164: String): Single<ServiceResponse<VerifyResponse>> {
check((sessionId != null && recoveryPassword == null) || (sessionId == null && recoveryPassword != null))
return Single.fromCallable {
var completed = false
var attempts = 0
lateinit var changeNumberResponse: ServiceResponse<VerifyAccountResponse>
while (!completed && attempts < 5) {
val (request: ChangePhoneNumberRequest, metadata: PendingChangeNumberMetadata) = createChangeNumberRequest(
sessionId = sessionId,
recoveryPassword = recoveryPassword,
newE164 = newE164
)
SignalStore.misc.setPendingChangeNumberMetadata(metadata)
changeNumberResponse = accountManager.changeNumber(request)
val possibleError: Throwable? = changeNumberResponse.applicationError.orElse(null)
if (possibleError is MismatchedDevicesException) {
messageSender.handleChangeNumberMismatchDevices(possibleError.mismatchedDevices)
attempts++
} else {
completed = true
}
}
VerifyResponse.from(
response = changeNumberResponse,
masterKey = null,
pin = null,
aciPreKeyCollection = null,
pniPreKeyCollection = null
)
}.subscribeOn(Schedulers.single())
.onErrorReturn { t -> ServiceResponse.forExecutionError(t) }
}
fun changeNumber(
sessionId: String,
newE164: String,
pin: String,
svrAuthCredentials: SvrAuthCredentialSet
): Single<ServiceResponse<VerifyResponse>> {
return Single.fromCallable {
val masterKey: MasterKey
val registrationLock: String
try {
masterKey = SvrRepository.restoreMasterKeyPreRegistration(svrAuthCredentials, pin)
registrationLock = masterKey.deriveRegistrationLock()
} catch (e: SvrWrongPinException) {
return@fromCallable ServiceResponse.forExecutionError(e)
} catch (e: SvrNoDataException) {
return@fromCallable ServiceResponse.forExecutionError(e)
} catch (e: IOException) {
return@fromCallable ServiceResponse.forExecutionError(e)
}
var completed = false
var attempts = 0
lateinit var changeNumberResponse: ServiceResponse<VerifyAccountResponse>
while (!completed && attempts < 5) {
val (request: ChangePhoneNumberRequest, metadata: PendingChangeNumberMetadata) = createChangeNumberRequest(
sessionId = sessionId,
newE164 = newE164,
registrationLock = registrationLock
)
SignalStore.misc.setPendingChangeNumberMetadata(metadata)
changeNumberResponse = accountManager.changeNumber(request)
val possibleError: Throwable? = changeNumberResponse.applicationError.orElse(null)
if (possibleError is MismatchedDevicesException) {
messageSender.handleChangeNumberMismatchDevices(possibleError.mismatchedDevices)
attempts++
} else {
completed = true
}
}
VerifyResponse.from(
response = changeNumberResponse,
masterKey = masterKey,
pin = pin,
aciPreKeyCollection = null,
pniPreKeyCollection = null
)
}.subscribeOn(Schedulers.single())
.onErrorReturn { t -> ServiceResponse.forExecutionError(t) }
}
@Suppress("UsePropertyAccessSyntax")
fun whoAmI(): Single<WhoAmIResponse> {
return Single.fromCallable { AppDependencies.signalServiceAccountManager.getWhoAmI() }
.subscribeOn(Schedulers.single())
}
}
@WorkerThread
fun changeLocalNumber(e164: String, pni: PNI): Single<Unit> {
fun changeLocalNumber(e164: String, pni: ServiceId.PNI) {
val oldStorageId: ByteArray? = Recipient.self().storageId
SignalDatabase.recipients.updateSelfE164(e164, pni)
val newStorageId: ByteArray? = Recipient.self().storageId
@@ -243,7 +132,7 @@ class ChangeNumberRepository(
throw AssertionError("No change number metadata")
}
val originalPni = PNI.parseOrThrow(metadata.previousPni)
val originalPni = ServiceId.PNI.parseOrThrow(metadata.previousPni)
if (originalPni == pni) {
Log.i(TAG, "No change has occurred, PNI is unchanged: $pni")
@@ -269,6 +158,8 @@ class ChangeNumberRepository(
}
pniMetadataStore.activeSignedPreKeyId = signedPreKey.id
Log.i(TAG, "Submitting prekeys with PNI identity key: ${pniIdentityKeyPair.publicKey.fingerprint}")
accountManager.setPreKeys(
PreKeyUpload(
serviceIdType = ServiceIdType.PNI,
@@ -303,28 +194,100 @@ class ChangeNumberRepository(
AppDependencies.jobManager.add(RefreshAttributesJob())
return rotateCertificates()
rotateCertificates()
}
@Suppress("UsePropertyAccessSyntax")
private fun rotateCertificates(): Single<Unit> {
@WorkerThread
private fun rotateCertificates() {
val certificateTypes = SignalStore.phoneNumberPrivacy.allCertificateTypes
Log.i(TAG, "Rotating these certificates $certificateTypes")
return Single.fromCallable {
for (certificateType in certificateTypes) {
val certificate: ByteArray? = when (certificateType) {
CertificateType.ACI_AND_E164 -> accountManager.getSenderCertificate()
CertificateType.ACI_ONLY -> accountManager.getSenderCertificateForPhoneNumberPrivacy()
else -> throw AssertionError()
}
Log.i(TAG, "Successfully got $certificateType certificate")
SignalStore.certificate.setUnidentifiedAccessCertificate(certificateType, certificate)
for (certificateType in certificateTypes) {
val certificate: ByteArray? = when (certificateType) {
CertificateType.ACI_AND_E164 -> accountManager.senderCertificate
CertificateType.ACI_ONLY -> accountManager.senderCertificateForPhoneNumberPrivacy
else -> throw AssertionError()
}
}.subscribeOn(Schedulers.single())
Log.i(TAG, "Successfully got $certificateType certificate")
SignalStore.certificate.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<VerifyAccountResponse>
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
@@ -410,11 +373,12 @@ class ChangeNumberRepository(
return ChangeNumberRequestData(request, metadata)
}
fun verifyAccount(sessionId: String, code: String): Single<ServiceResponse<RegistrationSessionMetadataResponse>> {
return Single.fromCallable {
accountManager.verifyAccount(code, sessionId)
}.subscribeOn(Schedulers.io())
}
private data class ChangeNumberRequestData(val changeNumberRequest: ChangePhoneNumberRequest, val pendingChangeNumberMetadata: PendingChangeNumberMetadata)
data class ChangeNumberRequestData(val changeNumberRequest: ChangePhoneNumberRequest, val pendingChangeNumberMetadata: PendingChangeNumberMetadata)
data class NumberChangeResult(
val uuid: String,
val pni: String,
val storageCapable: Boolean,
val number: String
)
}

View File

@@ -3,10 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
package org.thoughtcrime.securesms.components.settings.app.changenumber
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException
@@ -23,7 +23,7 @@ import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
*/
sealed class ChangeNumberResult(cause: Throwable?) : RegistrationResult(cause) {
companion object {
fun from(networkResult: NetworkResult<ChangeNumberV2Repository.NumberChangeResult>): ChangeNumberResult {
fun from(networkResult: NetworkResult<ChangeNumberRepository.NumberChangeResult>): ChangeNumberResult {
return when (networkResult) {
is NetworkResult.Success -> Success(networkResult.result)
is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable)
@@ -56,7 +56,7 @@ sealed class ChangeNumberResult(cause: Throwable?) : RegistrationResult(cause) {
}
}
class Success(val numberChangeResult: ChangeNumberV2Repository.NumberChangeResult) : ChangeNumberResult(null)
class Success(val numberChangeResult: ChangeNumberRepository.NumberChangeResult) : ChangeNumberResult(null)
class IncorrectRecoveryPassword(cause: Throwable) : ChangeNumberResult(cause)
class AuthorizationFailed(cause: Throwable) : ChangeNumberResult(cause)
class MalformedRequest(cause: Throwable) : ChangeNumberResult(cause)

View File

@@ -3,16 +3,16 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
package org.thoughtcrime.securesms.components.settings.app.changenumber
import org.thoughtcrime.securesms.registration.v2.data.network.Challenge
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.whispersystems.signalservice.api.svr.Svr3Credentials
import org.whispersystems.signalservice.internal.push.AuthCredentials
/**
* State holder for [ChangeNumberV2ViewModel]
* State holder for [ChangeNumberViewModel]
*/
data class ChangeNumberState(
val number: NumberViewState = NumberViewState.INITIAL,

View File

@@ -1,37 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.registration.fragments.CaptchaFragment
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
/**
* Helpers for various aspects of the change number flow.
*/
object ChangeNumberUtil {
@JvmStatic
fun getViewModel(fragment: Fragment): ChangeNumberViewModel {
val navController = NavHostFragment.findNavController(fragment)
return ViewModelProvider(
navController.getViewModelStoreOwner(R.id.app_settings_change_number),
ChangeNumberViewModel.Factory(navController.getBackStackEntry(R.id.app_settings_change_number))
).get(ChangeNumberViewModel::class.java)
}
fun getCaptchaArguments(): Bundle {
return Bundle().apply {
putSerializable(
CaptchaFragment.EXTRA_VIEW_MODEL_PROVIDER,
object : CaptchaFragment.CaptchaViewModelProvider {
override fun get(fragment: CaptchaFragment): BaseRegistrationViewModel = getViewModel(fragment)
}
)
}
}
fun Fragment.changeNumberSuccess() {
requireActivity().finish()

View File

@@ -1,109 +1,148 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.content.Context
import android.content.DialogInterface.OnClickListener
import android.os.Bundle
import android.view.View
import android.widget.TextView
import android.widget.Toast
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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.concurrent.LifecycleDisposable
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.ChangeNumberUtil.getCaptchaArguments
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private val TAG: String = Log.tag(ChangeNumberVerifyFragment::class.java)
/**
* Screen to show while the change number is in-progress.
*/
class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phone_number_verify) {
private lateinit var viewModel: ChangeNumberViewModel
private var requestingCaptcha: Boolean = false
private val lifecycleDisposable: LifecycleDisposable = LifecycleDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleDisposable.bindTo(lifecycle)
viewModel = getViewModel(this)
companion object {
private val TAG: String = Log.tag(ChangeNumberVerifyFragment::class.java)
}
private val viewModel by activityViewModels<ChangeNumberViewModel>()
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() }
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)
if (!requestingCaptcha || viewModel.hasCaptchaToken()) {
requestCode()
} else {
Log.d(TAG, "Captcha required.")
Toast.makeText(requireContext(), R.string.ChangeNumberVerifyFragment__captcha_required, Toast.LENGTH_SHORT).show()
findNavController().navigateUp()
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) VerifyAccountRepository.Mode.SMS_WITH_LISTENER else VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER
val mccMncProducer = MccMncProducer(requireContext())
lifecycleDisposable += viewModel
.ensureDecryptionsDrained()
.onErrorComplete()
.andThen(viewModel.changeNumberWithRecoveryPassword())
.flatMap { changed ->
if (changed) {
Log.d(TAG, "Successfully changed number using recovery password.")
Single.just(RequestCodeResult.RecoveryPasswordWorked)
} else {
viewModel.requestVerificationCode(mode, mccMncProducer.mcc, mccMncProducer.mnc)
.map { p -> RequestCodeResult.RequestedVerificationCode(p) }
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result ->
if (result is RequestCodeResult.RecoveryPasswordWorked) {
changeNumberSuccess()
return@subscribe
}
val processor: RegistrationSessionProcessor = (result as RequestCodeResult.RequestedVerificationCode).processor
if (processor.verificationCodeRequestSuccess()) {
Log.i(TAG, "Successfully requested SMS code.")
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
} else if (processor.captchaRequired(viewModel.excludedChallenges)) {
Log.i(TAG, "Unable to request sms code due to captcha required")
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_captchaFragment, getCaptchaArguments())
requestingCaptcha = true
} else if (processor.rateLimit()) {
Log.i(TAG, "Unable to request sms code due to rate limit")
showErrorDialog(requireContext(), R.string.RegistrationActivity_rate_limited_to_service) { _, _ -> findNavController().navigateUp() }
} else {
Log.w(TAG, "Unable to request sms code", processor.error)
showErrorDialog(requireContext(), R.string.RegistrationActivity_unable_to_request_verification_code) { _, _ -> findNavController().navigateUp() }
}
}
val mode = if (ChangeNumberVerifyFragmentArgs.fromBundle(requireArguments()).smsListenerEnabled) RegistrationRepository.Mode.SMS_WITH_LISTENER else RegistrationRepository.Mode.SMS_WITHOUT_LISTENER
viewModel.initiateChangeNumberSession(requireContext(), mode)
}
private fun showErrorDialog(context: Context, @StringRes message: Int, onPositiveButtonClickListener: OnClickListener?) {
MaterialAlertDialogBuilder(context).setMessage(message).setPositiveButton(android.R.string.ok, onPositiveButtonClickListener).show()
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(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberEnterCodeFragment())
}
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)
}
is VerificationCodeRequestResult.TokenNotAccepted -> {
Log.i(TAG, "Token was not accepted.")
showErrorDialog(R.string.RegistrationActivity_additional_verification_required)
}
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 sealed interface RequestCodeResult {
object RecoveryPasswordWorked : RequestCodeResult
class RequestedVerificationCode(val processor: RegistrationSessionProcessor) : RequestCodeResult
private fun handleChallenges(remainingChallenges: List<Challenge>) {
Log.i(TAG, "Handling challenge(s): ${remainingChallenges.joinToString { it.key }}")
when (remainingChallenges.first()) {
Challenge.CAPTCHA -> {
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.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
}
}
}

View File

@@ -1,62 +1,82 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.app.Application
import androidx.annotation.WorkerThread
import androidx.lifecycle.AbstractSavedStateViewModelFactory
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.savedstate.SavedStateRegistryOwner
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
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.RegistrationSessionProcessor
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.SmsRetrieverReceiver
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.registration.VerifyResponse
import org.thoughtcrime.securesms.registration.VerifyResponseProcessor
import org.thoughtcrime.securesms.registration.VerifyResponseWithRegistrationLockProcessor
import org.thoughtcrime.securesms.registration.VerifyResponseWithoutKbs
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.util.DefaultValueLiveData
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.push.exceptions.IncorrectCodeException
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Objects
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
private val TAG: String = Log.tag(ChangeNumberViewModel::class.java)
/**
* [ViewModel] for the change number flow.
*
* @see [RegistrationViewModel], from which this is derived.
*/
class ChangeNumberViewModel : ViewModel() {
class ChangeNumberViewModel(
private val localNumber: String,
private val changeNumberRepository: ChangeNumberRepository,
savedState: SavedStateHandle,
password: String,
verifyAccountRepository: VerifyAccountRepository,
companion object {
private val TAG = Log.tag(ChangeNumberViewModel::class.java)
val CHANGE_NUMBER_LOCK = ReentrantLock()
}
private val repository = ChangeNumberRepository()
private val store = MutableStateFlow(ChangeNumberState())
private val serialContext = SignalExecutors.SERIAL.asCoroutineDispatcher()
private val smsRetrieverReceiver: SmsRetrieverReceiver = SmsRetrieverReceiver(AppDependencies.application)
) : BaseRegistrationViewModel(savedState, verifyAccountRepository, password) {
var oldNumberState: NumberViewState = NumberViewState.Builder().build()
private set
private val initialLocalNumber = SignalStore.account.e164
private val password = SignalStore.account.servicePassword!!
private val liveOldNumberState = DefaultValueLiveData(oldNumberState)
private val liveNewNumberState = DefaultValueLiveData(number)
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(localNumber, null)
.parse(SignalStore.account.e164!!, null)
.countryCode
setOldCountry(countryCode)
setNewCountry(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")
}
@@ -69,45 +89,83 @@ class ChangeNumberViewModel(
smsRetrieverReceiver.unregisterReceiver()
}
fun getLiveOldNumber(): LiveData<NumberViewState> {
return liveOldNumberState
}
// region Public Getters and Setters
fun getLiveNewNumber(): LiveData<NumberViewState> {
return liveNewNumberState
}
val number: NumberViewState
get() = store.value.number
fun setOldNationalNumber(number: String) {
oldNumberState = oldNumberState.toBuilder()
.nationalNumber(number)
.build()
val oldNumberState: NumberViewState
get() = store.value.oldPhoneNumber
liveOldNumberState.value = oldNumberState
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) {
oldNumberState = oldNumberState.toBuilder()
.selectedCountryDisplayName(country)
.countryCode(countryCode)
.build()
liveOldNumberState.value = oldNumberState
store.update {
it.copy(oldPhoneNumber = oldNumberState.toBuilder().selectedCountryDisplayName(country).countryCode(countryCode).build())
}
}
fun setNewNationalNumber(number: String) {
setNationalNumber(number)
liveNewNumberState.value = this.number
fun setNewNationalNumber(updatedNumber: String) {
store.update {
it.copy(number = number.toBuilder().nationalNumber(updatedNumber).build())
}
}
fun setNewCountry(countryCode: Int, country: String? = null) {
onCountrySelected(country, countryCode)
store.update {
it.copy(number = number.toBuilder().selectedCountryDisplayName(country).countryCode(countryCode).build())
}
}
liveNewNumberState.value = this.number
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 == localNumber) {
return if (oldNumberState.e164Number == initialLocalNumber) {
if (number.isValid) {
ContinueStatus.CAN_CONTINUE
} else {
@@ -118,129 +176,378 @@ class ChangeNumberViewModel(
}
}
fun ensureDecryptionsDrained(): Completable {
return changeNumberRepository.ensureDecryptionsDrained()
}
// endregion
override fun verifyCodeWithoutRegistrationLock(code: String): Single<VerifyResponseProcessor> {
return super.verifyCodeWithoutRegistrationLock(code)
.compose(ChangeNumberRepository::acquireReleaseChangeNumberLock)
.flatMap(this::attemptToUnlockChangeNumber)
}
// region Public actions
override fun verifyCodeAndRegisterAccountWithRegistrationLock(pin: String): Single<VerifyResponseWithRegistrationLockProcessor> {
return super.verifyCodeAndRegisterAccountWithRegistrationLock(pin)
.compose(ChangeNumberRepository::acquireReleaseChangeNumberLock)
.flatMap(this::attemptToUnlockChangeNumber)
}
fun checkWhoAmI(onSuccess: () -> Unit, onError: (Throwable) -> Unit) {
Log.v(TAG, "checkWhoAmI()")
viewModelScope.launch(Dispatchers.IO) {
try {
val whoAmI = repository.whoAmI()
private fun <T : VerifyResponseProcessor> attemptToUnlockChangeNumber(processor: T): Single<T> {
return if (processor.hasResult() || processor.isServerSentError()) {
SignalStore.misc.unlockChangeNumber()
SignalStore.misc.clearPendingChangeNumberMetadata()
Single.just(processor)
} else {
changeNumberRepository.whoAmI()
.map { whoAmI ->
if (Objects.equals(whoAmI.number, localNumber)) {
Log.i(TAG, "Local and remote numbers match, we can unlock.")
SignalStore.misc.unlockChangeNumber()
SignalStore.misc.clearPendingChangeNumberMetadata()
}
processor
if (whoAmI.number == SignalStore.account.e164) {
return@launch bail { Log.i(TAG, "Local and remote numbers match, nothing needs to be done.") }
}
.onErrorReturn { processor }
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)
}
}
}
}
override fun verifyAccountWithoutRegistrationLock(): Single<ServiceResponse<VerifyResponse>> {
val sessionId = sessionId ?: throw IllegalStateException("No valid registration session")
return changeNumberRepository.verifyAccount(sessionId, textCodeEntered)
.map { RegistrationSessionProcessor.RegistrationSessionProcessorForVerification(it) }
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess {
if (it.hasResult()) {
setCanSmsAtTime(it.getNextCodeViaSmsAttempt())
setCanCallAtTime(it.getNextCodeViaCallAttempt())
}
}
.observeOn(Schedulers.io())
.flatMap { processor ->
if (processor.isAlreadyVerified() || processor.hasResult() && processor.isVerified()) {
changeNumberRepository.changeNumber(sessionId = sessionId, newE164 = number.e164Number)
} else if (processor.error == null) {
Single.just<ServiceResponse<VerifyResponse>>(ServiceResponse.forApplicationError(IncorrectCodeException(), 403, null))
} else {
Single.just<ServiceResponse<VerifyResponse>>(ServiceResponse.coerceError(processor.response))
}
}
}
override fun verifyAccountWithRegistrationLock(pin: String, svrAuthCredentials: SvrAuthCredentialSet): Single<ServiceResponse<VerifyResponse>> {
val sessionId = sessionId ?: throw IllegalStateException("No valid registration session")
return changeNumberRepository.changeNumber(sessionId, number.e164Number, pin, svrAuthCredentials)
}
@WorkerThread
override fun onVerifySuccess(processor: VerifyResponseProcessor): Single<VerifyResponseProcessor> {
return changeNumberRepository.changeLocalNumber(number.e164Number, PNI.parseOrThrow(processor.result.verifyAccountResponse.pni))
.map { processor }
.onErrorReturn { t ->
Log.w(TAG, "Error attempting to change local number", t)
VerifyResponseWithoutKbs(ServiceResponse.forUnknownError(t))
}
}
override fun onVerifySuccessWithRegistrationLock(processor: VerifyResponseWithRegistrationLockProcessor, pin: String): Single<VerifyResponseWithRegistrationLockProcessor> {
return changeNumberRepository.changeLocalNumber(number.e164Number, PNI.parseOrThrow(processor.result.verifyAccountResponse.pni))
.map { processor }
.onErrorReturn { t ->
Log.w(TAG, "Error attempting to change local number", t)
VerifyResponseWithRegistrationLockProcessor(ServiceResponse.forUnknownError(t), processor.svrAuthCredentials)
}
}
fun changeNumberWithRecoveryPassword(): Single<Boolean> {
val recoveryPassword = SignalStore.svr.recoveryPassword
return if (SignalStore.svr.hasPin() && recoveryPassword != null) {
changeNumberRepository.changeNumber(recoveryPassword = recoveryPassword, newE164 = number.e164Number)
.map { r -> VerifyResponseWithoutKbs(r) }
.flatMap { p ->
if (p.hasResult()) {
onVerifySuccess(p).map { true }
} else {
Single.just(false)
}
}
} else {
Single.just(false)
fun registerSmsListenerWithCompletionListener(context: Context, onComplete: (Boolean) -> Unit) {
Log.v(TAG, "registerSmsListenerWithCompletionListener()")
viewModelScope.launch {
val listenerRegistered = RegistrationRepository.registerSmsListener(context)
onComplete(listenerRegistered)
}
}
class Factory(owner: SavedStateRegistryOwner) : AbstractSavedStateViewModelFactory(owner, null) {
override fun <T : ViewModel> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
val context: Application = AppDependencies.application
val localNumber: String = SignalStore.account.e164!!
val password: String = SignalStore.account.servicePassword!!
val viewModel = ChangeNumberViewModel(
localNumber = localNumber,
changeNumberRepository = ChangeNumberRepository(),
savedState = handle,
password = password,
verifyAccountRepository = VerifyAccountRepository(context)
fun verifyCodeWithoutRegistrationLock(context: Context, code: String, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
Log.v(TAG, "verifyCodeWithoutRegistrationLock()")
store.update {
it.copy(
inProgress = true,
enteredCode = code
)
}
return requireNotNull(modelClass.cast(viewModel))
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 = pin,
svrAuthCredentials = SvrAuthCredentialSet(
svr2Credentials = store.value.svr2Credentials,
svr3Credentials = store.value.svr3Credentials
)
)
}
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!")
store.update {
it.copy(
captchaToken = null,
inProgress = true,
changeNumberOutcome = null
)
}
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(
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(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 RegistrationViewModel.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(
svr2Credentials = result.svr2Credentials,
svr3Credentials = result.svr3Credentials
)
}
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(
svr2Credentials = result.svr2Credentials,
svr3Credentials = result.svr3Credentials
)
}
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 <T> 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
CAN_CONTINUE, INVALID_NUMBER, OLD_NUMBER_DOESNT_MATCH
}
}

View File

@@ -1,74 +0,0 @@
/*
* 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<ChangeNumberV2ViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(view.findViewById(R.id.account_locked_title))
val description = view.findViewById<TextView>(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<View>(R.id.account_locked_next).setOnClickListener { onNext() }
view.findViewById<View>(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()
}
}

View File

@@ -1,61 +0,0 @@
/*
* 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<ChangeNumberV2ViewModel>()
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()
)
}
}

View File

@@ -1,167 +0,0 @@
/*
* 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<ChangeNumberV2ViewModel>()
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()
}
}
}
}

View File

@@ -1,99 +0,0 @@
/*
* 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()
}
}

View File

@@ -1,55 +0,0 @@
/*
* 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<View>(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<View>(R.id.change_number_pin_differs_update_pin).setOnClickListener {
changePin.launch(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()))
}
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, confirmCancelDialog)
}
}

View File

@@ -1,328 +0,0 @@
/*
* 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<ChangeNumberV2ViewModel>()
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()
}
}
}

View File

@@ -1,38 +0,0 @@
/*
* 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)
}
}
}

View File

@@ -1,384 +0,0 @@
/*
* 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 { cancellationCause ->
AppDependencies
.incomingMessageObserver
.removeDecryptionDrainedListener(drainedListener)
Log.d(TAG, "Decryptions draining canceled.", cancellationCause)
}
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.certificate.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<VerifyAccountResponse>
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<OutgoingPushMessage>()
val devicePniSignedPreKeys = mutableMapOf<Int, SignedPreKeyEntity>()
val devicePniLastResortKyberPreKeys = mutableMapOf<Int, KyberPreKeyEntity>()
val pniRegistrationIds = mutableMapOf<Int, Int>()
val primaryDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID
val devices: List<Int> = 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
)
}

View File

@@ -1,553 +0,0 @@
/*
* 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 = pin,
svrAuthCredentials = SvrAuthCredentialSet(
svr2Credentials = store.value.svr2Credentials,
svr3Credentials = store.value.svr3Credentials
)
)
}
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!")
store.update {
it.copy(
captchaToken = null,
inProgress = true,
changeNumberOutcome = null
)
}
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(
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(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(
svr2Credentials = result.svr2Credentials,
svr3Credentials = result.svr3Credentials
)
}
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(
svr2Credentials = result.svr2Credentials,
svr3Credentials = result.svr3Credentials
)
}
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 <T> 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
}
}

View File

@@ -1,149 +0,0 @@
/*
* 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<ChangeNumberV2ViewModel>()
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)
}
is VerificationCodeRequestResult.TokenNotAccepted -> {
Log.i(TAG, "Token was not accepted.")
showErrorDialog(R.string.RegistrationActivity_additional_verification_required)
}
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<Challenge>) {
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
}
}
}