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

@@ -16,7 +16,6 @@ import org.signal.core.util.logging.Log;
import org.signal.core.util.tracing.Tracer;
import org.signal.devicetransfer.TransferStatus;
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity;
import org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberLockV2Activity;
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
@@ -28,8 +27,7 @@ import org.thoughtcrime.securesms.pin.PinRestoreActivity;
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2Activity;
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppStartup;
@@ -181,7 +179,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_TRANSFER_ONGOING;
} else if (SignalStore.misc().isOldDeviceTransferLocked()) {
return STATE_TRANSFER_LOCKED;
} else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class && getClass() != ChangeNumberLockV2Activity.class) {
} else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class) {
return STATE_CHANGE_NUMBER_LOCK;
} else {
return STATE_NORMAL;
@@ -222,11 +220,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
if (RemoteConfig.registrationV2()) {
return RegistrationV2Activity.newIntentForNewRegistration(this, getIntent());
} else {
return RegistrationNavigationActivity.newIntentForNewRegistration(this, getIntent());
}
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
private Intent getEnterSignalPinIntent() {
@@ -269,11 +263,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getChangeNumberLockIntent() {
if (RemoteConfig.registrationV2()) {
return ChangeNumberLockV2Activity.createIntent(this);
} else {
return ChangeNumberLockActivity.createIntent(this);
}
return ChangeNumberLockActivity.createIntent(this);
}
private Intent getRoutedIntent(Intent destination, @Nullable Intent nextIntent) {

View File

@@ -28,7 +28,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.fragments.RestoreBackupFragment;
import org.thoughtcrime.securesms.restore.restorelocalbackup.PassphraseAsYouTypeFormatter;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -170,7 +170,7 @@ public class BackupDialog {
Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
positiveButton.setEnabled(false);
RestoreBackupFragment.PassphraseAsYouTypeFormatter formatter = new RestoreBackupFragment.PassphraseAsYouTypeFormatter();
PassphraseAsYouTypeFormatter formatter = new PassphraseAsYouTypeFormatter();
prompt.addTextChangedListener(new AfterTextChanged(editable -> {
formatter.afterTextChanged(editable);

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
}
}
}

View File

@@ -273,7 +273,7 @@ import org.thoughtcrime.securesms.recipients.RecipientExporter
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity
import org.thoughtcrime.securesms.revealable.ViewOnceUtil
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
@@ -3706,7 +3706,7 @@ class ConversationFragment :
}
override fun reRegisterAction() {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
startActivity(RegistrationActivity.newIntentForReRegistration(requireContext()))
}
override fun reviewJoinRequestsAction() {
@@ -3781,7 +3781,7 @@ class ConversationFragment :
}
override fun onReRegisterClicked() {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
startActivity(RegistrationActivity.newIntentForReRegistration(requireContext()))
}
override fun onCancelGroupRequestClicked() {

View File

@@ -158,7 +158,7 @@ import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.sms.MessageSender;
@@ -794,7 +794,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
} else if (reminderActionId == R.id.reminder_action_fix_username_link) {
startActivity(AppSettingsActivity.usernameLinkSettings(requireContext()));
} else if (reminderActionId == R.id.reminder_action_re_register) {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()));
startActivity(RegistrationActivity.newIntentForReRegistration(requireContext()));
}
}

View File

@@ -42,7 +42,7 @@ public abstract class DeviceTransferFragment extends LoggingFragment {
protected boolean transferFinished;
public DeviceTransferFragment() {
super(R.layout.device_transfer_fragment);
super(R.layout.fragment_device_transfer);
}
@Override

View File

@@ -37,18 +37,18 @@ public final class NewDeviceTransferFragment extends DeviceTransferFragment {
@Override
protected void navigateToRestartTransfer() {
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_newDeviceTransfer_to_newDeviceTransferInstructions);
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferInstructions());
}
@Override
protected void navigateAwayFromTransfer() {
EventBus.getDefault().unregister(serverTaskListener);
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_restart_to_welcomeFragment);
requireActivity().finish();
}
@Override
protected void navigateToTransferComplete() {
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_newDeviceTransfer_to_newDeviceTransferComplete);
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferComplete());
}
private class ServerTaskListener {

View File

@@ -63,7 +63,7 @@ public final class TransferOrRestoreFragment extends LoggingFragment {
private void launchSelection(BackupRestorationType restorationType) {
switch (restorationType) {
case DEVICE_TRANSFER -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_new_device_transfer_instructions);
case LOCAL_BACKUP -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_choose_backup);
case LOCAL_BACKUP -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_transfer_or_restore_to_local_restore);
case REMOTE_BACKUP -> {}
default -> throw new IllegalArgumentException();
}

View File

@@ -14,7 +14,7 @@ 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.components.settings.app.changenumber.ChangeNumberRepository
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberViewModel
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
@@ -87,7 +87,7 @@ class PnpInitializeDevicesJob private constructor(parameters: Parameters) : Base
return
}
ChangeNumberRepository.CHANGE_NUMBER_LOCK.lock()
ChangeNumberViewModel.CHANGE_NUMBER_LOCK.lock()
try {
if (SignalStore.misc.hasPniInitializedDevices) {
Log.w(TAG, "We found out that things have been initialized after we got the lock! No need to do anything else.")
@@ -112,7 +112,7 @@ class PnpInitializeDevicesJob private constructor(parameters: Parameters) : Base
SignalStore.misc.hasPniInitializedDevices = true
} finally {
ChangeNumberRepository.CHANGE_NUMBER_LOCK.unlock()
ChangeNumberViewModel.CHANGE_NUMBER_LOCK.unlock()
}
}

View File

@@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.payments.backup.RecoveryPhraseStates;
import org.thoughtcrime.securesms.payments.backup.confirm.PaymentsRecoveryPhraseConfirmFragment;
import org.thoughtcrime.securesms.payments.preferences.model.InfoCard;
import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.SpanUtil;
@@ -262,7 +262,7 @@ public class PaymentsHomeFragment extends LoggingFragment {
if (actionId == R.id.reminder_action_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext());
} else if (actionId == R.id.reminder_action_re_register) {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()));
startActivity(RegistrationActivity.newIntentForReRegistration(requireContext()));
}
});
} else {

View File

@@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.profiles.manage.EditProfileViewModel.AvatarState
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameDeleteResult
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.NameUtil.getAbbreviation
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
@@ -389,7 +389,7 @@ class EditProfileFragment : LoggingFragment() {
.setMessage(R.string.EditProfileFragment_unregistered_dialog_body)
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(R.string.EditProfileFragment_unregistered_dialog_reregister_button) { d, _ ->
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
startActivity(RegistrationActivity.newIntentForReRegistration(requireContext()))
d.dismiss()
}
.show()

View File

@@ -1,101 +0,0 @@
package org.thoughtcrime.securesms.registration;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
public final class RegistrationNavigationActivity extends AppCompatActivity {
private static final String TAG = Log.tag(RegistrationNavigationActivity.class);
public static final String RE_REGISTRATION_EXTRA = "re_registration";
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private SmsRetrieverReceiver smsRetrieverReceiver;
private RegistrationViewModel viewModel;
public static Intent newIntentForNewRegistration(@NonNull Context context, @Nullable Intent originalIntent) {
Intent intent = new Intent(context, RegistrationNavigationActivity.class);
intent.putExtra(RE_REGISTRATION_EXTRA, false);
if (originalIntent != null) {
intent.setData(originalIntent.getData());
}
return intent;
}
public static Intent newIntentForReRegistration(@NonNull Context context) {
Intent intent = new Intent(context, RegistrationNavigationActivity.class);
intent.putExtra(RE_REGISTRATION_EXTRA, true);
return intent;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
dynamicTheme.onCreate(this);
super.onCreate(savedInstanceState);
viewModel = new ViewModelProvider(this, new RegistrationViewModel.Factory(this, isReregister(getIntent()))).get(RegistrationViewModel.class);
setContentView(R.layout.activity_registration_navigation);
initializeChallengeListener();
if (getIntent() != null && getIntent().getData() != null) {
CommunicationActions.handlePotentialProxyLinkUrl(this, getIntent().getDataString());
}
}
@Override
protected void onResume() {
super.onResume();
dynamicTheme.onResume(this);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
if (intent.getData() != null) {
CommunicationActions.handlePotentialProxyLinkUrl(this, intent.getDataString());
}
viewModel.setIsReregister(isReregister(intent));
}
@Override
protected void onDestroy() {
super.onDestroy();
shutdownChallengeListener();
}
private boolean isReregister(@NonNull Intent intent) {
return intent.getBooleanExtra(RE_REGISTRATION_EXTRA, false);
}
private void initializeChallengeListener() {
smsRetrieverReceiver = new SmsRetrieverReceiver(getApplication());
smsRetrieverReceiver.registerReceiver();
}
private void shutdownChallengeListener() {
if (smsRetrieverReceiver != null) {
smsRetrieverReceiver.unregisterReceiver();
smsRetrieverReceiver = null;
}
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data
package org.thoughtcrime.securesms.registration.data
import android.app.backup.BackupManager
import android.content.Context
@@ -45,12 +45,12 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.PushChallengeRequest
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.registration.v2.data.network.BackupAuthCheckResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationSessionCheckResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.service.DirectoryRefreshListener
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
package org.thoughtcrime.securesms.registration.data.network
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.svr.Svr3Credentials

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
package org.thoughtcrime.securesms.registration.data.network
import org.signal.core.util.logging.Log

View File

@@ -3,10 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
package org.thoughtcrime.securesms.registration.data.network
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
package org.thoughtcrime.securesms.registration.data.network
/**
* This is a merging of the NetworkResult pattern and the Processor pattern of registration v1.

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
package org.thoughtcrime.securesms.registration.data.network
import org.signal.core.util.logging.Log
import org.whispersystems.signalservice.api.NetworkResult

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
package org.thoughtcrime.securesms.registration.data.network
import org.signal.core.util.logging.Log
import org.whispersystems.signalservice.api.NetworkResult

View File

@@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
package org.thoughtcrime.securesms.registration.data.network
import okio.IOException
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException
import org.whispersystems.signalservice.api.push.exceptions.CaptchaRequiredException

View File

@@ -1,24 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
public class AccountLockedFragment extends BaseAccountLockedFragment {
public AccountLockedFragment() {
super(R.layout.account_locked_fragment);
}
@Override
protected BaseRegistrationViewModel getViewModel() {
return new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
}
@Override
protected void onNext() {
requireActivity().finish();
}
}

View File

@@ -1,73 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
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.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
import java.util.concurrent.TimeUnit;
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView;
/**
* Base fragment used by registration and change number flow to show an account as locked.
*/
public abstract class BaseAccountLockedFragment extends LoggingFragment {
public BaseAccountLockedFragment(int contentLayoutId) {
super(contentLayoutId);
}
@Override
@CallSuper
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
setDebugLogSubmitMultiTapView(view.findViewById(R.id.account_locked_title));
TextView description = view.findViewById(R.id.account_locked_description);
BaseRegistrationViewModel viewModel = getViewModel();
viewModel.getLockedTimeRemaining().observe(getViewLifecycleOwner(),
t -> description.setText(getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t)))
);
view.findViewById(R.id.account_locked_next).setOnClickListener(v -> onNext());
view.findViewById(R.id.account_locked_learn_more).setOnClickListener(v -> learnMore());
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
onNext();
}
});
}
private void learnMore() {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url)));
startActivity(intent);
}
private static long durationToDays(long duration) {
return duration != 0L ? getLockoutDays(duration) : 7;
}
private static int getLockoutDays(long timeRemainingMs) {
return (int) TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1;
}
protected abstract BaseRegistrationViewModel getViewModel();
protected abstract void onNext();
}

View File

@@ -1,442 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
import android.os.Bundle;
import android.view.View;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.CallSuper;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.navigation.Navigation;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.registration.ActionCountDownButton;
import org.thoughtcrime.securesms.components.registration.VerificationCodeView;
import org.thoughtcrime.securesms.components.registration.VerificationPinKeyboard;
import org.thoughtcrime.securesms.registration.ReceivedSmsEvent;
import org.thoughtcrime.securesms.registration.VerifyAccountRepository;
import org.thoughtcrime.securesms.registration.VerifyResponseProcessor;
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer;
import org.whispersystems.signalservice.internal.push.LockedException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView;
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.showConfirmNumberDialogIfTranslated;
/**
* Base fragment used by registration and change number flow to input an SMS verification code or request a
* phone code after requesting SMS.
*
* @param <ViewModel> - The concrete view model used by the subclasses, for ease of access in said subclass
*/
public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistrationViewModel> extends LoggingFragment implements SignalStrengthPhoneStateListener.Callback {
private static final String TAG = Log.tag(BaseEnterSmsCodeFragment.class);
private ScrollView scrollView;
private TextView subheader;
private VerificationCodeView verificationCodeView;
private VerificationPinKeyboard keyboard;
private ActionCountDownButton callMeCountDown;
private ActionCountDownButton resendSmsCountDown;
private MaterialButton wrongNumber;
private MaterialButton bottomSheetButton;
private boolean autoCompleting;
private ViewModel viewModel;
protected final LifecycleDisposable disposables = new LifecycleDisposable();
public BaseEnterSmsCodeFragment(@LayoutRes int contentLayoutId) {
super(contentLayoutId);
}
@Override
@CallSuper
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header));
scrollView = view.findViewById(R.id.scroll_view);
subheader = view.findViewById(R.id.verification_subheader);
verificationCodeView = view.findViewById(R.id.code);
keyboard = view.findViewById(R.id.keyboard);
callMeCountDown = view.findViewById(R.id.call_me_count_down);
resendSmsCountDown = view.findViewById(R.id.resend_sms_count_down);
wrongNumber = view.findViewById(R.id.wrong_number);
bottomSheetButton = view.findViewById(R.id.having_trouble_button);
new SignalStrengthPhoneStateListener(this, this);
connectKeyboard(verificationCodeView, keyboard);
ViewUtil.hideKeyboard(requireContext(), view);
setOnCodeFullyEnteredListener(verificationCodeView);
wrongNumber.setOnClickListener(v -> returnToPhoneEntryScreen());
bottomSheetButton.setOnClickListener( v -> showBottomSheet());
callMeCountDown.setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in);
resendSmsCountDown.setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in);
callMeCountDown.setOnClickListener(v -> handlePhoneCallRequest());
resendSmsCountDown.setOnClickListener(v -> handleSmsRequest());
callMeCountDown.setListener((v, remaining) -> {
if (remaining <= 30) {
scrollView.smoothScrollTo(0, v.getBottom());
callMeCountDown.setListener(null);
}
});
resendSmsCountDown.setListener((v, remaining) -> {
if (remaining <= 30) {
scrollView.smoothScrollTo(0, v.getBottom());
resendSmsCountDown.setListener(null);
}
});
disposables.bindTo(getViewLifecycleOwner().getLifecycle());
viewModel = getViewModel();
viewModel.getIncorrectCodeAttempts().observe(getViewLifecycleOwner(), (attempts) -> {
if (attempts >= 3) {
bottomSheetButton.setVisibility(View.VISIBLE);
}
});
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
viewModel.resetSession();
this.remove();
requireActivity().getOnBackPressedDispatcher().onBackPressed();
}
});
}
protected abstract ViewModel getViewModel();
protected abstract void handleSuccessfulVerify();
protected abstract void navigateToCaptcha();
protected abstract void navigateToRegistrationLock(long timeRemaining);
protected abstract void navigateToKbsAccountLocked();
private void returnToPhoneEntryScreen() {
viewModel.resetSession();
Navigation.findNavController(requireView()).navigateUp();
}
private void setOnCodeFullyEnteredListener(VerificationCodeView verificationCodeView) {
verificationCodeView.setOnCompleteListener(code -> {
callMeCountDown.setVisibility(View.INVISIBLE);
resendSmsCountDown.setVisibility(View.INVISIBLE);
wrongNumber.setVisibility(View.INVISIBLE);
keyboard.displayProgress();
Disposable verify = viewModel.verifyCodeWithoutRegistrationLock(code)
.observeOn(AndroidSchedulers.mainThread())
.subscribe((VerifyResponseProcessor processor) -> {
if (!processor.hasResult()) {
Log.w(TAG, "post verify: ", processor.getError());
}
if (processor.hasResult()) {
handleSuccessfulVerify();
} else if (processor.rateLimit()) {
handleRateLimited();
} else if (processor.registrationLock() && !processor.isRegistrationLockPresentAndSvrExhausted()) {
LockedException lockedException = processor.getLockedException();
handleRegistrationLock(lockedException.getTimeRemaining());
} else if (processor.authorizationFailed()) {
handleIncorrectCodeError();
} else {
Log.w(TAG, "Unable to verify code", processor.getError());
handleGeneralError();
}
});
disposables.add(verify);
});
}
protected void displaySuccess(@NonNull Runnable runAfterAnimation) {
keyboard.displaySuccess().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
runAfterAnimation.run();
}
});
}
protected void handleRateLimited() {
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean r) {
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext());
builder.setTitle(R.string.RegistrationActivity_too_many_attempts)
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
callMeCountDown.setVisibility(View.VISIBLE);
resendSmsCountDown.setVisibility(View.VISIBLE);
wrongNumber.setVisibility(View.VISIBLE);
verificationCodeView.clear();
keyboard.displayKeyboard();
})
.show();
}
});
}
protected void handleRegistrationLock(long timeRemaining) {
keyboard.displayLocked().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean r) {
navigateToRegistrationLock(timeRemaining);
}
});
}
protected void handleSvrAccountLocked() {
navigateToKbsAccountLocked();
}
protected void handleIncorrectCodeError() {
viewModel.incrementIncorrectCodeAttempts();
Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_code, Toast.LENGTH_LONG).show();
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
callMeCountDown.setVisibility(View.VISIBLE);
resendSmsCountDown.setVisibility(View.VISIBLE);
wrongNumber.setVisibility(View.VISIBLE);
verificationCodeView.clear();
keyboard.displayKeyboard();
}
});
}
protected void handleGeneralError() {
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show();
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
callMeCountDown.setVisibility(View.VISIBLE);
resendSmsCountDown.setVisibility(View.VISIBLE);
wrongNumber.setVisibility(View.VISIBLE);
verificationCodeView.clear();
keyboard.displayKeyboard();
}
});
}
@Override
public void onStart() {
super.onStart();
EventBus.getDefault().register(this);
}
@Override
public void onStop() {
super.onStop();
EventBus.getDefault().unregister(this);
}
@Subscribe(threadMode = ThreadMode.MAIN)
public void onVerificationCodeReceived(@NonNull ReceivedSmsEvent event) {
verificationCodeView.clear();
List<Integer> parsedCode = convertVerificationCodeToDigits(event.getCode());
autoCompleting = true;
final int size = parsedCode.size();
for (int i = 0; i < size; i++) {
final int index = i;
verificationCodeView.postDelayed(() -> {
verificationCodeView.append(parsedCode.get(index));
if (index == size - 1) {
autoCompleting = false;
}
}, i * 200L);
}
}
private static List<Integer> convertVerificationCodeToDigits(@Nullable String code) {
if (code == null || code.length() != 6) {
return Collections.emptyList();
}
List<Integer> result = new ArrayList<>(code.length());
try {
for (int i = 0; i < code.length(); i++) {
result.add(Integer.parseInt(Character.toString(code.charAt(i))));
}
} catch (NumberFormatException e) {
Log.w(TAG, "Failed to convert code into digits.", e);
return Collections.emptyList();
}
return result;
}
private void handlePhoneCallRequest() {
showConfirmNumberDialogIfTranslated(requireContext(),
R.string.RegistrationActivity_phone_number_verification_dialog_title,
R.string.RegistrationActivity_you_will_receive_a_call_to_verify_this_number,
viewModel.getNumber().getE164Number(),
() -> handleCodeCallRequestAfterConfirm(VerifyAccountRepository.Mode.PHONE_CALL),
this::returnToPhoneEntryScreen);
}
private void handleSmsRequest() {
showConfirmNumberDialogIfTranslated(requireContext(),
R.string.RegistrationActivity_phone_number_verification_dialog_title,
R.string.RegistrationActivity_a_verification_code_will_be_sent_to_this_number,
viewModel.getNumber().getE164Number(),
() -> handleCodeCallRequestAfterConfirm(VerifyAccountRepository.Mode.SMS_WITH_LISTENER),
this::returnToPhoneEntryScreen);
}
private void handleCodeCallRequestAfterConfirm(VerifyAccountRepository.Mode mode) {
MccMncProducer mccMncProducer = new MccMncProducer(requireContext());
Disposable request = viewModel.requestVerificationCode(mode, mccMncProducer.getMcc(), mccMncProducer.getMnc())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(processor -> {
if (processor.hasResult()) {
Toast.makeText(requireContext(), getCodeRequestedToastText(mode), Toast.LENGTH_LONG).show();
} else if (processor.captchaRequired(viewModel.getExcludedChallenges())) {
navigateToCaptcha();
} else if (processor.rateLimit()) {
handleRateLimited();
} else {
Log.w(TAG, "Unable to request phone code", processor.getError());
Toast.makeText(requireContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show();
}
});
disposables.add(request);
}
@StringRes
private int getCodeRequestedToastText(VerifyAccountRepository.Mode mode) {
switch (mode) {
case PHONE_CALL:
return R.string.RegistrationActivity_call_requested;
case SMS_WITH_LISTENER:
case SMS_WITHOUT_LISTENER:
return R.string.RegistrationActivity_sms_requested;
default:
return R.string.RegistrationActivity_code_requested;
}
}
private void connectKeyboard(VerificationCodeView verificationCodeView, VerificationPinKeyboard keyboard) {
keyboard.setOnKeyPressListener(key -> {
if (!autoCompleting) {
if (key >= 0) {
verificationCodeView.append(key);
} else {
verificationCodeView.delete();
}
}
});
}
@Override
public void onResume() {
super.onResume();
String sessionE164 = viewModel.getSessionE164();
if (sessionE164 == null) {
returnToPhoneEntryScreen();
return;
}
subheader.setText(requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, viewModel.getNumber().getFullFormattedNumber()));
Disposable request = viewModel.validateSession(sessionE164)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(processor -> {
if (!processor.hasResult()) {
Log.d(TAG, "Network error.");
returnToPhoneEntryScreen();
} else if (processor.isInvalidSession()) {
Log.d(TAG, "Registration session is invalid.");
returnToPhoneEntryScreen();
} else if (processor.cannotSubmitVerificationAttempt()) {
Log.d(TAG, "Cannot submit any more verification attempts.");
returnToPhoneEntryScreen();
} else if (processor.mustWaitToSubmitProof()) {
Log.d(TAG, "Blocked from submitting proof at this time.");
handleRateLimited();
}
// else session state is valid and server is ready to accept code
});
disposables.add(request);
viewModel.getCanCallAtTime().observe(getViewLifecycleOwner(), callAtTime -> {
if (callAtTime > 0) {
callMeCountDown.setVisibility(View.VISIBLE);
callMeCountDown.startCountDownTo(callAtTime);
} else {
callMeCountDown.setVisibility(View.INVISIBLE);
}
});
viewModel.getCanSmsAtTime().observe(getViewLifecycleOwner(), smsAtTime -> {
if (smsAtTime > 0) {
resendSmsCountDown.setVisibility(View.VISIBLE);
resendSmsCountDown.startCountDownTo(smsAtTime);
} else {
resendSmsCountDown.setVisibility(View.INVISIBLE);
}
});
}
private void showBottomSheet() {
ContactSupportBottomSheetFragment bottomSheet = new ContactSupportBottomSheetFragment();
bottomSheet.show(getChildFragmentManager(), "support_bottom_sheet");
}
@Override
public void onNoCellSignalPresent() {
// TODO animate in bottom sheet
}
@Override
public void onCellSignalPresent() {
// TODO animate away bottom sheet
}
}

