Change Number V2.

This commit is contained in:
Nicholas Tinsley
2024-05-31 16:57:38 -04:00
committed by Cody Henthorne
parent b99c2165fa
commit 1e35403c87
23 changed files with 3741 additions and 8 deletions

View File

@@ -783,6 +783,12 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity
android:name=".components.settings.app.changenumber.v2.ChangeNumberLockV2Activity"
android:theme="@style/Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity
android:name=".components.settings.conversation.ConversationSettingsActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"

View File

@@ -16,6 +16,7 @@ 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;
@@ -180,7 +181,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) {
} else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class && getClass() != ChangeNumberLockV2Activity.class) {
return STATE_CHANGE_NUMBER_LOCK;
} else {
return STATE_NORMAL;
@@ -264,7 +265,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getChangeNumberLockIntent() {
return ChangeNumberLockActivity.createIntent(this);
if (FeatureFlags.registrationV2()) {
return ChangeNumberLockV2Activity.createIntent(this);
} else {
return ChangeNumberLockActivity.createIntent(this);
}
}
private Intent getRoutedIntent(Intent destination, @Nullable Intent nextIntent) {

View File

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

View File

@@ -126,7 +126,7 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
}
)
parentFragmentManager.setFragmentResultListener(OLD_NUMBER_COUNTRY_SELECT, this) { _, bundle ->
parentFragmentManager.setFragmentResultListener(OLD_NUMBER_COUNTRY_SELECT, this) { _: String, bundle: Bundle ->
viewModel.setOldCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY))
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import java.util.concurrent.TimeUnit
/**
* Screen visible to the user when they are registration locked and have no SVR data.
*/
class ChangeNumberAccountLockedV2Fragment : LoggingFragment(R.layout.fragment_change_number_account_locked) {
companion object {
private val TAG = Log.tag(ChangeNumberAccountLockedV2Fragment::class.java)
}
private val viewModel by activityViewModels<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

@@ -0,0 +1,31 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.os.Bundle
import android.view.View
import androidx.fragment.app.activityViewModels
import org.thoughtcrime.securesms.registration.v2.data.network.Challenge
import org.thoughtcrime.securesms.registration.v2.ui.captcha.CaptchaV2Fragment
/**
* Screen visible to the user when they are to solve a captcha. @see [CaptchaV2Fragment]
*/
class ChangeNumberCaptchaV2Fragment : CaptchaV2Fragment() {
private val viewModel by activityViewModels<ChangeNumberV2ViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.addPresentedChallenge(Challenge.CAPTCHA)
}
override fun handleCaptchaToken(token: String) {
viewModel.setCaptchaResponse(token)
}
override fun handleUserExit() {
viewModel.removePresentedChallenge(Challenge.CAPTCHA)
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Screen visible to the user for them to confirm their new phone number was entered correctly.
*/
class ChangeNumberConfirmV2Fragment : LoggingFragment(R.layout.fragment_change_number_confirm) {
companion object {
private val TAG = Log.tag(ChangeNumberConfirmV2Fragment::class.java)
}
private val viewModel by activityViewModels<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

@@ -0,0 +1,303 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.databinding.FragmentChangeNumberEnterCodeBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ReceivedSmsEvent
import org.thoughtcrime.securesms.registration.fragments.ContactSupportBottomSheetFragment
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
import org.thoughtcrime.securesms.registration.fragments.SignalStrengthPhoneStateListener
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
/**
* Screen used to enter the registration code provided by the service.
*/
class ChangeNumberEnterCodeV2Fragment : LoggingFragment(R.layout.fragment_change_number_enter_code) {
companion object {
private val TAG: String = Log.tag(ChangeNumberEnterCodeV2Fragment::class.java)
}
private val viewModel by activityViewModels<ChangeNumberV2ViewModel>()
private val binding: FragmentChangeNumberEnterCodeBinding by ViewBinderDelegate(FragmentChangeNumberEnterCodeBinding::bind)
private lateinit var phoneStateListener: SignalStrengthPhoneStateListener
private var autopilotCodeEntryActive = false
private val bottomSheet = ContactSupportBottomSheetFragment()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.title = viewModel.number.fullFormattedNumber
toolbar.setNavigationOnClickListener {
Log.d(TAG, "Toolbar navigation clicked.")
navigateUp()
}
binding.codeEntryLayout.verifyHeader.setOnClickListener(null)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
Log.d(TAG, "onBackPressed")
navigateUp()
}
}
)
RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.codeEntryLayout.verifyHeader)
phoneStateListener = SignalStrengthPhoneStateListener(this, PhoneStateCallback())
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
navigateUp()
}
}
)
binding.codeEntryLayout.wrongNumber.setOnClickListener {
navigateUp()
}
binding.codeEntryLayout.code.setOnCompleteListener {
viewModel.verifyCodeWithoutRegistrationLock(requireContext(), it, ::handleSessionErrorResponse, ::handleChangeNumberErrorResponse)
}
binding.codeEntryLayout.havingTroubleButton.setOnClickListener {
bottomSheet.show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
binding.codeEntryLayout.callMeCountDown.apply {
setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in)
setOnClickListener {
viewModel.initiateChangeNumberSession(requireContext(), RegistrationRepository.Mode.PHONE_CALL)
}
}
binding.codeEntryLayout.resendSmsCountDown.apply {
setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in)
setOnClickListener {
viewModel.initiateChangeNumberSession(requireContext(), RegistrationRepository.Mode.SMS_WITHOUT_LISTENER)
}
}
binding.codeEntryLayout.keyboard.setOnKeyPressListener { key ->
if (!autopilotCodeEntryActive) {
if (key >= 0) {
binding.codeEntryLayout.code.append(key)
} else {
binding.codeEntryLayout.code.delete()
}
}
}
viewModel.incorrectCodeAttempts.observe(viewLifecycleOwner) { attempts: Int ->
if (attempts >= 3) {
binding.codeEntryLayout.havingTroubleButton.visible = true
}
}
viewModel.uiState.observe(viewLifecycleOwner, ::onStateUpdate)
}
private fun onStateUpdate(state: ChangeNumberState) {
binding.codeEntryLayout.resendSmsCountDown.startCountDownTo(state.nextSmsTimestamp)
binding.codeEntryLayout.callMeCountDown.startCountDownTo(state.nextCallTimestamp)
when (val outcome = state.changeNumberOutcome) {
is ChangeNumberOutcome.RecoveryPasswordWorked,
is ChangeNumberOutcome.VerificationCodeWorked -> changeNumberSuccess()
is ChangeNumberOutcome.ChangeNumberRequestOutcome -> if (!state.inProgress && !outcome.result.isSuccess()) {
presentGenericError(outcome.result)
}
null -> Unit
}
if (state.inProgress) {
binding.codeEntryLayout.keyboard.displayProgress()
} else {
binding.codeEntryLayout.keyboard.displayKeyboard()
}
}
private fun navigateUp() {
if (SignalStore.misc().isChangeNumberLocked) {
Log.d(TAG, "Change number locked, navigateUp")
startActivity(ChangeNumberLockV2Activity.createIntent(requireContext()))
} else {
Log.d(TAG, "navigateUp")
findNavController().navigateUp()
}
}
private fun handleSessionErrorResponse(result: VerificationCodeRequestResult) {
when (result) {
is VerificationCodeRequestResult.Success -> binding.codeEntryLayout.keyboard.displaySuccess()
is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog()
is VerificationCodeRequestResult.AttemptsExhausted -> presentAccountLocked()
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
else -> presentGenericError(result)
}
}
private fun handleChangeNumberErrorResponse(result: ChangeNumberResult) {
when (result) {
is ChangeNumberResult.Success -> binding.codeEntryLayout.keyboard.displaySuccess()
is ChangeNumberResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
is ChangeNumberResult.AuthorizationFailed -> presentIncorrectCodeDialog()
is ChangeNumberResult.AttemptsExhausted -> presentAccountLocked()
is ChangeNumberResult.RateLimited -> presentRateLimitedDialog()
else -> presentGenericError(result)
}
}
private fun presentAccountLocked() {
binding.codeEntryLayout.keyboard.displayLocked().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
findNavController().safeNavigate(ChangeNumberEnterCodeV2FragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
}
}
)
}
private fun presentRegistrationLocked(timeRemaining: Long) {
binding.codeEntryLayout.keyboard.displayLocked().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
Log.i(TAG, "Account is registration locked, cannot register.")
findNavController().safeNavigate(ChangeNumberEnterCodeV2FragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
}
}
)
}
private fun presentRateLimitedDialog() {
binding.codeEntryLayout.keyboard.displayFailure().addListener(
object : AssertedSuccessListener<Boolean?>() {
override fun onSuccess(result: Boolean?) {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_too_many_attempts)
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
binding.codeEntryLayout.callMeCountDown.visibility = View.VISIBLE
binding.codeEntryLayout.resendSmsCountDown.visibility = View.VISIBLE
binding.codeEntryLayout.wrongNumber.visibility = View.VISIBLE
binding.codeEntryLayout.code.clear()
binding.codeEntryLayout.keyboard.displayKeyboard()
}
show()
}
}
}
)
}
private fun presentIncorrectCodeDialog() {
viewModel.incrementIncorrectCodeAttempts()
Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_code, Toast.LENGTH_LONG).show()
binding.codeEntryLayout.keyboard.displayFailure().addListener(object : AssertedSuccessListener<Boolean?>() {
override fun onSuccess(result: Boolean?) {
binding.codeEntryLayout.callMeCountDown.setVisibility(View.VISIBLE)
binding.codeEntryLayout.resendSmsCountDown.setVisibility(View.VISIBLE)
binding.codeEntryLayout.wrongNumber.setVisibility(View.VISIBLE)
binding.codeEntryLayout.code.clear()
binding.codeEntryLayout.keyboard.displayKeyboard()
}
})
}
private fun presentGenericError(requestResult: RegistrationResult) {
binding.codeEntryLayout.keyboard.displayFailure().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
Log.w(TAG, "Encountered unexpected error!", requestResult.getCause())
MaterialAlertDialogBuilder(requireContext()).apply {
null?.let<String, MaterialAlertDialogBuilder> {
setTitle(it)
}
setMessage(getString(R.string.RegistrationActivity_error_connecting_to_service))
setPositiveButton(android.R.string.ok) { _, _ ->
navigateUp()
viewModel.resetLocalSessionState()
}
show()
}
}
}
)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onVerificationCodeReceived(event: ReceivedSmsEvent) {
binding.codeEntryLayout.code.clear()
if (event.code.isBlank() || event.code.length != ReceivedSmsEvent.CODE_LENGTH) {
Log.i(TAG, "Received invalid code of length ${event.code.length}. Ignoring.")
return
}
val finalIndex = ReceivedSmsEvent.CODE_LENGTH - 1
autopilotCodeEntryActive = true
try {
event.code
.map { it.digitToInt() }
.forEachIndexed { i, digit ->
binding.codeEntryLayout.code.postDelayed({
binding.codeEntryLayout.code.append(digit)
if (i == finalIndex) {
autopilotCodeEntryActive = false
}
}, i * 200L)
}
} catch (notADigit: IllegalArgumentException) {
Log.w(TAG, "Failed to convert code into digits.", notADigit)
autopilotCodeEntryActive = false
}
}
private inner class PhoneStateCallback : SignalStrengthPhoneStateListener.Callback {
override fun onNoCellSignalPresent() {
bottomSheet.show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
override fun onCellSignalPresent() {
if (bottomSheet.isResumed) {
bottomSheet.dismiss()
}
}
}
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentChangeNumberEnterPhoneNumberV2Binding
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragment
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragmentArgs
import org.thoughtcrime.securesms.registration.util.ChangeNumberInputController
import org.thoughtcrime.securesms.util.Dialogs
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Screen for the user to enter their old and new phone numbers.
*/
class ChangeNumberEnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_change_number_enter_phone_number_v2) {
companion object {
private val TAG: String = Log.tag(ChangeNumberEnterPhoneNumberV2Fragment::class.java)
private const val OLD_NUMBER_COUNTRY_SELECT = "old_number_country"
private const val NEW_NUMBER_COUNTRY_SELECT = "new_number_country"
}
private val binding: FragmentChangeNumberEnterPhoneNumberV2Binding by ViewBinderDelegate(FragmentChangeNumberEnterPhoneNumberV2Binding::bind)
private val viewModel by activityViewModels<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

@@ -0,0 +1,99 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
/**
* A captive activity that can determine if an interrupted/erred change number request
* caused a disparity between the server and our locally stored number.
*/
class ChangeNumberLockV2Activity : PassphraseRequiredActivity() {
companion object {
private val TAG: String = Log.tag(ChangeNumberLockV2Activity::class.java)
@JvmStatic
fun createIntent(context: Context): Intent {
return Intent(context, ChangeNumberLockV2Activity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
}
}
private val viewModel: ChangeNumberV2ViewModel by viewModels()
private val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
dynamicTheme.onCreate(this)
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
Log.d(TAG, "Back button press swallowed.")
}
}
)
setContentView(R.layout.activity_change_number_lock)
checkWhoAmI()
}
override fun onResume() {
super.onResume()
dynamicTheme.onResume(this)
}
private fun checkWhoAmI() {
viewModel.checkWhoAmI(::onChangeStatusConfirmed, ::onFailedToGetChangeNumberStatus)
}
private fun onChangeStatusConfirmed() {
SignalStore.misc().clearPendingChangeNumberMetadata()
MaterialAlertDialogBuilder(this)
.setTitle(R.string.ChangeNumberLockActivity__change_status_confirmed)
.setMessage(getString(R.string.ChangeNumberLockActivity__your_number_has_been_confirmed_as_s, PhoneNumberFormatter.prettyPrint(SignalStore.account().e164!!)))
.setPositiveButton(android.R.string.ok) { _, _ ->
startActivity(MainActivity.clearTop(this))
finish()
}
.setCancelable(false)
.show()
}
private fun onFailedToGetChangeNumberStatus(error: Throwable) {
Log.w(TAG, "Unable to determine status of change number", error)
MaterialAlertDialogBuilder(this)
.setTitle(R.string.ChangeNumberLockActivity__change_status_unconfirmed)
.setMessage(getString(R.string.ChangeNumberLockActivity__we_could_not_determine_the_status_of_your_change_number_request, error.javaClass.simpleName))
.setPositiveButton(R.string.ChangeNumberLockActivity__retry) { _, _ -> checkWhoAmI() }
.setNegativeButton(R.string.ChangeNumberLockActivity__leave) { _, _ -> finish() }
.setNeutralButton(R.string.ChangeNumberLockActivity__submit_debug_log) { _, _ ->
startActivity(Intent(this, SubmitDebugLogActivity::class.java))
finish()
}
.setCancelable(false)
.show()
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
/**
* A screen to educate the user if their PIN differs from old number to new number.
*/
class ChangeNumberPinDiffersV2Fragment : LoggingFragment(R.layout.fragment_change_number_pin_differs) {
companion object {
private val TAG = Log.tag(ChangeNumberPinDiffersV2Fragment::class.java)
}
private val confirmCancelDialog = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.ChangeNumberPinDiffersFragment__keep_old_pin_question)
.setPositiveButton(android.R.string.ok) { _, _ -> changeNumberSuccess() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<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

@@ -0,0 +1,328 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.os.Bundle
import android.text.InputType
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.databinding.FragmentRegistrationLockBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.v2.ui.registrationlock.RegistrationLockV2Fragment
import org.thoughtcrime.securesms.registration.v2.ui.registrationlock.RegistrationLockV2FragmentArgs
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.kbs.PinHashUtil
import java.util.concurrent.TimeUnit
/**
* Screen presented to the user if the new account is registration locked, and allows them to enter their PIN.
*/
class ChangeNumberRegistrationLockV2Fragment : LoggingFragment(R.layout.fragment_change_number_registration_lock) {
companion object {
private val TAG = Log.tag(RegistrationLockV2Fragment::class.java)
}
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind)
private val viewModel by activityViewModels<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

@@ -0,0 +1,72 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationResult
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException
import org.whispersystems.signalservice.api.push.exceptions.IncorrectRegistrationRecoveryPasswordException
import org.whispersystems.signalservice.api.push.exceptions.MalformedRequestException
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.LockedException
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
/**
* This is a processor to map a [VerifyAccountResponse] to all the known outcomes.
*/
sealed class ChangeNumberResult(cause: Throwable?) : RegistrationResult(cause) {
companion object {
fun from(networkResult: NetworkResult<ChangeNumberV2Repository.NumberChangeResult>): ChangeNumberResult {
return when (networkResult) {
is NetworkResult.Success -> Success(networkResult.result)
is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable)
is NetworkResult.NetworkError -> UnknownError(networkResult.exception)
is NetworkResult.StatusCodeError -> {
when (val cause = networkResult.exception) {
is IncorrectRegistrationRecoveryPasswordException -> IncorrectRecoveryPassword(cause)
is AuthorizationFailedException -> AuthorizationFailed(cause)
is MalformedRequestException -> MalformedRequest(cause)
is RateLimitException -> createRateLimitProcessor(cause)
is LockedException -> RegistrationLocked(cause = cause, timeRemaining = cause.timeRemaining, svr2Credentials = cause.svr2Credentials)
else -> {
if (networkResult.code == 422) {
ValidationError(cause)
} else {
UnknownError(cause)
}
}
}
}
}
}
private fun createRateLimitProcessor(exception: RateLimitException): ChangeNumberResult {
return if (exception.retryAfterMilliseconds.isPresent) {
RateLimited(exception, exception.retryAfterMilliseconds.get())
} else {
AttemptsExhausted(exception)
}
}
}
class Success(val numberChangeResult: ChangeNumberV2Repository.NumberChangeResult) : ChangeNumberResult(null)
class IncorrectRecoveryPassword(cause: Throwable) : ChangeNumberResult(cause)
class AuthorizationFailed(cause: Throwable) : ChangeNumberResult(cause)
class MalformedRequest(cause: Throwable) : ChangeNumberResult(cause)
class ValidationError(cause: Throwable) : ChangeNumberResult(cause)
class RateLimited(cause: Throwable, val timeRemaining: Long) : ChangeNumberResult(cause)
class AttemptsExhausted(cause: Throwable) : ChangeNumberResult(cause)
class RegistrationLocked(cause: Throwable, val timeRemaining: Long, val svr2Credentials: AuthCredentials?) : ChangeNumberResult(cause)
class UnknownError(cause: Throwable) : ChangeNumberResult(cause)
class SvrNoData(cause: SvrNoDataException) : ChangeNumberResult(cause)
class SvrWrongPin(cause: SvrWrongPinException) : ChangeNumberResult(cause) {
val triesRemaining = cause.triesRemaining
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import org.thoughtcrime.securesms.registration.v2.data.network.Challenge
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.whispersystems.signalservice.internal.push.AuthCredentials
/**
* State holder for [ChangeNumberV2ViewModel]
*/
data class ChangeNumberState(
val number: NumberViewState = NumberViewState.INITIAL,
val enteredCode: String? = null,
val enteredPin: String = "",
val oldPhoneNumber: NumberViewState = NumberViewState.INITIAL,
val sessionId: String? = null,
val changeNumberOutcome: ChangeNumberOutcome? = null,
val lockedTimeRemaining: Long = 0L,
val svrCredentials: AuthCredentials? = null,
val svrTriesRemaining: Int = 10,
val incorrectCodeAttempts: Int = 0,
val nextSmsTimestamp: Long = 0L,
val nextCallTimestamp: Long = 0L,
val inProgress: Boolean = false,
val captchaToken: String? = null,
val challengesRequested: List<Challenge> = emptyList(),
val challengesPresented: Set<Challenge> = emptySet(),
val allowedToRequestCode: Boolean = false
) {
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
}
sealed interface ChangeNumberOutcome {
data object RecoveryPasswordWorked : ChangeNumberOutcome
data object VerificationCodeWorked : ChangeNumberOutcome
class ChangeNumberRequestOutcome(val result: VerificationCodeRequestResult) : ChangeNumberOutcome
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentChangePhoneNumberV2Binding
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Screen used to educate the user about what they're about to do (change their phone number)
*/
class ChangeNumberV2Fragment : LoggingFragment(R.layout.fragment_change_phone_number_v2) {
companion object {
private val TAG = Log.tag(ChangeNumberV2Fragment::class.java)
}
private val binding: FragmentChangePhoneNumberV2Binding by ViewBinderDelegate(FragmentChangePhoneNumberV2Binding::bind)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
binding.changePhoneNumberContinue.setOnClickListener {
findNavController().safeNavigate(R.id.action_changePhoneNumberFragment_to_enterPhoneNumberChangeFragment)
}
}
}

View File

@@ -0,0 +1,384 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignalProtocolStore
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.protocol.util.Medium
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.keyvalue.CertificateType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest
import org.whispersystems.signalservice.api.account.PreKeyUpload
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceIdType
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import org.whispersystems.signalservice.internal.push.SyncMessage
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
import kotlin.coroutines.resume
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Repository to perform data operations during change number.
*
* @see [org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository]
*/
class ChangeNumberV2Repository(
private val accountManager: SignalServiceAccountManager = AppDependencies.signalServiceAccountManager,
private val messageSender: SignalServiceMessageSender = AppDependencies.signalServiceMessageSender
) {
companion object {
private val TAG = Log.tag(ChangeNumberV2Repository::class.java)
}
fun whoAmI(): WhoAmIResponse {
return accountManager.whoAmI
}
suspend fun ensureDecryptionsDrained(timeout: Duration = 15.seconds) =
withTimeoutOrNull(timeout) {
suspendCancellableCoroutine {
val drainedListener = object : Runnable {
override fun run() {
AppDependencies
.incomingMessageObserver
.removeDecryptionDrainedListener(this)
Log.d(TAG, "Decryptions drained.")
it.resume(true)
}
}
it.invokeOnCancellation {
AppDependencies
.incomingMessageObserver
.removeDecryptionDrainedListener(drainedListener)
Log.d(TAG, "Decryptions draining canceled.")
}
AppDependencies
.incomingMessageObserver
.addDecryptionDrainedListener(drainedListener)
Log.d(TAG, "Waiting for decryption drain.")
}
}
@WorkerThread
fun changeLocalNumber(e164: String, pni: ServiceId.PNI) {
val oldStorageId: ByteArray? = Recipient.self().storageId
SignalDatabase.recipients.updateSelfE164(e164, pni)
val newStorageId: ByteArray? = Recipient.self().storageId
if (e164 != SignalStore.account().requireE164() && MessageDigest.isEqual(oldStorageId, newStorageId)) {
Log.w(TAG, "Self storage id was not rotated, attempting to rotate again")
SignalDatabase.recipients.rotateStorageId(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
val secondAttemptStorageId: ByteArray? = Recipient.self().storageId
if (MessageDigest.isEqual(oldStorageId, secondAttemptStorageId)) {
Log.w(TAG, "Second attempt also failed to rotate storage id")
}
}
AppDependencies.recipientCache.clear()
SignalStore.account().setE164(e164)
SignalStore.account().setPni(pni)
AppDependencies.resetProtocolStores()
AppDependencies.groupsV2Authorization.clear()
val metadata: PendingChangeNumberMetadata? = SignalStore.misc().pendingChangeNumberMetadata
if (metadata == null) {
Log.w(TAG, "No change number metadata, this shouldn't happen")
throw AssertionError("No change number metadata")
}
val originalPni = ServiceId.PNI.parseOrThrow(metadata.previousPni)
if (originalPni == pni) {
Log.i(TAG, "No change has occurred, PNI is unchanged: $pni")
} else {
val pniIdentityKeyPair = IdentityKeyPair(metadata.pniIdentityKeyPair.toByteArray())
val pniRegistrationId = metadata.pniRegistrationId
val pniSignedPreyKeyId = metadata.pniSignedPreKeyId
val pniLastResortKyberPreKeyId = metadata.pniLastResortKyberPreKeyId
val pniProtocolStore = AppDependencies.protocolStore.pni()
val pniMetadataStore = SignalStore.account().pniPreKeys
SignalStore.account().pniRegistrationId = pniRegistrationId
SignalStore.account().setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair)
val signedPreKey = pniProtocolStore.loadSignedPreKey(pniSignedPreyKeyId)
val oneTimeEcPreKeys = PreKeyUtil.generateAndStoreOneTimeEcPreKeys(pniProtocolStore, pniMetadataStore)
val lastResortKyberPreKey = pniProtocolStore.loadLastResortKyberPreKeys().firstOrNull { it.id == pniLastResortKyberPreKeyId }
val oneTimeKyberPreKeys = PreKeyUtil.generateAndStoreOneTimeKyberPreKeys(pniProtocolStore, pniMetadataStore)
if (lastResortKyberPreKey == null) {
Log.w(TAG, "Last-resort kyber prekey is missing!")
}
pniMetadataStore.activeSignedPreKeyId = signedPreKey.id
Log.i(TAG, "Submitting prekeys with PNI identity key: ${pniIdentityKeyPair.publicKey.fingerprint}")
accountManager.setPreKeys(
PreKeyUpload(
serviceIdType = ServiceIdType.PNI,
signedPreKey = signedPreKey,
oneTimeEcPreKeys = oneTimeEcPreKeys,
lastResortKyberPreKey = lastResortKyberPreKey,
oneTimeKyberPreKeys = oneTimeKyberPreKeys
)
)
pniMetadataStore.isSignedPreKeyRegistered = true
pniMetadataStore.lastResortKyberPreKeyId = pniLastResortKyberPreKeyId
pniProtocolStore.identities().saveIdentityWithoutSideEffects(
Recipient.self().id,
pni,
pniProtocolStore.identityKeyPair.publicKey,
IdentityTable.VerifiedStatus.VERIFIED,
true,
System.currentTimeMillis(),
true
)
SignalStore.misc().hasPniInitializedDevices = true
AppDependencies.groupsV2Authorization.clear()
}
Recipient.self().live().refresh()
StorageSyncHelper.scheduleSyncForDataChange()
AppDependencies.resetNetwork()
AppDependencies.incomingMessageObserver
AppDependencies.jobManager.add(RefreshAttributesJob())
rotateCertificates()
}
@WorkerThread
private fun rotateCertificates() {
val certificateTypes = SignalStore.phoneNumberPrivacy().allCertificateTypes
Log.i(TAG, "Rotating these certificates $certificateTypes")
for (certificateType in certificateTypes) {
val certificate: ByteArray? = when (certificateType) {
CertificateType.ACI_AND_E164 -> accountManager.senderCertificate
CertificateType.ACI_ONLY -> accountManager.senderCertificateForPhoneNumberPrivacy
else -> throw AssertionError()
}
Log.i(TAG, "Successfully got $certificateType certificate")
SignalStore.certificateValues().setUnidentifiedAccessCertificate(certificateType, certificate)
}
}
suspend fun changeNumberWithRecoveryPassword(recoveryPassword: String, newE164: String): ChangeNumberResult {
return changeNumberInternal(recoveryPassword = recoveryPassword, newE164 = newE164)
}
suspend fun changeNumberWithoutRegistrationLock(sessionId: String, newE164: String): ChangeNumberResult {
return changeNumberInternal(sessionId = sessionId, newE164 = newE164)
}
suspend fun changeNumberWithRegistrationLock(
sessionId: String,
newE164: String,
pin: String,
svrAuthCredentials: SvrAuthCredentialSet
): ChangeNumberResult {
val masterKey: MasterKey
try {
masterKey = SvrRepository.restoreMasterKeyPreRegistration(svrAuthCredentials, pin)
} catch (e: SvrWrongPinException) {
return ChangeNumberResult.SvrWrongPin(e)
} catch (e: SvrNoDataException) {
return ChangeNumberResult.SvrNoData(e)
} catch (e: IOException) {
return ChangeNumberResult.UnknownError(e)
}
val registrationLock = masterKey.deriveRegistrationLock()
return changeNumberInternal(sessionId = sessionId, registrationLock = registrationLock, newE164 = newE164)
}
/**
* Sends a request to the service to change the phone number associated with this account.
*/
private suspend fun changeNumberInternal(sessionId: String? = null, recoveryPassword: String? = null, registrationLock: String? = null, newE164: String): ChangeNumberResult {
check((sessionId != null && recoveryPassword == null) || (sessionId == null && recoveryPassword != null))
var completed = false
var attempts = 0
lateinit var result: NetworkResult<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

@@ -0,0 +1,537 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.SmsRetrieverReceiver
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.v2.data.network.Challenge
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import java.io.IOException
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* [ViewModel] for the change number flow.
*
* @see [RegistrationV2ViewModel], from which this is derived.
*/
class ChangeNumberV2ViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(ChangeNumberV2ViewModel::class.java)
val CHANGE_NUMBER_LOCK = ReentrantLock()
}
private val repository = ChangeNumberV2Repository()
private val store = MutableStateFlow(ChangeNumberState())
private val serialContext = SignalExecutors.SERIAL.asCoroutineDispatcher()
private val smsRetrieverReceiver: SmsRetrieverReceiver = SmsRetrieverReceiver(AppDependencies.application)
private val initialLocalNumber = SignalStore.account().e164
private val password = SignalStore.account().servicePassword!!
val uiState = store.asLiveData()
val liveOldNumberState = store.map { it.oldPhoneNumber }.asLiveData()
val liveNewNumberState = store.map { it.number }.asLiveData()
val liveLockedTimeRemaining = store.map { it.lockedTimeRemaining }.asLiveData()
val incorrectCodeAttempts = store.map { it.incorrectCodeAttempts }.asLiveData()
init {
try {
val countryCode: Int = PhoneNumberUtil.getInstance()
.parse(SignalStore.account().e164!!, null)
.countryCode
store.update {
it.copy(
number = it.number.toBuilder().countryCode(countryCode).build(),
oldPhoneNumber = it.oldPhoneNumber.toBuilder().countryCode(countryCode).build()
)
}
} catch (e: NumberParseException) {
Log.i(TAG, "Unable to parse number for default country code")
}
smsRetrieverReceiver.registerReceiver()
}
override fun onCleared() {
super.onCleared()
smsRetrieverReceiver.unregisterReceiver()
}
// region Public Getters and Setters
val number: NumberViewState
get() = store.value.number
val oldNumberState: NumberViewState
get() = store.value.oldPhoneNumber
val svrTriesRemaining: Int
get() = store.value.svrTriesRemaining
fun setOldNationalNumber(updatedNumber: String) {
store.update {
it.copy(oldPhoneNumber = oldNumberState.toBuilder().nationalNumber(updatedNumber).build())
}
}
fun setOldCountry(countryCode: Int, country: String? = null) {
store.update {
it.copy(oldPhoneNumber = oldNumberState.toBuilder().selectedCountryDisplayName(country).countryCode(countryCode).build())
}
}
fun setNewNationalNumber(updatedNumber: String) {
store.update {
it.copy(number = number.toBuilder().nationalNumber(updatedNumber).build())
}
}
fun setNewCountry(countryCode: Int, country: String? = null) {
store.update {
it.copy(number = number.toBuilder().selectedCountryDisplayName(country).countryCode(countryCode).build())
}
}
fun setCaptchaResponse(token: String) {
Log.v(TAG, "setCaptchaResponse()")
store.update {
it.copy(captchaToken = token)
}
}
fun setEnteredPin(pin: String) {
store.update {
it.copy(enteredPin = pin)
}
}
fun incrementIncorrectCodeAttempts() {
store.update {
it.copy(incorrectCodeAttempts = it.incorrectCodeAttempts + 1)
}
}
fun addPresentedChallenge(challenge: Challenge) {
Log.v(TAG, "addPresentedChallenge()")
store.update {
it.copy(challengesPresented = it.challengesPresented.plus(challenge))
}
}
fun removePresentedChallenge(challenge: Challenge) {
Log.v(TAG, "addPresentedChallenge()")
store.update {
it.copy(challengesPresented = it.challengesPresented.minus(challenge))
}
}
fun resetLocalSessionState() {
Log.v(TAG, "resetLocalSessionState()")
store.update {
it.copy(inProgress = false, changeNumberOutcome = null, captchaToken = null, challengesRequested = emptyList(), allowedToRequestCode = false)
}
}
fun canContinue(): ContinueStatus {
return if (oldNumberState.e164Number == initialLocalNumber) {
if (number.isValid) {
ContinueStatus.CAN_CONTINUE
} else {
ContinueStatus.INVALID_NUMBER
}
} else {
ContinueStatus.OLD_NUMBER_DOESNT_MATCH
}
}
// endregion
// region Public actions
fun checkWhoAmI(onSuccess: () -> Unit, onError: (Throwable) -> Unit) {
Log.v(TAG, "checkWhoAmI()")
viewModelScope.launch(Dispatchers.IO) {
try {
val whoAmI = repository.whoAmI()
if (whoAmI.number == SignalStore.account().e164) {
return@launch bail { Log.i(TAG, "Local and remote numbers match, nothing needs to be done.") }
}
Log.i(TAG, "Local (${SignalStore.account().e164}) and remote (${whoAmI.number}) numbers do not match, updating local.")
withLockOnSerialExecutor {
repository.changeLocalNumber(whoAmI.number, ServiceId.PNI.parseOrThrow(whoAmI.pni))
}
withContext(Dispatchers.Main) {
onSuccess()
}
} catch (ioException: IOException) {
Log.w(TAG, "Encountered an exception when requesting whoAmI()", ioException)
withContext(Dispatchers.Main) {
onError(ioException)
}
}
}
}
fun registerSmsListenerWithCompletionListener(context: Context, onComplete: (Boolean) -> Unit) {
Log.v(TAG, "registerSmsListenerWithCompletionListener()")
viewModelScope.launch {
val listenerRegistered = RegistrationRepository.registerSmsListener(context)
onComplete(listenerRegistered)
}
}
fun verifyCodeWithoutRegistrationLock(context: Context, code: String, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
Log.v(TAG, "verifyCodeWithoutRegistrationLock()")
store.update {
it.copy(
inProgress = true,
enteredCode = code
)
}
viewModelScope.launch {
verifyCodeInternal(context = context, pin = null, verificationErrorHandler = verificationErrorHandler, numberChangeErrorHandler = numberChangeErrorHandler)
}
}
fun verifyCodeAndRegisterAccountWithRegistrationLock(context: Context, pin: String, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
Log.v(TAG, "verifyCodeAndRegisterAccountWithRegistrationLock()")
store.update { it.copy(inProgress = true) }
viewModelScope.launch {
verifyCodeInternal(context = context, pin = pin, verificationErrorHandler = verificationErrorHandler, numberChangeErrorHandler = numberChangeErrorHandler)
}
}
private suspend fun verifyCodeInternal(context: Context, pin: String?, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
val sessionId = getOrCreateValidSession(context)?.body?.id ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val registrationData = getRegistrationData(context)
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
handleVerificationError(verificationResponse, verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
}
val result: ChangeNumberResult = if (pin == null) {
repository.changeNumberWithoutRegistrationLock(sessionId = sessionId, newE164 = number.e164Number)
} else {
repository.changeNumberWithRegistrationLock(sessionId = sessionId, newE164 = number.e164Number, pin, SvrAuthCredentialSet(null, store.value.svrCredentials))
}
if (result is ChangeNumberResult.Success) {
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
} else {
handleChangeNumberError(result, numberChangeErrorHandler)
}
}
fun submitCaptchaToken(context: Context) {
Log.v(TAG, "submitCaptchaToken()")
val e164 = number.e164Number
val captchaToken = store.value.captchaToken ?: throw IllegalStateException("Can't submit captcha token if no captcha token is set!")
viewModelScope.launch {
Log.d(TAG, "Getting session in order to submit captcha token…")
val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Bailing captcha token submission due to invalid session.") }
if (!Challenge.parse(session.body.requestedInformation).contains(Challenge.CAPTCHA)) {
Log.d(TAG, "Captcha submission no longer necessary, bailing.")
store.update {
it.copy(
captchaToken = null,
inProgress = false,
changeNumberOutcome = null
)
}
return@launch
}
Log.d(TAG, "Submitting captcha token…")
val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, session.body.id, captchaToken)
Log.d(TAG, "Captcha token submitted.")
store.update {
it.copy(captchaToken = null, inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(captchaSubmissionResult))
}
}
}
fun requestAndSubmitPushToken(context: Context) {
Log.v(TAG, "validatePushToken()")
addPresentedChallenge(Challenge.PUSH)
val e164 = number.e164Number
viewModelScope.launch {
Log.d(TAG, "Getting session in order to perform push token verification…")
val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Bailing from push token verification due to invalid session.") }
if (!Challenge.parse(session.body.requestedInformation).contains(Challenge.PUSH)) {
Log.d(TAG, "Push submission no longer necessary, bailing.")
store.update {
it.copy(
inProgress = false,
changeNumberOutcome = null
)
}
return@launch
}
Log.d(TAG, "Requesting push challenge token…")
val pushSubmissionResult = RegistrationRepository.requestAndVerifyPushToken(context, session.body.id, e164, password)
Log.d(TAG, "Push challenge token submitted.")
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(pushSubmissionResult))
}
}
}
fun initiateChangeNumberSession(context: Context, mode: RegistrationRepository.Mode) {
Log.v(TAG, "changeNumber()")
store.update { it.copy(inProgress = true) }
viewModelScope.launch {
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
if (!encryptionDrained) {
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
}
val changed = changeNumberWithRecoveryPassword()
if (changed) {
Log.d(TAG, "Successfully changed number using recovery password, which cleaned up after itself.")
return@launch
}
requestVerificationCode(context, mode)
}
}
// endregion
// region Private actions
private fun updateLocalStateFromSession(response: RegistrationSessionMetadataResponse) {
Log.v(TAG, "updateLocalStateFromSession()")
store.update {
it.copy(sessionId = response.body.id, challengesRequested = Challenge.parse(response.body.requestedInformation), allowedToRequestCode = response.body.allowedToRequestCode)
}
}
private suspend fun getOrCreateValidSession(context: Context): RegistrationSessionMetadataResponse? {
Log.v(TAG, "getOrCreateValidSession()")
val e164 = number.e164Number
val mccMncProducer = MccMncProducer(context)
val existingSessionId = store.value.sessionId
return RegistrationV2ViewModel.getOrCreateValidSession(context = context, existingSessionId = existingSessionId, e164 = e164, password = password, mcc = mccMncProducer.mcc, mnc = mccMncProducer.mnc, successListener = { freshMetadata ->
Log.v(TAG, "Valid session received, updating local state.")
updateLocalStateFromSession(freshMetadata)
}, errorHandler = { result ->
val requestCode: VerificationCodeRequestResult = when (result) {
is RegistrationSessionCreationResult.RateLimited -> VerificationCodeRequestResult.RateLimited(result.getCause(), result.timeRemaining)
is RegistrationSessionCreationResult.MalformedRequest -> VerificationCodeRequestResult.MalformedRequest(result.getCause())
else -> VerificationCodeRequestResult.UnknownError(result.getCause())
}
store.update {
it.copy(changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(requestCode))
}
})
}
private suspend fun changeNumberWithRecoveryPassword(): Boolean {
Log.v(TAG, "changeNumberWithRecoveryPassword()")
SignalStore.svr().recoveryPassword?.let { recoveryPassword ->
if (SignalStore.svr().hasPin()) {
val result = repository.changeNumberWithRecoveryPassword(recoveryPassword = recoveryPassword, newE164 = number.e164Number)
if (result is ChangeNumberResult.Success) {
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
return true
}
Log.d(TAG, "Encountered error while trying to change number with recovery password.", result.getCause())
}
}
return false
}
private suspend fun handleSuccessfulChangedRemoteNumber(e164: String, pni: ServiceId.PNI, changeNumberOutcome: ChangeNumberOutcome) {
var result = changeNumberOutcome
Log.v(TAG, "handleSuccessfulChangedRemoteNumber(${result.javaClass.simpleName}")
try {
withLockOnSerialExecutor {
repository.changeLocalNumber(e164, pni)
}
} catch (ioException: IOException) {
Log.w(TAG, "Failed to change local number!", ioException)
result = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(ioException))
}
store.update {
it.copy(inProgress = false, changeNumberOutcome = result)
}
}
private fun handleVerificationError(result: VerificationCodeRequestResult, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit) {
Log.v(TAG, "handleVerificationError(${result.javaClass.simpleName}")
when (result) {
is VerificationCodeRequestResult.Success -> Unit
is VerificationCodeRequestResult.RegistrationLocked ->
store.update {
it.copy(
svrCredentials = result.svr2Credentials
)
}
else -> Log.i(TAG, "Received exception during verification.", result.getCause())
}
verificationErrorHandler(result)
}
private fun handleChangeNumberError(result: ChangeNumberResult, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
Log.v(TAG, "handleChangeNumberError(${result.javaClass.simpleName}")
when (result) {
is ChangeNumberResult.Success -> Unit
is ChangeNumberResult.RegistrationLocked ->
store.update {
it.copy(
svrCredentials = result.svr2Credentials
)
}
is ChangeNumberResult.SvrWrongPin -> {
store.update {
it.copy(
svrTriesRemaining = result.triesRemaining
)
}
}
else -> Log.i(TAG, "Received exception during change number.", result.getCause())
}
numberChangeErrorHandler(result)
}
private suspend fun requestVerificationCode(context: Context, mode: RegistrationRepository.Mode) {
Log.v(TAG, "requestVerificationCode()")
val e164 = number.e164Number
val validSession = getOrCreateValidSession(context)
if (validSession == null) {
Log.w(TAG, "Bailing on requesting verification code because could not create a session!")
resetLocalSessionState()
return
}
val result = if (!validSession.body.allowedToRequestCode) {
val challenges = validSession.body.requestedInformation.joinToString()
Log.i(TAG, "Not allowed to request code! Remaining challenges: $challenges")
VerificationCodeRequestResult.ChallengeRequired(Challenge.parse(validSession.body.requestedInformation))
} else {
store.update {
it.copy(changeNumberOutcome = null, challengesRequested = emptyList())
}
val response = RegistrationRepository.requestSmsCode(context = context, sessionId = validSession.body.id, e164 = e164, password = password, mode = mode)
Log.d(TAG, "SMS code request submitted")
response
}
val challengesRequested = if (result is VerificationCodeRequestResult.ChallengeRequired) {
result.challenges
} else {
emptyList()
}
Log.d(TAG, "Received result: ${result.javaClass.canonicalName}\nwith challenges: ${challengesRequested.joinToString { it.key }}")
store.update {
it.copy(changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result), challengesRequested = challengesRequested, inProgress = false)
}
}
private suspend fun getRegistrationData(context: Context): RegistrationData {
val currentState = store.value
val code = currentState.enteredCode ?: throw IllegalStateException("Can't construct registration data without entered code!")
val e164: String = number.e164Number ?: throw IllegalStateException("Can't construct registration data without E164!")
val recoveryPassword = if (currentState.sessionId == null) SignalStore.svr().getRecoveryPassword() else null
val fcmToken = RegistrationRepository.getFcmToken(context)
return RegistrationData(code, e164, password, RegistrationRepository.getRegistrationId(), RegistrationRepository.getProfileKey(e164), fcmToken, RegistrationRepository.getPniRegistrationId(), recoveryPassword)
}
// endregion
// region Utility Functions
/**
* Used for early returns in order to end the in-progress visual state, as well as print a log message explaining what happened.
*
* @param logMessage Logging code is wrapped in lambda so that our automated tools detect the various [Log] calls with their accompanying messages.
*/
private fun bail(logMessage: () -> Unit) {
logMessage()
store.update {
it.copy(inProgress = false)
}
}
/**
* Anything that runs through this will be run serially, with locks.
*/
private suspend fun <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

@@ -0,0 +1,144 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber.v2
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberVerifyFragmentArgs
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.v2.data.network.Challenge
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Screen to show while the change number is in-progress.
*/
class ChangeNumberVerifyV2Fragment : LoggingFragment(R.layout.fragment_change_phone_number_verify) {
companion object {
private val TAG: String = Log.tag(ChangeNumberVerifyV2Fragment::class.java)
}
private val viewModel by activityViewModels<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)
}
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

@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
@@ -9,7 +7,8 @@
<include layout="@layout/dsl_settings_toolbar" />
<include
layout="@layout/fragment_registration_enter_code"
layout="@layout/fragment_registration_enter_code_v2"
android:id="@+id/code_entry_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"

View File

@@ -0,0 +1,166 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/dsl_settings_toolbar" />
<ScrollView
android:id="@+id/change_number_enter_phone_number_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="24dp"
android:fillViewport="true"
app:layout_constraintBottom_toTopOf="@+id/change_number_enter_phone_number_continue"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:paddingEnd="32dp">
<TextView
android:id="@+id/change_number_enter_phone_number_old_number_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/ChangeNumberEnterPhoneNumberFragment__your_old_number"
android:textAppearance="@style/TextAppearance.Signal.Body2.Bold"
android:textColor="@color/signal_text_primary_dialog"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<FrameLayout
android:id="@+id/change_number_enter_phone_number_old_number_spinner_frame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/labeled_edit_text_background_inactive"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/change_number_enter_phone_number_old_number_label">
<Spinner
android:id="@+id/change_number_enter_phone_number_old_number_spinner"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:textAlignment="viewStart" />
</FrameLayout>
<LinearLayout
android:id="@+id/change_number_enter_phone_number_old_number_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layoutDirection="ltr"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/change_number_enter_phone_number_old_number_spinner_frame">
<org.thoughtcrime.securesms.components.LabeledEditText
android:id="@+id/change_number_enter_phone_number_old_number_country_code"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_weight="1"
app:labeledEditText_background="@color/signal_background_primary"
app:labeledEditText_textLayout="@layout/country_code_text" />
<org.thoughtcrime.securesms.components.LabeledEditText
android:id="@+id/change_number_enter_phone_number_old_number_number"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="3"
app:labeledEditText_background="@color/signal_background_primary"
app:labeledEditText_label="@string/ChangeNumberEnterPhoneNumberFragment__old_phone_number"
app:labeledEditText_textLayout="@layout/phone_text" />
</LinearLayout>
<TextView
android:id="@+id/change_number_enter_phone_number_new_number_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/ChangeNumberEnterPhoneNumberFragment__your_new_number"
android:textAppearance="@style/TextAppearance.Signal.Body2.Bold"
android:textColor="@color/signal_text_primary_dialog"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/change_number_enter_phone_number_old_number_input_layout" />
<FrameLayout
android:id="@+id/change_number_enter_phone_number_new_number_spinner_frame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/labeled_edit_text_background_inactive"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/change_number_enter_phone_number_new_number_label">
<Spinner
android:id="@+id/change_number_enter_phone_number_new_number_spinner"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:textAlignment="viewStart" />
</FrameLayout>
<LinearLayout
android:id="@+id/change_number_enter_phone_number_new_number_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layoutDirection="ltr"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/change_number_enter_phone_number_new_number_spinner_frame">
<org.thoughtcrime.securesms.components.LabeledEditText
android:id="@+id/change_number_enter_phone_number_new_number_country_code"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_weight="1"
app:labeledEditText_background="@color/signal_background_primary"
app:labeledEditText_textLayout="@layout/country_code_text" />
<org.thoughtcrime.securesms.components.LabeledEditText
android:id="@+id/change_number_enter_phone_number_new_number_number"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="3"
app:labeledEditText_background="@color/signal_background_primary"
app:labeledEditText_label="@string/ChangeNumberEnterPhoneNumberFragment__new_phone_number"
app:labeledEditText_textLayout="@layout/phone_text" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<com.google.android.material.button.MaterialButton
android:id="@+id/change_number_enter_phone_number_continue"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="16dp"
android:text="@string/ChangeNumberFragment__continue"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/change_number_enter_phone_number_scroll" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/dsl_settings_toolbar" />
<ScrollView
android:id="@+id/change_phone_number_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="24dp"
android:fillViewport="true"
app:layout_constraintBottom_toTopOf="@+id/change_phone_number_continue"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/change_phone_number_hero"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:layout_constraintBottom_toTopOf="@id/change_phone_number_header"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="spread_inside"
app:srcCompat="@drawable/change_number_hero_image" />
<TextView
android:id="@+id/change_phone_number_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="32dp"
android:gravity="center"
android:text="@string/AccountSettingsFragment__change_phone_number"
android:textAppearance="@style/TextAppearance.Signal.Title1"
app:layout_constraintBottom_toTopOf="@id/change_phone_number_body"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/change_phone_number_hero" />
<TextView
android:id="@+id/change_phone_number_body"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginStart="32dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="32dp"
android:gravity="center_horizontal"
android:text="@string/ChangeNumberFragment__use_this_to_change_your_current_phone_number_to_a_new_phone_number"
android:textAppearance="@style/TextAppearance.Signal.Body1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/change_phone_number_header" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<com.google.android.material.button.MaterialButton
android:id="@+id/change_phone_number_continue"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="32dp"
android:text="@string/ChangeNumberFragment__continue"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/change_phone_number_scroll" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,184 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/app_settings_change_number"
app:startDestination="@id/changePhoneNumberFragment">
<fragment
android:id="@+id/changePhoneNumberFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberV2Fragment"
tools:layout="@layout/fragment_change_phone_number">
<action
android:id="@+id/action_changePhoneNumberFragment_to_enterPhoneNumberChangeFragment"
app:destination="@id/enterPhoneNumberChangeFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/enterPhoneNumberChangeFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberEnterPhoneNumberV2Fragment"
tools:layout="@layout/fragment_change_number_enter_phone_number">
<action
android:id="@+id/action_enterPhoneNumberChangeFragment_to_changePhoneNumberConfirmFragment"
app:destination="@id/changePhoneNumberConfirmFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_enterPhoneNumberChangeFragment_to_countryPickerFragment"
app:destination="@id/countryPickerFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/changePhoneNumberConfirmFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberConfirmV2Fragment"
tools:layout="@layout/fragment_change_number_confirm">
<action
android:id="@+id/action_changePhoneNumberConfirmFragment_to_changePhoneNumberVerifyFragment"
app:destination="@id/changePhoneNumberVerifyFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@+id/enterPhoneNumberChangeFragment" />
</fragment>
<fragment
android:id="@+id/countryPickerFragment"
android:name="org.thoughtcrime.securesms.registration.fragments.CountryPickerFragment"
tools:layout="@layout/fragment_registration_country_picker">
<argument
android:name="result_key"
android:defaultValue="@null"
app:argType="string"
app:nullable="true" />
</fragment>
<fragment
android:id="@+id/changePhoneNumberVerifyFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberVerifyV2Fragment"
tools:layout="@layout/fragment_change_phone_number_verify">
<argument android:name="sms_listener_enabled"
android:defaultValue="false"
app:argType="boolean" />
<action
android:id="@+id/action_changePhoneNumberVerifyFragment_to_captchaFragment"
app:destination="@id/captchaFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment"
app:destination="@id/changeNumberEnterCodeFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@+id/enterPhoneNumberChangeFragment" />
</fragment>
<fragment
android:id="@+id/captchaFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberCaptchaV2Fragment"
tools:layout="@layout/fragment_registration_captcha" />
<fragment
android:id="@+id/changeNumberEnterCodeFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberEnterCodeV2Fragment"
tools:layout="@layout/fragment_change_number_enter_code">
<action
android:id="@+id/action_changeNumberEnterCodeFragment_to_captchaFragment"
app:destination="@id/captchaFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_changeNumberEnterCodeFragment_to_changeNumberRegistrationLock"
app:destination="@id/changeNumberRegistrationLock"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/enterPhoneNumberChangeFragment" />
<action
android:id="@+id/action_changeNumberEnterCodeFragment_to_changeNumberAccountLocked"
app:destination="@id/changeNumberAccountLocked"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/enterPhoneNumberChangeFragment" />
</fragment>
<fragment
android:id="@+id/changeNumberRegistrationLock"
android:name="org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberRegistrationLockV2Fragment"
tools:layout="@layout/fragment_change_number_registration_lock">
<argument
android:name="timeRemaining"
app:argType="long" />
<action
android:id="@+id/action_changeNumberRegistrationLock_to_changeNumberAccountLocked"
app:destination="@id/changeNumberAccountLocked"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/enterPhoneNumberChangeFragment" />
<action
android:id="@+id/action_changeNumberRegistrationLock_to_changeNumberPinDiffers"
app:destination="@id/changeNumberPinDiffers"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/enterPhoneNumberChangeFragment" />
</fragment>
<fragment
android:id="@+id/changeNumberAccountLocked"
android:name="org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberAccountLockedV2Fragment"
tools:layout="@layout/fragment_change_number_account_locked" />
<fragment
android:id="@+id/changeNumberPinDiffers"
android:name="org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberPinDiffersV2Fragment"
tools:layout="@layout/fragment_change_number_pin_differs" />
<action
android:id="@+id/action_pop_app_settings_change_number"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/changePhoneNumberFragment"
app:popUpToInclusive="true" />
</navigation>