View File

@@ -1,283 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
import android.content.res.Resources;
import android.os.Bundle;
import android.text.InputType;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.button.MaterialButton;
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.lock.v2.PinKeyboardType;
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView;
/**
* Base fragment used by registration and change number flow to deal with a registration locked account.
*/
public abstract class BaseRegistrationLockFragment extends LoggingFragment {
private static final String TAG = Log.tag(BaseRegistrationLockFragment.class);
/**
* Applies to both V1 and V2 pins, because some V2 pins may have been migrated from V1.
*/
public static final int MINIMUM_PIN_LENGTH = 4;
private EditText pinEntry;
private View forgotPin;
protected CircularProgressMaterialButton pinButton;
private TextView errorLabel;
private MaterialButton keyboardToggle;
private long timeRemaining;
private BaseRegistrationViewModel viewModel;
private final LifecycleDisposable disposables = new LifecycleDisposable();
public BaseRegistrationLockFragment(int contentLayoutId) {
super(contentLayoutId);
}
@Override
@CallSuper
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title));
pinEntry = view.findViewById(R.id.kbs_lock_pin_input);
pinButton = view.findViewById(R.id.kbs_lock_pin_confirm);
errorLabel = view.findViewById(R.id.kbs_lock_pin_input_label);
keyboardToggle = view.findViewById(R.id.kbs_lock_keyboard_toggle);
forgotPin = view.findViewById(R.id.kbs_lock_forgot_pin);
RegistrationLockFragmentArgs args = RegistrationLockFragmentArgs.fromBundle(requireArguments());
timeRemaining = args.getTimeRemaining();
forgotPin.setVisibility(View.GONE);
forgotPin.setOnClickListener(v -> handleForgottenPin(timeRemaining));
pinEntry.setImeOptions(EditorInfo.IME_ACTION_DONE);
pinEntry.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE) {
ViewUtil.hideKeyboard(requireContext(), v);
handlePinEntry();
return true;
}
return false;
});
enableAndFocusPinEntry();
pinButton.setOnClickListener((v) -> {
ViewUtil.hideKeyboard(requireContext(), pinEntry);
handlePinEntry();
});
keyboardToggle.setOnClickListener((v) -> {
PinKeyboardType keyboardType = getPinEntryKeyboardType();
updateKeyboard(keyboardType.getOther());
keyboardToggle.setIconResource(keyboardType.getIconResource());
});
PinKeyboardType keyboardType = getPinEntryKeyboardType().getOther();
keyboardToggle.setIconResource(keyboardType.getIconResource());
disposables.bindTo(getViewLifecycleOwner().getLifecycle());
viewModel = getViewModel();
viewModel.getLockedTimeRemaining()
.observe(getViewLifecycleOwner(), t -> timeRemaining = t);
Integer triesRemaining = viewModel.getSvrTriesRemaining();
if (triesRemaining != null) {
if (triesRemaining <= 3) {
int daysRemaining = getLockoutDays(timeRemaining);
new 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, (dialog, which) -> sendEmailToSupport())
.show();
}
if (triesRemaining < 5) {
errorLabel.setText(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining));
}
}
}
protected abstract BaseRegistrationViewModel getViewModel();
private String getTriesRemainingDialogMessage(int triesRemaining, int daysRemaining) {
Resources resources = requireContext().getResources();
String tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining);
String 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;
}
protected PinKeyboardType getPinEntryKeyboardType() {
boolean isNumeric = (pinEntry.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER;
return isNumeric ? PinKeyboardType.NUMERIC : PinKeyboardType.ALPHA_NUMERIC;
}
private void handlePinEntry() {
pinEntry.setEnabled(false);
final String pin = pinEntry.getText().toString();
int 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 < MINIMUM_PIN_LENGTH) {
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show();
enableAndFocusPinEntry();
return;
}
pinButton.setSpinning();
Disposable verify = viewModel.verifyCodeAndRegisterAccountWithRegistrationLock(pin)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(processor -> {
if (processor.hasResult()) {
handleSuccessfulPinEntry(pin);
} else if (processor.wrongPin()) {
onIncorrectKbsRegistrationLockPin(Objects.requireNonNull(processor.getSvrTriesRemaining()));
} else if (processor.isRegistrationLockPresentAndSvrExhausted() || processor.registrationLock()) {
onKbsAccountLocked();
} else if (processor.rateLimit()) {
onRateLimited();
} else {
Log.w(TAG, "Unable to verify code with registration lock", processor.getError());
onError();
}
});
disposables.add(verify);
}
public void onIncorrectKbsRegistrationLockPin(int svrTriesRemaining) {
pinButton.cancelSpinning();
pinEntry.getText().clear();
enableAndFocusPinEntry();
viewModel.setSvrTriesRemaining(svrTriesRemaining);
if (svrTriesRemaining == 0) {
Log.w(TAG, "Account locked. User out of attempts on KBS.");
onAccountLocked();
return;
}
if (svrTriesRemaining == 3) {
int daysRemaining = getLockoutDays(timeRemaining);
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__incorrect_pin)
.setMessage(getTriesRemainingDialogMessage(svrTriesRemaining, daysRemaining))
.setPositiveButton(android.R.string.ok, null)
.show();
}
if (svrTriesRemaining > 5) {
errorLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again);
} else {
errorLabel.setText(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, svrTriesRemaining, svrTriesRemaining));
forgotPin.setVisibility(View.VISIBLE);
}
}
public void onRateLimited() {
pinButton.cancelSpinning();
enableAndFocusPinEntry();
new 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();
}
public void onKbsAccountLocked() {
onAccountLocked();
}
public void onError() {
pinButton.cancelSpinning();
enableAndFocusPinEntry();
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show();
}
private void handleForgottenPin(long timeRemainingMs) {
int lockoutDays = getLockoutDays(timeRemainingMs);
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__forgot_your_pin)
.setMessage(requireContext().getResources().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, (dialog, which) -> sendEmailToSupport())
.show();
}
private static int getLockoutDays(long timeRemainingMs) {
return (int) TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1;
}
private void onAccountLocked() {
navigateToAccountLocked();
}
protected abstract void navigateToAccountLocked();
private void updateKeyboard(@NonNull PinKeyboardType keyboard) {
boolean isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC;
pinEntry.setInputType(isAlphaNumeric ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD
: InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
pinEntry.getText().clear();
}
private void enableAndFocusPinEntry() {
pinEntry.setEnabled(true);
pinEntry.setFocusable(true);
ViewUtil.focusAndShowKeyboard(pinEntry);
}
protected abstract void handleSuccessfulPinEntry(@NonNull String pin);
protected abstract void sendEmailToSupport();
}

View File

@@ -1,83 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
import android.annotation.SuppressLint;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import java.io.Serializable;
/**
* Fragment that displays a Captcha in a WebView.
*/
public final class CaptchaFragment extends LoggingFragment {
public static final String EXTRA_VIEW_MODEL_PROVIDER = "view_model_provider";
private BaseRegistrationViewModel viewModel;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_registration_captcha, container, false);
}
@Override
@SuppressLint("SetJavaScriptEnabled")
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
WebView webView = view.findViewById(R.id.registration_captcha_web_view);
webView.getSettings().setJavaScriptEnabled(true);
webView.clearCache(true);
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url != null && url.startsWith(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME)) {
handleToken(url.substring(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME.length()));
return true;
}
return false;
}
});
webView.loadUrl(BuildConfig.SIGNAL_CAPTCHA_URL);
CaptchaViewModelProvider provider = null;
if (getArguments() != null) {
provider = (CaptchaViewModelProvider) requireArguments().getSerializable(EXTRA_VIEW_MODEL_PROVIDER);
}
if (provider == null) {
viewModel = new ViewModelProvider(
requireActivity()).get(RegistrationViewModel.class);
} else {
viewModel = provider.get(this);
}
}
private void handleToken(@NonNull String token) {
viewModel.setCaptchaResponse(token);
NavHostFragment.findNavController(this).navigateUp();
}
public interface CaptchaViewModelProvider extends Serializable {
@NonNull BaseRegistrationViewModel get(@NonNull CaptchaFragment fragment);
}
}

View File

@@ -1,80 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.provider.DocumentsContract;
import android.text.method.LinkMovementMethod;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import androidx.navigation.Navigation;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
public class ChooseBackupFragment extends LoggingFragment {
private static final String TAG = Log.tag(ChooseBackupFragment.class);
private static final short OPEN_FILE_REQUEST_CODE = 3862;
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState)
{
return inflater.inflate(R.layout.fragment_registration_choose_backup, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
View chooseBackupButton = view.findViewById(R.id.choose_backup_fragment_button);
chooseBackupButton.setOnClickListener(this::onChooseBackupSelected);
TextView learnMore = view.findViewById(R.id.choose_backup_fragment_learn_more);
learnMore.setText(HtmlCompat.fromHtml(String.format("<a href=\"%s\">%s</a>", getString(R.string.backup_support_url), getString(R.string.ChooseBackupFragment__learn_more)), 0));
learnMore.setMovementMethod(LinkMovementMethod.getInstance());
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == OPEN_FILE_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null) {
ChooseBackupFragmentDirections.ActionRestore restore = ChooseBackupFragmentDirections.actionRestore();
restore.setUri(data.getData());
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), restore);
}
}
private void onChooseBackupSelected(@NonNull View view) {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("application/octet-stream");
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true);
if (Build.VERSION.SDK_INT >= 26) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings().getLatestSignalBackupDirectory());
}
try {
startActivityForResult(intent, OPEN_FILE_REQUEST_CODE);
} catch (ActivityNotFoundException e) {
Toast.makeText(requireContext(), R.string.ChooseBackupFragment__no_file_browser_available, Toast.LENGTH_LONG).show();
Log.w(TAG, "No matching activity!", e);
}
}
}

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.fragments;
import android.os.Bundle;
@@ -20,8 +25,8 @@ import androidx.loader.content.Loader;
import androidx.navigation.fragment.NavHostFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberViewModel;
import org.thoughtcrime.securesms.database.loaders.CountryListLoader;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import java.util.ArrayList;
import java.util.Map;
@@ -32,7 +37,7 @@ public final class CountryPickerFragment extends ListFragment implements LoaderM
public static final String KEY_COUNTRY_CODE = "country_code";
private EditText countryFilter;
private RegistrationViewModel model;
private ChangeNumberViewModel model;
private String resultKey;
@Override
@@ -50,7 +55,7 @@ public final class CountryPickerFragment extends ListFragment implements LoaderM
}
if (resultKey == null) {
model = new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
model = new ViewModelProvider(requireActivity()).get(ChangeNumberViewModel.class);
}
countryFilter = view.findViewById(R.id.country_search);
@@ -67,7 +72,7 @@ public final class CountryPickerFragment extends ListFragment implements LoaderM
String countryName = item.get("country_name");
if (resultKey == null) {
model.onCountrySelected(countryName, countryCode);
model.setNewCountry(countryCode, countryName);
} else {
Bundle result = new Bundle();
result.putString(KEY_COUNTRY, countryName);

View File

@@ -1,494 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.ScrollView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavController;
import androidx.navigation.Navigation;
import androidx.navigation.fragment.NavHostFragment;
import com.google.android.gms.auth.api.phone.SmsRetriever;
import com.google.android.gms.auth.api.phone.SmsRetrieverClient;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
import com.google.android.gms.tasks.Task;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.textfield.TextInputLayout;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor;
import org.thoughtcrime.securesms.registration.VerifyAccountRepository.Mode;
import org.thoughtcrime.securesms.registration.util.RegistrationNumberInputController;
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.SupportEmailUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView;
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.showConfirmNumberDialogIfTranslated;
public final class EnterPhoneNumberFragment extends LoggingFragment implements RegistrationNumberInputController.Callbacks {
private static final String TAG = Log.tag(EnterPhoneNumberFragment.class);
private TextInputLayout countryCode;
private TextInputLayout number;
private CircularProgressMaterialButton register;
private View cancel;
private ScrollView scrollView;
private RegistrationViewModel viewModel;
private final LifecycleDisposable disposables = new LifecycleDisposable();
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_registration_enter_phone_number, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header));
countryCode = view.findViewById(R.id.country_code);
number = view.findViewById(R.id.number);
cancel = view.findViewById(R.id.cancel_button);
scrollView = view.findViewById(R.id.scroll_view);
register = view.findViewById(R.id.registerButton);
RegistrationNumberInputController controller = new RegistrationNumberInputController(requireContext(),
this,
Objects.requireNonNull(number.getEditText()),
countryCode);
register.setOnClickListener(v -> handleRegister(requireContext()));
disposables.bindTo(getViewLifecycleOwner().getLifecycle());
viewModel = new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
if (viewModel.isReregister()) {
cancel.setVisibility(View.VISIBLE);
cancel.setOnClickListener(v -> requireActivity().finish());
} else {
cancel.setVisibility(View.GONE);
}
viewModel.getLiveNumber().observe(getViewLifecycleOwner(), controller::updateNumberFormatter);
if (viewModel.hasCaptchaToken()) {
ThreadUtil.runOnMainDelayed(() -> handleRegister(requireContext()), 250);
}
Toolbar toolbar = view.findViewById(R.id.toolbar);
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
final ActionBar supportActionBar = ((AppCompatActivity) requireActivity()).getSupportActionBar();
if (supportActionBar != null) {
supportActionBar.setTitle(null);
}
final NumberViewState viewModelNumber = viewModel.getNumber();
if (viewModelNumber.getCountryCode() == 0) {
controller.prepopulateCountryCode();
}
controller.setNumberAndCountryCode(viewModelNumber);
ViewUtil.focusAndShowKeyboard(number.getEditText());
if (viewModel.hasUserSkippedReRegisterFlow() && viewModel.shouldAutoShowSmsConfirmDialog()) {
viewModel.setAutoShowSmsConfirmDialog(false);
ThreadUtil.runOnMainDelayed(() -> handleRegister(requireContext()), 250);
}
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.enter_phone_number, menu);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.phone_menu_use_proxy) {
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), EnterPhoneNumberFragmentDirections.actionEditProxy());
return true;
} else {
return false;
}
}
private void handleRegister(@NonNull Context context) {
if (viewModel.getNumber().getCountryCode() == 0) {
showErrorDialog(context, getString(R.string.RegistrationActivity_you_must_specify_your_country_code));
return;
}
if (TextUtils.isEmpty(viewModel.getNumber().getNationalNumber())) {
showErrorDialog(context, getString(R.string.RegistrationActivity_please_enter_a_valid_phone_number_to_register));
return;
}
final NumberViewState number = viewModel.getNumber();
final String e164number = number.getE164Number();
if (!number.isValid()) {
Dialogs.showAlertDialog(context,
getString(R.string.RegistrationActivity_invalid_number),
String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), e164number));
return;
}
PlayServicesUtil.PlayServicesStatus fcmStatus = PlayServicesUtil.getPlayServicesStatus(context);
if (fcmStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
confirmNumberPrompt(context, e164number, () -> onE164EnteredSuccessfully(context, true));
} else if (fcmStatus == PlayServicesUtil.PlayServicesStatus.MISSING) {
confirmNumberPrompt(context, e164number, () -> handlePromptForNoPlayServices(context));
} else if (fcmStatus == PlayServicesUtil.PlayServicesStatus.NEEDS_UPDATE) {
GoogleApiAvailability.getInstance().getErrorDialog(requireActivity(), ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, 0).show();
} else {
Dialogs.showAlertDialog(context,
getString(R.string.RegistrationActivity_play_services_error),
getString(R.string.RegistrationActivity_google_play_services_is_updating_or_unavailable));
}
}
private void onE164EnteredSuccessfully(@NonNull Context context, boolean fcmSupported) {
enterInProgressUiState();
Log.d(TAG, "E164 entered successfully.");
Disposable disposable = viewModel.canEnterSkipSmsFlow()
.observeOn(AndroidSchedulers.mainThread())
.onErrorReturnItem(false)
.subscribe(canEnter -> {
if (canEnter) {
Log.i(TAG, "Entering skip flow.");
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), EnterPhoneNumberFragmentDirections.actionReRegisterWithPinFragment());
} else {
Log.i(TAG, "Unable to collect necessary data to enter skip flow, returning to normal");
handleRequestVerification(context, fcmSupported);
}
});
disposables.add(disposable);
}
private void handleRequestVerification(@NonNull Context context, boolean fcmSupported) {
if (fcmSupported) {
SmsRetrieverClient client = SmsRetriever.getClient(context);
Task<Void> task = client.startSmsRetriever();
AtomicBoolean handled = new AtomicBoolean(false);
Debouncer debouncer = new Debouncer(TimeUnit.SECONDS.toMillis(5));
debouncer.publish(() -> {
if (!handled.getAndSet(true)) {
Log.w(TAG, "Timed out waiting for SMS listener!");
requestVerificationCode(Mode.SMS_WITHOUT_LISTENER);
}
});
task.addOnSuccessListener(none -> {
if (!handled.getAndSet(true)) {
Log.i(TAG, "Successfully registered SMS listener.");
requestVerificationCode(Mode.SMS_WITH_LISTENER);
} else {
Log.w(TAG, "Successfully registered listener after timeout.");
}
debouncer.clear();
});
task.addOnFailureListener(e -> {
if (!handled.getAndSet(true)) {
Log.w(TAG, "Failed to register SMS listener.", e);
requestVerificationCode(Mode.SMS_WITHOUT_LISTENER);
} else {
Log.w(TAG, "Failed to register listener after timeout.");
}
debouncer.clear();
});
task.addOnCanceledListener(() -> {
if (!handled.getAndSet(true)) {
Log.w(TAG, "SMS listener registration canceled.");
requestVerificationCode(Mode.SMS_WITHOUT_LISTENER);
} else {
Log.w(TAG, "SMS listener registration canceled after timeout.");
}
debouncer.clear();
});
} else {
Log.i(TAG, "FCM is not supported, using no SMS listener");
requestVerificationCode(Mode.SMS_WITHOUT_LISTENER);
}
}
private void enterInProgressUiState() {
register.setSpinning();
countryCode.setEnabled(false);
number.setEnabled(false);
cancel.setVisibility(View.GONE);
}
private void exitInProgressUiState() {
register.cancelSpinning();
countryCode.setEnabled(true);
number.setEnabled(true);
if (viewModel.isReregister()) {
cancel.setVisibility(View.VISIBLE);
}
}
private void requestVerificationCode(@NonNull Mode mode) {
NavController navController = NavHostFragment.findNavController(this);
MccMncProducer mccMncProducer = new MccMncProducer(requireContext());
final DialogInterface.OnClickListener proceedToNextScreen = (dialog, which) -> SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionEnterVerificationCode());
Disposable request = viewModel.requestVerificationCode(mode, mccMncProducer.getMcc(), mccMncProducer.getMnc())
.doOnSubscribe(unused -> SignalStore.account().setRegistered(false))
.observeOn(AndroidSchedulers.mainThread())
.subscribe((RegistrationSessionProcessor processor) -> {
Context context = getContext();
if (context == null) {
Log.w(TAG, "[requestVerificationCode] Invalid context! Skipping.");
return;
}
if (processor.verificationCodeRequestSuccess()) {
disposables.add(updateFcmTokenValue());
SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionEnterVerificationCode());
} else if (processor.captchaRequired(viewModel.getExcludedChallenges())) {
Log.i(TAG, "Unable to request sms code due to captcha required");
SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionRequestCaptcha());
} else if (processor.exhaustedVerificationCodeAttempts()) {
Log.i(TAG, "Unable to request sms code due to exhausting attempts");
showErrorDialog(context, context.getString(R.string.RegistrationActivity_rate_limited_to_service));
} else if (processor.rateLimit()) {
Log.i(TAG, "Unable to request sms code due to rate limit");
showErrorDialog(context, context.getString(R.string.RegistrationActivity_rate_limited_to_try_again, formatMillisecondsToString(processor.getRateLimit())));
} else if (processor.isImpossibleNumber()) {
Log.w(TAG, "Impossible number", processor.getError());
Dialogs.showAlertDialog(requireContext(),
context.getString(R.string.RegistrationActivity_invalid_number),
String.format(context.getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), viewModel.getNumber().getFullFormattedNumber()));
} else if (processor.isNonNormalizedNumber()) {
handleNonNormalizedNumberError(processor.getOriginalNumber(), processor.getNormalizedNumber(), mode);
} else if (processor.isTokenRejected()) {
Log.i(TAG, "The server did not accept the information.", processor.getError());
showErrorDialog(context, context.getString(R.string.RegistrationActivity_we_need_to_verify_that_youre_human));
} else if (processor.externalServiceFailure()) {
Log.w(TAG, "The server reported a failure with an external service.", processor.getError());
showErrorDialog(context, context.getString(R.string.RegistrationActivity_unable_to_connect_to_service), proceedToNextScreen);
} else if (processor.invalidTransportModeFailure()) {
Log.w(TAG, "The server reported an invalid transport mode failure.", processor.getError());
new MaterialAlertDialogBuilder(context)
.setMessage(R.string.RegistrationActivity_we_couldnt_send_you_a_verification_code)
.setPositiveButton(R.string.RegistrationActivity_voice_call, (dialog, which) -> requestVerificationCode(Mode.PHONE_CALL))
.setNegativeButton(R.string.RegistrationActivity_cancel, null)
.show();
} else if ( processor.isMalformedRequest()){
Log.w(TAG, "The server reported a malformed request.", processor.getError());
showErrorDialog(context, context.getString(R.string.RegistrationActivity_unable_to_connect_to_service), proceedToNextScreen);
} else if (processor.isRetryException()) {
Log.w(TAG, "The server reported a failure that is retryable.", processor.getError());
showErrorDialog(context, context.getString(R.string.RegistrationActivity_unable_to_connect_to_service), proceedToNextScreen);
} else {
Log.i(TAG, "Unknown error during verification code request", processor.getError());
showErrorDialog(context, context.getString(R.string.RegistrationActivity_unable_to_connect_to_service));
}
exitInProgressUiState();
});
disposables.add(request);
}
private Disposable updateFcmTokenValue() {
return viewModel.updateFcmTokenValue().subscribe();
}
private String formatMillisecondsToString(long milliseconds) {
long totalSeconds = milliseconds / 1000;
long HH = totalSeconds / 3600;
long MM = (totalSeconds % 3600) / 60;
long SS = totalSeconds % 60;
return String.format(Locale.getDefault(), "%02d:%02d:%02d", HH, MM, SS);
}
public void showErrorDialog(Context context, String msg) {
showErrorDialog(context, msg, null);
}
public void showErrorDialog(Context context, String msg, DialogInterface.OnClickListener positiveButtonListener) {
new MaterialAlertDialogBuilder(context).setMessage(msg).setPositiveButton(android.R.string.ok, positiveButtonListener).show();
}
@Override
public void onNumberFocused() {
scrollView.postDelayed(() -> scrollView.smoothScrollTo(0, register.getBottom()), 250);
}
@Override
public void onNumberInputDone(@NonNull View view) {
ViewUtil.hideKeyboard(requireContext(), view);
handleRegister(requireContext());
}
@Override
public void setNationalNumber(@NonNull String number) {
viewModel.setNationalNumber(number);
}
@Override
public void setCountry(int countryCode) {
viewModel.onCountrySelected(null, countryCode);
}
@Override
public void onStart() {
super.onStart();
String sessionE164 = viewModel.getSessionE164();
if (sessionE164 != null && viewModel.getSessionId() != null && viewModel.getCaptchaToken() == null) {
checkIfSessionIsInProgressAndAdvance(sessionE164);
}
}
private void checkIfSessionIsInProgressAndAdvance(@NonNull String sessionE164) {
NavController navController = NavHostFragment.findNavController(this);
Disposable request = viewModel.validateSession(sessionE164)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(processor -> {
if (processor.hasResult() && processor.canSubmitProofImmediately()) {
try {
viewModel.restorePhoneNumberStateFromE164(sessionE164);
SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionEnterVerificationCode());
} catch (NumberParseException numberParseException) {
viewModel.resetSession();
}
} else {
viewModel.resetSession();
}
});
disposables.add(request);
}
private void handleNonNormalizedNumberError(@NonNull String originalNumber, @NonNull String normalizedNumber, @NonNull Mode mode) {
try {
Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().parse(normalizedNumber, null);
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationActivity_non_standard_number_format)
.setMessage(getString(R.string.RegistrationActivity_the_number_you_entered_appears_to_be_a_non_standard, originalNumber, normalizedNumber))
.setNegativeButton(android.R.string.no, (d, i) -> d.dismiss())
.setNeutralButton(R.string.RegistrationActivity_contact_signal_support, (d, i) -> {
String subject = getString(R.string.RegistrationActivity_signal_android_phone_number_format);
String body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.RegistrationActivity_signal_android_phone_number_format, null, null);
CommunicationActions.openEmail(requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), subject, body);
d.dismiss();
})
.setPositiveButton(R.string.yes, (d, i) -> {
countryCode.getEditText().setText(String.valueOf(phoneNumber.getCountryCode()));
number.getEditText().setText(String.valueOf(phoneNumber.getNationalNumber()));
requestVerificationCode(mode);
d.dismiss();
})
.show();
} catch (NumberParseException e) {
Log.w(TAG, "Failed to parse number!", e);
Dialogs.showAlertDialog(requireContext(),
getString(R.string.RegistrationActivity_invalid_number),
String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), viewModel.getNumber().getFullFormattedNumber()));
}
}
private void handlePromptForNoPlayServices(@NonNull Context context) {
Log.d(TAG, "Device does not have Play Services, showing consent dialog.");
new MaterialAlertDialogBuilder(context)
.setTitle(R.string.RegistrationActivity_missing_google_play_services)
.setMessage(R.string.RegistrationActivity_this_device_is_missing_google_play_services)
.setPositiveButton(R.string.RegistrationActivity_i_understand, (dialog1, which) -> onE164EnteredSuccessfully(context, false))
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void confirmNumberPrompt(@NonNull Context context,
@NonNull String e164number,
@NonNull Runnable onConfirmed)
{
enterInProgressUiState();
disposables.add(
viewModel.canEnterSkipSmsFlow()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(canSkipSms -> {
Log.d(TAG, "Showing confirm number dialog. canSkipSms = " + canSkipSms + " hasUserSkipped = " + viewModel.hasUserSkippedReRegisterFlow());
final EditText editText = this.number.getEditText();
showConfirmNumberDialogIfTranslated(context,
viewModel.hasUserSkippedReRegisterFlow() ? R.string.RegistrationActivity_additional_verification_required
: R.string.RegistrationActivity_phone_number_verification_dialog_title,
canSkipSms ? null
: R.string.RegistrationActivity_a_verification_code_will_be_sent_to_this_number,
e164number,
() -> {
Log.d(TAG, "User confirmed number.");
if (editText != null) {
ViewUtil.hideKeyboard(context, editText);
}
onConfirmed.run();
},
() -> {
Log.d(TAG, "User canceled confirm number, returning to edit number.");
exitInProgressUiState();
if (editText != null) {
ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(editText);
}
});
}
)
);
}
}

View File

@@ -1,59 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
import androidx.annotation.NonNull;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation;
import androidx.navigation.fragment.NavHostFragment;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.signal.core.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import java.io.IOException;
public final class EnterSmsCodeFragment extends BaseEnterSmsCodeFragment<RegistrationViewModel> implements SignalStrengthPhoneStateListener.Callback {
private static final String TAG = Log.tag(EnterSmsCodeFragment.class);
public EnterSmsCodeFragment() {
super(R.layout.fragment_registration_enter_code);
}
@Override
protected @NonNull RegistrationViewModel getViewModel() {
return new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
}
@Override
protected void handleSuccessfulVerify() {
SimpleTask.run(() -> {
long startTime = System.currentTimeMillis();
try {
RemoteConfig.refreshSync();
Log.i(TAG, "Took " + (System.currentTimeMillis() - startTime) + " ms to get feature flags.");
} catch (IOException e) {
Log.w(TAG, "Failed to refresh flags after " + (System.currentTimeMillis() - startTime) + " ms.", e);
}
return null;
}, none -> displaySuccess(() -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), EnterSmsCodeFragmentDirections.actionSuccessfulRegistration())));
}
@Override
protected void navigateToRegistrationLock(long timeRemaining) {
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()),
EnterSmsCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining));
}
@Override
protected void navigateToCaptcha() {
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), EnterSmsCodeFragmentDirections.actionRequestCaptcha());
}
@Override
protected void navigateToKbsAccountLocked() {
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), RegistrationLockFragmentDirections.actionAccountLocked());
}
}

View File

@@ -1,103 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.fragments
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.navArgs
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.registration.compose.GrantPermissionsScreen
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel
import org.thoughtcrime.securesms.util.BackupUtil
/**
* Fragment displayed during registration which allows a user to read through
* what permissions are granted to Signal and why, and a means to either skip
* granting those permissions or continue to grant via system dialogs.
*/
class GrantPermissionsFragment : ComposeFragment() {
private val args by navArgs<GrantPermissionsFragmentArgs>()
private val viewModel by activityViewModels<RegistrationViewModel>()
private val isSearchingForBackup = mutableStateOf(false)
@Composable
override fun FragmentContent() {
val isSearchingForBackup by this.isSearchingForBackup
GrantPermissionsScreen(
deviceBuildVersion = Build.VERSION.SDK_INT,
isSearchingForBackup = isSearchingForBackup,
isBackupSelectionRequired = BackupUtil.isUserSelectionRequired(LocalContext.current),
onNextClicked = this::onNextClicked,
onNotNowClicked = this::onNotNowClicked
)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String?>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
private fun onNextClicked() {
when (args.welcomeAction) {
WelcomeAction.CONTINUE -> {
WelcomeFragment.continueClicked(
this,
viewModel,
{ isSearchingForBackup.value = true },
{ isSearchingForBackup.value = false },
GrantPermissionsFragmentDirections.actionSkipRestore(),
GrantPermissionsFragmentDirections.actionRestore()
)
}
WelcomeAction.RESTORE_BACKUP -> {
WelcomeFragment.restoreFromBackupClicked(
this,
viewModel,
GrantPermissionsFragmentDirections.actionTransferOrRestore()
)
}
}
}
private fun onNotNowClicked() {
when (args.welcomeAction) {
WelcomeAction.CONTINUE -> {
WelcomeFragment.gatherInformationAndContinue(
this,
viewModel,
{ isSearchingForBackup.value = true },
{ isSearchingForBackup.value = false },
GrantPermissionsFragmentDirections.actionSkipRestore(),
GrantPermissionsFragmentDirections.actionRestore()
)
}
WelcomeAction.RESTORE_BACKUP -> {
WelcomeFragment.gatherInformationAndChooseBackup(
this,
viewModel,
GrantPermissionsFragmentDirections.actionTransferOrRestore()
)
}
}
}
/**
* Which welcome action the user selected which prompted this
* screen.
*/
enum class WelcomeAction {
CONTINUE,
RESTORE_BACKUP
}
}

View File

@@ -1,282 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments
import android.os.Bundle
import android.text.InputType
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.PinRestoreEntryFragmentBinding
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.registration.VerifyResponseWithRegistrationLockProcessor
import org.thoughtcrime.securesms.registration.viewmodel.ReRegisterWithPinViewModel
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel
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
/**
* Using a recovery password or restored KBS token attempt to register in the skip flow.
*/
class ReRegisterWithPinFragment : LoggingFragment(R.layout.pin_restore_entry_fragment) {
companion object {
private val TAG = Log.tag(ReRegisterWithPinFragment::class.java)
}
private var _binding: PinRestoreEntryFragmentBinding? = null
private val binding: PinRestoreEntryFragmentBinding
get() = _binding!!
private val registrationViewModel: RegistrationViewModel by activityViewModels()
private val reRegisterViewModel: ReRegisterWithPinViewModel by viewModels()
private val disposables = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
_binding = PinRestoreEntryFragmentBinding.bind(view)
disposables.bindTo(viewLifecycleOwner.lifecycle)
RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.pinRestorePinTitle)
binding.pinRestorePinDescription.setText(R.string.RegistrationLockFragment__enter_the_pin_you_created_for_your_account)
binding.pinRestoreForgotPin.visibility = View.GONE
binding.pinRestoreForgotPin.setOnClickListener { onNeedHelpClicked() }
binding.pinRestoreSkipButton.setOnClickListener { onSkipClicked() }
binding.pinRestorePinInput.imeOptions = EditorInfo.IME_ACTION_DONE
binding.pinRestorePinInput.setOnEditorActionListener { v, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
ViewUtil.hideKeyboard(requireContext(), v!!)
handlePinEntry()
return@setOnEditorActionListener true
}
false
}
enableAndFocusPinEntry()
binding.pinRestorePinConfirm.setOnClickListener {
handlePinEntry()
}
binding.pinRestoreKeyboardToggle.setOnClickListener {
val currentKeyboardType: PinKeyboardType = getPinEntryKeyboardType()
updateKeyboard(currentKeyboardType.other)
binding.pinRestoreKeyboardToggle.setIconResource(currentKeyboardType.iconResource)
}
binding.pinRestoreKeyboardToggle.setIconResource(getPinEntryKeyboardType().other.iconResource)
reRegisterViewModel.updateSvrTriesRemaining(registrationViewModel.svrTriesRemaining)
disposables += reRegisterViewModel.triesRemaining.subscribe(this::updateTriesRemaining)
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
private fun handlePinEntry() {
val pin: String? = binding.pinRestorePinInput.text?.toString()
val trimmedLength = pin?.trim()?.length ?: 0
if (trimmedLength == 0) {
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
if (trimmedLength < BaseRegistrationLockFragment.MINIMUM_PIN_LENGTH) {
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, BaseRegistrationLockFragment.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
disposables += registrationViewModel.verifyReRegisterWithPin(pin!!)
.doOnSubscribe {
ViewUtil.hideKeyboard(requireContext(), binding.pinRestorePinInput)
binding.pinRestorePinInput.isEnabled = false
binding.pinRestorePinConfirm.setSpinning()
}
.doAfterTerminate {
binding.pinRestorePinInput.isEnabled = true
binding.pinRestorePinConfirm.cancelSpinning()
}
.subscribe { processor ->
if (processor.hasResult()) {
Log.i(TAG, "Successfully re-registered via skip flow")
try {
findNavController().safeNavigate(R.id.action_reRegisterWithPinFragment_to_registrationCompletePlaceHolderFragment)
return@subscribe
} catch (ise: IllegalStateException) {
Log.w(TAG, "Could not get parent activity fragment manager!")
}
try {
val hostActivity = activity
if (hostActivity != null) {
Navigation.findNavController(hostActivity, R.id.nav_host_fragment).safeNavigate(R.id.action_reRegisterWithPinFragment_to_registrationCompletePlaceHolderFragment)
return@subscribe
} else {
Log.w(TAG, "Could not get parent activity!")
}
} catch (ise: IllegalStateException) {
Log.w(TAG, "Could not find navigation host fragment!")
}
activity?.let {
Log.w(TAG, "Could not navigate to registration complete. Finishing activity gracefully.")
it.finish()
}
return@subscribe
}
reRegisterViewModel.hasIncorrectGuess = true
if (processor is VerifyResponseWithRegistrationLockProcessor && processor.wrongPin()) {
reRegisterViewModel.updateSvrTriesRemaining(processor.svrTriesRemaining)
if (processor.svrTriesRemaining != null) {
registrationViewModel.svrTriesRemaining = processor.svrTriesRemaining
}
return@subscribe
} else if (processor.isRegistrationLockPresentAndSvrExhausted()) {
Log.w(TAG, "Unable to continue skip flow, KBS is locked")
onAccountLocked()
} else if (processor.isIncorrectRegistrationRecoveryPassword()) {
Log.w(TAG, "Registration recovery password was incorrect. Moving to SMS verification.")
onSkipPinEntry()
} else if (processor.isServerSentError()) {
Log.i(TAG, "Error from server, not likely recoverable", processor.error)
genericErrorDialog()
} else {
Log.i(TAG, "Unexpected error occurred", processor.error)
genericErrorDialog()
}
}
}
private fun updateTriesRemaining(triesRemaining: Int) {
if (reRegisterViewModel.hasIncorrectGuess) {
if (triesRemaining == 1 && !reRegisterViewModel.isLocalVerification) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_incorrect_pin)
.setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining))
.setPositiveButton(android.R.string.ok, null)
.show()
}
if (triesRemaining > 5) {
binding.pinRestorePinInputLabel.setText(R.string.PinRestoreEntryFragment_incorrect_pin)
} else {
binding.pinRestorePinInputLabel.text = resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, triesRemaining, triesRemaining)
}
binding.pinRestoreForgotPin.visibility = View.VISIBLE
} else {
if (triesRemaining == 1) {
binding.pinRestoreForgotPin.visibility = View.VISIBLE
if (!reRegisterViewModel.isLocalVerification) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining))
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
if (triesRemaining == 0) {
Log.w(TAG, "Account locked. User out of attempts on KBS.")
onAccountLocked()
}
}
private fun onAccountLocked() {
Log.d(TAG, "Showing Incorrect PIN dialog. Is local verification: ${reRegisterViewModel.isLocalVerification}")
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_out_of_guesses_local else R.string.PinRestoreLockedFragment_youve_run_out_of_pin_guesses
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_incorrect_pin)
.setMessage(message)
.setCancelable(false)
.setPositiveButton(R.string.ReRegisterWithPinFragment_send_sms_code) { _, _ -> onSkipPinEntry() }
.setNegativeButton(R.string.AccountLockedFragment__learn_more) { _, _ -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.PinRestoreLockedFragment_learn_more_url)) }
.show()
}
private fun enableAndFocusPinEntry() {
binding.pinRestorePinInput.isEnabled = true
binding.pinRestorePinInput.isFocusable = true
ViewUtil.focusAndShowKeyboard(binding.pinRestorePinInput)
}
private fun getPinEntryKeyboardType(): PinKeyboardType {
val isNumeric = binding.pinRestorePinInput.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.pinRestorePinInput.inputType = if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
binding.pinRestorePinInput.text?.clear()
}
private fun onNeedHelpClicked() {
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_need_help_local else R.string.PinRestoreEntryFragment_your_pin_is_a_d_digit_code
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_need_help)
.setMessage(getString(message, SvrConstants.MINIMUM_PIN_LENGTH))
.setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() }
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ ->
val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.ReRegisterWithPinFragment_support_email_subject, null, null)
CommunicationActions.openEmail(
requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getString(R.string.ReRegisterWithPinFragment_support_email_subject),
body
)
}
.setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null)
.show()
}
private fun onSkipClicked() {
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_skip_local else R.string.PinRestoreEntryFragment_if_you_cant_remember_your_pin
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_skip_pin_entry)
.setMessage(message)
.setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() }
.setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null)
.show()
}
private fun onSkipPinEntry() {
Log.d(TAG, "User skipping PIN entry.")
registrationViewModel.setUserSkippedReRegisterFlow(true)
findNavController().safeNavigate(R.id.action_reRegisterWithPinFragment_to_enterPhoneNumberFragment)
}
private fun genericErrorDialog() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.RegistrationActivity_error_connecting_to_service)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}