View File

@@ -0,0 +1,951 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/app_settings"
app:startDestination="@id/appSettingsFragment">
<fragment
android:id="@+id/appSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.AppSettingsFragment"
android:label="app_settings_fragment"
tools:layout="@layout/dsl_settings_fragment">
<action
android:id="@+id/action_appSettingsFragment_to_usernameLinkSettingsFragment"
app:destination="@id/username_link_settings"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_usernameEducationFragment"
app:destination="@id/manageProfileActivity"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit">
<argument
android:name="start_at_username"
android:defaultValue="true"
app:argType="boolean" />
</action>
<action
android:id="@+id/action_appSettingsFragment_to_accountSettingsFragment"
app:destination="@id/accountSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_deviceActivity"
app:destination="@id/deviceActivity"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_paymentsActivity"
app:destination="@id/paymentsActivity"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_appearanceSettingsFragment"
app:destination="@id/appearanceSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_chatsSettingsFragment"
app:destination="@id/chatsSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_notificationsSettingsFragment"
app:destination="@id/notificationsSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_privacySettingsFragment"
app:destination="@id/privacy_settings"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_dataAndStorageSettingsFragment"
app:destination="@id/dataAndStorageSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_manageProfileActivity"
app:destination="@id/manageProfileActivity"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_helpSettingsFragment"
app:destination="@id/helpSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_inviteActivity"
app:destination="@id/inviteActivity"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_appUpdatesSettingsFragment"
app:destination="@id/appUpdatesFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_internalSettingsFragment"
app:destination="@id/internalSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_manageDonationsFragment"
app:destination="@id/manageDonationsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_storyPrivacySettings"
app:destination="@+id/story_privacy_settings"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit">
<argument
android:name="title_id"
app:argType="integer"
app:nullable="false" />
</action>
</fragment>
<activity
android:id="@+id/manageProfileActivity"
android:name="org.thoughtcrime.securesms.profiles.manage.EditProfileActivity"
android:label="manage_profile_activity" />
<!-- region Account Settings and subpages -->
<fragment
android:id="@+id/accountSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.AccountSettingsFragment"
android:label="account_settings_fragment"
tools:layout="@layout/dsl_settings_fragment">
<action
android:id="@+id/action_accountSettingsFragment_to_changePhoneNumberFragment"
app:destination="@id/app_settings_change_number"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_accountSettingsFragment_to_deleteAccountFragment"
app:destination="@id/deleteAccountFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_accountSettingsFragment_to_advancedPinSettingsActivity"
app:destination="@id/advancedPinSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_accountSettingsFragment_to_oldDeviceTransferActivity"
app:destination="@id/oldDeviceTransferActivity"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_accountSettingsFragment_to_exportAccountFragment"
app:destination="@id/exportAccountDataFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<include app:graph="@navigation/app_settings_change_number_v2" />
<fragment
android:id="@+id/advancedPinSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.wrapped.WrappedAdvancedPinPreferenceFragment"
android:label="advanced_pin_settings_fragment" />
<fragment
android:id="@+id/deleteAccountFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.wrapped.WrappedDeleteAccountFragment"
android:label="delete_account_fragment"
tools:layout="@layout/delete_account_fragment" />
<fragment
android:id="@+id/exportAccountDataFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.export.ExportAccountDataFragment"
android:label="export_account_data_fragment" />
<activity
android:id="@+id/oldDeviceTransferActivity"
android:name="org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity"
android:label="old_device_transfer_Activity"
tools:layout="@layout/old_device_transfer_activity" />
<!-- CreateKbsPinActivity -->
<!-- endregion -->
<!-- Linked Devices -->
<activity
android:id="@+id/deviceActivity"
android:name="org.thoughtcrime.securesms.DeviceActivity"
android:label="device_activity" />
<!-- Payments -->
<activity
android:id="@+id/paymentsActivity"
android:name="org.thoughtcrime.securesms.payments.preferences.PaymentsActivity"
android:label="payments_activity" />
<!-- region Appearance settings and Subpages -->
<fragment
android:id="@+id/appearanceSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.appearance.AppearanceSettingsFragment"
android:label="appearance_settings_fragment"
tools:layout="@layout/dsl_settings_fragment">
<action
android:id="@+id/action_appearanceSettings_to_wallpaperActivity"
app:destination="@id/wallpaperActivity"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appearanceSettings_to_appIconActivity"
app:destination="@id/appIconSelectionFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<activity
android:id="@+id/wallpaperActivity"
android:name="org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity"
android:label="wallpaper_activity" />
<!-- App Icon settings and Subpages -->
<fragment
android:id="@+id/appIconSelectionFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.appearance.appicon.AppIconSelectionFragment"
android:label="@string/preferences__app_icon">
<action
android:id="@+id/action_appIconSelectionFragment_to_appIconTutorialFragment"
app:destination="@id/appIconTutorialFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/appIconTutorialFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.appearance.appicon.AppIconTutorialFragment"
android:label="@string/preferences__app_icon" />
<!-- endregion -->
<!-- region Chats settings and subpages -->
<fragment
android:id="@+id/chatsSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.ChatsSettingsFragment"
android:label="chats_settings_fragment"
tools:layout="@layout/dsl_settings_fragment">
<action
android:id="@+id/action_chatsSettingsFragment_to_backupsPreferenceFragment"
app:destination="@id/backupsPreferenceFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_chatsSettingsFragment_to_editReactionsFragment"
app:destination="@id/editReactionsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_chatsSettingsFragment_to_remoteBackupsSettingsFragment"
app:destination="@id/remoteBackupsSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/backupsPreferenceFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.wrapped.WrappedBackupsPreferenceFragment"
android:label="backups_preference_fragment"
tools:layout="@layout/fragment_backups" />
<fragment
android:id="@+id/editReactionsFragment"
android:name="org.thoughtcrime.securesms.reactions.edit.EditReactionsFragment"
android:label="edit_reactions_fragment"
tools:layout="@layout/edit_reactions_fragment" />
<!-- endregion -->
<!-- Notifications -->
<fragment
android:id="@+id/notificationsSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.notifications.NotificationsSettingsFragment"
android:label="notifications_settings_fragment">
<action
android:id="@+id/action_notificationsSettingsFragment_to_notificationProfilesFragment"
app:destination="@id/notificationProfilesFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<!-- region Privacy -->
<include app:graph="@navigation/privacy_settings" />
<!-- region Data and Storage -->
<fragment
android:id="@+id/dataAndStorageSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.data.DataAndStorageSettingsFragment"
android:label="data_and_storage_settings_fragment">
<action
android:id="@+id/action_dataAndStorageSettingsFragment_to_storagePreferenceFragment"
app:destination="@id/storagePreferenceFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_dataAndStorageSettingsFragment_to_editProxyFragment"
app:destination="@id/editProxyFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/storagePreferenceFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.storage.ManageStorageSettingsFragment"
android:label="storage_preference_fragment" />
<fragment
android:id="@+id/editProxyFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.wrapped.WrappedEditProxyFragment"
android:label="edit_proxy_fragment" />
<!-- endregion -->
<!-- region Help -->
<fragment
android:id="@+id/helpSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.help.HelpSettingsFragment"
android:label="help_settings_fragment">
<action
android:id="@+id/action_helpSettingsFragment_to_helpFragment"
app:destination="@id/helpFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_helpSettingsFragment_to_submitDebugLogActivity"
app:destination="@id/submitDebugLogActivity"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_helpSettingsFragment_to_licenseFragment"
app:destination="@id/licenseFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/helpFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.wrapped.WrappedHelpFragment"
android:label="help_fragment">
<argument
android:name="start_category_index"
android:defaultValue="0"
app:argType="integer"
app:nullable="false" />
</fragment>
<fragment
android:id="@+id/licenseFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.help.LicenseFragment"
android:label="license_fragment" />
<activity
android:id="@+id/submitDebugLogActivity"
android:name="org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity"
android:label="submit_debug_log_activity" />
<!-- endregion -->
<activity
android:id="@+id/inviteActivity"
android:name="org.thoughtcrime.securesms.InviteActivity"
android:label="invite_activity"
tools:layout="@layout/invite_activity" />
<!-- region Direct-To-Page actions -->
<action
android:id="@+id/action_direct_to_backupsPreferenceFragment"
app:destination="@id/backupsPreferenceFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_direct_to_helpFragment"
app:destination="@id/helpFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_editProxyFragment"
app:destination="@id/editProxyFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_direct_to_notificationsSettingsFragment"
app:destination="@id/notificationsSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_direct_to_changeNumberFragment"
app:destination="@id/app_settings_change_number"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_direct_to_manageDonations"
app:destination="@id/manageDonationsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_donateToSignal"
app:destination="@id/donate_to_signal"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true">
<argument
android:name="start_type"
app:argType="org.thoughtcrime.securesms.database.InAppPaymentTable$Type"
app:nullable="false" />
</action>
<action
android:id="@+id/action_direct_to_notificationProfiles"
app:destination="@id/notificationProfilesFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_direct_to_createNotificationProfiles"
app:destination="@id/editNotificationProfileFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_notificationProfileDetails"
app:destination="@id/notificationProfileDetailsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_privacy"
app:destination="@id/privacy_settings"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_devices"
app:destination="@id/deviceActivity"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_usernameLinkSettings"
app:destination="@id/username_link_settings"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_usernameRecovery"
app:destination="@id/createUsernameFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true">
<argument
android:name="mode"
android:defaultValue="RECOVERY"
app:argType="org.thoughtcrime.securesms.profiles.manage.UsernameEditMode" />
</action>
<!-- endregion -->
<!-- Internal Settings -->
<fragment
android:id="@+id/internalSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.InternalSettingsFragment"
android:label="internal_settings_fragment">
<action
android:id="@+id/action_internalSettingsFragment_to_donorErrorConfigurationFragment"
app:destination="@id/donorErrorConfigurationFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_storyDialogsLauncherFragment"
app:destination="@id/storyDialogsLauncherFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_internalSearchFragment"
app:destination="@id/internalSearchFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_internalSvrPlaygroundFragment"
app:destination="@id/internalSvrPlaygroundFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_internalConversationSpringboardFragment"
app:destination="@id/internalConversationSpringboardFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_oneTimeDonationConfigurationFragment"
app:destination="@id/oneTimeDonationConfigurationFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_terminalDonationConfigurationFragment"
app:destination="@id/terminalDonationConfigurationFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_internalBackupPlaygroundFragment"
app:destination="@id/internalBackupPlaygroundFragment" />
</fragment>
<fragment
android:id="@+id/terminalDonationConfigurationFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.InternalTerminalDonationConfigurationFragment"
android:label="terminal_donation_configuration_fragment" />
<fragment
android:id="@+id/oneTimeDonationConfigurationFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.InternalPendingOneTimeDonationConfigurationFragment"
android:label="one_time_donation_configuration_fragment" />
<fragment
android:id="@+id/donorErrorConfigurationFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.donor.InternalDonorErrorConfigurationFragment"
android:label="donor_error_configuration_fragment" />
<fragment
android:id="@+id/storyDialogsLauncherFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.InternalStoryDialogLauncherFragment"
android:label="story_dialogs_launcher_fragment" />
<fragment
android:id="@+id/internalSearchFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.search.InternalSearchFragment"
android:label="internal_search_fragment" />
<fragment
android:id="@+id/internalSvrPlaygroundFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.svr.InternalSvrPlaygroundFragment"
android:label="internal_svr_playground_fragment" />
<fragment
android:id="@+id/internalConversationSpringboardFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.conversation.springboard.InternalConversationSpringboardFragment"
android:label="internal_conversation_springboard_fragment">
<action
android:id="@+id/action_internalConversationSpringboardFragment_to_internalConversationTestFragment"
app:destination="@id/internalConversationTestFragment" />
</fragment>
<fragment
android:id="@+id/internalConversationTestFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.conversation.test.InternalConversationTestFragment"
android:label="internal_conversation_test_fragment" />
<fragment
android:id="@+id/internalBackupPlaygroundFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundFragment"
android:label="internal_backup_playground_fragment" />
<!-- endregion -->
<!-- App updates -->
<fragment
android:id="@+id/appUpdatesFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.updates.AppUpdatesSettingsFragment"
android:label="app_update_fragment" />
<!-- Subscriptions -->
<fragment
android:id="@+id/manageDonationsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.manage.ManageDonationsFragment"
android:label="manage_donations_fragment"
tools:layout="@layout/dsl_settings_fragment">
<action
android:id="@+id/action_manageDonationsFragment_to_manage_badges"
app:destination="@id/manage_badges"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_manageDonationsFragment_to_donationReceiptListFragment"
app:destination="@id/donationReceiptListFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_manageDonationsFragment_to_subscribeLearnMoreBottomSheetDialog"
app:destination="@id/subscribeLearnMoreBottomSheetDialog" />
<action
android:id="@+id/action_manageDonationsFragment_to_donateToSignalFragment"
app:destination="@id/donate_to_signal"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit">
<argument
android:name="start_type"
app:argType="org.thoughtcrime.securesms.database.InAppPaymentTable$Type"
app:nullable="false" />
</action>
</fragment>
<fragment
android:id="@+id/donationReceiptListFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list.DonationReceiptListFragment"
android:label="donation_receipt_list_fragment"
tools:layout="@layout/donation_receipt_list_fragment">
<action
android:id="@+id/action_donationReceiptListFragment_to_donationReceiptDetailFragment"
app:destination="@id/donationReceiptDetailFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/donationReceiptDetailFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail.DonationReceiptDetailFragment"
android:label="donation_receipt_detail_fragment"
tools:layout="@layout/donation_receipt_detail_fragment">
<argument
android:name="id"
app:argType="long" />
</fragment>
<include app:graph="@navigation/donate_to_signal" />
<dialog
android:id="@+id/subscribeLearnMoreBottomSheetDialog"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeLearnMoreBottomSheetDialogFragment"
android:label="subscribe_learn_more_bottom_sheet_dialog"
tools:layout="@layout/subscribe_learn_more_bottom_sheet_dialog_fragment" />
<include app:graph="@navigation/manage_badges" />
<!-- endregion -->
<!-- Notification Profiles -->
<fragment
android:id="@+id/notificationProfilesFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesFragment"
tools:layout="@layout/notification_profiles_empty">
<action
android:id="@+id/action_notificationProfilesFragment_to_editNotificationProfileFragment"
app:destination="@id/editNotificationProfileFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_notificationProfilesFragment_to_notificationProfileDetailsFragment"
app:destination="@id/notificationProfileDetailsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/editNotificationProfileFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileFragment"
tools:layout="@layout/fragment_edit_notification_profile">
<argument
android:name="profileId"
android:defaultValue="-1L"
app:argType="long" />
<action
android:id="@+id/action_editNotificationProfileFragment_to_addAllowedMembersFragment"
app:destination="@id/addAllowedMembersFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/editNotificationProfileFragment"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/addAllowedMembersFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.notifications.profiles.AddAllowedMembersFragment"
tools:layout="@layout/fragment_add_allowed_members">
<argument
android:name="profileId"
app:argType="long" />
<action
android:id="@+id/action_addAllowedMembersFragment_to_selectRecipientsFragment"
app:destination="@id/selectRecipientsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_addAllowedMembersFragment_to_editNotificationProfileScheduleFragment"
app:destination="@id/editNotificationProfileScheduleFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/selectRecipientsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.notifications.profiles.SelectRecipientsFragment"
tools:layout="@layout/fragment_select_recipients_fragment">
<argument
android:name="profileId"
app:argType="long" />
<argument
android:name="currentSelection"
android:defaultValue="@null"
app:argType="org.thoughtcrime.securesms.recipients.RecipientId[]"
app:nullable="true" />
</fragment>
<fragment
android:id="@+id/notificationProfileDetailsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfileDetailsFragment"
tools:layout="@layout/dsl_settings_fragment">
<argument
android:name="profileId"
app:argType="long" />
<action
android:id="@+id/action_notificationProfileDetailsFragment_to_selectRecipientsFragment"
app:destination="@id/selectRecipientsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_notificationProfileDetailsFragment_to_editNotificationProfileFragment"
app:destination="@id/editNotificationProfileFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_notificationProfileDetailsFragment_to_editNotificationProfileScheduleFragment"
app:destination="@id/editNotificationProfileScheduleFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/editNotificationProfileScheduleFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragment"
tools:layout="@layout/fragment_edit_notification_profile_schedule">
<argument
android:name="profileId"
app:argType="long" />
<argument
android:name="createMode"
app:argType="boolean" />
<action
android:id="@+id/action_editNotificationProfileScheduleFragment_to_notificationProfileCreatedFragment"
app:destination="@id/notificationProfileCreatedFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/addAllowedMembersFragment"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/notificationProfileCreatedFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfileCreatedFragment"
tools:layout="@layout/fragment_notification_profile_created">
<argument
android:name="profileId"
app:argType="long" />
<action
android:id="@+id/action_notificationProfileCreatedFragment_to_notificationProfileDetailsFragment"
app:destination="@id/notificationProfileDetailsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/notificationProfileCreatedFragment"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/createUsernameFragment"
android:name="org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment"
tools:layout="@layout/username_edit_fragment">
<argument
android:name="mode"
android:defaultValue="NORMAL"
app:argType="org.thoughtcrime.securesms.profiles.manage.UsernameEditMode" />
</fragment>
<fragment
android:id="@+id/remoteBackupsSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.backups.RemoteBackupsSettingsFragment">
<action
android:id="@+id/action_remoteBackupsSettingsFragment_to_backupsTypeSettingsFragment"
app:destination="@id/backupsTypeSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/backupsTypeSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.backups.type.BackupsTypeSettingsFragment" />
<include app:graph="@navigation/username_link_settings" />
<include app:graph="@navigation/story_privacy_settings" />
</navigation>