View File

@@ -1,91 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.navigation.ActivityNavigator
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.pin.PinRestoreActivity
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel
/**
* [RegistrationCompleteFragment] is not visible to the user, but functions as basically a redirect towards one of:
* - [PIN Restore flow activity](org.thoughtcrime.securesms.pin.PinRestoreActivity)
* - [Profile](org.thoughtcrime.securesms.profiles.edit.EditProfileActivity) / [PIN creation](org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity) flow activities (this class chains the necessary activities together as an intent)
* - Exit registration flow and progress to conversation list
*/
class RegistrationCompleteFragment : LoggingFragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_registration_blank, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val activity = requireActivity()
val viewModel: RegistrationViewModel by viewModels(ownerProducer = { requireActivity() })
if (SignalStore.misc.hasLinkedDevices) {
SignalStore.misc.shouldShowLinkedDevicesReminder = viewModel.isReregister
}
if (SignalStore.storageService.needsAccountRestore()) {
Log.i(TAG, "Performing pin restore.")
activity.startActivity(Intent(activity, PinRestoreActivity::class.java))
} else {
val isProfileNameEmpty = Recipient.self().profileName.isEmpty
val isAvatarEmpty = !AvatarHelper.hasAvatar(activity, Recipient.self().id)
val needsProfile = isProfileNameEmpty || isAvatarEmpty
val needsPin = !SignalStore.svr.hasPin() && !viewModel.isReregister
Log.i(TAG, "Pin restore flow not required. Profile name: $isProfileNameEmpty | Profile avatar: $isAvatarEmpty | Needs PIN: $needsPin")
if (!needsProfile && !needsPin) {
AppDependencies.jobManager
.startChain(ProfileUploadJob())
.then(listOf(MultiDeviceProfileKeyUpdateJob(), MultiDeviceProfileContentUpdateJob()))
.enqueue()
RegistrationUtil.maybeMarkRegistrationComplete()
}
var startIntent = MainActivity.clearTop(activity)
if (needsPin) {
startIntent = chainIntents(CreateSvrPinActivity.getIntentForPinCreate(activity), startIntent)
}
if (needsProfile) {
startIntent = chainIntents(CreateProfileActivity.getIntentForUserProfile(activity), startIntent)
}
activity.startActivity(startIntent)
}
activity.finish()
ActivityNavigator.applyPopAnimationsToPendingTransition(activity)
}
private fun chainIntents(sourceIntent: Intent, nextIntent: Intent): Intent {
sourceIntent.putExtra("next_intent", nextIntent)
return sourceIntent
}
companion object {
private val TAG = Log.tag(RegistrationCompleteFragment::class.java)
}
}

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.fragments;
public final class RegistrationConstants {

View File

@@ -1,92 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
import androidx.annotation.NonNull;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob;
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob;
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.signal.core.util.Stopwatch;
import org.thoughtcrime.securesms.util.SupportEmailUtil;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
public final class RegistrationLockFragment extends BaseRegistrationLockFragment {
private static final String TAG = Log.tag(RegistrationLockFragment.class);
public RegistrationLockFragment() {
super(R.layout.fragment_registration_lock);
}
@Override
protected BaseRegistrationViewModel getViewModel() {
return new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
}
@Override
protected void navigateToAccountLocked() {
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), RegistrationLockFragmentDirections.actionAccountLocked());
}
@Override
protected void handleSuccessfulPinEntry(@NonNull String pin) {
SignalStore.pin().setKeyboardType(getPinEntryKeyboardType());
SimpleTask.run(() -> {
SignalStore.onboarding().clearAll();
Stopwatch stopwatch = new Stopwatch("RegistrationLockRestore");
AppDependencies.getJobManager().runSynchronously(new StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN);
stopwatch.split("AccountRestore");
AppDependencies
.getJobManager()
.startChain(new StorageSyncJob())
.then(new ReclaimUsernameAndLinkJob())
.enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10));
stopwatch.split("ContactRestore");
try {
RemoteConfig.refreshSync();
} catch (IOException e) {
Log.w(TAG, "Failed to refresh flags.", e);
}
stopwatch.split("RemoteConfig");
stopwatch.stop(TAG);
return null;
}, none -> {
pinButton.cancelSpinning();
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), RegistrationLockFragmentDirections.actionSuccessfulRegistration());
});
}
@Override
protected void sendEmailToSupport() {
int subject = R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v2_pin;
String body = SupportEmailUtil.generateSupportEmailBody(requireContext(),
subject,
null,
null);
CommunicationActions.openEmail(requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getString(subject),
body);
}
}

View File

@@ -1,494 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.Spanned;
import android.text.TextWatcher;
import android.text.style.ReplacementSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.StringRes;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.AppInitialization;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.backup.BackupEvent;
import org.thoughtcrime.securesms.backup.BackupPassphrase;
import org.thoughtcrime.securesms.backup.FullBackupImporter;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.database.NoExternalStorageException;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.jobmanager.impl.DataRestoreConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton;
import java.io.IOException;
import java.util.Locale;
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView;
public final class RestoreBackupFragment extends LoggingFragment {
private static final String TAG = Log.tag(RestoreBackupFragment.class);
private static final short OPEN_DOCUMENT_TREE_RESULT_CODE = 13782;
private TextView restoreBackupSize;
private TextView restoreBackupTime;
private TextView restoreBackupProgress;
private CircularProgressMaterialButton restoreButton;
private View skipRestoreButton;
private RegistrationViewModel viewModel;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_registration_restore_backup, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header));
Log.i(TAG, "Backup restore.");
restoreBackupSize = view.findViewById(R.id.backup_size_text);
restoreBackupTime = view.findViewById(R.id.backup_created_text);
restoreBackupProgress = view.findViewById(R.id.backup_progress_text);
restoreButton = view.findViewById(R.id.restore_button);
skipRestoreButton = view.findViewById(R.id.skip_restore_button);
skipRestoreButton.setOnClickListener((v) -> {
Log.i(TAG, "User skipped backup restore.");
SafeNavigation.safeNavigate(Navigation.findNavController(view),
RestoreBackupFragmentDirections.actionSkip());
});
viewModel = new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
if (viewModel.isReregister()) {
Log.i(TAG, "Skipping backup restore during re-register.");
SafeNavigation.safeNavigate(Navigation.findNavController(view),
RestoreBackupFragmentDirections.actionSkipNoReturn());
return;
}
if (viewModel.hasBackupCompleted()) {
onBackupComplete();
return;
}
if (SignalStore.settings().isBackupEnabled()) {
Log.i(TAG, "Backups enabled, so a backup must have been previously restored.");
SafeNavigation.safeNavigate(Navigation.findNavController(view),
RestoreBackupFragmentDirections.actionSkipNoReturn());
return;
}
RestoreBackupFragmentArgs args = RestoreBackupFragmentArgs.fromBundle(requireArguments());
if ((Build.VERSION.SDK_INT < 29 || BackupUtil.isUserSelectionRequired(requireContext())) && args.getUri() != null) {
Log.i(TAG, "Restoring backup from passed uri");
initializeBackupForUri(view, args.getUri());
return;
}
if (BackupUtil.canUserAccessBackupDirectory(requireContext())) {
initializeBackupDetection(view);
} else {
Log.i(TAG, "Skipping backup detection. We don't have the permission.");
SafeNavigation.safeNavigate(Navigation.findNavController(view),
RestoreBackupFragmentDirections.actionSkipNoReturn());
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == OPEN_DOCUMENT_TREE_RESULT_CODE && resultCode == Activity.RESULT_OK && data != null && data.getData() != null) {
Uri backupDirectoryUri = data.getData();
int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION |
Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
SignalStore.settings().setSignalBackupDirectory(backupDirectoryUri);
requireContext().getContentResolver()
.takePersistableUriPermission(backupDirectoryUri, takeFlags);
enableBackups(requireContext());
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()),
RestoreBackupFragmentDirections.actionBackupRestored());
}
}
private void initializeBackupForUri(@NonNull View view, @NonNull Uri uri) {
getFromUri(requireContext(), uri, backup -> handleBackupInfo(view, backup));
}
@SuppressLint("StaticFieldLeak")
private void initializeBackupDetection(@NonNull View view) {
searchForBackup(backup -> handleBackupInfo(view, backup));
}
private void handleBackupInfo(@NonNull View view, @Nullable BackupUtil.BackupInfo backup) {
Context context = getContext();
if (context == null) {
Log.i(TAG, "No context on fragment, must have navigated away.");
return;
}
if (backup == null) {
Log.i(TAG, "Skipping backup detection. No backup found, or permission revoked since.");
SafeNavigation.safeNavigate(Navigation.findNavController(view),
RestoreBackupFragmentDirections.actionNoBackupFound());
} else {
restoreBackupSize.setText(getString(R.string.RegistrationActivity_backup_size_s, Util.getPrettyFileSize(backup.getSize())));
restoreBackupTime.setText(getString(R.string.RegistrationActivity_backup_timestamp_s, DateUtils.getExtendedRelativeTimeSpanString(requireContext(), Locale.getDefault(), backup.getTimestamp())));
restoreButton.setOnClickListener((v) -> handleRestore(v.getContext(), backup));
}
}
interface OnBackupSearchResultListener {
@MainThread
void run(@Nullable BackupUtil.BackupInfo backup);
}
static void searchForBackup(@NonNull OnBackupSearchResultListener listener) {
new AsyncTask<Void, Void, BackupUtil.BackupInfo>() {
@Override
protected @Nullable
BackupUtil.BackupInfo doInBackground(Void... voids) {
try {
return BackupUtil.getLatestBackup();
} catch (NoExternalStorageException e) {
Log.w(TAG, e);
return null;
}
}
@Override
protected void onPostExecute(@Nullable BackupUtil.BackupInfo backup) {
listener.run(backup);
}
}.execute();
}
static void getFromUri(@NonNull Context context,
@NonNull Uri backupUri,
@NonNull OnBackupSearchResultListener listener)
{
SimpleTask.run(() -> {
try {
return BackupUtil.getBackupInfoFromSingleUri(context, backupUri);
} catch (BackupUtil.BackupFileException e) {
Log.w(TAG, "Could not restore backup.", e);
postToastForBackupRestorationFailure(context, e);
return null;
}
},
listener::run);
}
private static void postToastForBackupRestorationFailure(@NonNull Context context, @NonNull BackupUtil.BackupFileException exception) {
final @StringRes int errorResId;
switch (exception.getState()) {
case READABLE:
throw new AssertionError("Unexpected error state.");
case NOT_FOUND:
errorResId = R.string.RestoreBackupFragment__backup_not_found;
break;
case UNSUPPORTED_FILE_EXTENSION:
errorResId = R.string.RestoreBackupFragment__backup_has_a_bad_extension;
break;
default:
errorResId = R.string.RestoreBackupFragment__backup_could_not_be_read;
}
ThreadUtil.postToMain(() -> Toast.makeText(context, errorResId, Toast.LENGTH_LONG).show());
}
private void handleRestore(@NonNull Context context, @NonNull BackupUtil.BackupInfo backup) {
View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null);
EditText prompt = view.findViewById(R.id.restore_passphrase_input);
prompt.addTextChangedListener(new PassphraseAsYouTypeFormatter());
new MaterialAlertDialogBuilder(context)
.setTitle(R.string.RegistrationActivity_enter_backup_passphrase)
.setView(view)
.setPositiveButton(R.string.RegistrationActivity_restore, (dialog, which) -> {
InputMethodManager inputMethodManager = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.hideSoftInputFromWindow(prompt.getWindowToken(), 0);
restoreButton.setSpinning();
skipRestoreButton.setVisibility(View.INVISIBLE);
String passphrase = prompt.getText().toString();
restoreAsynchronously(context, backup, passphrase);
})
.setNegativeButton(android.R.string.cancel, null)
.show();
Log.i(TAG, "Prompt for backup passphrase shown to user.");
}
@SuppressLint("StaticFieldLeak")
private void restoreAsynchronously(@NonNull Context context,
@NonNull BackupUtil.BackupInfo backup,
@NonNull String passphrase)
{
new AsyncTask<Void, Void, BackupImportResult>() {
@Override
protected BackupImportResult doInBackground(Void... voids) {
try {
Log.i(TAG, "Starting backup restore.");
DataRestoreConstraint.setRestoringData(true);
SQLiteDatabase database = SignalDatabase.getBackupDatabase();
BackupPassphrase.set(context, passphrase);
if (!FullBackupImporter.validatePassphrase(context, backup.getUri(), passphrase)) {
return BackupImportResult.FAILURE_UNKNOWN;
}
FullBackupImporter.importFile(context,
AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
database,
backup.getUri(),
passphrase);
SignalDatabase.runPostBackupRestoreTasks(database);
NotificationChannels.getInstance().restoreContactNotificationChannels();
enableBackups(context);
AppInitialization.onPostBackupRestore(context);
Log.i(TAG, "Backup restore complete.");
return BackupImportResult.SUCCESS;
} catch (FullBackupImporter.DatabaseDowngradeException e) {
Log.w(TAG, "Failed due to the backup being from a newer version of Signal.", e);
return BackupImportResult.FAILURE_VERSION_DOWNGRADE;
} catch (FullBackupImporter.ForeignKeyViolationException e) {
Log.w(TAG, "Failed due to foreign key constraint violations.", e);
return BackupImportResult.FAILURE_FOREIGN_KEY;
} catch (IOException e) {
Log.w(TAG, e);
return BackupImportResult.FAILURE_UNKNOWN;
} finally {
DataRestoreConstraint.setRestoringData(false);
}
}
@Override
protected void onPostExecute(@NonNull BackupImportResult result) {
viewModel.markBackupCompleted();
restoreButton.cancelSpinning();
skipRestoreButton.setVisibility(View.VISIBLE);
restoreBackupProgress.setText("");
switch (result) {
case SUCCESS:
Log.i(TAG, "Successful backup restore.");
break;
case FAILURE_VERSION_DOWNGRADE:
Toast.makeText(context, R.string.RegistrationActivity_backup_failure_downgrade, Toast.LENGTH_LONG).show();
break;
case FAILURE_FOREIGN_KEY:
Toast.makeText(context, R.string.RegistrationActivity_backup_failure_foreign_key, Toast.LENGTH_LONG).show();
break;
case FAILURE_UNKNOWN:
Toast.makeText(context, R.string.RegistrationActivity_incorrect_backup_passphrase, Toast.LENGTH_LONG).show();
break;
}
}
}.execute();
}
@Override
public void onStart() {
super.onStart();
EventBus.getDefault().register(this);
}
@Override
public void onResume() {
super.onResume();
if (viewModel != null && viewModel.hasBackupCompleted()) {
onBackupComplete();
}
}
@Override
public void onStop() {
super.onStop();
EventBus.getDefault().unregister(this);
}
@Subscribe(threadMode = ThreadMode.MAIN)
public void onEvent(@NonNull BackupEvent event) {
long count = event.getCount();
if (count == 0) {
restoreBackupProgress.setText(R.string.RegistrationActivity_checking);
} else {
restoreBackupProgress.setText(getString(R.string.RegistrationActivity_d_messages_so_far, count));
}
restoreButton.setSpinning();
skipRestoreButton.setVisibility(View.INVISIBLE);
if (event.getType() == BackupEvent.Type.FINISHED) {
onBackupComplete();
}
}
private void onBackupComplete() {
if (BackupUtil.isUserSelectionRequired(requireContext()) && !BackupUtil.canUserAccessBackupDirectory(requireContext())) {
displayConfirmationDialog(requireContext());
} else {
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()),
RestoreBackupFragmentDirections.actionBackupRestored());
}
}
private void enableBackups(@NonNull Context context) {
if (BackupUtil.canUserAccessBackupDirectory(context)) {
LocalBackupListener.setNextBackupTimeToIntervalFromNow(context);
SignalStore.settings().setBackupEnabled(true);
LocalBackupListener.schedule(context);
}
}
@RequiresApi(29)
private void displayConfirmationDialog(@NonNull Context context) {
new MaterialAlertDialogBuilder(context)
.setTitle(R.string.RestoreBackupFragment__restore_complete)
.setMessage(R.string.RestoreBackupFragment__to_continue_using_backups_please_choose_a_folder)
.setPositiveButton(R.string.RestoreBackupFragment__choose_folder, (dialog, which) -> {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION |
Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivityForResult(intent, OPEN_DOCUMENT_TREE_RESULT_CODE);
})
.setNegativeButton(R.string.RestoreBackupFragment__not_now, (dialog, which) -> {
BackupPassphrase.set(context, null);
dialog.dismiss();
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()),
RestoreBackupFragmentDirections.actionBackupRestored());
})
.setCancelable(false)
.show();
}
private enum BackupImportResult {
SUCCESS,
FAILURE_VERSION_DOWNGRADE,
FAILURE_FOREIGN_KEY,
FAILURE_UNKNOWN
}
public static class PassphraseAsYouTypeFormatter implements TextWatcher {
private static final int GROUP_SIZE = 5;
@Override
public void afterTextChanged(Editable editable) {
removeSpans(editable);
addSpans(editable);
}
private static void removeSpans(Editable editable) {
SpaceSpan[] paddingSpans = editable.getSpans(0, editable.length(), SpaceSpan.class);
for (SpaceSpan span : paddingSpans) {
editable.removeSpan(span);
}
}
private static void addSpans(Editable editable) {
final int length = editable.length();
for (int i = GROUP_SIZE; i < length; i += GROUP_SIZE) {
editable.setSpan(new SpaceSpan(), i - 1, i, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (editable.length() > BackupUtil.PASSPHRASE_LENGTH) {
editable.delete(BackupUtil.PASSPHRASE_LENGTH, editable.length());
}
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
}
/**
* A {@link ReplacementSpan} adds a small space after a single character.
* Based on https://stackoverflow.com/a/51949578
*/
private static class SpaceSpan extends ReplacementSpan {
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
return (int) (paint.measureText(text, start, end) * 1.7f);
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
canvas.drawText(text.subSequence(start, end).toString(), x, y, paint);
}
}
}

View File

@@ -1,257 +0,0 @@
package org.thoughtcrime.securesms.registration.fragments;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.ActivityNavigator;
import androidx.navigation.NavDirections;
import androidx.navigation.Navigation;
import androidx.navigation.fragment.NavHostFragment;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.logging.Log;
import org.signal.devicetransfer.DeviceToDeviceTransferService;
import org.signal.devicetransfer.TransferStatus;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton;
import java.util.Optional;
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView;
public final class WelcomeFragment extends LoggingFragment {
private static final String TAG = Log.tag(WelcomeFragment.class);
private CircularProgressMaterialButton continueButton;
private RegistrationViewModel viewModel;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_registration_welcome, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
viewModel = new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
if (viewModel.isReregister()) {
if (viewModel.hasRestoreFlowBeenShown()) {
Log.i(TAG, "We've come back to the home fragment on a restore, user must be backing out");
if (!Navigation.findNavController(view).popBackStack()) {
FragmentActivity activity = requireActivity();
activity.finish();
ActivityNavigator.applyPopAnimationsToPendingTransition(activity);
}
return;
}
initializeNumber(requireContext(), viewModel);
Log.i(TAG, "Skipping restore because this is a reregistration.");
viewModel.setWelcomeSkippedOnRestore();
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this),
WelcomeFragmentDirections.actionSkipRestore());
} else {
setDebugLogSubmitMultiTapView(view.findViewById(R.id.image));
setDebugLogSubmitMultiTapView(view.findViewById(R.id.title));
continueButton = view.findViewById(R.id.welcome_continue_button);
continueButton.setOnClickListener(v -> onContinueClicked());
Button restoreFromBackup = view.findViewById(R.id.welcome_transfer_or_restore);
restoreFromBackup.setOnClickListener(v -> onRestoreFromBackupClicked());
TextView welcomeTermsButton = view.findViewById(R.id.welcome_terms_button);
welcomeTermsButton.setOnClickListener(v -> onTermsClicked());
if (!canUserSelectBackup()) {
restoreFromBackup.setText(R.string.registration_activity__transfer_account);
}
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
@Override
public void onResume() {
super.onResume();
if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null) {
Log.i(TAG, "Found existing transferStatus, redirect to transfer flow");
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_welcomeFragment_to_deviceTransferSetup);
} else {
DeviceToDeviceTransferService.stop(requireContext());
}
}
private void onContinueClicked() {
if (Permissions.isRuntimePermissionsRequired()) {
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this),
WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(GrantPermissionsFragment.WelcomeAction.CONTINUE));
} else {
gatherInformationAndContinue(
this,
viewModel,
() -> continueButton.setSpinning(),
() -> continueButton.cancelSpinning(),
WelcomeFragmentDirections.actionSkipRestore(),
WelcomeFragmentDirections.actionRestore()
);
}
}
private void onRestoreFromBackupClicked() {
if (Permissions.isRuntimePermissionsRequired()) {
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this),
WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(GrantPermissionsFragment.WelcomeAction.RESTORE_BACKUP));
} else {
gatherInformationAndChooseBackup(this, viewModel, WelcomeFragmentDirections.actionTransferOrRestore());
}
}
static void continueClicked(@NonNull Fragment fragment,
@NonNull RegistrationViewModel viewModel,
@NonNull Runnable onSearchForBackupStarted,
@NonNull Runnable onSearchForBackupFinished,
@NonNull NavDirections actionSkipRestore,
@NonNull NavDirections actionRestore)
{
boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(fragment.requireContext());
Permissions.with(fragment)
.request(WelcomePermissions.getWelcomePermissions(isUserSelectionRequired))
.ifNecessary()
.onAnyResult(() -> gatherInformationAndContinue(fragment,
viewModel,
onSearchForBackupStarted,
onSearchForBackupFinished,
actionSkipRestore,
actionRestore))
.execute();
}
static void restoreFromBackupClicked(@NonNull Fragment fragment,
@NonNull RegistrationViewModel viewModel,
@NonNull NavDirections actionTransferOrRestore)
{
boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(fragment.requireContext());
Permissions.with(fragment)
.request(WelcomePermissions.getWelcomePermissions(isUserSelectionRequired))
.ifNecessary()
.onAnyResult(() -> gatherInformationAndChooseBackup(fragment, viewModel, actionTransferOrRestore))
.execute();
}
static void gatherInformationAndContinue(
@NonNull Fragment fragment,
@NonNull RegistrationViewModel viewModel,
@NonNull Runnable onSearchForBackupStarted,
@NonNull Runnable onSearchForBackupFinished,
@NonNull NavDirections actionSkipRestore,
@NonNull NavDirections actionRestore
) {
onSearchForBackupStarted.run();
RestoreBackupFragment.searchForBackup(backup -> {
Context context = fragment.getContext();
if (context == null) {
Log.i(TAG, "No context on fragment, must have navigated away.");
return;
}
TextSecurePreferences.setHasSeenWelcomeScreen(fragment.requireContext(), true);
initializeNumber(fragment.requireContext(), viewModel);
onSearchForBackupFinished.run();
if (backup == null) {
Log.i(TAG, "Skipping backup. No backup found, or no permission to look.");
SafeNavigation.safeNavigate(NavHostFragment.findNavController(fragment),
actionSkipRestore);
} else {
SafeNavigation.safeNavigate(NavHostFragment.findNavController(fragment),
actionRestore);
}
});
}
static void gatherInformationAndChooseBackup(@NonNull Fragment fragment,
@NonNull RegistrationViewModel viewModel,
@NonNull NavDirections actionTransferOrRestore) {
TextSecurePreferences.setHasSeenWelcomeScreen(fragment.requireContext(), true);
initializeNumber(fragment.requireContext(), viewModel);
SafeNavigation.safeNavigate(NavHostFragment.findNavController(fragment),
actionTransferOrRestore);
}
@SuppressLint("MissingPermission")
private static void initializeNumber(@NonNull Context context, @NonNull RegistrationViewModel viewModel) {
Optional<Phonenumber.PhoneNumber> localNumber = Optional.empty();
if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) {
localNumber = Util.getDeviceNumber(context);
} else {
Log.i(TAG, "No phone permission");
}
if (localNumber.isPresent()) {
Log.i(TAG, "Phone number detected");
Phonenumber.PhoneNumber phoneNumber = localNumber.get();
String nationalNumber = PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL);
viewModel.onNumberDetected(phoneNumber.getCountryCode(), nationalNumber);
} else {
Log.i(TAG, "No number detected");
Optional<String> simCountryIso = Util.getSimCountryIso(context);
if (simCountryIso.isPresent() && !TextUtils.isEmpty(simCountryIso.get())) {
viewModel.onNumberDetected(PhoneNumberUtil.getInstance().getCountryCodeForRegion(simCountryIso.get()), "");
}
}
}
private void onTermsClicked() {
CommunicationActions.openBrowserLink(requireContext(), RegistrationConstants.TERMS_AND_CONDITIONS_URL);
}
private boolean canUserSelectBackup() {
return BackupUtil.isUserSelectionRequired(requireContext()) &&
!viewModel.isReregister() &&
!SignalStore.settings().isBackupEnabled();
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023 Signal Messenger, LLC
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui
package org.thoughtcrime.securesms.registration.ui
import android.content.Context
import android.content.Intent
@@ -23,19 +23,19 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.SmsRetrieverReceiver
import org.thoughtcrime.securesms.registration.v2.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Activity to hold the entire registration process.
*/
class RegistrationV2Activity : BaseActivity() {
class RegistrationActivity : BaseActivity() {
private val TAG = Log.tag(RegistrationV2Activity::class.java)
private val TAG = Log.tag(RegistrationActivity::class.java)
private val dynamicTheme = DynamicNoActionBarTheme()
val sharedViewModel: RegistrationV2ViewModel by viewModels()
val sharedViewModel: RegistrationViewModel by viewModels()
private var smsRetrieverReceiver: SmsRetrieverReceiver? = null
@@ -49,6 +49,8 @@ class RegistrationV2Activity : BaseActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_registration_navigation_v2)
sharedViewModel.isReregister = intent.getBooleanExtra(RE_REGISTRATION_EXTRA, false)
sharedViewModel.checkpoint.observe(this) {
if (it >= RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE) {
handleSuccessfulVerify()
@@ -85,11 +87,11 @@ class RegistrationV2Activity : BaseActivity() {
val startIntent = MainActivity.clearTop(this).apply {
if (needsPin) {
putExtra("next_intent", CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationV2Activity))
putExtra("next_intent", CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationActivity))
} else if (!SignalStore.registration.hasSkippedTransferOrRestore() && RemoteConfig.messageBackups) {
putExtra("next_intent", RemoteRestoreActivity.getIntent(this@RegistrationV2Activity))
putExtra("next_intent", RemoteRestoreActivity.getIntent(this@RegistrationActivity))
} else if (needsProfile) {
putExtra("next_intent", CreateProfileActivity.getIntentForUserProfile(this@RegistrationV2Activity))
putExtra("next_intent", CreateProfileActivity.getIntentForUserProfile(this@RegistrationActivity))
}
}
@@ -113,12 +115,21 @@ class RegistrationV2Activity : BaseActivity() {
}
companion object {
const val RE_REGISTRATION_EXTRA: String = "re_registration"
@JvmStatic
fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent {
return Intent(context, RegistrationV2Activity::class.java).apply {
return Intent(context, RegistrationActivity::class.java).apply {
putExtra(RE_REGISTRATION_EXTRA, false)
setData(originalIntent.data)
}
}
@JvmStatic
fun newIntentForReRegistration(context: Context): Intent {
return Intent(context, RegistrationActivity::class.java).apply {
putExtra(RE_REGISTRATION_EXTRA, true)
}
}
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui
package org.thoughtcrime.securesms.registration.ui
/**
* An ordered list of checkpoints of the registration process.

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui
package org.thoughtcrime.securesms.registration.ui
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber

View File

@@ -3,21 +3,21 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui
package org.thoughtcrime.securesms.registration.ui
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.v2.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.whispersystems.signalservice.api.svr.Svr3Credentials
import org.whispersystems.signalservice.internal.push.AuthCredentials
/**
* State holder shared across all of registration.
*/
data class RegistrationV2State(
data class RegistrationState(
val sessionId: String? = null,
val enteredCode: String = "",
val phoneNumber: Phonenumber.PhoneNumber? = fetchExistingE164FromValues(),
@@ -50,7 +50,7 @@ data class RegistrationV2State(
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
companion object {
private val TAG = Log.tag(RegistrationV2State::class)
private val TAG = Log.tag(RegistrationState::class)
private fun fetchExistingE164FromValues(): Phonenumber.PhoneNumber? {
val existingE164 = SignalStore.registration.sessionE164

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui
package org.thoughtcrime.securesms.registration.ui
import android.Manifest
import android.content.Context
@@ -36,29 +36,29 @@ import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.v2.data.network.BackupAuthCheckResult
import org.thoughtcrime.securesms.registration.v2.data.network.Challenge
import org.thoughtcrime.securesms.registration.v2.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationSessionCheckResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.AlreadyVerified
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.AttemptsExhausted
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.ChallengeRequired
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.ExternalServiceFailure
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.ImpossibleNumber
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.InvalidTransportModeFailure
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.MalformedRequest
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.MustRetry
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.NoSuchSession
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.NonNormalizedNumber
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.RateLimited
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.RegistrationLocked
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.Success
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.TokenNotAccepted
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.UnknownError
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.AlreadyVerified
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.AttemptsExhausted
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ChallengeRequired
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ExternalServiceFailure
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ImpossibleNumber
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.InvalidTransportModeFailure
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.MalformedRequest
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.MustRetry
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.NoSuchSession
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.NonNormalizedNumber
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.RateLimited
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.RegistrationLocked
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.Success
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.TokenNotAccepted
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.UnknownError
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Util
@@ -74,9 +74,9 @@ import kotlin.time.Duration.Companion.minutes
/**
* ViewModel shared across all of registration.
*/
class RegistrationV2ViewModel : ViewModel() {
class RegistrationViewModel : ViewModel() {
private val store = MutableStateFlow(RegistrationV2State())
private val store = MutableStateFlow(RegistrationState())
private val password = Util.getSecret(18)
private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
@@ -102,8 +102,13 @@ class RegistrationV2ViewModel : ViewModel() {
val svrTriesRemaining: Int
get() = store.value.svrTriesRemaining
val isReregister: Boolean
var isReregister: Boolean
get() = store.value.isReRegister
set(value) {
store.update {
it.copy(isReRegister = value)
}
}
val phoneNumber: Phonenumber.PhoneNumber?
get() = store.value.phoneNumber
@@ -857,7 +862,7 @@ class RegistrationV2ViewModel : ViewModel() {
}
companion object {
private val TAG = Log.tag(RegistrationV2ViewModel::class.java)
private val TAG = Log.tag(RegistrationViewModel::class.java)
private suspend fun restoreBackupTier() = withContext(Dispatchers.IO) {
val startTime = System.currentTimeMillis()

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.accountlocked
package org.thoughtcrime.securesms.registration.ui.accountlocked
import android.content.Intent
import android.net.Uri
@@ -15,14 +15,14 @@ import androidx.fragment.app.activityViewModels
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import kotlin.time.Duration.Companion.milliseconds
/**
* Screen educating the user that they need to wait some number of days to register.
*/
class AccountLockedV2Fragment : LoggingFragment(R.layout.account_locked_fragment) {
private val viewModel by activityViewModels<RegistrationV2ViewModel>()
class AccountLockedFragment : LoggingFragment(R.layout.account_locked_fragment) {
private val viewModel by activityViewModels<RegistrationViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.captcha
package org.thoughtcrime.securesms.registration.ui.captcha
import android.annotation.SuppressLint
import android.os.Bundle
@@ -17,12 +17,12 @@ import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationCaptchaV2Binding
import org.thoughtcrime.securesms.databinding.FragmentRegistrationCaptchaBinding
import org.thoughtcrime.securesms.registration.fragments.RegistrationConstants
abstract class CaptchaV2Fragment : LoggingFragment(R.layout.fragment_registration_captcha_v2) {
abstract class CaptchaFragment : LoggingFragment(R.layout.fragment_registration_captcha) {
private val binding: FragmentRegistrationCaptchaV2Binding by ViewBinderDelegate(FragmentRegistrationCaptchaV2Binding::bind)
private val binding: FragmentRegistrationCaptchaBinding by ViewBinderDelegate(FragmentRegistrationCaptchaBinding::bind)
private val backListener = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {

View File

@@ -3,22 +3,22 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.captcha
package org.thoughtcrime.securesms.registration.ui.captcha
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.RegistrationV2ViewModel
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
/**
* Screen that displays a captcha as part of the registration flow.
* This subclass plugs in [RegistrationV2ViewModel] to the shared super class.
* This subclass plugs in [RegistrationViewModel] to the shared super class.
*
* @see CaptchaV2Fragment
* @see CaptchaFragment
*/
class RegistrationCaptchaV2Fragment : CaptchaV2Fragment() {
private val sharedViewModel by activityViewModels<RegistrationV2ViewModel>()
class RegistrationCaptchaFragment : CaptchaFragment() {
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedViewModel.addPresentedChallenge(Challenge.CAPTCHA)

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.entercode
package org.thoughtcrime.securesms.registration.ui.entercode
import android.content.DialogInterface
import android.os.Bundle
@@ -21,16 +21,16 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterCodeV2Binding
import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterCodeBinding
import org.thoughtcrime.securesms.registration.ReceivedSmsEvent
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
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.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.fragments.SignalStrengthPhoneStateListener
import org.thoughtcrime.securesms.registration.v2.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
@@ -38,16 +38,16 @@ import org.thoughtcrime.securesms.util.visible
/**
* The final screen of account registration, where the user enters their verification code.
*/
class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter_code_v2) {
class EnterCodeFragment : LoggingFragment(R.layout.fragment_registration_enter_code) {
companion object {
private const val BOTTOM_SHEET_TAG = "support_bottom_sheet"
}
private val TAG = Log.tag(EnterCodeV2Fragment::class.java)
private val TAG = Log.tag(EnterCodeFragment::class.java)
private val sharedViewModel by activityViewModels<RegistrationV2ViewModel>()
private val binding: FragmentRegistrationEnterCodeV2Binding by ViewBinderDelegate(FragmentRegistrationEnterCodeV2Binding::bind)
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val binding: FragmentRegistrationEnterCodeBinding by ViewBinderDelegate(FragmentRegistrationEnterCodeBinding::bind)
private lateinit var phoneStateListener: SignalStrengthPhoneStateListener
@@ -158,7 +158,7 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter
binding.keyboard.displayLocked().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
findNavController().safeNavigate(EnterCodeV2FragmentDirections.actionAccountLocked())
findNavController().safeNavigate(EnterCodeFragmentDirections.actionAccountLocked())
}
}
)
@@ -168,7 +168,7 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter
binding.keyboard.displayLocked().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
findNavController().safeNavigate(EnterCodeV2FragmentDirections.actionRequireKbsLockPin(timeRemaining))
findNavController().safeNavigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining))
}
}
)

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.grantpermissions
package org.thoughtcrime.securesms.registration.ui.grantpermissions
import android.app.Activity
import android.content.pm.PackageManager
@@ -25,8 +25,8 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.compose.GrantPermissionsScreen
import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.restore.RestoreActivity
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -35,10 +35,10 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
* Screen in account registration that provides rationales for the suggested runtime permissions.
*/
@RequiresApi(23)
class GrantPermissionsV2Fragment : ComposeFragment() {
class GrantPermissionsFragment : ComposeFragment() {
private val sharedViewModel by activityViewModels<RegistrationV2ViewModel>()
private val args by navArgs<GrantPermissionsV2FragmentArgs>()
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val args by navArgs<GrantPermissionsFragmentArgs>()
private val isSearchingForBackup = mutableStateOf(false)
private val requestPermissionLauncher = registerForActivityResult(
@@ -50,7 +50,7 @@ class GrantPermissionsV2Fragment : ComposeFragment() {
when (val resultCode = result.resultCode) {
Activity.RESULT_OK -> {
sharedViewModel.onBackupSuccessfullyRestored()
NavHostFragment.findNavController(this).safeNavigate(GrantPermissionsV2FragmentDirections.actionEnterPhoneNumber())
NavHostFragment.findNavController(this).safeNavigate(GrantPermissionsFragmentDirections.actionEnterPhoneNumber())
}
Activity.RESULT_CANCELED -> Log.w(TAG, "Backup restoration canceled.")
else -> Log.w(TAG, "Backup restoration activity ended with unknown result code: $resultCode")
@@ -102,7 +102,7 @@ class GrantPermissionsV2Fragment : ComposeFragment() {
private fun proceedToNextScreen() {
when (welcomeAction) {
WelcomeAction.CONTINUE -> findNavController().safeNavigate(GrantPermissionsV2FragmentDirections.actionEnterPhoneNumber())
WelcomeAction.CONTINUE -> findNavController().safeNavigate(GrantPermissionsFragmentDirections.actionEnterPhoneNumber())
WelcomeAction.RESTORE_BACKUP -> {
val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity())
launchRestoreActivity.launch(restoreIntent)
@@ -120,6 +120,6 @@ class GrantPermissionsV2Fragment : ComposeFragment() {
}
companion object {
private val TAG = Log.tag(GrantPermissionsV2Fragment::class.java)
private val TAG = Log.tag(GrantPermissionsFragment::class.java)
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.phonenumber
package org.thoughtcrime.securesms.registration.ui.phonenumber
import android.content.Context
import android.content.DialogInterface
@@ -40,19 +40,19 @@ 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.FragmentRegistrationEnterPhoneNumberV2Binding
import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterPhoneNumberBinding
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationState
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.toE164
import org.thoughtcrime.securesms.registration.util.CountryPrefix
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.RegistrationResult
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.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2State
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel
import org.thoughtcrime.securesms.registration.v2.ui.toE164
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Dialogs
import org.thoughtcrime.securesms.util.PlayServicesUtil
@@ -67,12 +67,12 @@ import kotlin.time.Duration.Companion.milliseconds
/**
* Screen in registration where the user enters their phone number.
*/
class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registration_enter_phone_number_v2) {
class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_enter_phone_number) {
private val TAG = Log.tag(EnterPhoneNumberV2Fragment::class.java)
private val sharedViewModel by activityViewModels<RegistrationV2ViewModel>()
private val fragmentViewModel by viewModels<EnterPhoneNumberV2ViewModel>()
private val binding: FragmentRegistrationEnterPhoneNumberV2Binding by ViewBinderDelegate(FragmentRegistrationEnterPhoneNumberV2Binding::bind)
private val TAG = Log.tag(EnterPhoneNumberFragment::class.java)
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val fragmentViewModel by viewModels<EnterPhoneNumberViewModel>()
private val binding: FragmentRegistrationEnterPhoneNumberBinding by ViewBinderDelegate(FragmentRegistrationEnterPhoneNumberBinding::bind)
private val skipToNextScreen: DialogInterface.OnClickListener = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> moveToVerificationEntryScreen() }
@@ -139,7 +139,7 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
sharedViewModel.setPhoneNumber(null)
}
if (fragmentState.error != EnterPhoneNumberV2State.Error.NONE) {
if (fragmentState.error != EnterPhoneNumberState.Error.NONE) {
presentLocalError(fragmentState)
}
}
@@ -237,7 +237,7 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
}
}
private fun presentRegisterButton(sharedState: RegistrationV2State) {
private fun presentRegisterButton(sharedState: RegistrationState) {
binding.registerButton.isEnabled = sharedState.phoneNumber != null && PhoneNumberUtil.getInstance().isValidNumber(sharedState.phoneNumber)
if (sharedState.inProgress) {
binding.registerButton.setSpinning()
@@ -246,11 +246,11 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
}
}
private fun presentLocalError(state: EnterPhoneNumberV2State) {
private fun presentLocalError(state: EnterPhoneNumberState) {
when (state.error) {
EnterPhoneNumberV2State.Error.NONE -> Unit
EnterPhoneNumberState.Error.NONE -> Unit
EnterPhoneNumberV2State.Error.INVALID_PHONE_NUMBER -> {
EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER -> {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_invalid_number)
setMessage(
@@ -266,15 +266,15 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
}
}
EnterPhoneNumberV2State.Error.PLAY_SERVICES_MISSING -> {
EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING -> {
handlePromptForNoPlayServices()
}
EnterPhoneNumberV2State.Error.PLAY_SERVICES_NEEDS_UPDATE -> {
EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE -> {
GoogleApiAvailability.getInstance().getErrorDialog(requireActivity(), ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, 0)?.show()
}
EnterPhoneNumberV2State.Error.PLAY_SERVICES_TRANSIENT -> {
EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT -> {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_play_services_error)
setMessage(R.string.RegistrationActivity_google_play_services_is_updating_or_unavailable)
@@ -340,7 +340,7 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
}
private fun moveToCaptcha() {
findNavController().safeNavigate(EnterPhoneNumberV2FragmentDirections.actionRequestCaptcha())
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionRequestCaptcha())
}
private fun presentRemoteErrorDialog(message: String, positiveButtonListener: DialogInterface.OnClickListener? = null) {
@@ -400,7 +400,7 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
sharedViewModel.uiState.value?.let { value ->
val now = System.currentTimeMillis()
if (value.phoneNumber == null) {
fragmentViewModel.setError(EnterPhoneNumberV2State.Error.INVALID_PHONE_NUMBER)
fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER)
sharedViewModel.setInProgress(false)
} else if (now < value.nextSmsTimestamp) {
moveToVerificationEntryScreen()
@@ -411,9 +411,9 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
}
}
private fun onFcmTokenRetrieved(value: RegistrationV2State) {
private fun onFcmTokenRetrieved(value: RegistrationState) {
if (value.phoneNumber == null) {
fragmentViewModel.setError(EnterPhoneNumberV2State.Error.INVALID_PHONE_NUMBER)
fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER)
sharedViewModel.setInProgress(false)
} else {
presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = false)
@@ -440,23 +440,23 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
}
PlayServicesUtil.PlayServicesStatus.MISSING -> {
fragmentViewModel.setError(EnterPhoneNumberV2State.Error.PLAY_SERVICES_MISSING)
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING)
return false
}
PlayServicesUtil.PlayServicesStatus.NEEDS_UPDATE -> {
fragmentViewModel.setError(EnterPhoneNumberV2State.Error.PLAY_SERVICES_NEEDS_UPDATE)
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE)
return false
}
PlayServicesUtil.PlayServicesStatus.TRANSIENT_ERROR -> {
fragmentViewModel.setError(EnterPhoneNumberV2State.Error.PLAY_SERVICES_TRANSIENT)
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT)
return false
}
null -> {
Log.w(TAG, "Null result received from PlayServicesUtil, marking Play Services as missing.")
fragmentViewModel.setError(EnterPhoneNumberV2State.Error.PLAY_SERVICES_MISSING)
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING)
return false
}
}
@@ -516,12 +516,12 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
}
private fun moveToEnterPinScreen() {
findNavController().safeNavigate(EnterPhoneNumberV2FragmentDirections.actionReRegisterWithPinV2Fragment())
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionReRegisterWithPinV2Fragment())
sharedViewModel.setInProgress(false)
}
private fun moveToVerificationEntryScreen() {
findNavController().safeNavigate(EnterPhoneNumberV2FragmentDirections.actionEnterVerificationCode())
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode())
sharedViewModel.setInProgress(false)
}
@@ -530,8 +530,8 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
findNavController().popBackStack()
}
private inner class FcmTokenRetrievedObserver : LiveDataObserverCallback<RegistrationV2State>(sharedViewModel.uiState) {
override fun onValue(value: RegistrationV2State): Boolean {
private inner class FcmTokenRetrievedObserver : LiveDataObserverCallback<RegistrationState>(sharedViewModel.uiState) {
override fun onValue(value: RegistrationState): Boolean {
val fcmRetrieved = value.isFcmSupported
if (fcmRetrieved) {
onFcmTokenRetrieved(value)
@@ -547,7 +547,7 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return if (menuItem.itemId == R.id.phone_menu_use_proxy) {
NavHostFragment.findNavController(this@EnterPhoneNumberV2Fragment).safeNavigate(EnterPhoneNumberV2FragmentDirections.actionEditProxy())
NavHostFragment.findNavController(this@EnterPhoneNumberFragment).safeNavigate(EnterPhoneNumberFragmentDirections.actionEditProxy())
true
} else {
false

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.phonenumber
import android.text.TextWatcher
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
/**
* State holder for the phone number entry screen, including phone number and Play Services errors.
*/
data class EnterPhoneNumberState(val countryPrefixIndex: Int = 0, val phoneNumber: String = "", val phoneNumberFormatter: TextWatcher? = null, val mode: RegistrationRepository.Mode = RegistrationRepository.Mode.SMS_WITHOUT_LISTENER, val error: Error = Error.NONE) {
enum class Error {
NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.phonenumber
package org.thoughtcrime.securesms.registration.ui.phonenumber
import android.telephony.PhoneNumberFormattingTextWatcher
import android.text.TextWatcher
@@ -19,17 +19,17 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.util.CountryPrefix
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
/**
* ViewModel for the phone number entry screen.
*/
class EnterPhoneNumberV2ViewModel : ViewModel() {
class EnterPhoneNumberViewModel : ViewModel() {
private val TAG = Log.tag(EnterPhoneNumberV2ViewModel::class.java)
private val TAG = Log.tag(EnterPhoneNumberViewModel::class.java)
private val store = MutableStateFlow(EnterPhoneNumberV2State())
private val store = MutableStateFlow(EnterPhoneNumberState())
val uiState = store.asLiveData()
val formatter: TextWatcher?
@@ -85,11 +85,11 @@ class EnterPhoneNumberV2ViewModel : ViewModel() {
}
}
fun parsePhoneNumber(state: EnterPhoneNumberV2State): PhoneNumber {
fun parsePhoneNumber(state: EnterPhoneNumberState): PhoneNumber {
return PhoneNumberUtil.getInstance().parse(state.phoneNumber, supportedCountryPrefixes[state.countryPrefixIndex].regionCode)
}
fun isEnteredNumberValid(state: EnterPhoneNumberV2State): Boolean {
fun isEnteredNumberValid(state: EnterPhoneNumberState): Boolean {
return try {
PhoneNumberUtil.getInstance().isValidNumber(parsePhoneNumber(state))
} catch (ex: NumberParseException) {
@@ -114,10 +114,10 @@ class EnterPhoneNumberV2ViewModel : ViewModel() {
}
fun clearError() {
setError(EnterPhoneNumberV2State.Error.NONE)
setError(EnterPhoneNumberState.Error.NONE)
}
fun setError(error: EnterPhoneNumberV2State.Error) {
fun setError(error: EnterPhoneNumberState.Error) {
store.update {
it.copy(error = error)
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.registrationlock
package org.thoughtcrime.securesms.registration.ui.registrationlock
import android.os.Bundle
import android.text.InputType
@@ -23,25 +23,25 @@ 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.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.v2.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
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 java.util.concurrent.TimeUnit
class RegistrationLockV2Fragment : LoggingFragment(R.layout.fragment_registration_lock) {
class RegistrationLockFragment : LoggingFragment(R.layout.fragment_registration_lock) {
companion object {
private val TAG = Log.tag(RegistrationLockV2Fragment::class.java)
private val TAG = Log.tag(RegistrationLockFragment::class.java)
}
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind)
private val viewModel by activityViewModels<RegistrationV2ViewModel>()
private val viewModel by activityViewModels<RegistrationViewModel>()
private var timeRemaining: Long = 0
@@ -49,7 +49,7 @@ class RegistrationLockV2Fragment : LoggingFragment(R.layout.fragment_registratio
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title))
val args: RegistrationLockV2FragmentArgs = RegistrationLockV2FragmentArgs.fromBundle(requireArguments())
val args: RegistrationLockFragmentArgs = RegistrationLockFragmentArgs.fromBundle(requireArguments())
timeRemaining = args.getTimeRemaining()
@@ -140,7 +140,7 @@ class RegistrationLockV2Fragment : LoggingFragment(R.layout.fragment_registratio
is VerificationCodeRequestResult.Success -> Unit
is VerificationCodeRequestResult.RateLimited -> onRateLimited()
is VerificationCodeRequestResult.AttemptsExhausted -> {
findNavController().safeNavigate(RegistrationLockV2FragmentDirections.actionAccountLocked())
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
}
is VerificationCodeRequestResult.RegistrationLocked -> {
@@ -162,7 +162,7 @@ class RegistrationLockV2Fragment : LoggingFragment(R.layout.fragment_registratio
is RegisterAccountResult.Success -> Unit
is RegisterAccountResult.RateLimited -> onRateLimited()
is RegisterAccountResult.AttemptsExhausted -> {
findNavController().safeNavigate(RegistrationLockV2FragmentDirections.actionAccountLocked())
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
}
is RegisterAccountResult.RegistrationLocked -> {
@@ -174,7 +174,7 @@ class RegistrationLockV2Fragment : LoggingFragment(R.layout.fragment_registratio
is RegisterAccountResult.SvrWrongPin -> onIncorrectKbsRegistrationLockPin(result.triesRemaining)
is RegisterAccountResult.SvrNoData -> {
findNavController().safeNavigate(RegistrationLockV2FragmentDirections.actionAccountLocked())
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
}
else -> {
@@ -191,7 +191,7 @@ class RegistrationLockV2Fragment : LoggingFragment(R.layout.fragment_registratio
if (svrTriesRemaining == 0) {
Log.w(TAG, "Account locked. User out of attempts on KBS.")
findNavController().safeNavigate(RegistrationLockV2FragmentDirections.actionAccountLocked())
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
return
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.reregisterwithpin
package org.thoughtcrime.securesms.registration.ui.reregisterwithpin
import android.os.Bundle
import android.text.InputType
@@ -21,23 +21,23 @@ import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationPinRestoreEntryV2Binding
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
import org.thoughtcrime.securesms.registration.v2.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2State
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationState
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
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
class ReRegisterWithPinV2Fragment : LoggingFragment(R.layout.fragment_registration_pin_restore_entry_v2) {
class ReRegisterWithPinFragment : LoggingFragment(R.layout.fragment_registration_pin_restore_entry_v2) {
companion object {
private val TAG = Log.tag(ReRegisterWithPinV2Fragment::class.java)
private val TAG = Log.tag(ReRegisterWithPinFragment::class.java)
}
private val registrationViewModel by activityViewModels<RegistrationV2ViewModel>()
private val reRegisterViewModel by viewModels<ReRegisterWithPinV2ViewModel>()
private val registrationViewModel by activityViewModels<RegistrationViewModel>()
private val reRegisterViewModel by viewModels<ReRegisterWithPinViewModel>()
private val binding: FragmentRegistrationPinRestoreEntryV2Binding by ViewBinderDelegate(FragmentRegistrationPinRestoreEntryV2Binding::bind)
@@ -79,11 +79,11 @@ class ReRegisterWithPinV2Fragment : LoggingFragment(R.layout.fragment_registrati
registrationViewModel.uiState.observe(viewLifecycleOwner, ::updateViewState)
}
private fun updateViewState(state: RegistrationV2State) {
private fun updateViewState(state: RegistrationState) {
if (state.networkError != null) {
genericErrorDialog()
} else if (!state.canSkipSms) {
findNavController().safeNavigate(ReRegisterWithPinV2FragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberV2Fragment())
findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberV2Fragment())
} else if (state.isRegistrationLockEnabled && state.svrTriesRemaining == 0) {
Log.w(TAG, "Unable to continue skip flow, KBS is locked")
onAccountLocked()
@@ -263,7 +263,7 @@ class ReRegisterWithPinV2Fragment : LoggingFragment(R.layout.fragment_registrati
is RegisterAccountResult.IncorrectRecoveryPassword -> {
registrationViewModel.setUserSkippedReRegisterFlow(true)
findNavController().safeNavigate(ReRegisterWithPinV2FragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberV2Fragment())
findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberV2Fragment())
}
is RegisterAccountResult.AttemptsExhausted,

View File

@@ -3,9 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.reregisterwithpin
package org.thoughtcrime.securesms.registration.ui.reregisterwithpin
data class ReRegisterWithPinV2State(
data class ReRegisterWithPinState(
val isLocalVerification: Boolean = false,
val hasIncorrectGuess: Boolean = false,
val localPinMatches: Boolean = false

View File

@@ -3,19 +3,19 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.reregisterwithpin
package org.thoughtcrime.securesms.registration.ui.reregisterwithpin
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
class ReRegisterWithPinV2ViewModel : ViewModel() {
class ReRegisterWithPinViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(ReRegisterWithPinV2ViewModel::class.java)
private val TAG = Log.tag(ReRegisterWithPinViewModel::class.java)
}
private val store = MutableStateFlow(ReRegisterWithPinV2State())
private val store = MutableStateFlow(ReRegisterWithPinState())
val isLocalVerification: Boolean
get() = store.value.isLocalVerification

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.restore
package org.thoughtcrime.securesms.registration.ui.restore
import android.content.Context
import android.content.Intent

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.welcome
package org.thoughtcrime.securesms.registration.ui.welcome
import android.app.Activity
import android.content.pm.PackageManager
@@ -18,13 +18,13 @@ 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.FragmentRegistrationWelcomeV2Binding
import org.thoughtcrime.securesms.databinding.FragmentRegistrationWelcomeBinding
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel
import org.thoughtcrime.securesms.registration.v2.ui.grantpermissions.GrantPermissionsV2Fragment
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.grantpermissions.GrantPermissionsFragment
import org.thoughtcrime.securesms.restore.RestoreActivity
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -36,15 +36,15 @@ import org.thoughtcrime.securesms.util.visible
/**
* First screen that is displayed on the very first app launch.
*/
class WelcomeV2Fragment : LoggingFragment(R.layout.fragment_registration_welcome_v2) {
private val sharedViewModel by activityViewModels<RegistrationV2ViewModel>()
private val binding: FragmentRegistrationWelcomeV2Binding by ViewBinderDelegate(FragmentRegistrationWelcomeV2Binding::bind)
class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome) {
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val binding: FragmentRegistrationWelcomeBinding by ViewBinderDelegate(FragmentRegistrationWelcomeBinding::bind)
private val launchRestoreActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
when (val resultCode = result.resultCode) {
Activity.RESULT_OK -> {
sharedViewModel.onBackupSuccessfullyRestored()
findNavController().safeNavigate(WelcomeV2FragmentDirections.actionGoToRegistration())
findNavController().safeNavigate(WelcomeFragmentDirections.actionGoToRegistration())
}
Activity.RESULT_CANCELED -> {
Log.w(TAG, "Backup restoration canceled.")
@@ -67,10 +67,10 @@ class WelcomeV2Fragment : LoggingFragment(R.layout.fragment_registration_welcome
private fun onContinueClicked() {
TextSecurePreferences.setHasSeenWelcomeScreen(requireContext(), true)
if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
findNavController().safeNavigate(WelcomeV2FragmentDirections.actionWelcomeFragmentToGrantPermissionsV2Fragment(GrantPermissionsV2Fragment.WelcomeAction.CONTINUE))
findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsV2Fragment(GrantPermissionsFragment.WelcomeAction.CONTINUE))
} else {
sharedViewModel.maybePrefillE164(requireContext())
findNavController().safeNavigate(WelcomeV2FragmentDirections.actionSkipRestore())
findNavController().safeNavigate(WelcomeFragmentDirections.actionSkipRestore())
}
}
@@ -85,7 +85,7 @@ class WelcomeV2Fragment : LoggingFragment(R.layout.fragment_registration_welcome
private fun onTransferOrRestoreClicked() {
if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
findNavController().safeNavigate(WelcomeV2FragmentDirections.actionWelcomeFragmentToGrantPermissionsV2Fragment(GrantPermissionsV2Fragment.WelcomeAction.RESTORE_BACKUP))
findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsV2Fragment(GrantPermissionsFragment.WelcomeAction.RESTORE_BACKUP))
} else {
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
@@ -95,7 +95,7 @@ class WelcomeV2Fragment : LoggingFragment(R.layout.fragment_registration_welcome
}
companion object {
private val TAG = Log.tag(WelcomeV2Fragment::class.java)
private val TAG = Log.tag(WelcomeFragment::class.java)
private const val TERMS_AND_CONDITIONS_URL = "https://signal.org/legal"
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.util
data class CountryPrefix(val digits: Int, val regionCode: String) {
override fun toString(): String {
return "+$digits"
}
}

View File

@@ -1,179 +0,0 @@
package org.thoughtcrime.securesms.registration.util
import android.content.Context
import android.text.Editable
import android.text.TextUtils
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.View
import android.view.View.OnFocusChangeListener
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.TextView
import com.google.android.material.textfield.MaterialAutoCompleteTextView
import com.google.android.material.textfield.TextInputLayout
import com.google.i18n.phonenumbers.AsYouTypeFormatter
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
/**
* Handle the logic and formatting of phone number input specifically for registration number the flow.
*/
class RegistrationNumberInputController(
val context: Context,
val callbacks: Callbacks,
private val phoneNumberInputLayout: EditText,
countryCodeInputLayout: TextInputLayout
) {
private val spinnerView: MaterialAutoCompleteTextView = countryCodeInputLayout.editText as MaterialAutoCompleteTextView
private val supportedCountryPrefixes: List<CountryPrefix> = PhoneNumberUtil.getInstance().supportedCallingCodes
.map { CountryPrefix(it, PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(it)) }
.sortedBy { it.digits.toString() }
private val spinnerAdapter: ArrayAdapter<CountryPrefix> = ArrayAdapter<CountryPrefix>(context, R.layout.registration_country_code_dropdown_item, supportedCountryPrefixes)
private val countryCodeEntryListener = CountryCodeEntryListener()
private var countryFormatter: AsYouTypeFormatter? = null
private var isUpdating = true
init {
setUpNumberInput()
spinnerView.threshold = 100
spinnerView.setAdapter(spinnerAdapter)
spinnerView.addTextChangedListener(countryCodeEntryListener)
}
fun prepopulateCountryCode() {
if (spinnerView.editableText.isBlank()) {
spinnerView.setText(supportedCountryPrefixes[0].toString())
}
}
private fun advanceToPhoneNumberInput() {
if (!isUpdating) {
phoneNumberInputLayout.requestFocus()
}
val numberLength: Int = phoneNumberInputLayout.text?.length ?: 0
phoneNumberInputLayout.setSelection(numberLength, numberLength)
}
private fun setUpNumberInput() {
phoneNumberInputLayout.addTextChangedListener(NumberChangedListener())
phoneNumberInputLayout.onFocusChangeListener = OnFocusChangeListener { v: View?, hasFocus: Boolean ->
if (hasFocus) {
callbacks.onNumberFocused()
}
}
phoneNumberInputLayout.imeOptions = EditorInfo.IME_ACTION_DONE
phoneNumberInputLayout.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
callbacks.onNumberInputDone(v!!)
return@setOnEditorActionListener true
}
false
}
}
fun setNumberAndCountryCode(numberViewState: NumberViewState) {
val countryCode = numberViewState.countryCode
isUpdating = true
phoneNumberInputLayout.setText(numberViewState.nationalNumber)
if (numberViewState.countryCode != 0) {
spinnerView.setText(supportedCountryPrefixes.first { it.digits == numberViewState.countryCode }.toString())
}
val regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode)
setCountryFormatter(regionCode)
isUpdating = false
}
fun updateNumberFormatter(numberViewState: NumberViewState) {
val countryCode = numberViewState.countryCode
isUpdating = true
val regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode)
setCountryFormatter(regionCode)
isUpdating = false
}
private fun setCountryFormatter(regionCode: String?) {
val util = PhoneNumberUtil.getInstance()
countryFormatter = if (regionCode != null) util.getAsYouTypeFormatter(regionCode) else null
reformatText(phoneNumberInputLayout.text)
}
private fun reformatText(editable: Editable): String? {
if (TextUtils.isEmpty(editable)) {
return null
}
val countryFormatter: AsYouTypeFormatter = countryFormatter ?: return null
countryFormatter.clear()
var formattedNumber: String? = null
val justDigits = StringBuilder()
for (character in editable) {
if (Character.isDigit(character)) {
formattedNumber = countryFormatter.inputDigit(character)
justDigits.append(character)
}
}
if (formattedNumber != null && editable.toString() != formattedNumber) {
editable.replace(0, editable.length, formattedNumber)
}
return if (justDigits.isEmpty()) {
null
} else {
justDigits.toString()
}
}
inner class NumberChangedListener : TextWatcher {
override fun afterTextChanged(s: Editable) {
val number: String = reformatText(s) ?: return
if (!isUpdating) {
callbacks.setNationalNumber(number)
}
}
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
}
inner class CountryCodeEntryListener : TextWatcher {
override fun afterTextChanged(s: Editable?) {
if (s.isNullOrEmpty()) {
return
}
if (s[0] != '+') {
s.insert(0, "+")
}
supportedCountryPrefixes.firstOrNull { it.toString() == s.toString() }?.let {
setCountryFormatter(it.regionCode)
callbacks.setCountry(it.digits)
advanceToPhoneNumberInput()
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
}
interface Callbacks {
fun onNumberFocused()
fun onNumberInputDone(view: View)
fun setNationalNumber(number: String)
fun setCountry(countryCode: Int)
}
}
data class CountryPrefix(val digits: Int, val regionCode: String) {
override fun toString(): String {
return "+$digits"
}
}

View File

@@ -1,18 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.phonenumber
import android.text.TextWatcher
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
/**
* State holder for the phone number entry screen, including phone number and Play Services errors.
*/
data class EnterPhoneNumberV2State(val countryPrefixIndex: Int = 0, val phoneNumber: String = "", val phoneNumberFormatter: TextWatcher? = null, val mode: RegistrationRepository.Mode = RegistrationRepository.Mode.SMS_WITHOUT_LISTENER, val error: Error = Error.NONE) {
enum class Error {
NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT
}
}

View File

@@ -1,4 +0,0 @@
package org.thoughtcrime.securesms.registration.viewmodel;
public final class BaseEnterCodeViewModelDelegate {
}

View File

@@ -1,397 +0,0 @@
package org.thoughtcrime.securesms.registration.viewmodel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.SavedStateHandle;
import androidx.lifecycle.ViewModel;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor;
import org.thoughtcrime.securesms.registration.VerifyAccountRepository;
import org.thoughtcrime.securesms.registration.VerifyAccountRepository.Mode;
import org.thoughtcrime.securesms.registration.VerifyResponse;
import org.thoughtcrime.securesms.registration.VerifyResponseProcessor;
import org.thoughtcrime.securesms.registration.VerifyResponseWithRegistrationLockProcessor;
import org.thoughtcrime.securesms.registration.VerifyResponseHitRegistrationLock;
import org.thoughtcrime.securesms.registration.VerifyResponseWithoutKbs;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
/**
* Base view model used in registration and change number flow. Handles the storage of all data
* shared between the two flows, orchestrating verification, and calling to subclasses to perform
* the specific verify operations for each flow.
*/
public abstract class BaseRegistrationViewModel extends ViewModel {
private static final String TAG = Log.tag(BaseRegistrationViewModel.class);
private static final String STATE_NUMBER = "NUMBER";
private static final String STATE_REGISTRATION_SECRET = "REGISTRATION_SECRET";
private static final String STATE_VERIFICATION_CODE = "TEXT_CODE_ENTERED";
private static final String STATE_CAPTCHA = "CAPTCHA";
private static final String STATE_PUSH_TIMED_OUT = "PUSH_TIMED_OUT";
private static final String STATE_INCORRECT_CODE_ATTEMPTS = "STATE_INCORRECT_CODE_ATTEMPTS";
private static final String STATE_REQUEST_RATE_LIMITER = "REQUEST_RATE_LIMITER";
private static final String STATE_SVR_AUTH = "SVR_AUTH";
private static final String STATE_SVR_TRIES_REMAINING = "SVR_TRIES_REMAINING";
private static final String STATE_TIME_REMAINING = "TIME_REMAINING";
private static final String STATE_CAN_CALL_AT_TIME = "CAN_CALL_AT_TIME";
private static final String STATE_CAN_SMS_AT_TIME = "CAN_SMS_AT_TIME";
private static final String STATE_RECOVERY_PASSWORD = "RECOVERY_PASSWORD";
protected final SavedStateHandle savedState;
protected final VerifyAccountRepository verifyAccountRepository;
public BaseRegistrationViewModel(@NonNull SavedStateHandle savedStateHandle,
@NonNull VerifyAccountRepository verifyAccountRepository,
@NonNull String password)
{
this.savedState = savedStateHandle;
this.verifyAccountRepository = verifyAccountRepository;
setInitialDefaultValue(STATE_NUMBER, NumberViewState.INITIAL);
setInitialDefaultValue(STATE_REGISTRATION_SECRET, password);
setInitialDefaultValue(STATE_VERIFICATION_CODE, "");
setInitialDefaultValue(STATE_INCORRECT_CODE_ATTEMPTS, 0);
setInitialDefaultValue(STATE_REQUEST_RATE_LIMITER, new LocalCodeRequestRateLimiter(60_000));
setInitialDefaultValue(STATE_RECOVERY_PASSWORD, SignalStore.svr().getRecoveryPassword());
setInitialDefaultValue(STATE_PUSH_TIMED_OUT, false);
}
protected <T> void setInitialDefaultValue(@NonNull String key, @Nullable T initialValue) {
if (!savedState.contains(key) || savedState.get(key) == null) {
savedState.set(key, initialValue);
}
}
public @Nullable String getSessionId() {
return SignalStore.registration().getSessionId();
}
public void setSessionId(String sessionId) {
SignalStore.registration().setSessionId(sessionId);
}
public @Nullable String getSessionE164() {
return SignalStore.registration().getSessionE164();
}
public void setSessionE164(String sessionE164) {
SignalStore.registration().setSessionE164(sessionE164);
}
public void resetSession() {
setSessionE164(null);
setSessionId(null);
}
public @NonNull NumberViewState getNumber() {
//noinspection ConstantConditions
return savedState.get(STATE_NUMBER);
}
public @NonNull LiveData<NumberViewState> getLiveNumber() {
return savedState.getLiveData(STATE_NUMBER);
}
public void restorePhoneNumberStateFromE164(String e164) throws NumberParseException {
Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().parse(e164, null);
onCountrySelected(null, phoneNumber.getCountryCode());
setNationalNumber(String.valueOf(phoneNumber.getNationalNumber()));
}
public void onCountrySelected(@Nullable String selectedCountryName, int countryCode) {
setViewState(getNumber().toBuilder()
.selectedCountryDisplayName(selectedCountryName)
.countryCode(countryCode)
.build());
}
public void setNationalNumber(String number) {
NumberViewState numberViewState = getNumber().toBuilder().nationalNumber(number).build();
setViewState(numberViewState);
}
protected void setViewState(NumberViewState numberViewState) {
if (!numberViewState.equals(getNumber())) {
savedState.set(STATE_NUMBER, numberViewState);
}
}
public @NonNull String getRegistrationSecret() {
//noinspection ConstantConditions
return savedState.get(STATE_REGISTRATION_SECRET);
}
public @NonNull String getTextCodeEntered() {
//noinspection ConstantConditions
return savedState.get(STATE_VERIFICATION_CODE);
}
public @Nullable String getCaptchaToken() {
return savedState.get(STATE_CAPTCHA);
}
public boolean hasCaptchaToken() {
return getCaptchaToken() != null;
}
public void setCaptchaResponse(@Nullable String captchaToken) {
savedState.set(STATE_CAPTCHA, captchaToken);
}
public void clearCaptchaResponse() {
setCaptchaResponse(null);
}
public void onVerificationCodeEntered(String code) {
savedState.set(STATE_VERIFICATION_CODE, code);
}
public void incrementIncorrectCodeAttempts() {
//noinspection ConstantConditions
savedState.set(STATE_INCORRECT_CODE_ATTEMPTS, (Integer) savedState.get(STATE_INCORRECT_CODE_ATTEMPTS) + 1);
}
public LiveData<Integer> getIncorrectCodeAttempts() {
return savedState.getLiveData(STATE_INCORRECT_CODE_ATTEMPTS, 0);
}
public void markPushChallengeTimedOut() {
savedState.set(STATE_PUSH_TIMED_OUT, true);
}
public List<String> getExcludedChallenges() {
ArrayList<String> challengeKeys = new ArrayList<>();
if (Boolean.TRUE.equals(savedState.get(STATE_PUSH_TIMED_OUT))) {
challengeKeys.add(RegistrationSessionProcessor.PUSH_CHALLENGE_KEY);
}
return challengeKeys;
}
protected void setSvrAuthCredentials(SvrAuthCredentialSet credentials) {
savedState.set(STATE_SVR_AUTH, credentials);
}
protected @Nullable SvrAuthCredentialSet getSvrAuthCredentials() {
return savedState.get(STATE_SVR_AUTH);
}
public @Nullable Integer getSvrTriesRemaining() {
return savedState.get(STATE_SVR_TRIES_REMAINING);
}
public void setSvrTriesRemaining(@Nullable Integer triesRemaining) {
savedState.set(STATE_SVR_TRIES_REMAINING, triesRemaining);
}
public void setRecoveryPassword(@Nullable String recoveryPassword) {
savedState.set(STATE_RECOVERY_PASSWORD, recoveryPassword);
}
public @Nullable String getRecoveryPassword() {
return savedState.get(STATE_RECOVERY_PASSWORD);
}
public LiveData<Long> getLockedTimeRemaining() {
return savedState.getLiveData(STATE_TIME_REMAINING, 0L);
}
public LiveData<Long> getCanCallAtTime() {
return savedState.getLiveData(STATE_CAN_CALL_AT_TIME, 0L);
}
public LiveData<Long> getCanSmsAtTime() {
return savedState.getLiveData(STATE_CAN_SMS_AT_TIME, 0L);
}
public void setLockedTimeRemaining(long lockedTimeRemaining) {
savedState.set(STATE_TIME_REMAINING, lockedTimeRemaining);
}
public void setCanCallAtTime(long callingTimestamp) {
savedState.getLiveData(STATE_CAN_CALL_AT_TIME).postValue(callingTimestamp);
}
public void setCanSmsAtTime(long smsTimestamp) {
savedState.getLiveData(STATE_CAN_SMS_AT_TIME).postValue(smsTimestamp);
}
public Single<RegistrationSessionProcessor> requestVerificationCode(@NonNull Mode mode, @Nullable String mcc, @Nullable String mnc) {
final String e164 = getNumber().getE164Number();
return getValidSession(e164, mcc, mnc)
.flatMap(processor -> {
if (!processor.hasResult()) {
return Single.just(processor);
}
String sessionId = processor.getSessionId();
setSessionId(sessionId);
setSessionE164(e164);
return handleRequiredChallenges(processor, e164);
})
.flatMap(processor -> {
if (!processor.hasResult()) {
return Single.just(processor);
}
if (!processor.isAllowedToRequestCode()) {
return Single.just(processor);
}
String sessionId = processor.getSessionId();
clearCaptchaResponse();
return verifyAccountRepository.requestVerificationCode(sessionId,
getNumber().getE164Number(),
getRegistrationSecret(),
mode)
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForVerification::new);
})
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess((RegistrationSessionProcessor processor) -> {
if (processor.hasResult() && processor.isAllowedToRequestCode()) {
setCanSmsAtTime(processor.getNextCodeViaSmsAttempt());
setCanCallAtTime(processor.getNextCodeViaCallAttempt());
}
});
}
public Single<RegistrationSessionProcessor.RegistrationSessionProcessorForSession> validateSession(String e164) {
String storedSessionId = null;
if (e164.equals(getSessionE164())) {
storedSessionId = getSessionId();
}
return verifyAccountRepository.validateSession(storedSessionId, e164, getRegistrationSecret())
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new);
}
public Single<RegistrationSessionProcessor.RegistrationSessionProcessorForSession> getValidSession(String e164, @Nullable String mcc, @Nullable String mnc) {
return validateSession(e164)
.flatMap(processor -> {
if (processor.isInvalidSession()) {
return verifyAccountRepository.requestValidSession(e164, getRegistrationSecret(), mcc, mnc)
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new)
.doOnSuccess(createSessionProcessor -> {
if (createSessionProcessor.pushChallengeTimedOut()) {
Log.w(TAG, "Registration push challenge timed out.");
markPushChallengeTimedOut();
}
});
} else {
return Single.just(processor);
}
});
}
public Single<RegistrationSessionProcessor> handleRequiredChallenges(RegistrationSessionProcessor processor, String e164) {
final String sessionId = processor.getSessionId();
if (processor.isAllowedToRequestCode()) {
Log.d(TAG, "All challenges satisfied.");
return Single.just(processor);
}
if (hasCaptchaToken() && processor.captchaRequired(getExcludedChallenges())) {
Log.d(TAG, "Submitting completed captcha challenge");
final String captcha = Objects.requireNonNull(getCaptchaToken());
clearCaptchaResponse();
return verifyAccountRepository.verifyCaptcha(sessionId, captcha, e164, getRegistrationSecret())
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new);
} else {
String challenge = processor.getChallenge(getExcludedChallenges());
Log.d(TAG, "Handling challenge of type " + challenge);
if (challenge != null) {
switch (challenge) {
case RegistrationSessionProcessor.PUSH_CHALLENGE_KEY:
return verifyAccountRepository.requestAndVerifyPushToken(sessionId,
getNumber().getE164Number(),
getRegistrationSecret())
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new);
case RegistrationSessionProcessor.CAPTCHA_KEY:
// fall through to passing the processor back so that the eventual subscriber will check captchaRequired() and handle accordingly
default:
break;
}
}
}
return Single.just(processor);
}
public Single<VerifyResponseProcessor> verifyCodeWithoutRegistrationLock(@NonNull String code) {
onVerificationCodeEntered(code);
return verifyAccountWithoutRegistrationLock()
.flatMap(response -> {
if (response.getResult().isPresent() && response.getResult().get().getMasterKey() != null) {
return onVerifySuccessWithRegistrationLock(new VerifyResponseWithRegistrationLockProcessor(response, null), response.getResult().get().getPin());
}
VerifyResponseProcessor processor = new VerifyResponseWithoutKbs(response);
if (processor.hasResult()) {
return onVerifySuccess(processor);
} else if (processor.registrationLock() && !processor.isRegistrationLockPresentAndSvrExhausted()) {
return Single.just(new VerifyResponseHitRegistrationLock(processor.getResponse()));
}
return Single.just(processor);
})
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess(processor -> {
if (processor.registrationLock() && !processor.isRegistrationLockPresentAndSvrExhausted()) {
setLockedTimeRemaining(processor.getLockedException().getTimeRemaining());
setSvrTriesRemaining(processor.getSvrTriesRemaining());
setSvrAuthCredentials(processor.getSvrAuthCredentials());
} else if (processor.isRegistrationLockPresentAndSvrExhausted()) {
setLockedTimeRemaining(processor.getLockedException().getTimeRemaining());
}
});
}
public Single<VerifyResponseWithRegistrationLockProcessor> verifyCodeAndRegisterAccountWithRegistrationLock(@NonNull String pin) {
SvrAuthCredentialSet authCredentials = Objects.requireNonNull(getSvrAuthCredentials());
return verifyAccountWithRegistrationLock(pin, authCredentials)
.map(r -> new VerifyResponseWithRegistrationLockProcessor(r, authCredentials))
.flatMap(processor -> {
if (processor.hasResult()) {
return onVerifySuccessWithRegistrationLock(processor, pin);
} else if (processor.wrongPin()) {
return Single.just(new VerifyResponseWithRegistrationLockProcessor(processor.getResponse(), authCredentials));
}
return Single.just(processor);
})
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess(processor -> {
if (processor.wrongPin()) {
setSvrTriesRemaining(processor.getSvrTriesRemaining());
}
});
}
protected abstract Single<ServiceResponse<VerifyResponse>> verifyAccountWithoutRegistrationLock();
protected abstract Single<ServiceResponse<VerifyResponse>> verifyAccountWithRegistrationLock(@NonNull String pin, @NonNull SvrAuthCredentialSet svrAuthCredentials);
protected abstract Single<VerifyResponseProcessor> onVerifySuccess(@NonNull VerifyResponseProcessor processor);
protected abstract Single<VerifyResponseWithRegistrationLockProcessor> onVerifySuccessWithRegistrationLock(@NonNull VerifyResponseWithRegistrationLockProcessor processor, String pin);
}

View File

@@ -1,104 +0,0 @@
package org.thoughtcrime.securesms.registration.viewmodel;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.registration.VerifyAccountRepository.Mode;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public final class LocalCodeRequestRateLimiter implements Parcelable {
private final long timePeriod;
private final Map<Mode, Data> dataMap;
public LocalCodeRequestRateLimiter(long timePeriod) {
this.timePeriod = timePeriod;
this.dataMap = new HashMap<>();
}
@MainThread
public boolean canRequest(@NonNull Mode mode, @NonNull String e164Number, long currentTime) {
Data data = dataMap.get(mode);
return data == null || !data.limited(e164Number, currentTime);
}
/**
* Call this when the server has returned that it was successful in requesting a code via the specified mode.
*/
@MainThread
public void onSuccessfulRequest(@NonNull Mode mode, @NonNull String e164Number, long currentTime) {
dataMap.put(mode, new Data(e164Number, currentTime + timePeriod));
}
/**
* Call this if a mode was unsuccessful in sending.
*/
@MainThread
public void onUnsuccessfulRequest() {
dataMap.clear();
}
static class Data {
final String e164Number;
final long limitedUntil;
Data(@NonNull String e164Number, long limitedUntil) {
this.e164Number = e164Number;
this.limitedUntil = limitedUntil;
}
boolean limited(String e164Number, long currentTime) {
return this.e164Number.equals(e164Number) && currentTime < limitedUntil;
}
}
public static final Creator<LocalCodeRequestRateLimiter> CREATOR = new Creator<LocalCodeRequestRateLimiter>() {
@Override
public LocalCodeRequestRateLimiter createFromParcel(Parcel in) {
long timePeriod = in.readLong();
int numberOfMapEntries = in.readInt();
LocalCodeRequestRateLimiter localCodeRequestRateLimiter = new LocalCodeRequestRateLimiter(timePeriod);
for (int i = 0; i < numberOfMapEntries; i++) {
Mode mode = Mode.values()[in.readInt()];
String e164Number = in.readString();
long limitedUntil = in.readLong();
localCodeRequestRateLimiter.dataMap.put(mode, new Data(Objects.requireNonNull(e164Number), limitedUntil));
}
return localCodeRequestRateLimiter;
}
@Override
public LocalCodeRequestRateLimiter[] newArray(int size) {
return new LocalCodeRequestRateLimiter[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeLong(timePeriod);
dest.writeInt(dataMap.size());
for (Map.Entry<Mode, Data> a : dataMap.entrySet()) {
dest.writeInt(a.getKey().ordinal());
dest.writeString(a.getValue().e164Number);
dest.writeLong(a.getValue().limitedUntil);
}
}
}

View File

@@ -1,31 +0,0 @@
package org.thoughtcrime.securesms.registration.viewmodel
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.BehaviorSubject
/**
* Used during re-registration flow when pin entry is required to skip SMS verification. Mostly tracks
* guesses remaining in both the local and remote check flows.
*/
class ReRegisterWithPinViewModel : ViewModel() {
var isLocalVerification: Boolean = false
private set
var hasIncorrectGuess: Boolean = false
private val _triesRemaining: BehaviorSubject<Int> = BehaviorSubject.createDefault(10)
val triesRemaining: Observable<Int> = _triesRemaining.observeOn(AndroidSchedulers.mainThread())
fun updateSvrTriesRemaining(triesRemaining: Int?) {
if (triesRemaining == null) {
isLocalVerification = true
if (hasIncorrectGuess) {
_triesRemaining.onNext((_triesRemaining.value!! - 1).coerceAtLeast(0))
}
} else {
_triesRemaining.onNext(triesRemaining)
}
}
}

View File

@@ -1,458 +0,0 @@
package org.thoughtcrime.securesms.registration.viewmodel;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.AbstractSavedStateViewModelFactory;
import androidx.lifecycle.SavedStateHandle;
import androidx.lifecycle.ViewModel;
import androidx.savedstate.SavedStateRegistryOwner;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob;
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob;
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.pin.SvrWrongPinException;
import org.thoughtcrime.securesms.pin.SvrRepository;
import org.thoughtcrime.securesms.registration.RegistrationData;
import org.thoughtcrime.securesms.registration.RegistrationRepository;
import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor;
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.util.RemoteConfig;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.SvrNoDataException;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.kbs.PinHashUtil;
import org.whispersystems.signalservice.api.push.exceptions.IncorrectCodeException;
import org.whispersystems.signalservice.api.push.exceptions.IncorrectRegistrationRecoveryPasswordException;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse;
import org.signal.core.util.Base64;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class RegistrationViewModel extends BaseRegistrationViewModel {
private static final String TAG = Log.tag(RegistrationViewModel.class);
private static final String STATE_FCM_TOKEN = "FCM_TOKEN";
private static final String STATE_RESTORE_FLOW_SHOWN = "RESTORE_FLOW_SHOWN";
private static final String STATE_IS_REREGISTER = "IS_REREGISTER";
private static final String STATE_BACKUP_COMPLETED = "BACKUP_COMPLETED";
private final RegistrationRepository registrationRepository;
private boolean userSkippedReRegisterFlow = false;
private boolean autoShowSmsConfirmDialog = false;
public RegistrationViewModel(@NonNull SavedStateHandle savedStateHandle,
boolean isReregister,
@NonNull VerifyAccountRepository verifyAccountRepository,
@NonNull RegistrationRepository registrationRepository)
{
super(savedStateHandle, verifyAccountRepository, Util.getSecret(18));
this.registrationRepository = registrationRepository;
setInitialDefaultValue(STATE_RESTORE_FLOW_SHOWN, false);
setInitialDefaultValue(STATE_BACKUP_COMPLETED, false);
this.savedState.set(STATE_IS_REREGISTER, isReregister);
}
public boolean isReregister() {
//noinspection ConstantConditions
return savedState.get(STATE_IS_REREGISTER);
}
public void onNumberDetected(int countryCode, String nationalNumber) {
setViewState(getNumber().toBuilder()
.countryCode(countryCode)
.nationalNumber(nationalNumber)
.build());
}
public @Nullable String getFcmToken() {
String token = savedState.get(STATE_FCM_TOKEN);
if (token == null || token.isEmpty()) {
return null;
}
return token;
}
@MainThread
public void setFcmToken(@Nullable String fcmToken) {
savedState.set(STATE_FCM_TOKEN, fcmToken);
}
public void setWelcomeSkippedOnRestore() {
savedState.set(STATE_RESTORE_FLOW_SHOWN, true);
}
public boolean hasRestoreFlowBeenShown() {
//noinspection ConstantConditions
return savedState.get(STATE_RESTORE_FLOW_SHOWN);
}
public void setIsReregister(boolean isReregister) {
savedState.set(STATE_IS_REREGISTER, isReregister);
}
public void markBackupCompleted() {
savedState.set(STATE_BACKUP_COMPLETED, true);
}
public boolean hasBackupCompleted() {
Boolean completed = savedState.get(STATE_BACKUP_COMPLETED);
return completed != null ? completed : false;
}
public boolean hasUserSkippedReRegisterFlow() {
return userSkippedReRegisterFlow;
}
public void setUserSkippedReRegisterFlow(boolean userSkippedReRegisterFlow) {
Log.i(TAG, "User skipped re-register flow.");
this.userSkippedReRegisterFlow = userSkippedReRegisterFlow;
if (userSkippedReRegisterFlow) {
setAutoShowSmsConfirmDialog(true);
}
}
public boolean shouldAutoShowSmsConfirmDialog() {
return autoShowSmsConfirmDialog;
}
public void setAutoShowSmsConfirmDialog(boolean autoShowSmsConfirmDialog) {
this.autoShowSmsConfirmDialog = autoShowSmsConfirmDialog;
}
@Override
protected Single<ServiceResponse<VerifyResponse>> verifyAccountWithoutRegistrationLock() {
final String sessionId = getSessionId();
if (sessionId == null) {
throw new IllegalStateException("No valid registration session");
}
return verifyAccountRepository.verifyAccount(sessionId, getRegistrationData())
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForVerification::new)
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess(processor -> {
if (processor.hasResult()) {
setCanSmsAtTime(processor.getNextCodeViaSmsAttempt());
setCanCallAtTime(processor.getNextCodeViaCallAttempt());
}
})
.observeOn(Schedulers.io())
.flatMap(processor -> {
if (processor.isAlreadyVerified() || (processor.hasResult() && processor.isVerified())) {
return verifyAccountRepository.registerAccount(sessionId, getRegistrationData(), null, null);
} else if (processor.getError() == null) {
return Single.just(ServiceResponse.<VerifyResponse>forApplicationError(new IncorrectCodeException(), 403, null));
} else {
return Single.just(ServiceResponse.<VerifyResponse, RegistrationSessionMetadataResponse>coerceError(processor.getResponse()));
}
})
.flatMap(verifyAccountWithoutKbsResponse -> {
VerifyResponseProcessor processor = new VerifyResponseWithoutKbs(verifyAccountWithoutKbsResponse);
String pin = SignalStore.svr().getPin();
if ((processor.isRegistrationLockPresentAndSvrExhausted() || processor.registrationLock()) && SignalStore.svr().getRegistrationLockToken() != null && pin != null) {
return verifyAccountRepository.registerAccount(sessionId, getRegistrationData(), pin, () -> SignalStore.svr().getOrCreateMasterKey())
.map(verifyAccountWithPinResponse -> {
if (verifyAccountWithPinResponse.getResult().isPresent() && verifyAccountWithPinResponse.getResult().get().getMasterKey() != null) {
return verifyAccountWithPinResponse;
} else {
return verifyAccountWithoutKbsResponse;
}
});
} else {
return Single.just(verifyAccountWithoutKbsResponse);
}
})
.onErrorReturn(ServiceResponse::forUnknownError);
}
@Override
protected Single<ServiceResponse<VerifyResponse>> verifyAccountWithRegistrationLock(@NonNull String pin, @NonNull SvrAuthCredentialSet svrAuthCredentials) {
final String sessionId = getSessionId();
if (sessionId == null) {
throw new IllegalStateException("No valid registration session");
}
return verifyAccountRepository.verifyAccount(sessionId, getRegistrationData())
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForVerification::new)
.doOnSuccess(processor -> {
if (processor.hasResult()) {
setCanSmsAtTime(processor.getNextCodeViaSmsAttempt());
setCanCallAtTime(processor.getNextCodeViaCallAttempt());
}
})
.<ServiceResponse<VerifyResponse>>flatMap(processor -> {
if (processor.isAlreadyVerified() || (processor.hasResult() && processor.isVerified())) {
return verifyAccountRepository.registerAccount(sessionId, getRegistrationData(), pin, () -> SvrRepository.restoreMasterKeyPreRegistration(svrAuthCredentials, pin));
} else {
return Single.just(ServiceResponse.coerceError(processor.getResponse()));
}
})
.onErrorReturn(ServiceResponse::forUnknownError);
}
@Override
protected Single<VerifyResponseProcessor> onVerifySuccess(@NonNull VerifyResponseProcessor processor) {
return registrationRepository.registerAccount(getRegistrationData(), processor.getResult(), false)
.map(VerifyResponseWithoutKbs::new);
}
@Override
protected Single<VerifyResponseWithRegistrationLockProcessor> onVerifySuccessWithRegistrationLock(@NonNull VerifyResponseWithRegistrationLockProcessor processor, String pin) {
return registrationRepository.registerAccount(getRegistrationData(), processor.getResult(), true)
.map(processor::updatedIfRegistrationFailed);
}
private RegistrationData getRegistrationData() {
return new RegistrationData(getTextCodeEntered(),
getNumber().getE164Number(),
getRegistrationSecret(),
registrationRepository.getRegistrationId(),
registrationRepository.getProfileKey(getNumber().getE164Number()),
getFcmToken(),
registrationRepository.getPniRegistrationId(),
getSessionId() != null ? null : getRecoveryPassword());
}
public @NonNull Single<VerifyResponseProcessor> verifyReRegisterWithPin(@NonNull String pin) {
return Single.fromCallable(() -> verifyReRegisterWithPinInternal(pin))
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.flatMap(data -> {
if (data.canProceed) {
return updateFcmTokenValue().subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.onErrorReturnItem("")
.flatMap(s -> verifyReRegisterWithRecoveryPassword(pin, data.masterKey));
} else {
throw new IncorrectRegistrationRecoveryPasswordException();
}
})
.onErrorReturn(t -> new VerifyResponseWithRegistrationLockProcessor(ServiceResponse.forUnknownError(t), getSvrAuthCredentials()))
.map(p -> {
if (p instanceof VerifyResponseWithRegistrationLockProcessor) {
VerifyResponseWithRegistrationLockProcessor lockProcessor = (VerifyResponseWithRegistrationLockProcessor) p;
if (lockProcessor.wrongPin() && lockProcessor.getSvrTriesRemaining() != null) {
return new VerifyResponseWithRegistrationLockProcessor(lockProcessor.getResponse(), lockProcessor.getSvrAuthCredentials());
}
}
return p;
})
.doOnSuccess(p -> {
if (p.hasResult()) {
restoreFromStorageService();
}
})
.observeOn(AndroidSchedulers.mainThread());
}
@WorkerThread
private @NonNull ReRegistrationData verifyReRegisterWithPinInternal(@NonNull String pin)
throws SvrWrongPinException, IOException, SvrNoDataException
{
String localPinHash = SignalStore.svr().getLocalPinHash();
if (hasRecoveryPassword() && localPinHash != null) {
if (PinHashUtil.verifyLocalPinHash(localPinHash, pin)) {
Log.i(TAG, "Local pin matches input, attempting registration");
return ReRegistrationData.canProceed(SignalStore.svr().getOrCreateMasterKey());
} else {
throw new SvrWrongPinException(0);
}
} else {
SvrAuthCredentialSet authCredentials = getSvrAuthCredentials();
if (authCredentials == null) {
Log.w(TAG, "No SVR auth credentials, abort skip flow");
return ReRegistrationData.cannotProceed();
}
MasterKey masterKey = SvrRepository.restoreMasterKeyPreRegistration(authCredentials, pin);
setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword());
setSvrTriesRemaining(10);
return ReRegistrationData.canProceed(masterKey);
}
}
private Single<VerifyResponseProcessor> verifyReRegisterWithRecoveryPassword(@NonNull String pin, @NonNull MasterKey masterKey) {
RegistrationData registrationData = getRegistrationData();
if (registrationData.getRecoveryPassword() == null) {
throw new IllegalStateException("No valid recovery password");
}
return verifyAccountRepository.registerAccount(null, registrationData, null, null)
.observeOn(Schedulers.io())
.onErrorReturn(ServiceResponse::forUnknownError)
.map(VerifyResponseWithoutKbs::new)
.flatMap(processor -> {
if (processor.registrationLock()) {
setSvrAuthCredentials(processor.getSvrAuthCredentials());
return verifyAccountRepository.registerAccount(null, registrationData, pin, () -> masterKey)
.onErrorReturn(ServiceResponse::forUnknownError)
.map(r -> new VerifyResponseWithRegistrationLockProcessor(r, processor.getSvrAuthCredentials()));
} else {
return Single.just(processor);
}
})
.flatMap(processor -> {
if (processor.hasResult()) {
VerifyResponse verifyResponse = processor.getResult();
boolean setRegistrationLockEnabled = verifyResponse.getMasterKey() != null;
if (!setRegistrationLockEnabled) {
verifyResponse = new VerifyResponse(processor.getResult().getVerifyAccountResponse(), masterKey, pin, verifyResponse.getAciPreKeyCollection(), verifyResponse.getPniPreKeyCollection());
}
return registrationRepository.registerAccount(registrationData, verifyResponse, setRegistrationLockEnabled)
.map(r -> new VerifyResponseWithRegistrationLockProcessor(r, getSvrAuthCredentials()));
} else {
return Single.just(processor);
}
});
}
public @NonNull Single<Boolean> canEnterSkipSmsFlow() {
if (userSkippedReRegisterFlow) {
Log.d(TAG, "User skipped re-register flow.");
return Single.just(false);
}
Log.d(TAG, "Querying if user can enter skip SMS flow.");
return Single.just(hasRecoveryPassword())
.flatMap(hasRecoveryPassword -> {
Log.i(TAG, "Checking if user has existing recovery password: " + hasRecoveryPassword);
if (hasRecoveryPassword) {
return Single.just(true);
} else {
return checkForValidSvrAuthCredentials();
}
});
}
private Single<Boolean> checkForValidSvrAuthCredentials() {
final List<String> svrAuthTokenList = SignalStore.svr().getSvr2AuthTokens();
List<String> usernamePasswords = svrAuthTokenList
.stream()
.limit(10)
.map(t -> {
try {
return new String(Base64.decode(t.replace("Basic ", "").trim()), StandardCharsets.ISO_8859_1);
} catch (IOException e) {
return null;
}
})
.collect(Collectors.toList());
if (usernamePasswords.isEmpty()) {
Log.d(TAG, "No valid SVR tokens in local store.");
return Single.just(false);
}
Log.d(TAG, "Valid tokens in local store, validating with SVR.");
return registrationRepository.getSvrAuthCredential(getRegistrationData(), usernamePasswords)
.flatMap(p -> {
if (p.hasValidSvr2AuthCredential()) {
Log.d(TAG, "Saving valid SVR2 auth credential.");
setSvrAuthCredentials(new SvrAuthCredentialSet(p.requireSvr2AuthCredential(), null));
return Single.just(true);
} else {
Log.d(TAG, "SVR2 response contained no valid SVR2 auth credentials.");
return Single.just(false);
}
})
.onErrorReturnItem(false)
.observeOn(AndroidSchedulers.mainThread());
}
public Single<String> updateFcmTokenValue() {
return verifyAccountRepository.getFcmToken().observeOn(AndroidSchedulers.mainThread()).doOnSuccess(this::setFcmToken);
}
private void restoreFromStorageService() {
SignalStore.onboarding().clearAll();
Stopwatch stopwatch = new Stopwatch("ReRegisterRestore");
AppDependencies.getJobManager().runSynchronously(new StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN);
stopwatch.split("AccountRestore");
AppDependencies
.getJobManager()
.startChain(new StorageSyncJob())
.then(new ReclaimUsernameAndLinkJob())
.enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10));
stopwatch.split("ContactRestore");
try {
RemoteConfig.refreshSync();
} catch (IOException e) {
Log.w(TAG, "Failed to refresh flags.", e);
}
stopwatch.split("RemoteConfig");
stopwatch.stop(TAG);
}
private boolean hasRecoveryPassword() {
return getRecoveryPassword() != null && Objects.equals(getRegistrationData().getE164(), SignalStore.account().getE164());
}
private static class ReRegistrationData {
public boolean canProceed;
public MasterKey masterKey;
private ReRegistrationData(boolean canProceed, @Nullable MasterKey masterKey) {
this.canProceed = canProceed;
this.masterKey = masterKey;
}
public static ReRegistrationData cannotProceed() {
return new ReRegistrationData(false, null);
}
public static ReRegistrationData canProceed(@NonNull MasterKey masterKey) {
return new ReRegistrationData(true, masterKey);
}
}
public static final class Factory extends AbstractSavedStateViewModelFactory {
private final boolean isReregister;
public Factory(@NonNull SavedStateRegistryOwner owner, boolean isReregister) {
super(owner, null);
this.isReregister = isReregister;
}
@Override
protected @NonNull <T extends ViewModel> T create(@NonNull String key, @NonNull Class<T> modelClass, @NonNull SavedStateHandle handle) {
//noinspection ConstantConditions
return modelClass.cast(new RegistrationViewModel(handle,
isReregister,
new VerifyAccountRepository(AppDependencies.getApplication()),
new RegistrationRepository(AppDependencies.getApplication())));
}
}
}

View File

@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.v2.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
/**

View File

@@ -21,7 +21,7 @@ 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.FragmentChooseBackupV2Binding
import org.thoughtcrime.securesms.databinding.FragmentChooseBackupBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
import org.thoughtcrime.securesms.restore.RestoreViewModel
@@ -30,9 +30,9 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* This fragment presents a button to the user to browse their local file system for a legacy backup file.
*/
class ChooseBackupV2Fragment : LoggingFragment(R.layout.fragment_choose_backup_v2) {
class ChooseBackupFragment : LoggingFragment(R.layout.fragment_choose_backup) {
private val sharedViewModel by activityViewModels<RestoreViewModel>()
private val binding: FragmentChooseBackupV2Binding by ViewBinderDelegate(FragmentChooseBackupV2Binding::bind)
private val binding: FragmentChooseBackupBinding by ViewBinderDelegate(FragmentChooseBackupBinding::bind)
private val pickMedia = registerForActivityResult(BackupFileContract()) {
if (it != null) {
@@ -57,7 +57,7 @@ class ChooseBackupV2Fragment : LoggingFragment(R.layout.fragment_choose_backup_v
private fun onUserChoseBackupFile(backupFileUri: Uri) {
sharedViewModel.setBackupFileUri(backupFileUri)
NavHostFragment.findNavController(this).safeNavigate(ChooseBackupV2FragmentDirections.actionChooseLocalBackupFragmentToRestoreLocalBackupFragment())
NavHostFragment.findNavController(this).safeNavigate(ChooseBackupFragmentDirections.actionChooseLocalBackupFragmentToRestoreLocalBackupFragment())
}
private class BackupFileContract : ActivityResultContracts.GetContent() {
@@ -72,6 +72,6 @@ class ChooseBackupV2Fragment : LoggingFragment(R.layout.fragment_choose_backup_v
}
companion object {
private val TAG = Log.tag(ChooseBackupV2Fragment::class.java)
private val TAG = Log.tag(ChooseBackupFragment::class.java)
}
}

View File

@@ -19,15 +19,15 @@ import org.signal.devicetransfer.TransferStatus
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentDeviceTransferV2Binding
import org.thoughtcrime.securesms.databinding.FragmentDeviceTransferBinding
import org.thoughtcrime.securesms.restore.RestoreViewModel
import org.thoughtcrime.securesms.util.visible
sealed class DeviceTransferV2Fragment : LoggingFragment(R.layout.fragment_device_transfer_v2) {
sealed class DeviceTransferFragment : LoggingFragment(R.layout.fragment_device_transfer) {
private val onBackPressed = OnBackPressed()
private val transferModeListener = TransferModeListener()
protected val navigationViewModel: RestoreViewModel by activityViewModels()
protected val binding: FragmentDeviceTransferV2Binding by ViewBinderDelegate(FragmentDeviceTransferV2Binding::bind)
protected val binding: FragmentDeviceTransferBinding by ViewBinderDelegate(FragmentDeviceTransferBinding::bind)
protected var transferFinished: Boolean = false

View File

@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.restore.RestoreActivity
* This approximates that by taking the place of [EnterPhoneNumberFragment],
* then bridging us back to [RegistrationV2Activity] by immediately closing the [RestoreActivity].
*/
class RestoreCompleteV2Fragment : LoggingFragment(R.layout.fragment_registration_blank) {
class RestoreCompleteFragment : LoggingFragment(R.layout.fragment_registration_blank) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -33,6 +33,6 @@ class RestoreCompleteV2Fragment : LoggingFragment(R.layout.fragment_registration
}
companion object {
private val TAG = Log.tag(RestoreCompleteV2Fragment::class.java)
private val TAG = Log.tag(RestoreCompleteFragment::class.java)
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.restore.restorelocalbackup;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.text.Editable;
import android.text.Spanned;
import android.text.TextWatcher;
import android.text.style.ReplacementSpan;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.util.BackupUtil;
public class PassphraseAsYouTypeFormatter implements TextWatcher {
private static final int GROUP_SIZE = 5;
@Override
public void afterTextChanged(Editable editable) {
removeSpans(editable);
addSpans(editable);
}
private static void removeSpans(Editable editable) {
SpaceSpan[] paddingSpans = editable.getSpans(0, editable.length(), SpaceSpan.class);
for (SpaceSpan span : paddingSpans) {
editable.removeSpan(span);
}
}
private static void addSpans(Editable editable) {
final int length = editable.length();
for (int i = GROUP_SIZE; i < length; i += GROUP_SIZE) {
editable.setSpan(new SpaceSpan(), i - 1, i, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (editable.length() > BackupUtil.PASSPHRASE_LENGTH) {
editable.delete(BackupUtil.PASSPHRASE_LENGTH, editable.length());
}
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
/**
* A {@link ReplacementSpan} adds a small space after a single character.
* Based on https://stackoverflow.com/a/51949578
*/
private static class SpaceSpan extends ReplacementSpan {
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
return (int) (paint.measureText(text, start, end) * 1.7f);
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
canvas.drawText(text.subSequence(start, end).toString(), x, y, paint);
}
}
}

View File

@@ -23,10 +23,9 @@ import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.BackupEvent
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRestoreLocalBackupV2Binding
import org.thoughtcrime.securesms.databinding.FragmentRestoreLocalBackupBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.fragments.RestoreBackupFragment.PassphraseAsYouTypeFormatter
import org.thoughtcrime.securesms.restore.RestoreActivity
import org.thoughtcrime.securesms.restore.RestoreRepository
import org.thoughtcrime.securesms.restore.RestoreViewModel
@@ -41,7 +40,7 @@ import java.util.Locale
/**
* This fragment is used to monitor and manage an in-progress backup restore.
*/
class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_local_backup_v2) {
class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_local_backup) {
private val navigationViewModel: RestoreViewModel by activityViewModels()
private val restoreLocalBackupViewModel: RestoreLocalBackupViewModel by viewModels(
factoryProducer = ViewModelFactory.factoryProducer {
@@ -49,7 +48,7 @@ class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_loc
RestoreLocalBackupViewModel(fileBackupUri)
}
)
private val binding: FragmentRestoreLocalBackupV2Binding by ViewBinderDelegate(FragmentRestoreLocalBackupV2Binding::bind)
private val binding: FragmentRestoreLocalBackupBinding by ViewBinderDelegate(FragmentRestoreLocalBackupBinding::bind)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -68,8 +67,6 @@ class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_loc
findNavController().navigateUp()
}
// TODO [regv2]: check for re-register and skip ahead to phone number entry
if (SignalStore.settings.isBackupEnabled) {
Log.i(TAG, "Backups enabled, so a backup must have been previously restored.")
onBackupCompletedSuccessfully()
@@ -158,7 +155,7 @@ class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_loc
private fun presentProgressEnded() {
binding.restoreButton.cancelSpinning()
binding.cancelLocalRestoreButton.visible = true
binding.backupProgressText.text = ""
binding.backupProgressText.text = null
}
private fun presentRestoreProgress(backupProgressCount: Long) {

View File

@@ -19,7 +19,7 @@ import org.thoughtcrime.securesms.databinding.TransferOrRestoreOptionsBottomShee
import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.registration.v2.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.restore.RestoreActivity
import org.thoughtcrime.securesms.util.visible

Some files were not shown because too many files have changed in this diff Show More