mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-22 10:46:50 +00:00
Initial work to support Change Number.
This commit is contained in:
@@ -15,6 +15,7 @@ import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
@@ -110,7 +111,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
putParcelable(SELECT_AVATAR_MEDIA, it)
|
||||
}
|
||||
)
|
||||
Navigation.findNavController(v).popBackStack()
|
||||
ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
|
||||
},
|
||||
{
|
||||
setFragmentResult(
|
||||
@@ -119,7 +120,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
putBoolean(SELECT_AVATAR_CLEAR, true)
|
||||
}
|
||||
)
|
||||
Navigation.findNavController(v).popBackStack()
|
||||
ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
.setStartCategoryIndex(intent.getIntExtra(HelpFragment.START_CATEGORY_INDEX, 0))
|
||||
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
|
||||
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
|
||||
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +99,9 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
@JvmStatic
|
||||
fun notifications(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATIONS)
|
||||
|
||||
@JvmStatic
|
||||
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHANGE_NUMBER)
|
||||
|
||||
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
|
||||
return Intent(context, AppSettingsActivity::class.java)
|
||||
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
|
||||
@@ -110,7 +114,8 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
BACKUPS(1),
|
||||
HELP(2),
|
||||
PROXY(3),
|
||||
NOTIFICATIONS(4);
|
||||
NOTIFICATIONS(4),
|
||||
CHANGE_NUMBER(5);
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Int?): StartLocation {
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
|
||||
import org.thoughtcrime.securesms.lock.v2.KbsConstants
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
|
||||
@@ -103,6 +104,15 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
|
||||
sectionHeaderPref(R.string.AccountSettingsFragment__account)
|
||||
|
||||
if (FeatureFlags.changeNumber()) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_chats__transfer_account),
|
||||
summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registration.fragments.BaseAccountLockedFragment
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
|
||||
class ChangeNumberAccountLockedFragment : BaseAccountLockedFragment(R.layout.fragment_change_number_account_locked) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
}
|
||||
|
||||
override fun getViewModel(): BaseRegistrationViewModel {
|
||||
return ChangeNumberUtil.getViewModel(this)
|
||||
}
|
||||
|
||||
override fun onNext() {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class ChangeNumberConfirmFragment : LoggingFragment(R.layout.fragment_change_number_confirm) {
|
||||
private lateinit var viewModel: ChangeNumberViewModel
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel = ChangeNumberUtil.getViewModel(this)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setTitle(R.string.ChangeNumberEnterPhoneNumberFragment__change_number)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
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 { findNavController().navigate(R.id.action_changePhoneNumberConfirmFragment_to_changePhoneNumberVerifyFragment) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getCaptchaArguments
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
|
||||
import org.thoughtcrime.securesms.registration.fragments.BaseEnterCodeFragment
|
||||
|
||||
class ChangeNumberEnterCodeFragment : BaseEnterCodeFragment<ChangeNumberViewModel>(R.layout.fragment_change_number_enter_code) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.title = viewModel.number.fullFormattedNumber
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
view.findViewById<View>(R.id.verify_header).setOnClickListener(null)
|
||||
}
|
||||
|
||||
override fun getViewModel(): ChangeNumberViewModel {
|
||||
return getViewModel(this)
|
||||
}
|
||||
|
||||
override fun handleSuccessfulVerify() {
|
||||
displaySuccess { changeNumberSuccess() }
|
||||
}
|
||||
|
||||
override fun navigateToCaptcha() {
|
||||
findNavController().navigate(R.id.action_changeNumberEnterCodeFragment_to_captchaFragment, getCaptchaArguments())
|
||||
}
|
||||
|
||||
override fun navigateToRegistrationLock(timeRemaining: Long) {
|
||||
findNavController().navigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
|
||||
}
|
||||
|
||||
override fun navigateToKbsAccountLocked() {
|
||||
findNavController().navigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.View
|
||||
import android.widget.ScrollView
|
||||
import android.widget.Spinner
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.LabeledEditText
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberViewModel.ContinueStatus
|
||||
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragment
|
||||
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragmentArgs
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationNumberInputController
|
||||
import org.thoughtcrime.securesms.util.Dialogs
|
||||
|
||||
private const val OLD_NUMBER_COUNTRY_SELECT = "old_number_country"
|
||||
private const val NEW_NUMBER_COUNTRY_SELECT = "new_number_country"
|
||||
|
||||
class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_change_number_enter_phone_number) {
|
||||
|
||||
private lateinit var scrollView: ScrollView
|
||||
|
||||
private lateinit var oldNumberCountrySpinner: Spinner
|
||||
private lateinit var oldNumberCountryCode: LabeledEditText
|
||||
private lateinit var oldNumber: LabeledEditText
|
||||
|
||||
private lateinit var newNumberCountrySpinner: Spinner
|
||||
private lateinit var newNumberCountryCode: LabeledEditText
|
||||
private lateinit var newNumber: LabeledEditText
|
||||
|
||||
private lateinit var viewModel: ChangeNumberViewModel
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel = getViewModel(this)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setTitle(R.string.ChangeNumberEnterPhoneNumberFragment__change_number)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
view.findViewById<View>(R.id.change_number_enter_phone_number_continue).setOnClickListener {
|
||||
onContinue()
|
||||
}
|
||||
|
||||
scrollView = view.findViewById(R.id.change_number_enter_phone_number_scroll)
|
||||
|
||||
oldNumberCountrySpinner = view.findViewById(R.id.change_number_enter_phone_number_old_number_spinner)
|
||||
oldNumberCountryCode = view.findViewById(R.id.change_number_enter_phone_number_old_number_country_code)
|
||||
oldNumber = view.findViewById(R.id.change_number_enter_phone_number_old_number_number)
|
||||
|
||||
val oldController = RegistrationNumberInputController(
|
||||
requireContext(),
|
||||
oldNumberCountryCode,
|
||||
oldNumber,
|
||||
oldNumberCountrySpinner,
|
||||
false,
|
||||
object : RegistrationNumberInputController.Callbacks {
|
||||
override fun onNumberFocused() {
|
||||
scrollView.postDelayed({ scrollView.smoothScrollTo(0, oldNumber.bottom) }, 250)
|
||||
}
|
||||
|
||||
override fun onNumberInputNext(view: View) {
|
||||
newNumberCountryCode.requestFocus()
|
||||
}
|
||||
|
||||
override fun onNumberInputDone(view: View) = Unit
|
||||
|
||||
override fun onPickCountry(view: View) {
|
||||
val arguments: CountryPickerFragmentArgs = CountryPickerFragmentArgs.Builder().setResultKey(OLD_NUMBER_COUNTRY_SELECT).build()
|
||||
|
||||
findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle())
|
||||
}
|
||||
|
||||
override fun setNationalNumber(number: String) {
|
||||
viewModel.setOldNationalNumber(number)
|
||||
}
|
||||
|
||||
override fun setCountry(countryCode: Int) {
|
||||
viewModel.setOldCountry(countryCode)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
newNumberCountrySpinner = view.findViewById(R.id.change_number_enter_phone_number_new_number_spinner)
|
||||
newNumberCountryCode = view.findViewById(R.id.change_number_enter_phone_number_new_number_country_code)
|
||||
newNumber = view.findViewById(R.id.change_number_enter_phone_number_new_number_number)
|
||||
|
||||
val newController = RegistrationNumberInputController(
|
||||
requireContext(),
|
||||
newNumberCountryCode,
|
||||
newNumber,
|
||||
newNumberCountrySpinner,
|
||||
true,
|
||||
object : RegistrationNumberInputController.Callbacks {
|
||||
override fun onNumberFocused() {
|
||||
scrollView.postDelayed({ scrollView.smoothScrollTo(0, newNumber.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().navigate(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) { _, bundle ->
|
||||
viewModel.setOldCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY))
|
||||
}
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(NEW_NUMBER_COUNTRY_SELECT, this) { _, bundle ->
|
||||
viewModel.setNewCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY))
|
||||
}
|
||||
|
||||
viewModel.getLiveOldNumber().observe(viewLifecycleOwner, oldController::updateNumber)
|
||||
viewModel.getLiveNewNumber().observe(viewLifecycleOwner, newController::updateNumber)
|
||||
}
|
||||
|
||||
private fun onContinue() {
|
||||
if (TextUtils.isEmpty(oldNumberCountryCode.text)) {
|
||||
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_old_number_country_code), Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(oldNumber.text)) {
|
||||
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_old_phone_number), Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(newNumberCountryCode.text)) {
|
||||
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_new_number_country_code), Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(newNumber.text)) {
|
||||
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_new_phone_number), Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
when (viewModel.canContinue()) {
|
||||
ContinueStatus.CAN_CONTINUE -> findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_changePhoneNumberConfirmFragment)
|
||||
ContinueStatus.INVALID_NUMBER -> {
|
||||
Dialogs.showAlertDialog(
|
||||
context, getString(R.string.RegistrationActivity_invalid_number), String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), viewModel.number.e164Number)
|
||||
)
|
||||
}
|
||||
ContinueStatus.OLD_NUMBER_DOESNT_MATCH -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.ChangeNumberEnterPhoneNumberFragment__the_phone_number_you_entered_doesnt_match_your_accounts)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class ChangeNumberFragment : LoggingFragment(R.layout.fragment_change_phone_number) {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
view.findViewById<View>(R.id.change_phone_number_continue).setOnClickListener {
|
||||
findNavController().navigate(R.id.action_changePhoneNumberFragment_to_enterPhoneNumberChangeFragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
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.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
|
||||
|
||||
class ChangeNumberPinDiffersFragment : LoggingFragment(R.layout.fragment_change_number_pin_differs) {
|
||||
|
||||
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 == CreateKbsPinActivity.RESULT_OK) {
|
||||
changeNumberSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
view.findViewById<View>(R.id.change_number_pin_differs_update_pin).setOnClickListener {
|
||||
changePin.launch(CreateKbsPinActivity.getIntentForPinChangeFromSettings(requireContext()))
|
||||
}
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.ChangeNumberPinDiffersFragment__keep_old_pin_question)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> changeNumberSuccess() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.PinHashing
|
||||
import org.thoughtcrime.securesms.registration.fragments.BaseRegistrationLockFragment
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
import org.thoughtcrime.securesms.util.CircularProgressButtonUtil.cancelSpinning
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||
|
||||
class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layout.fragment_change_number_registration_lock) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
}
|
||||
|
||||
override fun getViewModel(): BaseRegistrationViewModel {
|
||||
return ChangeNumberUtil.getViewModel(this)
|
||||
}
|
||||
|
||||
override fun navigateToAccountLocked() {
|
||||
findNavController().navigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberAccountLocked())
|
||||
}
|
||||
|
||||
override fun handleSuccessfulPinEntry(pin: String) {
|
||||
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashing.verifyLocalPinHash(it, pin) } ?: false
|
||||
|
||||
cancelSpinning(pinButton)
|
||||
|
||||
if (pinsDiffer) {
|
||||
findNavController().navigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberPinDiffers())
|
||||
} else {
|
||||
changeNumberSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
override 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.CertificateType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.pin.KbsRepository
|
||||
import org.thoughtcrime.securesms.pin.KeyBackupSystemWrongPinException
|
||||
import org.thoughtcrime.securesms.pin.TokenData
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.KbsPinData
|
||||
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
|
||||
private val TAG: String = Log.tag(ChangeNumberRepository::class.java)
|
||||
|
||||
class ChangeNumberRepository(private val context: Context) {
|
||||
|
||||
private val accountManager = ApplicationDependencies.getSignalServiceAccountManager()
|
||||
|
||||
fun changeNumber(code: String, newE164: String): Single<ServiceResponse<VerifyAccountResponse>> {
|
||||
return Single.fromCallable { accountManager.changeNumber(code, newE164, null) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun changeNumber(
|
||||
code: String,
|
||||
newE164: String,
|
||||
pin: String,
|
||||
tokenData: TokenData
|
||||
): Single<ServiceResponse<VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse>> {
|
||||
return Single.fromCallable {
|
||||
try {
|
||||
val kbsData: KbsPinData = KbsRepository.restoreMasterKey(pin, tokenData.enclave, tokenData.basicAuth, tokenData.tokenResponse)!!
|
||||
val registrationLock: String = kbsData.masterKey.deriveRegistrationLock()
|
||||
|
||||
val response: ServiceResponse<VerifyAccountResponse> = accountManager.changeNumber(code, newE164, registrationLock)
|
||||
VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse.from(response, kbsData)
|
||||
} catch (e: KeyBackupSystemWrongPinException) {
|
||||
ServiceResponse.forExecutionError(e)
|
||||
} catch (e: KeyBackupSystemNoDataException) {
|
||||
ServiceResponse.forExecutionError(e)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun changeLocalNumber(e164: String): Single<Unit> {
|
||||
TextSecurePreferences.setLocalNumber(context, e164)
|
||||
|
||||
DatabaseFactory.getRecipientDatabase(context).updateSelfPhone(e164)
|
||||
|
||||
ApplicationDependencies.closeConnections()
|
||||
ApplicationDependencies.getIncomingMessageObserver()
|
||||
|
||||
return rotateCertificates()
|
||||
}
|
||||
|
||||
@Suppress("UsePropertyAccessSyntax")
|
||||
private fun rotateCertificates(): Single<Unit> {
|
||||
val certificateTypes = SignalStore.phoneNumberPrivacy().allCertificateTypes
|
||||
|
||||
Log.i(TAG, "Rotating these certificates $certificateTypes")
|
||||
|
||||
return Single.fromCallable {
|
||||
for (certificateType in certificateTypes) {
|
||||
val certificate: ByteArray? = when (certificateType) {
|
||||
CertificateType.UUID_AND_E164 -> accountManager.getSenderCertificate()
|
||||
CertificateType.UUID_ONLY -> accountManager.getSenderCertificateForPhoneNumberPrivacy()
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Successfully got $certificateType certificate")
|
||||
|
||||
SignalStore.certificateValues().setUnidentifiedAccessCertificate(certificateType, certificate)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registration.fragments.CaptchaFragment
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
|
||||
/**
|
||||
* Helpers for various aspects of the change number flow.
|
||||
*/
|
||||
object ChangeNumberUtil {
|
||||
@JvmStatic
|
||||
fun getViewModel(fragment: Fragment): ChangeNumberViewModel {
|
||||
val navController = NavHostFragment.findNavController(fragment)
|
||||
return ViewModelProvider(
|
||||
navController.getViewModelStoreOwner(R.id.app_settings_change_number),
|
||||
ChangeNumberViewModel.Factory(navController.getBackStackEntry(R.id.app_settings_change_number))
|
||||
).get(ChangeNumberViewModel::class.java)
|
||||
}
|
||||
|
||||
fun getCaptchaArguments(): Bundle {
|
||||
return Bundle().apply {
|
||||
putSerializable(
|
||||
CaptchaFragment.EXTRA_VIEW_MODEL_PROVIDER,
|
||||
object : CaptchaFragment.CaptchaViewModelProvider {
|
||||
override fun get(fragment: CaptchaFragment): BaseRegistrationViewModel = getViewModel(fragment)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Fragment.changeNumberSuccess() {
|
||||
findNavController().navigate(R.id.action_pop_app_settings_change_number)
|
||||
Toast.makeText(requireContext(), R.string.ChangeNumber__your_phone_number_has_been_changed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
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.getCaptchaArguments
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
private val TAG: String = Log.tag(ChangeNumberVerifyFragment::class.java)
|
||||
|
||||
class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phone_number_verify) {
|
||||
private lateinit var viewModel: ChangeNumberViewModel
|
||||
|
||||
private var requestingCaptcha: Boolean = false
|
||||
|
||||
private val lifecycleDisposable: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lifecycleDisposable.bindTo(lifecycle)
|
||||
viewModel = getViewModel(this)
|
||||
}
|
||||
|
||||
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() }
|
||||
|
||||
val status: TextView = view.findViewById(R.id.change_phone_number_verify_status)
|
||||
status.text = getString(R.string.ChangeNumberVerifyFragment__verifying_s, viewModel.number.fullFormattedNumber)
|
||||
|
||||
if (!requestingCaptcha || viewModel.hasCaptchaToken()) {
|
||||
requestCode()
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.ChangeNumberVerifyFragment__captcha_required, Toast.LENGTH_SHORT).show()
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestCode() {
|
||||
lifecycleDisposable.add(
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { processor ->
|
||||
if (processor.hasResult()) {
|
||||
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
|
||||
} else if (processor.localRateLimit()) {
|
||||
Log.i(TAG, "Unable to request sms code due to local rate limit")
|
||||
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
|
||||
} else if (processor.captchaRequired()) {
|
||||
Log.i(TAG, "Unable to request sms code due to captcha required")
|
||||
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_captchaFragment, getCaptchaArguments())
|
||||
requestingCaptcha = true
|
||||
} else if (processor.rateLimit()) {
|
||||
Log.i(TAG, "Unable to request sms code due to rate limit")
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show()
|
||||
findNavController().navigateUp()
|
||||
} else {
|
||||
Log.w(TAG, "Unable to request sms code", processor.error)
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show()
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.app.Application
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.AbstractSavedStateViewModelFactory
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import com.google.i18n.phonenumbers.NumberParseException
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.pin.KbsRepository
|
||||
import org.thoughtcrime.securesms.pin.TokenData
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseProcessor
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseWithoutKbs
|
||||
import org.thoughtcrime.securesms.registration.VerifyCodeWithRegistrationLockResponseProcessor
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
|
||||
private val TAG: String = Log.tag(ChangeNumberViewModel::class.java)
|
||||
|
||||
class ChangeNumberViewModel(
|
||||
private val localNumber: String,
|
||||
private val changeNumberRepository: ChangeNumberRepository,
|
||||
savedState: SavedStateHandle,
|
||||
password: String,
|
||||
verifyAccountRepository: VerifyAccountRepository,
|
||||
kbsRepository: KbsRepository,
|
||||
) : BaseRegistrationViewModel(savedState, verifyAccountRepository, kbsRepository, password) {
|
||||
|
||||
var oldNumberState: NumberViewState = NumberViewState.Builder().build()
|
||||
private set
|
||||
|
||||
private val liveOldNumberState = DefaultValueLiveData(oldNumberState)
|
||||
private val liveNewNumberState = DefaultValueLiveData(number)
|
||||
|
||||
init {
|
||||
try {
|
||||
val countryCode: Int = PhoneNumberUtil.getInstance()
|
||||
.parse(localNumber, null)
|
||||
.countryCode
|
||||
|
||||
setOldCountry(countryCode)
|
||||
setNewCountry(countryCode)
|
||||
} catch (e: NumberParseException) {
|
||||
Log.i(TAG, "Unable to parse number for default country code")
|
||||
}
|
||||
}
|
||||
|
||||
fun getLiveOldNumber(): LiveData<NumberViewState> {
|
||||
return liveOldNumberState
|
||||
}
|
||||
|
||||
fun getLiveNewNumber(): LiveData<NumberViewState> {
|
||||
return liveNewNumberState
|
||||
}
|
||||
|
||||
fun setOldNationalNumber(number: String) {
|
||||
oldNumberState = oldNumberState.toBuilder()
|
||||
.nationalNumber(number)
|
||||
.build()
|
||||
|
||||
liveOldNumberState.value = oldNumberState
|
||||
}
|
||||
|
||||
fun setOldCountry(countryCode: Int, country: String? = null) {
|
||||
oldNumberState = oldNumberState.toBuilder()
|
||||
.selectedCountryDisplayName(country)
|
||||
.countryCode(countryCode)
|
||||
.build()
|
||||
|
||||
liveOldNumberState.value = oldNumberState
|
||||
}
|
||||
|
||||
fun setNewNationalNumber(number: String) {
|
||||
setNationalNumber(number)
|
||||
|
||||
liveNewNumberState.value = this.number
|
||||
}
|
||||
|
||||
fun setNewCountry(countryCode: Int, country: String? = null) {
|
||||
onCountrySelected(country, countryCode)
|
||||
|
||||
liveNewNumberState.value = this.number
|
||||
}
|
||||
|
||||
fun canContinue(): ContinueStatus {
|
||||
return if (oldNumberState.e164Number == localNumber) {
|
||||
if (number.isValid) {
|
||||
ContinueStatus.CAN_CONTINUE
|
||||
} else {
|
||||
ContinueStatus.INVALID_NUMBER
|
||||
}
|
||||
} else {
|
||||
ContinueStatus.OLD_NUMBER_DOESNT_MATCH
|
||||
}
|
||||
}
|
||||
|
||||
override fun verifyAccountWithoutRegistrationLock(): Single<ServiceResponse<VerifyAccountResponse>> {
|
||||
return changeNumberRepository.changeNumber(textCodeEntered, number.e164Number)
|
||||
}
|
||||
|
||||
override fun verifyAccountWithRegistrationLock(pin: String, kbsTokenData: TokenData): Single<ServiceResponse<VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse>> {
|
||||
return changeNumberRepository.changeNumber(textCodeEntered, number.e164Number, pin, kbsTokenData)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onVerifySuccess(processor: VerifyAccountResponseProcessor): Single<VerifyAccountResponseProcessor> {
|
||||
return changeNumberRepository.changeLocalNumber(number.e164Number)
|
||||
.map { processor }
|
||||
.onErrorReturn { t ->
|
||||
Log.w(TAG, "Error attempting to change local number", t)
|
||||
VerifyAccountResponseWithoutKbs(ServiceResponse.forUnknownError(t))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVerifySuccessWithRegistrationLock(processor: VerifyCodeWithRegistrationLockResponseProcessor, pin: String): Single<VerifyCodeWithRegistrationLockResponseProcessor> {
|
||||
return changeNumberRepository.changeLocalNumber(number.e164Number)
|
||||
.map { processor }
|
||||
.onErrorReturn { t ->
|
||||
Log.w(TAG, "Error attempting to change local number", t)
|
||||
VerifyCodeWithRegistrationLockResponseProcessor(ServiceResponse.forUnknownError(t), processor.token)
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(owner: SavedStateRegistryOwner) : AbstractSavedStateViewModelFactory(owner, null) {
|
||||
|
||||
override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
|
||||
val context: Application = ApplicationDependencies.getApplication()
|
||||
val localNumber: String = TextSecurePreferences.getLocalNumber(context)
|
||||
val password: String = TextSecurePreferences.getPushServerPassword(context)
|
||||
|
||||
val viewModel = ChangeNumberViewModel(
|
||||
localNumber = localNumber,
|
||||
changeNumberRepository = ChangeNumberRepository(context),
|
||||
savedState = handle,
|
||||
password = password,
|
||||
verifyAccountRepository = VerifyAccountRepository(context),
|
||||
kbsRepository = KbsRepository()
|
||||
)
|
||||
|
||||
return requireNotNull(modelClass.cast(viewModel))
|
||||
}
|
||||
}
|
||||
|
||||
enum class ContinueStatus {
|
||||
CAN_CONTINUE,
|
||||
INVALID_NUMBER,
|
||||
OLD_NUMBER_DOESNT_MATCH
|
||||
}
|
||||
}
|
||||
@@ -2034,6 +2034,25 @@ public class RecipientDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
public void updateSelfPhone(@NonNull String e164) {
|
||||
SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
RecipientId id = Recipient.self().getId();
|
||||
RecipientId newId = getAndPossiblyMerge(Recipient.self().requireUuid(), e164, true);
|
||||
|
||||
if (id.equals(newId)) {
|
||||
Log.i(TAG, "[updateSelfPhone] Phone updated for self");
|
||||
} else {
|
||||
throw new AssertionError("[updateSelfPhone] Self recipient id changed when updating phone. old: " + id + " new: " + newId);
|
||||
}
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public void setUsername(@NonNull RecipientId id, @Nullable String username) {
|
||||
if (username != null) {
|
||||
Optional<RecipientId> existingUsername = getByUsername(username);
|
||||
|
||||
@@ -83,6 +83,7 @@ public class ApplicationMigrations {
|
||||
static final int ANNOUNCEMENT_GROUP_CAPABILITY = 41;
|
||||
static final int STICKER_MY_DAILY_LIFE = 42;
|
||||
static final int SENDER_KEY_3 = 43;
|
||||
static final int CHANGE_NUMBER_SYNC = 44;
|
||||
}
|
||||
|
||||
public static final int CURRENT_VERSION = 43;
|
||||
@@ -367,6 +368,10 @@ public class ApplicationMigrations {
|
||||
jobs.put(Version.SENDER_KEY_3, new AttributesMigrationJob());
|
||||
}
|
||||
|
||||
if (lastSeenVersion < Version.CHANGE_NUMBER_SYNC) {
|
||||
jobs.put(Version.CHANGE_NUMBER_SYNC, new AccountRecordMigrationJob());
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.google.android.gms.auth.api.phone.SmsRetriever;
|
||||
import com.google.android.gms.common.api.CommonStatusCodes;
|
||||
@@ -59,7 +59,7 @@ public final class RegistrationNavigationActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
viewModel = ViewModelProviders.of(this, new RegistrationViewModel.Factory(this, isReregister(getIntent()))).get(RegistrationViewModel.class);
|
||||
viewModel = new ViewModelProvider(this, new RegistrationViewModel.Factory(this, isReregister(getIntent()))).get(RegistrationViewModel.class);
|
||||
|
||||
setContentView(R.layout.activity_registration_navigation);
|
||||
initializeChallengeListener();
|
||||
|
||||
@@ -1,68 +1,24 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
public class AccountLockedFragment extends BaseAccountLockedFragment {
|
||||
|
||||
public class AccountLockedFragment extends LoggingFragment {
|
||||
|
||||
@Override
|
||||
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.account_locked_fragment, container, false);
|
||||
public AccountLockedFragment() {
|
||||
super(R.layout.account_locked_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
TextView description = view.findViewById(R.id.account_locked_description);
|
||||
|
||||
RegistrationViewModel viewModel = ViewModelProviders.of(requireActivity()).get(RegistrationViewModel.class);
|
||||
viewModel.getLockedTimeRemaining().observe(getViewLifecycleOwner(),
|
||||
t -> description.setText(getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t)))
|
||||
);
|
||||
|
||||
view.findViewById(R.id.account_locked_next).setOnClickListener(v -> onNext());
|
||||
view.findViewById(R.id.account_locked_learn_more).setOnClickListener(v -> learnMore());
|
||||
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
onNext();
|
||||
}
|
||||
});
|
||||
protected BaseRegistrationViewModel getViewModel() {
|
||||
return new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
|
||||
}
|
||||
|
||||
private void learnMore() {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url)));
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private static long durationToDays(Long duration) {
|
||||
return duration != null ? getLockoutDays(duration) : 7;
|
||||
}
|
||||
|
||||
private static int getLockoutDays(long timeRemainingMs) {
|
||||
return (int) TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1;
|
||||
}
|
||||
|
||||
private void onNext() {
|
||||
@Override
|
||||
protected void onNext() {
|
||||
requireActivity().finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.CallSuper;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Base fragment used by registration and change number flow to show an account as locked.
|
||||
*/
|
||||
public abstract class BaseAccountLockedFragment extends LoggingFragment {
|
||||
|
||||
public BaseAccountLockedFragment(int contentLayoutId) {
|
||||
super(contentLayoutId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@CallSuper
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
TextView description = view.findViewById(R.id.account_locked_description);
|
||||
|
||||
BaseRegistrationViewModel viewModel = getViewModel();
|
||||
viewModel.getLockedTimeRemaining().observe(getViewLifecycleOwner(),
|
||||
t -> description.setText(getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t)))
|
||||
);
|
||||
|
||||
view.findViewById(R.id.account_locked_next).setOnClickListener(v -> onNext());
|
||||
view.findViewById(R.id.account_locked_learn_more).setOnClickListener(v -> learnMore());
|
||||
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
onNext();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void learnMore() {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url)));
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private static long durationToDays(long duration) {
|
||||
return duration != 0L ? getLockoutDays(duration) : 7;
|
||||
}
|
||||
|
||||
private static int getLockoutDays(long timeRemainingMs) {
|
||||
return (int) TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1;
|
||||
}
|
||||
|
||||
protected abstract BaseRegistrationViewModel getViewModel();
|
||||
|
||||
protected abstract void onNext();
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView;
|
||||
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.showConfirmNumberDialogIfTranslated;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.CallSuper;
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.registration.CallMeCountDownView;
|
||||
import org.thoughtcrime.securesms.components.registration.VerificationCodeView;
|
||||
import org.thoughtcrime.securesms.components.registration.VerificationPinKeyboard;
|
||||
import org.thoughtcrime.securesms.registration.ReceivedSmsEvent;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
|
||||
import org.whispersystems.signalservice.internal.push.LockedException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
/**
|
||||
* Base fragment used by registration and change number flow to input an SMS verification code or request a
|
||||
* phone code after requesting SMS.
|
||||
*
|
||||
* @param <ViewModel> - The concrete view model used by the subclasses, for ease of access in said subclass
|
||||
*/
|
||||
public abstract class BaseEnterCodeFragment<ViewModel extends BaseRegistrationViewModel> extends LoggingFragment implements SignalStrengthPhoneStateListener.Callback {
|
||||
|
||||
private static final String TAG = Log.tag(BaseEnterCodeFragment.class);
|
||||
|
||||
private ScrollView scrollView;
|
||||
private TextView header;
|
||||
private VerificationCodeView verificationCodeView;
|
||||
private VerificationPinKeyboard keyboard;
|
||||
private CallMeCountDownView callMeCountDown;
|
||||
private View wrongNumber;
|
||||
private View noCodeReceivedHelp;
|
||||
private View serviceWarning;
|
||||
private boolean autoCompleting;
|
||||
|
||||
private ViewModel viewModel;
|
||||
|
||||
protected final LifecycleDisposable disposables = new LifecycleDisposable();
|
||||
|
||||
public BaseEnterCodeFragment(@LayoutRes int contentLayoutId) {
|
||||
super(contentLayoutId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@CallSuper
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header));
|
||||
|
||||
scrollView = view.findViewById(R.id.scroll_view);
|
||||
header = view.findViewById(R.id.verify_header);
|
||||
verificationCodeView = view.findViewById(R.id.code);
|
||||
keyboard = view.findViewById(R.id.keyboard);
|
||||
callMeCountDown = view.findViewById(R.id.call_me_count_down);
|
||||
wrongNumber = view.findViewById(R.id.wrong_number);
|
||||
noCodeReceivedHelp = view.findViewById(R.id.no_code);
|
||||
serviceWarning = view.findViewById(R.id.cell_service_warning);
|
||||
|
||||
new SignalStrengthPhoneStateListener(this, this);
|
||||
|
||||
connectKeyboard(verificationCodeView, keyboard);
|
||||
ViewUtil.hideKeyboard(requireContext(), view);
|
||||
|
||||
setOnCodeFullyEnteredListener(verificationCodeView);
|
||||
|
||||
wrongNumber.setOnClickListener(v -> onWrongNumber());
|
||||
|
||||
callMeCountDown.setOnClickListener(v -> handlePhoneCallRequest());
|
||||
|
||||
callMeCountDown.setListener((v, remaining) -> {
|
||||
if (remaining <= 30) {
|
||||
scrollView.smoothScrollTo(0, v.getBottom());
|
||||
callMeCountDown.setListener(null);
|
||||
}
|
||||
});
|
||||
|
||||
noCodeReceivedHelp.setOnClickListener(v -> sendEmailToSupport());
|
||||
|
||||
disposables.bindTo(getViewLifecycleOwner().getLifecycle());
|
||||
viewModel = getViewModel();
|
||||
viewModel.getSuccessfulCodeRequestAttempts().observe(getViewLifecycleOwner(), (attempts) -> {
|
||||
if (attempts >= 3) {
|
||||
noCodeReceivedHelp.setVisibility(View.VISIBLE);
|
||||
scrollView.postDelayed(() -> scrollView.smoothScrollTo(0, noCodeReceivedHelp.getBottom()), 15000);
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.onStartEnterCode();
|
||||
}
|
||||
|
||||
protected abstract ViewModel getViewModel();
|
||||
|
||||
protected abstract void handleSuccessfulVerify();
|
||||
|
||||
protected abstract void navigateToCaptcha();
|
||||
|
||||
protected abstract void navigateToRegistrationLock(long timeRemaining);
|
||||
|
||||
protected abstract void navigateToKbsAccountLocked();
|
||||
|
||||
private void onWrongNumber() {
|
||||
Navigation.findNavController(requireView()).navigateUp();
|
||||
}
|
||||
|
||||
private void setOnCodeFullyEnteredListener(VerificationCodeView verificationCodeView) {
|
||||
verificationCodeView.setOnCompleteListener(code -> {
|
||||
|
||||
callMeCountDown.setVisibility(View.INVISIBLE);
|
||||
wrongNumber.setVisibility(View.INVISIBLE);
|
||||
keyboard.displayProgress();
|
||||
|
||||
Disposable verify = viewModel.verifyCodeWithoutRegistrationLock(code)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(processor -> {
|
||||
if (!processor.hasResult()) {
|
||||
Log.w(TAG, "post verify: ", processor.getError());
|
||||
}
|
||||
if (processor.hasResult()) {
|
||||
handleSuccessfulVerify();
|
||||
} else if (processor.rateLimit()) {
|
||||
handleRateLimited();
|
||||
} else if (processor.registrationLock() && !processor.isKbsLocked()) {
|
||||
LockedException lockedException = processor.getLockedException();
|
||||
handleRegistrationLock(lockedException.getTimeRemaining());
|
||||
} else if (processor.isKbsLocked()) {
|
||||
handleKbsAccountLocked();
|
||||
} else if (processor.authorizationFailed()) {
|
||||
handleIncorrectCodeError();
|
||||
} else {
|
||||
Log.w(TAG, "Unable to verify code", processor.getError());
|
||||
handleGeneralError();
|
||||
}
|
||||
});
|
||||
|
||||
disposables.add(verify);
|
||||
});
|
||||
}
|
||||
|
||||
protected void displaySuccess(@NonNull Runnable runAfterAnimation) {
|
||||
keyboard.displaySuccess().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean result) {
|
||||
runAfterAnimation.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected void handleRateLimited() {
|
||||
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean r) {
|
||||
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext());
|
||||
|
||||
builder.setTitle(R.string.RegistrationActivity_too_many_attempts)
|
||||
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
callMeCountDown.setVisibility(View.VISIBLE);
|
||||
wrongNumber.setVisibility(View.VISIBLE);
|
||||
verificationCodeView.clear();
|
||||
keyboard.displayKeyboard();
|
||||
})
|
||||
.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected void handleRegistrationLock(long timeRemaining) {
|
||||
keyboard.displayLocked().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean r) {
|
||||
navigateToRegistrationLock(timeRemaining);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected void handleKbsAccountLocked() {
|
||||
navigateToKbsAccountLocked();
|
||||
}
|
||||
|
||||
protected void handleIncorrectCodeError() {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_code, Toast.LENGTH_LONG).show();
|
||||
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean result) {
|
||||
callMeCountDown.setVisibility(View.VISIBLE);
|
||||
wrongNumber.setVisibility(View.VISIBLE);
|
||||
verificationCodeView.clear();
|
||||
keyboard.displayKeyboard();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected void handleGeneralError() {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show();
|
||||
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean result) {
|
||||
callMeCountDown.setVisibility(View.VISIBLE);
|
||||
wrongNumber.setVisibility(View.VISIBLE);
|
||||
verificationCodeView.clear();
|
||||
keyboard.displayKeyboard();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
super.onStop();
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
public void onVerificationCodeReceived(@NonNull ReceivedSmsEvent event) {
|
||||
verificationCodeView.clear();
|
||||
|
||||
List<Integer> parsedCode = convertVerificationCodeToDigits(event.getCode());
|
||||
|
||||
autoCompleting = true;
|
||||
|
||||
final int size = parsedCode.size();
|
||||
|
||||
for (int i = 0; i < size; i++) {
|
||||
final int index = i;
|
||||
verificationCodeView.postDelayed(() -> {
|
||||
verificationCodeView.append(parsedCode.get(index));
|
||||
if (index == size - 1) {
|
||||
autoCompleting = false;
|
||||
}
|
||||
}, i * 200L);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Integer> convertVerificationCodeToDigits(@Nullable String code) {
|
||||
if (code == null || code.length() != 6) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Integer> result = new ArrayList<>(code.length());
|
||||
|
||||
try {
|
||||
for (int i = 0; i < code.length(); i++) {
|
||||
result.add(Integer.parseInt(Character.toString(code.charAt(i))));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, "Failed to convert code into digits.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void handlePhoneCallRequest() {
|
||||
showConfirmNumberDialogIfTranslated(requireContext(),
|
||||
R.string.RegistrationActivity_you_will_receive_a_call_to_verify_this_number,
|
||||
viewModel.getNumber().getE164Number(),
|
||||
this::handlePhoneCallRequestAfterConfirm,
|
||||
this::onWrongNumber);
|
||||
}
|
||||
|
||||
private void handlePhoneCallRequestAfterConfirm() {
|
||||
Disposable request = viewModel.requestVerificationCode(VerifyAccountRepository.Mode.PHONE_CALL)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_call_requested, Toast.LENGTH_LONG).show();
|
||||
} else if (processor.captchaRequired()) {
|
||||
navigateToCaptcha();
|
||||
} else if (processor.rateLimit()) {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show();
|
||||
} else {
|
||||
Log.w(TAG, "Unable to request phone code", processor.getError());
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
|
||||
disposables.add(request);
|
||||
}
|
||||
|
||||
private void connectKeyboard(VerificationCodeView verificationCodeView, VerificationPinKeyboard keyboard) {
|
||||
keyboard.setOnKeyPressListener(key -> {
|
||||
if (!autoCompleting) {
|
||||
if (key >= 0) {
|
||||
verificationCodeView.append(key);
|
||||
} else {
|
||||
verificationCodeView.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
header.setText(requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, viewModel.getNumber().getFullFormattedNumber()));
|
||||
|
||||
viewModel.getCanCallAtTime().observe(getViewLifecycleOwner(), callAtTime -> callMeCountDown.startCountDownTo(callAtTime));
|
||||
}
|
||||
|
||||
private void sendEmailToSupport() {
|
||||
String body = SupportEmailUtil.generateSupportEmailBody(requireContext(),
|
||||
R.string.RegistrationActivity_code_support_subject,
|
||||
null,
|
||||
null);
|
||||
CommunicationActions.openEmail(requireContext(),
|
||||
SupportEmailUtil.getSupportEmailAddress(requireContext()),
|
||||
getString(R.string.RegistrationActivity_code_support_subject),
|
||||
body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNoCellSignalPresent() {
|
||||
if (serviceWarning.getVisibility() == View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
serviceWarning.setVisibility(View.VISIBLE);
|
||||
serviceWarning.animate()
|
||||
.alpha(1)
|
||||
.setListener(null)
|
||||
.start();
|
||||
|
||||
scrollView.postDelayed(() -> {
|
||||
if (serviceWarning.getVisibility() == View.VISIBLE) {
|
||||
scrollView.smoothScrollTo(0, serviceWarning.getBottom());
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCellSignalPresent() {
|
||||
if (serviceWarning.getVisibility() != View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
serviceWarning.animate()
|
||||
.alpha(0)
|
||||
.setListener(new Animator.AnimatorListener() {
|
||||
@Override public void onAnimationEnd(Animator animation) {
|
||||
serviceWarning.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@Override public void onAnimationStart(Animator animation) {}
|
||||
|
||||
@Override public void onAnimationCancel(Animator animation) {}
|
||||
|
||||
@Override public void onAnimationRepeat(Animator animation) {}
|
||||
})
|
||||
.start();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView;
|
||||
import static org.thoughtcrime.securesms.util.CircularProgressButtonUtil.cancelSpinning;
|
||||
import static org.thoughtcrime.securesms.util.CircularProgressButtonUtil.setSpinning;
|
||||
|
||||
import android.content.res.Resources;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.CallSuper;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import com.dd.CircularProgressButton;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType;
|
||||
import org.thoughtcrime.securesms.pin.TokenData;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
/**
|
||||
* Base fragment used by registration and change number flow to deal with a registration locked account.
|
||||
*/
|
||||
public abstract class BaseRegistrationLockFragment extends LoggingFragment {
|
||||
|
||||
private static final String TAG = Log.tag(BaseRegistrationLockFragment.class);
|
||||
|
||||
/**
|
||||
* Applies to both V1 and V2 pins, because some V2 pins may have been migrated from V1.
|
||||
*/
|
||||
private static final int MINIMUM_PIN_LENGTH = 4;
|
||||
|
||||
private EditText pinEntry;
|
||||
private View forgotPin;
|
||||
protected CircularProgressButton pinButton;
|
||||
private TextView errorLabel;
|
||||
private TextView keyboardToggle;
|
||||
private long timeRemaining;
|
||||
|
||||
private BaseRegistrationViewModel viewModel;
|
||||
|
||||
private final LifecycleDisposable disposables = new LifecycleDisposable();
|
||||
|
||||
public BaseRegistrationLockFragment(int contentLayoutId) {
|
||||
super(contentLayoutId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@CallSuper
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title));
|
||||
|
||||
pinEntry = view.findViewById(R.id.kbs_lock_pin_input);
|
||||
pinButton = view.findViewById(R.id.kbs_lock_pin_confirm);
|
||||
errorLabel = view.findViewById(R.id.kbs_lock_pin_input_label);
|
||||
keyboardToggle = view.findViewById(R.id.kbs_lock_keyboard_toggle);
|
||||
forgotPin = view.findViewById(R.id.kbs_lock_forgot_pin);
|
||||
|
||||
RegistrationLockFragmentArgs args = RegistrationLockFragmentArgs.fromBundle(requireArguments());
|
||||
|
||||
timeRemaining = args.getTimeRemaining();
|
||||
|
||||
forgotPin.setVisibility(View.GONE);
|
||||
forgotPin.setOnClickListener(v -> handleForgottenPin(timeRemaining));
|
||||
|
||||
pinEntry.setImeOptions(EditorInfo.IME_ACTION_DONE);
|
||||
pinEntry.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
ViewUtil.hideKeyboard(requireContext(), v);
|
||||
handlePinEntry();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
enableAndFocusPinEntry();
|
||||
|
||||
pinButton.setOnClickListener((v) -> {
|
||||
ViewUtil.hideKeyboard(requireContext(), pinEntry);
|
||||
handlePinEntry();
|
||||
});
|
||||
|
||||
keyboardToggle.setOnClickListener((v) -> {
|
||||
PinKeyboardType keyboardType = getPinEntryKeyboardType();
|
||||
|
||||
updateKeyboard(keyboardType.getOther());
|
||||
keyboardToggle.setText(resolveKeyboardToggleText(keyboardType));
|
||||
});
|
||||
|
||||
PinKeyboardType keyboardType = getPinEntryKeyboardType().getOther();
|
||||
keyboardToggle.setText(resolveKeyboardToggleText(keyboardType));
|
||||
|
||||
disposables.bindTo(getViewLifecycleOwner().getLifecycle());
|
||||
viewModel = getViewModel();
|
||||
|
||||
viewModel.getLockedTimeRemaining()
|
||||
.observe(getViewLifecycleOwner(), t -> timeRemaining = t);
|
||||
|
||||
TokenData keyBackupCurrentToken = viewModel.getKeyBackupCurrentToken();
|
||||
|
||||
if (keyBackupCurrentToken != null) {
|
||||
int triesRemaining = keyBackupCurrentToken.getTriesRemaining();
|
||||
if (triesRemaining <= 3) {
|
||||
int daysRemaining = getLockoutDays(timeRemaining);
|
||||
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationLockFragment__not_many_tries_left)
|
||||
.setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support, (dialog, which) -> sendEmailToSupport())
|
||||
.show();
|
||||
}
|
||||
|
||||
if (triesRemaining < 5) {
|
||||
errorLabel.setText(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract BaseRegistrationViewModel getViewModel();
|
||||
|
||||
private String getTriesRemainingDialogMessage(int triesRemaining, int daysRemaining) {
|
||||
Resources resources = requireContext().getResources();
|
||||
String tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining);
|
||||
String days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining);
|
||||
|
||||
return tries + " " + days;
|
||||
}
|
||||
|
||||
protected PinKeyboardType getPinEntryKeyboardType() {
|
||||
boolean isNumeric = (pinEntry.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER;
|
||||
|
||||
return isNumeric ? PinKeyboardType.NUMERIC : PinKeyboardType.ALPHA_NUMERIC;
|
||||
}
|
||||
|
||||
private void handlePinEntry() {
|
||||
pinEntry.setEnabled(false);
|
||||
|
||||
final String pin = pinEntry.getText().toString();
|
||||
|
||||
int trimmedLength = pin.replace(" ", "").length();
|
||||
if (trimmedLength == 0) {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show();
|
||||
enableAndFocusPinEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedLength < MINIMUM_PIN_LENGTH) {
|
||||
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show();
|
||||
enableAndFocusPinEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
setSpinning(pinButton);
|
||||
|
||||
Disposable verify = viewModel.verifyCodeAndRegisterAccountWithRegistrationLock(pin)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
handleSuccessfulPinEntry(pin);
|
||||
} else if (processor.wrongPin()) {
|
||||
onIncorrectKbsRegistrationLockPin(processor.getToken());
|
||||
} else if (processor.isKbsLocked()) {
|
||||
onKbsAccountLocked();
|
||||
} else if (processor.rateLimit()) {
|
||||
onRateLimited();
|
||||
} else {
|
||||
Log.w(TAG, "Unable to verify code with registration lock", processor.getError());
|
||||
onError();
|
||||
}
|
||||
});
|
||||
|
||||
disposables.add(verify);
|
||||
}
|
||||
|
||||
public void onIncorrectKbsRegistrationLockPin(@NonNull TokenData tokenData) {
|
||||
cancelSpinning(pinButton);
|
||||
pinEntry.getText().clear();
|
||||
enableAndFocusPinEntry();
|
||||
|
||||
viewModel.setKeyBackupTokenData(tokenData);
|
||||
|
||||
int triesRemaining = tokenData.getTriesRemaining();
|
||||
|
||||
if (triesRemaining == 0) {
|
||||
Log.w(TAG, "Account locked. User out of attempts on KBS.");
|
||||
onAccountLocked();
|
||||
return;
|
||||
}
|
||||
|
||||
if (triesRemaining == 3) {
|
||||
int daysRemaining = getLockoutDays(timeRemaining);
|
||||
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationLockFragment__incorrect_pin)
|
||||
.setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
if (triesRemaining > 5) {
|
||||
errorLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again);
|
||||
} else {
|
||||
errorLabel.setText(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, triesRemaining, triesRemaining));
|
||||
forgotPin.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
public void onRateLimited() {
|
||||
cancelSpinning(pinButton);
|
||||
enableAndFocusPinEntry();
|
||||
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationActivity_too_many_attempts)
|
||||
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
public void onKbsAccountLocked() {
|
||||
onAccountLocked();
|
||||
}
|
||||
|
||||
public void onError() {
|
||||
cancelSpinning(pinButton);
|
||||
enableAndFocusPinEntry();
|
||||
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
private void handleForgottenPin(long timeRemainingMs) {
|
||||
int lockoutDays = getLockoutDays(timeRemainingMs);
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationLockFragment__forgot_your_pin)
|
||||
.setMessage(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support, (dialog, which) -> sendEmailToSupport())
|
||||
.show();
|
||||
}
|
||||
|
||||
private static int getLockoutDays(long timeRemainingMs) {
|
||||
return (int) TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1;
|
||||
}
|
||||
|
||||
private void onAccountLocked() {
|
||||
navigateToAccountLocked();
|
||||
}
|
||||
|
||||
protected abstract void navigateToAccountLocked();
|
||||
|
||||
private void updateKeyboard(@NonNull PinKeyboardType keyboard) {
|
||||
boolean isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC;
|
||||
|
||||
pinEntry.setInputType(isAlphaNumeric ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
: InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
|
||||
|
||||
pinEntry.getText().clear();
|
||||
}
|
||||
|
||||
private @StringRes static int resolveKeyboardToggleText(@NonNull PinKeyboardType keyboard) {
|
||||
if (keyboard == PinKeyboardType.ALPHA_NUMERIC) {
|
||||
return R.string.RegistrationLockFragment__enter_alphanumeric_pin;
|
||||
} else {
|
||||
return R.string.RegistrationLockFragment__enter_numeric_pin;
|
||||
}
|
||||
}
|
||||
|
||||
private void enableAndFocusPinEntry() {
|
||||
pinEntry.setEnabled(true);
|
||||
pinEntry.setFocusable(true);
|
||||
|
||||
if (pinEntry.requestFocus()) {
|
||||
ServiceUtil.getInputMethodManager(pinEntry.getContext()).showSoftInput(pinEntry, 0);
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract void handleSuccessfulPinEntry(@NonNull String pin);
|
||||
|
||||
protected abstract void sendEmailToSupport();
|
||||
}
|
||||
@@ -11,19 +11,23 @@ import android.webkit.WebViewClient;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Fragment that displays a Captcha in a WebView.
|
||||
*/
|
||||
public final class CaptchaFragment extends LoggingFragment {
|
||||
|
||||
private RegistrationViewModel viewModel;
|
||||
public static final String EXTRA_VIEW_MODEL_PROVIDER = "view_model_provider";
|
||||
|
||||
private BaseRegistrationViewModel viewModel;
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
@@ -53,11 +57,25 @@ public final class CaptchaFragment extends LoggingFragment {
|
||||
|
||||
webView.loadUrl(RegistrationConstants.SIGNAL_CAPTCHA_URL);
|
||||
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(RegistrationViewModel.class);
|
||||
CaptchaViewModelProvider provider = null;
|
||||
if (getArguments() != null) {
|
||||
provider = (CaptchaViewModelProvider) requireArguments().getSerializable(EXTRA_VIEW_MODEL_PROVIDER);
|
||||
}
|
||||
|
||||
if (provider == null) {
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(RegistrationViewModel.class);
|
||||
} else {
|
||||
viewModel = provider.get(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleToken(@NonNull String token) {
|
||||
viewModel.setCaptchaResponse(token);
|
||||
|
||||
NavHostFragment.findNavController(this).navigateUp();
|
||||
}
|
||||
|
||||
public interface CaptchaViewModelProvider extends Serializable {
|
||||
@NonNull BaseRegistrationViewModel get(@NonNull CaptchaFragment fragment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,9 @@ import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.ListFragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -29,8 +28,12 @@ import java.util.Map;
|
||||
|
||||
public final class CountryPickerFragment extends ListFragment implements LoaderManager.LoaderCallbacks<ArrayList<Map<String, String>>> {
|
||||
|
||||
public static final String KEY_COUNTRY = "country";
|
||||
public static final String KEY_COUNTRY_CODE = "country_code";
|
||||
|
||||
private EditText countryFilter;
|
||||
private RegistrationViewModel model;
|
||||
private String resultKey;
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) {
|
||||
@@ -41,7 +44,14 @@ public final class CountryPickerFragment extends ListFragment implements LoaderM
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
model = ViewModelProviders.of(requireActivity()).get(RegistrationViewModel.class);
|
||||
if (getArguments() != null) {
|
||||
CountryPickerFragmentArgs arguments = CountryPickerFragmentArgs.fromBundle(requireArguments());
|
||||
resultKey = arguments.getResultKey();
|
||||
}
|
||||
|
||||
if (resultKey == null) {
|
||||
model = new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
|
||||
}
|
||||
|
||||
countryFilter = view.findViewById(R.id.country_search);
|
||||
|
||||
@@ -56,14 +66,21 @@ public final class CountryPickerFragment extends ListFragment implements LoaderM
|
||||
int countryCode = Integer.parseInt(item.get("country_code").replace("+", ""));
|
||||
String countryName = item.get("country_name");
|
||||
|
||||
model.onCountrySelected(countryName, countryCode);
|
||||
if (resultKey == null) {
|
||||
model.onCountrySelected(countryName, countryCode);
|
||||
} else {
|
||||
Bundle result = new Bundle();
|
||||
result.putString(KEY_COUNTRY, countryName);
|
||||
result.putInt(KEY_COUNTRY_CODE, countryCode);
|
||||
getParentFragmentManager().setFragmentResult(resultKey, result);
|
||||
}
|
||||
|
||||
NavHostFragment.findNavController(this).navigateUp();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<ArrayList<Map<String, String>>> onCreateLoader(int id, @Nullable Bundle args) {
|
||||
return new CountryListLoader(getActivity());
|
||||
return new CountryListLoader(getActivity());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -71,7 +88,7 @@ public final class CountryPickerFragment extends ListFragment implements LoaderM
|
||||
@NonNull ArrayList<Map<String, String>> results)
|
||||
{
|
||||
((TextView) getListView().getEmptyView()).setText(
|
||||
R.string.country_selection_fragment__no_matching_countries);
|
||||
R.string.country_selection_fragment__no_matching_countries);
|
||||
String[] from = { "country_name", "country_code" };
|
||||
int[] to = { R.id.country_name, R.id.country_code };
|
||||
|
||||
|
||||
@@ -1,163 +1,33 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView;
|
||||
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.showConfirmNumberDialogIfTranslated;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.os.Bundle;
|
||||
import android.telephony.PhoneStateListener;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.registration.CallMeCountDownView;
|
||||
import org.thoughtcrime.securesms.components.registration.VerificationCodeView;
|
||||
import org.thoughtcrime.securesms.components.registration.VerificationPinKeyboard;
|
||||
import org.thoughtcrime.securesms.registration.ReceivedSmsEvent;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.whispersystems.signalservice.internal.push.LockedException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
public final class EnterCodeFragment extends LoggingFragment implements SignalStrengthPhoneStateListener.Callback {
|
||||
public final class EnterCodeFragment extends BaseEnterCodeFragment<RegistrationViewModel> implements SignalStrengthPhoneStateListener.Callback {
|
||||
|
||||
private static final String TAG = Log.tag(EnterCodeFragment.class);
|
||||
|
||||
private ScrollView scrollView;
|
||||
private TextView header;
|
||||
private VerificationCodeView verificationCodeView;
|
||||
private VerificationPinKeyboard keyboard;
|
||||
private CallMeCountDownView callMeCountDown;
|
||||
private View wrongNumber;
|
||||
private View noCodeReceivedHelp;
|
||||
private View serviceWarning;
|
||||
private boolean autoCompleting;
|
||||
|
||||
private PhoneStateListener signalStrengthListener;
|
||||
private RegistrationViewModel viewModel;
|
||||
|
||||
private final LifecycleDisposable disposables = new LifecycleDisposable();
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_registration_enter_code, container, false);
|
||||
public EnterCodeFragment() {
|
||||
super(R.layout.fragment_registration_enter_code);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header));
|
||||
|
||||
scrollView = view.findViewById(R.id.scroll_view);
|
||||
header = view.findViewById(R.id.verify_header);
|
||||
verificationCodeView = view.findViewById(R.id.code);
|
||||
keyboard = view.findViewById(R.id.keyboard);
|
||||
callMeCountDown = view.findViewById(R.id.call_me_count_down);
|
||||
wrongNumber = view.findViewById(R.id.wrong_number);
|
||||
noCodeReceivedHelp = view.findViewById(R.id.no_code);
|
||||
serviceWarning = view.findViewById(R.id.cell_service_warning);
|
||||
|
||||
signalStrengthListener = new SignalStrengthPhoneStateListener(this, this);
|
||||
|
||||
connectKeyboard(verificationCodeView, keyboard);
|
||||
ViewUtil.hideKeyboard(requireContext(), view);
|
||||
|
||||
setOnCodeFullyEnteredListener(verificationCodeView);
|
||||
|
||||
wrongNumber.setOnClickListener(v -> onWrongNumber());
|
||||
|
||||
callMeCountDown.setOnClickListener(v -> handlePhoneCallRequest());
|
||||
|
||||
callMeCountDown.setListener((v, remaining) -> {
|
||||
if (remaining <= 30) {
|
||||
scrollView.smoothScrollTo(0, v.getBottom());
|
||||
callMeCountDown.setListener(null);
|
||||
}
|
||||
});
|
||||
|
||||
noCodeReceivedHelp.setOnClickListener(v -> sendEmailToSupport());
|
||||
|
||||
disposables.bindTo(getViewLifecycleOwner().getLifecycle());
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(RegistrationViewModel.class);
|
||||
viewModel.getSuccessfulCodeRequestAttempts().observe(getViewLifecycleOwner(), (attempts) -> {
|
||||
if (attempts >= 3) {
|
||||
noCodeReceivedHelp.setVisibility(View.VISIBLE);
|
||||
scrollView.postDelayed(() -> scrollView.smoothScrollTo(0, noCodeReceivedHelp.getBottom()), 15000);
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.onStartEnterCode();
|
||||
protected @NonNull RegistrationViewModel getViewModel() {
|
||||
return ViewModelProviders.of(requireActivity()).get(RegistrationViewModel.class);
|
||||
}
|
||||
|
||||
private void onWrongNumber() {
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(EnterCodeFragmentDirections.actionWrongNumber());
|
||||
}
|
||||
|
||||
private void setOnCodeFullyEnteredListener(VerificationCodeView verificationCodeView) {
|
||||
verificationCodeView.setOnCompleteListener(code -> {
|
||||
|
||||
callMeCountDown.setVisibility(View.INVISIBLE);
|
||||
wrongNumber.setVisibility(View.INVISIBLE);
|
||||
keyboard.displayProgress();
|
||||
|
||||
Disposable verify = viewModel.verifyCodeAndRegisterAccountWithoutRegistrationLock(code)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
handleSuccessfulRegistration();
|
||||
} else if (processor.rateLimit()) {
|
||||
handleRateLimited();
|
||||
} else if (processor.registrationLock() && !processor.isKbsLocked()) {
|
||||
LockedException lockedException = processor.getLockedException();
|
||||
handleRegistrationLock(lockedException.getTimeRemaining());
|
||||
} else if (processor.isKbsLocked()) {
|
||||
handleKbsAccountLocked();
|
||||
} else if (processor.authorizationFailed()) {
|
||||
handleIncorrectCodeError();
|
||||
} else {
|
||||
Log.w(TAG, "Unable to verify code", processor.getError());
|
||||
handleGeneralError();
|
||||
}
|
||||
});
|
||||
|
||||
disposables.add(verify);
|
||||
});
|
||||
}
|
||||
|
||||
public void handleSuccessfulRegistration() {
|
||||
@Override
|
||||
protected void handleSuccessfulVerify() {
|
||||
SimpleTask.run(() -> {
|
||||
long startTime = System.currentTimeMillis();
|
||||
try {
|
||||
@@ -167,222 +37,22 @@ public final class EnterCodeFragment extends LoggingFragment implements SignalSt
|
||||
Log.w(TAG, "Failed to refresh flags after " + (System.currentTimeMillis() - startTime) + " ms.", e);
|
||||
}
|
||||
return null;
|
||||
}, none -> {
|
||||
keyboard.displaySuccess().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean result) {
|
||||
Navigation.findNavController(requireView()).navigate(EnterCodeFragmentDirections.actionSuccessfulRegistration());
|
||||
}
|
||||
});
|
||||
});
|
||||
}, none -> displaySuccess(() -> Navigation.findNavController(requireView()).navigate(EnterCodeFragmentDirections.actionSuccessfulRegistration())));
|
||||
}
|
||||
|
||||
public void handleRateLimited() {
|
||||
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean r) {
|
||||
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext());
|
||||
|
||||
builder.setTitle(R.string.RegistrationActivity_too_many_attempts)
|
||||
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
callMeCountDown.setVisibility(View.VISIBLE);
|
||||
wrongNumber.setVisibility(View.VISIBLE);
|
||||
verificationCodeView.clear();
|
||||
keyboard.displayKeyboard();
|
||||
})
|
||||
.show();
|
||||
}
|
||||
});
|
||||
@Override
|
||||
protected void navigateToRegistrationLock(long timeRemaining) {
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining));
|
||||
}
|
||||
|
||||
public void handleRegistrationLock(long timeRemaining) {
|
||||
keyboard.displayLocked().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean r) {
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining, false));
|
||||
}
|
||||
});
|
||||
@Override
|
||||
protected void navigateToCaptcha() {
|
||||
NavHostFragment.findNavController(this).navigate(EnterCodeFragmentDirections.actionRequestCaptcha());
|
||||
}
|
||||
|
||||
public void handleKbsAccountLocked() {
|
||||
@Override
|
||||
protected void navigateToKbsAccountLocked() {
|
||||
Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionAccountLocked());
|
||||
}
|
||||
|
||||
public void handleIncorrectCodeError() {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_code, Toast.LENGTH_LONG).show();
|
||||
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean result) {
|
||||
callMeCountDown.setVisibility(View.VISIBLE);
|
||||
wrongNumber.setVisibility(View.VISIBLE);
|
||||
verificationCodeView.clear();
|
||||
keyboard.displayKeyboard();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void handleGeneralError() {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show();
|
||||
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean result) {
|
||||
callMeCountDown.setVisibility(View.VISIBLE);
|
||||
wrongNumber.setVisibility(View.VISIBLE);
|
||||
verificationCodeView.clear();
|
||||
keyboard.displayKeyboard();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
super.onStop();
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
public void onVerificationCodeReceived(@NonNull ReceivedSmsEvent event) {
|
||||
verificationCodeView.clear();
|
||||
|
||||
List<Integer> parsedCode = convertVerificationCodeToDigits(event.getCode());
|
||||
|
||||
autoCompleting = true;
|
||||
|
||||
final int size = parsedCode.size();
|
||||
|
||||
for (int i = 0; i < size; i++) {
|
||||
final int index = i;
|
||||
verificationCodeView.postDelayed(() -> {
|
||||
verificationCodeView.append(parsedCode.get(index));
|
||||
if (index == size - 1) {
|
||||
autoCompleting = false;
|
||||
}
|
||||
}, i * 200);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Integer> convertVerificationCodeToDigits(@Nullable String code) {
|
||||
if (code == null || code.length() != 6) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Integer> result = new ArrayList<>(code.length());
|
||||
|
||||
try {
|
||||
for (int i = 0; i < code.length(); i++) {
|
||||
result.add(Integer.parseInt(Character.toString(code.charAt(i))));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, "Failed to convert code into digits.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void handlePhoneCallRequest() {
|
||||
showConfirmNumberDialogIfTranslated(requireContext(),
|
||||
R.string.RegistrationActivity_you_will_receive_a_call_to_verify_this_number,
|
||||
viewModel.getNumber().getE164Number(),
|
||||
this::handlePhoneCallRequestAfterConfirm,
|
||||
this::onWrongNumber);
|
||||
}
|
||||
|
||||
private void handlePhoneCallRequestAfterConfirm() {
|
||||
Disposable request = viewModel.requestVerificationCode(VerifyAccountRepository.Mode.PHONE_CALL)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_call_requested, Toast.LENGTH_LONG).show();
|
||||
} else if (processor.captchaRequired()) {
|
||||
NavHostFragment.findNavController(this).navigate(EnterCodeFragmentDirections.actionRequestCaptcha());
|
||||
} else if (processor.rateLimit()) {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show();
|
||||
} else {
|
||||
Log.w(TAG, "Unable to request phone code", processor.getError());
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
|
||||
disposables.add(request);
|
||||
}
|
||||
|
||||
private void connectKeyboard(VerificationCodeView verificationCodeView, VerificationPinKeyboard keyboard) {
|
||||
keyboard.setOnKeyPressListener(key -> {
|
||||
if (!autoCompleting) {
|
||||
if (key >= 0) {
|
||||
verificationCodeView.append(key);
|
||||
} else {
|
||||
verificationCodeView.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
header.setText(requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, viewModel.getNumber().getFullFormattedNumber()));
|
||||
|
||||
viewModel.getCanCallAtTime().observe(getViewLifecycleOwner(), callAtTime -> callMeCountDown.startCountDownTo(callAtTime));
|
||||
}
|
||||
|
||||
private void sendEmailToSupport() {
|
||||
String body = SupportEmailUtil.generateSupportEmailBody(requireContext(),
|
||||
R.string.RegistrationActivity_code_support_subject,
|
||||
null,
|
||||
null);
|
||||
CommunicationActions.openEmail(requireContext(),
|
||||
SupportEmailUtil.getSupportEmailAddress(requireContext()),
|
||||
getString(R.string.RegistrationActivity_code_support_subject),
|
||||
body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNoCellSignalPresent() {
|
||||
if (serviceWarning.getVisibility() == View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
serviceWarning.setVisibility(View.VISIBLE);
|
||||
serviceWarning.animate()
|
||||
.alpha(1)
|
||||
.setListener(null)
|
||||
.start();
|
||||
|
||||
scrollView.postDelayed(() -> {
|
||||
if (serviceWarning.getVisibility() == View.VISIBLE) {
|
||||
scrollView.smoothScrollTo(0, serviceWarning.getBottom());
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCellSignalPresent() {
|
||||
if (serviceWarning.getVisibility() != View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
serviceWarning.animate()
|
||||
.alpha(0)
|
||||
.setListener(new Animator.AnimatorListener() {
|
||||
@Override public void onAnimationEnd(Animator animation) {
|
||||
serviceWarning.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@Override public void onAnimationStart(Animator animation) {}
|
||||
|
||||
@Override public void onAnimationCancel(Animator animation) {}
|
||||
|
||||
@Override public void onAnimationRepeat(Animator animation) {}
|
||||
})
|
||||
.start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,20 +7,13 @@ import static org.thoughtcrime.securesms.util.CircularProgressButtonUtil.setSpin
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.Toast;
|
||||
@@ -29,7 +22,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
@@ -41,15 +34,15 @@ import com.google.android.gms.common.ConnectionResult;
|
||||
import com.google.android.gms.common.GoogleApiAvailability;
|
||||
import com.google.android.gms.tasks.Task;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.i18n.phonenumbers.AsYouTypeFormatter;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.LabeledEditText;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository.Mode;
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationNumberInputController;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.util.Dialogs;
|
||||
@@ -61,14 +54,12 @@ import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
public final class EnterPhoneNumberFragment extends LoggingFragment {
|
||||
public final class EnterPhoneNumberFragment extends LoggingFragment implements RegistrationNumberInputController.Callbacks {
|
||||
|
||||
private static final String TAG = Log.tag(EnterPhoneNumberFragment.class);
|
||||
|
||||
private LabeledEditText countryCode;
|
||||
private LabeledEditText number;
|
||||
private ArrayAdapter<String> countrySpinnerAdapter;
|
||||
private AsYouTypeFormatter countryFormatter;
|
||||
private CircularProgressButton register;
|
||||
private Spinner countrySpinner;
|
||||
private View cancel;
|
||||
@@ -101,14 +92,17 @@ public final class EnterPhoneNumberFragment extends LoggingFragment {
|
||||
scrollView = view.findViewById(R.id.scroll_view);
|
||||
register = view.findViewById(R.id.registerButton);
|
||||
|
||||
initializeSpinner(countrySpinner);
|
||||
|
||||
setUpNumberInput();
|
||||
RegistrationNumberInputController controller = new RegistrationNumberInputController(requireContext(),
|
||||
countryCode,
|
||||
number,
|
||||
countrySpinner,
|
||||
false,
|
||||
this);
|
||||
|
||||
register.setOnClickListener(v -> handleRegister(requireContext()));
|
||||
|
||||
disposables.bindTo(getViewLifecycleOwner().getLifecycle());
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(RegistrationViewModel.class);
|
||||
viewModel = new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
|
||||
|
||||
if (viewModel.isReregister()) {
|
||||
cancel.setVisibility(View.VISIBLE);
|
||||
@@ -117,18 +111,12 @@ public final class EnterPhoneNumberFragment extends LoggingFragment {
|
||||
cancel.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
NumberViewState number = viewModel.getNumber();
|
||||
|
||||
initNumber(number);
|
||||
|
||||
countryCode.getInput().addTextChangedListener(new CountryCodeChangedListener());
|
||||
viewModel.getLiveNumber().observe(getViewLifecycleOwner(), controller::updateNumber);
|
||||
|
||||
if (viewModel.hasCaptchaToken()) {
|
||||
handleRegister(requireContext());
|
||||
ThreadUtil.runOnMainDelayed(() -> handleRegister(requireContext()), 250);
|
||||
}
|
||||
|
||||
countryCode.getInput().setImeOptions(EditorInfo.IME_ACTION_NEXT);
|
||||
|
||||
Toolbar toolbar = view.findViewById(R.id.toolbar);
|
||||
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
|
||||
((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(null);
|
||||
@@ -149,28 +137,6 @@ public final class EnterPhoneNumberFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void setUpNumberInput() {
|
||||
EditText numberInput = number.getInput();
|
||||
|
||||
numberInput.addTextChangedListener(new NumberChangedListener());
|
||||
|
||||
number.setOnFocusChangeListener((v, hasFocus) -> {
|
||||
if (hasFocus) {
|
||||
scrollView.postDelayed(() -> scrollView.smoothScrollTo(0, register.getBottom()), 250);
|
||||
}
|
||||
});
|
||||
|
||||
numberInput.setImeOptions(EditorInfo.IME_ACTION_DONE);
|
||||
numberInput.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
ViewUtil.hideKeyboard(requireContext(), v);
|
||||
handleRegister(requireContext());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private void handleRegister(@NonNull Context context) {
|
||||
if (TextUtils.isEmpty(countryCode.getText())) {
|
||||
Toast.makeText(context, getString(R.string.RegistrationActivity_you_must_specify_your_country_code), Toast.LENGTH_LONG).show();
|
||||
@@ -276,154 +242,35 @@ public final class EnterPhoneNumberFragment extends LoggingFragment {
|
||||
disposables.add(request);
|
||||
}
|
||||
|
||||
private void initializeSpinner(Spinner countrySpinner) {
|
||||
countrySpinnerAdapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item);
|
||||
countrySpinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
|
||||
setCountryDisplay(getString(R.string.RegistrationActivity_select_your_country));
|
||||
|
||||
countrySpinner.setAdapter(countrySpinnerAdapter);
|
||||
countrySpinner.setOnTouchListener((view, event) -> {
|
||||
if (event.getAction() == MotionEvent.ACTION_UP) {
|
||||
pickCountry(view);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
countrySpinner.setOnKeyListener((view, keyCode, event) -> {
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && event.getAction() == KeyEvent.ACTION_UP) {
|
||||
pickCountry(view);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
@Override
|
||||
public void onNumberFocused() {
|
||||
scrollView.postDelayed(() -> scrollView.smoothScrollTo(0, register.getBottom()), 250);
|
||||
}
|
||||
|
||||
private void pickCountry(@NonNull View view) {
|
||||
@Override
|
||||
public void onNumberInputNext(@NonNull View view) {
|
||||
// Intentionally left blank
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNumberInputDone(@NonNull View view) {
|
||||
ViewUtil.hideKeyboard(requireContext(), view);
|
||||
handleRegister(requireContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPickCountry(@NonNull View view) {
|
||||
Navigation.findNavController(view).navigate(R.id.action_pickCountry);
|
||||
}
|
||||
|
||||
private void initNumber(@NonNull NumberViewState numberViewState) {
|
||||
int countryCode = numberViewState.getCountryCode();
|
||||
String number = numberViewState.getNationalNumber();
|
||||
String regionDisplayName = numberViewState.getCountryDisplayName();
|
||||
|
||||
this.countryCode.setText(String.valueOf(countryCode));
|
||||
|
||||
setCountryDisplay(regionDisplayName);
|
||||
|
||||
String regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode);
|
||||
setCountryFormatter(regionCode);
|
||||
|
||||
if (!TextUtils.isEmpty(number)) {
|
||||
this.number.setText(String.valueOf(number));
|
||||
}
|
||||
@Override
|
||||
public void setNationalNumber(@NonNull String number) {
|
||||
viewModel.setNationalNumber(number);
|
||||
}
|
||||
|
||||
private void setCountryDisplay(String regionDisplayName) {
|
||||
countrySpinnerAdapter.clear();
|
||||
if (regionDisplayName == null) {
|
||||
countrySpinnerAdapter.add(getString(R.string.RegistrationActivity_select_your_country));
|
||||
} else {
|
||||
countrySpinnerAdapter.add(regionDisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
private class CountryCodeChangedListener implements TextWatcher {
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
if (TextUtils.isEmpty(s) || !TextUtils.isDigitsOnly(s)) {
|
||||
setCountryDisplay(null);
|
||||
countryFormatter = null;
|
||||
return;
|
||||
}
|
||||
|
||||
int countryCode = Integer.parseInt(s.toString());
|
||||
String regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode);
|
||||
|
||||
setCountryFormatter(regionCode);
|
||||
|
||||
if (!TextUtils.isEmpty(regionCode) && !regionCode.equals("ZZ")) {
|
||||
number.requestFocus();
|
||||
|
||||
int numberLength = number.getText().length();
|
||||
number.getInput().setSelection(numberLength, numberLength);
|
||||
}
|
||||
|
||||
viewModel.onCountrySelected(null, countryCode);
|
||||
setCountryDisplay(viewModel.getNumber().getCountryDisplayName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
}
|
||||
}
|
||||
|
||||
private class NumberChangedListener implements TextWatcher {
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
String number = reformatText(s);
|
||||
|
||||
if (number == null) return;
|
||||
|
||||
viewModel.setNationalNumber(number);
|
||||
|
||||
setCountryDisplay(viewModel.getNumber().getCountryDisplayName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private String reformatText(Editable s) {
|
||||
if (countryFormatter == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(s)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
countryFormatter.clear();
|
||||
|
||||
String formattedNumber = null;
|
||||
StringBuilder justDigits = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (Character.isDigit(c)) {
|
||||
formattedNumber = countryFormatter.inputDigit(c);
|
||||
justDigits.append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (formattedNumber != null && !s.toString().equals(formattedNumber)) {
|
||||
s.replace(0, s.length(), formattedNumber);
|
||||
}
|
||||
|
||||
if (justDigits.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return justDigits.toString();
|
||||
}
|
||||
|
||||
private void setCountryFormatter(@Nullable String regionCode) {
|
||||
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
|
||||
|
||||
countryFormatter = regionCode != null ? util.getAsYouTypeFormatter(regionCode) : null;
|
||||
|
||||
reformatText(number.getText());
|
||||
@Override
|
||||
public void setCountry(int countryCode) {
|
||||
viewModel.onCountrySelected(null, countryCode);
|
||||
}
|
||||
|
||||
private void handlePromptForNoPlayServices(@NonNull Context context) {
|
||||
|
||||
@@ -1,314 +1,48 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import static org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView;
|
||||
import static org.thoughtcrime.securesms.util.CircularProgressButtonUtil.cancelSpinning;
|
||||
import static org.thoughtcrime.securesms.util.CircularProgressButtonUtil.setSpinning;
|
||||
|
||||
import android.content.res.Resources;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.dd.CircularProgressButton;
|
||||
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.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob;
|
||||
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType;
|
||||
import org.thoughtcrime.securesms.pin.TokenData;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
public final class RegistrationLockFragment extends LoggingFragment {
|
||||
public final class RegistrationLockFragment extends BaseRegistrationLockFragment {
|
||||
|
||||
private static final String TAG = Log.tag(RegistrationLockFragment.class);
|
||||
|
||||
/**
|
||||
* Applies to both V1 and V2 pins, because some V2 pins may have been migrated from V1.
|
||||
*/
|
||||
private static final int MINIMUM_PIN_LENGTH = 4;
|
||||
|
||||
private EditText pinEntry;
|
||||
private View forgotPin;
|
||||
private CircularProgressButton pinButton;
|
||||
private TextView errorLabel;
|
||||
private TextView keyboardToggle;
|
||||
private long timeRemaining;
|
||||
private boolean isV1RegistrationLock;
|
||||
private RegistrationViewModel viewModel;
|
||||
|
||||
private final LifecycleDisposable disposables = new LifecycleDisposable();
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_registration_lock, container, false);
|
||||
public RegistrationLockFragment() {
|
||||
super(R.layout.fragment_registration_lock);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title));
|
||||
|
||||
pinEntry = view.findViewById(R.id.kbs_lock_pin_input);
|
||||
pinButton = view.findViewById(R.id.kbs_lock_pin_confirm);
|
||||
errorLabel = view.findViewById(R.id.kbs_lock_pin_input_label);
|
||||
keyboardToggle = view.findViewById(R.id.kbs_lock_keyboard_toggle);
|
||||
forgotPin = view.findViewById(R.id.kbs_lock_forgot_pin);
|
||||
|
||||
RegistrationLockFragmentArgs args = RegistrationLockFragmentArgs.fromBundle(requireArguments());
|
||||
|
||||
timeRemaining = args.getTimeRemaining();
|
||||
isV1RegistrationLock = args.getIsV1RegistrationLock();
|
||||
|
||||
if (isV1RegistrationLock) {
|
||||
keyboardToggle.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
forgotPin.setVisibility(View.GONE);
|
||||
forgotPin.setOnClickListener(v -> handleForgottenPin(timeRemaining));
|
||||
|
||||
pinEntry.setImeOptions(EditorInfo.IME_ACTION_DONE);
|
||||
pinEntry.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
ViewUtil.hideKeyboard(requireContext(), v);
|
||||
handlePinEntry();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
enableAndFocusPinEntry();
|
||||
|
||||
pinButton.setOnClickListener((v) -> {
|
||||
ViewUtil.hideKeyboard(requireContext(), pinEntry);
|
||||
handlePinEntry();
|
||||
});
|
||||
|
||||
keyboardToggle.setOnClickListener((v) -> {
|
||||
PinKeyboardType keyboardType = getPinEntryKeyboardType();
|
||||
|
||||
updateKeyboard(keyboardType.getOther());
|
||||
keyboardToggle.setText(resolveKeyboardToggleText(keyboardType));
|
||||
});
|
||||
|
||||
PinKeyboardType keyboardType = getPinEntryKeyboardType().getOther();
|
||||
keyboardToggle.setText(resolveKeyboardToggleText(keyboardType));
|
||||
|
||||
disposables.bindTo(getViewLifecycleOwner().getLifecycle());
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(RegistrationViewModel.class);
|
||||
|
||||
viewModel.getLockedTimeRemaining()
|
||||
.observe(getViewLifecycleOwner(), t -> timeRemaining = t);
|
||||
|
||||
TokenData keyBackupCurrentToken = viewModel.getKeyBackupCurrentToken();
|
||||
|
||||
if (keyBackupCurrentToken != null) {
|
||||
int triesRemaining = keyBackupCurrentToken.getTriesRemaining();
|
||||
if (triesRemaining <= 3) {
|
||||
int daysRemaining = getLockoutDays(timeRemaining);
|
||||
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationLockFragment__not_many_tries_left)
|
||||
.setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support, (dialog, which) -> sendEmailToSupport())
|
||||
.show();
|
||||
}
|
||||
|
||||
if (triesRemaining < 5) {
|
||||
errorLabel.setText(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining));
|
||||
}
|
||||
}
|
||||
protected BaseRegistrationViewModel getViewModel() {
|
||||
return new ViewModelProvider(requireActivity()).get(RegistrationViewModel.class);
|
||||
}
|
||||
|
||||
private String getTriesRemainingDialogMessage(int triesRemaining, int daysRemaining) {
|
||||
Resources resources = requireContext().getResources();
|
||||
String tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining);
|
||||
String days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining);
|
||||
|
||||
return tries + " " + days;
|
||||
}
|
||||
|
||||
private PinKeyboardType getPinEntryKeyboardType() {
|
||||
boolean isNumeric = (pinEntry.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER;
|
||||
|
||||
return isNumeric ? PinKeyboardType.NUMERIC : PinKeyboardType.ALPHA_NUMERIC;
|
||||
}
|
||||
|
||||
private void handlePinEntry() {
|
||||
pinEntry.setEnabled(false);
|
||||
|
||||
final String pin = pinEntry.getText().toString();
|
||||
|
||||
int trimmedLength = pin.replace(" ", "").length();
|
||||
if (trimmedLength == 0) {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show();
|
||||
enableAndFocusPinEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedLength < MINIMUM_PIN_LENGTH) {
|
||||
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show();
|
||||
enableAndFocusPinEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
setSpinning(pinButton);
|
||||
|
||||
Disposable verify = viewModel.verifyCodeAndRegisterAccountWithRegistrationLock(pin)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
handleSuccessfulPinEntry();
|
||||
} else if (processor.wrongPin()) {
|
||||
onIncorrectKbsRegistrationLockPin(processor.getToken());
|
||||
} else if (processor.isKbsLocked()) {
|
||||
onKbsAccountLocked();
|
||||
} else if (processor.rateLimit()) {
|
||||
onRateLimited();
|
||||
} else {
|
||||
Log.w(TAG, "Unable to verify code with registration lock", processor.getError());
|
||||
onError();
|
||||
}
|
||||
});
|
||||
|
||||
disposables.add(verify);
|
||||
}
|
||||
|
||||
public void onIncorrectKbsRegistrationLockPin(@NonNull TokenData tokenData) {
|
||||
cancelSpinning(pinButton);
|
||||
pinEntry.getText().clear();
|
||||
enableAndFocusPinEntry();
|
||||
|
||||
viewModel.setKeyBackupTokenData(tokenData);
|
||||
|
||||
int triesRemaining = tokenData.getTriesRemaining();
|
||||
|
||||
if (triesRemaining == 0) {
|
||||
Log.w(TAG, "Account locked. User out of attempts on KBS.");
|
||||
onAccountLocked();
|
||||
return;
|
||||
}
|
||||
|
||||
if (triesRemaining == 3) {
|
||||
int daysRemaining = getLockoutDays(timeRemaining);
|
||||
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationLockFragment__incorrect_pin)
|
||||
.setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
if (triesRemaining > 5) {
|
||||
errorLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again);
|
||||
} else {
|
||||
errorLabel.setText(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, triesRemaining, triesRemaining));
|
||||
forgotPin.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void onRateLimited() {
|
||||
cancelSpinning(pinButton);
|
||||
enableAndFocusPinEntry();
|
||||
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationActivity_too_many_attempts)
|
||||
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
|
||||
public void onKbsAccountLocked() {
|
||||
onAccountLocked();
|
||||
}
|
||||
|
||||
|
||||
public void onError() {
|
||||
cancelSpinning(pinButton);
|
||||
enableAndFocusPinEntry();
|
||||
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
private void handleForgottenPin(long timeRemainingMs) {
|
||||
int lockoutDays = getLockoutDays(timeRemainingMs);
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationLockFragment__forgot_your_pin)
|
||||
.setMessage(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support, (dialog, which) -> sendEmailToSupport())
|
||||
.show();
|
||||
}
|
||||
|
||||
private static int getLockoutDays(long timeRemainingMs) {
|
||||
return (int) TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1;
|
||||
}
|
||||
|
||||
private void onAccountLocked() {
|
||||
@Override
|
||||
protected void navigateToAccountLocked() {
|
||||
Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionAccountLocked());
|
||||
}
|
||||
|
||||
private void updateKeyboard(@NonNull PinKeyboardType keyboard) {
|
||||
boolean isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC;
|
||||
|
||||
pinEntry.setInputType(isAlphaNumeric ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
: InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
|
||||
|
||||
pinEntry.getText().clear();
|
||||
}
|
||||
|
||||
private @StringRes static int resolveKeyboardToggleText(@NonNull PinKeyboardType keyboard) {
|
||||
if (keyboard == PinKeyboardType.ALPHA_NUMERIC) {
|
||||
return R.string.RegistrationLockFragment__enter_alphanumeric_pin;
|
||||
} else {
|
||||
return R.string.RegistrationLockFragment__enter_numeric_pin;
|
||||
}
|
||||
}
|
||||
|
||||
private void enableAndFocusPinEntry() {
|
||||
pinEntry.setEnabled(true);
|
||||
pinEntry.setFocusable(true);
|
||||
|
||||
if (pinEntry.requestFocus()) {
|
||||
ServiceUtil.getInputMethodManager(pinEntry.getContext()).showSoftInput(pinEntry, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSuccessfulPinEntry() {
|
||||
@Override
|
||||
protected void handleSuccessfulPinEntry(@NonNull String pin) {
|
||||
SignalStore.pinValues().setKeyboardType(getPinEntryKeyboardType());
|
||||
|
||||
SimpleTask.run(() -> {
|
||||
@@ -338,9 +72,9 @@ public final class RegistrationLockFragment extends LoggingFragment {
|
||||
});
|
||||
}
|
||||
|
||||
private void sendEmailToSupport() {
|
||||
int subject = isV1RegistrationLock ? R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v1_pin
|
||||
: R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v2_pin;
|
||||
@Override
|
||||
protected void sendEmailToSupport() {
|
||||
int subject = R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v2_pin;
|
||||
|
||||
String body = SupportEmailUtil.generateSupportEmailBody(requireContext(),
|
||||
subject,
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package org.thoughtcrime.securesms.registration.util;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Spinner;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.i18n.phonenumbers.AsYouTypeFormatter;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.LabeledEditText;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState;
|
||||
|
||||
/**
|
||||
* Handle the logic and formatting of phone number input for registration/change number flows.
|
||||
*/
|
||||
public final class RegistrationNumberInputController {
|
||||
|
||||
private final Context context;
|
||||
private final LabeledEditText countryCode;
|
||||
private final LabeledEditText number;
|
||||
private final boolean lastInput;
|
||||
private final Callbacks callbacks;
|
||||
|
||||
private ArrayAdapter<String> countrySpinnerAdapter;
|
||||
private AsYouTypeFormatter countryFormatter;
|
||||
private boolean isUpdating = true;
|
||||
|
||||
public RegistrationNumberInputController(@NonNull Context context,
|
||||
@NonNull LabeledEditText countryCode,
|
||||
@NonNull LabeledEditText number,
|
||||
@NonNull Spinner countrySpinner,
|
||||
boolean lastInput,
|
||||
@NonNull Callbacks callbacks)
|
||||
{
|
||||
this.context = context;
|
||||
this.countryCode = countryCode;
|
||||
this.number = number;
|
||||
this.lastInput = lastInput;
|
||||
this.callbacks = callbacks;
|
||||
|
||||
initializeSpinner(countrySpinner);
|
||||
setUpNumberInput();
|
||||
|
||||
this.countryCode.getInput().addTextChangedListener(new CountryCodeChangedListener());
|
||||
this.countryCode.getInput().setImeOptions(EditorInfo.IME_ACTION_NEXT);
|
||||
}
|
||||
|
||||
private void setUpNumberInput() {
|
||||
EditText numberInput = number.getInput();
|
||||
|
||||
numberInput.addTextChangedListener(new NumberChangedListener());
|
||||
|
||||
number.setOnFocusChangeListener((v, hasFocus) -> {
|
||||
if (hasFocus) {
|
||||
callbacks.onNumberFocused();
|
||||
}
|
||||
});
|
||||
|
||||
numberInput.setImeOptions(lastInput ? EditorInfo.IME_ACTION_DONE : EditorInfo.IME_ACTION_NEXT);
|
||||
numberInput.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_NEXT) {
|
||||
callbacks.onNumberInputNext(v);
|
||||
return true;
|
||||
} else if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
callbacks.onNumberInputDone(v);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private void initializeSpinner(@NonNull Spinner countrySpinner) {
|
||||
countrySpinnerAdapter = new ArrayAdapter<>(context, android.R.layout.simple_spinner_item);
|
||||
countrySpinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
|
||||
setCountryDisplay(context.getString(R.string.RegistrationActivity_select_your_country));
|
||||
|
||||
countrySpinner.setAdapter(countrySpinnerAdapter);
|
||||
|
||||
countrySpinner.setOnTouchListener((view, event) -> {
|
||||
if (event.getAction() == MotionEvent.ACTION_UP) {
|
||||
callbacks.onPickCountry(view);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
countrySpinner.setOnKeyListener((view, keyCode, event) -> {
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && event.getAction() == KeyEvent.ACTION_UP) {
|
||||
callbacks.onPickCountry(view);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
public void updateNumber(@NonNull NumberViewState numberViewState) {
|
||||
int countryCode = numberViewState.getCountryCode();
|
||||
String countryCodeString = String.valueOf(countryCode);
|
||||
String number = numberViewState.getNationalNumber();
|
||||
String regionDisplayName = numberViewState.getCountryDisplayName();
|
||||
|
||||
isUpdating = true;
|
||||
|
||||
setCountryDisplay(regionDisplayName);
|
||||
|
||||
if (this.countryCode.getText() == null || !this.countryCode.getText().toString().equals(countryCodeString)) {
|
||||
this.countryCode.setText(countryCodeString);
|
||||
|
||||
String regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode);
|
||||
setCountryFormatter(regionCode);
|
||||
}
|
||||
|
||||
if (!justDigits(this.number.getText()).equals(number) && !TextUtils.isEmpty(number)) {
|
||||
this.number.setText(number);
|
||||
}
|
||||
|
||||
isUpdating = false;
|
||||
}
|
||||
|
||||
private String justDigits(@Nullable Editable text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder justDigits = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (Character.isDigit(c)) {
|
||||
justDigits.append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return justDigits.toString();
|
||||
}
|
||||
|
||||
private void setCountryDisplay(@Nullable String regionDisplayName) {
|
||||
countrySpinnerAdapter.clear();
|
||||
if (regionDisplayName == null) {
|
||||
countrySpinnerAdapter.add(context.getString(R.string.RegistrationActivity_select_your_country));
|
||||
} else {
|
||||
countrySpinnerAdapter.add(regionDisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
private void setCountryFormatter(@Nullable String regionCode) {
|
||||
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
|
||||
|
||||
countryFormatter = regionCode != null ? util.getAsYouTypeFormatter(regionCode) : null;
|
||||
|
||||
reformatText(number.getText());
|
||||
}
|
||||
|
||||
private String reformatText(Editable s) {
|
||||
if (countryFormatter == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(s)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
countryFormatter.clear();
|
||||
|
||||
String formattedNumber = null;
|
||||
StringBuilder justDigits = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (Character.isDigit(c)) {
|
||||
formattedNumber = countryFormatter.inputDigit(c);
|
||||
justDigits.append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (formattedNumber != null && !s.toString().equals(formattedNumber)) {
|
||||
s.replace(0, s.length(), formattedNumber);
|
||||
}
|
||||
|
||||
if (justDigits.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return justDigits.toString();
|
||||
}
|
||||
|
||||
private class NumberChangedListener implements TextWatcher {
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
String number = reformatText(s);
|
||||
|
||||
if (number == null) return;
|
||||
|
||||
if (!isUpdating) {
|
||||
callbacks.setNationalNumber(number);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private class CountryCodeChangedListener implements TextWatcher {
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
if (TextUtils.isEmpty(s) || !TextUtils.isDigitsOnly(s)) {
|
||||
setCountryDisplay(null);
|
||||
countryFormatter = null;
|
||||
return;
|
||||
}
|
||||
|
||||
int countryCode = Integer.parseInt(s.toString());
|
||||
String regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode);
|
||||
|
||||
setCountryFormatter(regionCode);
|
||||
|
||||
if (!TextUtils.isEmpty(regionCode) && !regionCode.equals("ZZ")) {
|
||||
if (!isUpdating) {
|
||||
number.requestFocus();
|
||||
}
|
||||
|
||||
int numberLength = number.getText().length();
|
||||
number.getInput().setSelection(numberLength, numberLength);
|
||||
}
|
||||
|
||||
if (!isUpdating) {
|
||||
callbacks.setCountry(countryCode);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
}
|
||||
}
|
||||
|
||||
public interface Callbacks {
|
||||
void onNumberFocused();
|
||||
|
||||
void onNumberInputNext(@NonNull View view);
|
||||
|
||||
void onNumberInputDone(@NonNull View view);
|
||||
|
||||
void onPickCountry(@NonNull View view);
|
||||
|
||||
void setNationalNumber(@NonNull String number);
|
||||
|
||||
void setCountry(int countryCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package org.thoughtcrime.securesms.registration.viewmodel;
|
||||
|
||||
public final class BaseEnterCodeViewModelDelegate {
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package org.thoughtcrime.securesms.registration.viewmodel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.SavedStateHandle;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.thoughtcrime.securesms.pin.KbsRepository;
|
||||
import org.thoughtcrime.securesms.pin.TokenData;
|
||||
import org.thoughtcrime.securesms.registration.RequestVerificationCodeResponseProcessor;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository.Mode;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseProcessor;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseWithFailedKbs;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseWithSuccessfulKbs;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseWithoutKbs;
|
||||
import org.thoughtcrime.securesms.registration.VerifyCodeWithRegistrationLockResponseProcessor;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
|
||||
/**
|
||||
* Base view model used in registration and change number flow. Handles the storage of all data
|
||||
* shared between the two flows, orchestrating verification, and calling to subclasses to peform
|
||||
* the specific verify operations for each flow.
|
||||
*/
|
||||
public abstract class BaseRegistrationViewModel extends ViewModel {
|
||||
|
||||
private static final long FIRST_CALL_AVAILABLE_AFTER_MS = TimeUnit.SECONDS.toMillis(64);
|
||||
private static final long SUBSEQUENT_CALL_AVAILABLE_AFTER_MS = TimeUnit.SECONDS.toMillis(300);
|
||||
|
||||
private static final String STATE_NUMBER = "NUMBER";
|
||||
private static final String STATE_REGISTRATION_SECRET = "REGISTRATION_SECRET";
|
||||
private static final String STATE_VERIFICATION_CODE = "TEXT_CODE_ENTERED";
|
||||
private static final String STATE_CAPTCHA = "CAPTCHA";
|
||||
private static final String STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS = "SUCCESSFUL_CODE_REQUEST_ATTEMPTS";
|
||||
private static final String STATE_REQUEST_RATE_LIMITER = "REQUEST_RATE_LIMITER";
|
||||
private static final String STATE_KBS_TOKEN = "KBS_TOKEN";
|
||||
private static final String STATE_TIME_REMAINING = "TIME_REMAINING";
|
||||
private static final String STATE_CAN_CALL_AT_TIME = "CAN_CALL_AT_TIME";
|
||||
|
||||
protected final SavedStateHandle savedState;
|
||||
protected final VerifyAccountRepository verifyAccountRepository;
|
||||
protected final KbsRepository kbsRepository;
|
||||
|
||||
public BaseRegistrationViewModel(@NonNull SavedStateHandle savedStateHandle,
|
||||
@NonNull VerifyAccountRepository verifyAccountRepository,
|
||||
@NonNull KbsRepository kbsRepository,
|
||||
@NonNull String password)
|
||||
{
|
||||
this.savedState = savedStateHandle;
|
||||
|
||||
this.verifyAccountRepository = verifyAccountRepository;
|
||||
this.kbsRepository = kbsRepository;
|
||||
|
||||
setInitialDefaultValue(STATE_NUMBER, NumberViewState.INITIAL);
|
||||
setInitialDefaultValue(STATE_REGISTRATION_SECRET, password);
|
||||
setInitialDefaultValue(STATE_VERIFICATION_CODE, "");
|
||||
setInitialDefaultValue(STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS, 0);
|
||||
setInitialDefaultValue(STATE_REQUEST_RATE_LIMITER, new LocalCodeRequestRateLimiter(60_000));
|
||||
}
|
||||
|
||||
protected <T> void setInitialDefaultValue(@NonNull String key, @NonNull T initialValue) {
|
||||
if (!savedState.contains(key) || savedState.get(key) == null) {
|
||||
savedState.set(key, initialValue);
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull NumberViewState getNumber() {
|
||||
//noinspection ConstantConditions
|
||||
return savedState.get(STATE_NUMBER);
|
||||
}
|
||||
|
||||
public @NonNull LiveData<NumberViewState> getLiveNumber() {
|
||||
return savedState.getLiveData(STATE_NUMBER);
|
||||
}
|
||||
|
||||
public void onCountrySelected(@Nullable String selectedCountryName, int countryCode) {
|
||||
setViewState(getNumber().toBuilder()
|
||||
.selectedCountryDisplayName(selectedCountryName)
|
||||
.countryCode(countryCode).build());
|
||||
}
|
||||
|
||||
public void setNationalNumber(String number) {
|
||||
NumberViewState numberViewState = getNumber().toBuilder().nationalNumber(number).build();
|
||||
setViewState(numberViewState);
|
||||
}
|
||||
|
||||
protected void setViewState(NumberViewState numberViewState) {
|
||||
if (!numberViewState.equals(getNumber())) {
|
||||
savedState.set(STATE_NUMBER, numberViewState);
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull String getRegistrationSecret() {
|
||||
//noinspection ConstantConditions
|
||||
return savedState.get(STATE_REGISTRATION_SECRET);
|
||||
}
|
||||
|
||||
public @NonNull String getTextCodeEntered() {
|
||||
//noinspection ConstantConditions
|
||||
return savedState.get(STATE_VERIFICATION_CODE);
|
||||
}
|
||||
|
||||
public @Nullable String getCaptchaToken() {
|
||||
return savedState.get(STATE_CAPTCHA);
|
||||
}
|
||||
|
||||
public boolean hasCaptchaToken() {
|
||||
return getCaptchaToken() != null;
|
||||
}
|
||||
|
||||
public void setCaptchaResponse(@Nullable String captchaToken) {
|
||||
savedState.set(STATE_CAPTCHA, captchaToken);
|
||||
}
|
||||
|
||||
public void clearCaptchaResponse() {
|
||||
setCaptchaResponse(null);
|
||||
}
|
||||
|
||||
public void onVerificationCodeEntered(String code) {
|
||||
savedState.set(STATE_VERIFICATION_CODE, code);
|
||||
}
|
||||
|
||||
public void markASuccessfulAttempt() {
|
||||
//noinspection ConstantConditions
|
||||
savedState.set(STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS, (Integer) savedState.get(STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS) + 1);
|
||||
}
|
||||
|
||||
public LiveData<Integer> getSuccessfulCodeRequestAttempts() {
|
||||
return savedState.getLiveData(STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS, 0);
|
||||
}
|
||||
|
||||
public @NonNull LocalCodeRequestRateLimiter getRequestLimiter() {
|
||||
//noinspection ConstantConditions
|
||||
return savedState.get(STATE_REQUEST_RATE_LIMITER);
|
||||
}
|
||||
|
||||
public void updateLimiter() {
|
||||
savedState.set(STATE_REQUEST_RATE_LIMITER, savedState.get(STATE_REQUEST_RATE_LIMITER));
|
||||
}
|
||||
|
||||
public @Nullable TokenData getKeyBackupCurrentToken() {
|
||||
return savedState.get(STATE_KBS_TOKEN);
|
||||
}
|
||||
|
||||
public void setKeyBackupTokenData(@Nullable TokenData tokenData) {
|
||||
savedState.set(STATE_KBS_TOKEN, tokenData);
|
||||
}
|
||||
|
||||
public LiveData<Long> getLockedTimeRemaining() {
|
||||
return savedState.getLiveData(STATE_TIME_REMAINING, 0L);
|
||||
}
|
||||
|
||||
public LiveData<Long> getCanCallAtTime() {
|
||||
return savedState.getLiveData(STATE_CAN_CALL_AT_TIME, 0L);
|
||||
}
|
||||
|
||||
public void setLockedTimeRemaining(long lockedTimeRemaining) {
|
||||
savedState.set(STATE_TIME_REMAINING, lockedTimeRemaining);
|
||||
}
|
||||
|
||||
public void onStartEnterCode() {
|
||||
savedState.set(STATE_CAN_CALL_AT_TIME, System.currentTimeMillis() + FIRST_CALL_AVAILABLE_AFTER_MS);
|
||||
}
|
||||
|
||||
public void onCallRequested() {
|
||||
savedState.set(STATE_CAN_CALL_AT_TIME, System.currentTimeMillis() + SUBSEQUENT_CALL_AVAILABLE_AFTER_MS);
|
||||
}
|
||||
|
||||
public Single<RequestVerificationCodeResponseProcessor> requestVerificationCode(@NonNull Mode mode) {
|
||||
String captcha = getCaptchaToken();
|
||||
clearCaptchaResponse();
|
||||
|
||||
if (mode == Mode.PHONE_CALL) {
|
||||
onCallRequested();
|
||||
} else if (!getRequestLimiter().canRequest(mode, getNumber().getE164Number(), System.currentTimeMillis())) {
|
||||
return Single.just(RequestVerificationCodeResponseProcessor.forLocalRateLimit());
|
||||
}
|
||||
|
||||
return verifyAccountRepository.requestVerificationCode(getNumber().getE164Number(),
|
||||
getRegistrationSecret(),
|
||||
mode,
|
||||
captcha)
|
||||
.map(RequestVerificationCodeResponseProcessor::new)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSuccess(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
markASuccessfulAttempt();
|
||||
getRequestLimiter().onSuccessfulRequest(mode, getNumber().getE164Number(), System.currentTimeMillis());
|
||||
} else {
|
||||
getRequestLimiter().onUnsuccessfulRequest();
|
||||
}
|
||||
updateLimiter();
|
||||
});
|
||||
}
|
||||
|
||||
public Single<VerifyAccountResponseProcessor> verifyCodeWithoutRegistrationLock(@NonNull String code) {
|
||||
onVerificationCodeEntered(code);
|
||||
|
||||
return verifyAccountWithoutRegistrationLock()
|
||||
.map(VerifyAccountResponseWithoutKbs::new)
|
||||
.flatMap(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
return onVerifySuccess(processor);
|
||||
} else if (processor.registrationLock() && !processor.isKbsLocked()) {
|
||||
return kbsRepository.getToken(processor.getLockedException().getBasicStorageCredentials())
|
||||
.map(r -> r.getResult().isPresent() ? new VerifyAccountResponseWithSuccessfulKbs(processor.getResponse(), r.getResult().get())
|
||||
: new VerifyAccountResponseWithFailedKbs(r));
|
||||
}
|
||||
return Single.just(processor);
|
||||
})
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSuccess(processor -> {
|
||||
if (processor.registrationLock() && !processor.isKbsLocked()) {
|
||||
setLockedTimeRemaining(processor.getLockedException().getTimeRemaining());
|
||||
setKeyBackupTokenData(processor.getTokenData());
|
||||
} else if (processor.isKbsLocked()) {
|
||||
setLockedTimeRemaining(processor.getLockedException().getTimeRemaining());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Single<VerifyCodeWithRegistrationLockResponseProcessor> verifyCodeAndRegisterAccountWithRegistrationLock(@NonNull String pin) {
|
||||
TokenData kbsTokenData = Objects.requireNonNull(getKeyBackupCurrentToken());
|
||||
|
||||
return verifyAccountWithRegistrationLock(pin, kbsTokenData)
|
||||
.map(r -> new VerifyCodeWithRegistrationLockResponseProcessor(r, kbsTokenData))
|
||||
.flatMap(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
return onVerifySuccessWithRegistrationLock(processor, pin);
|
||||
} else if (processor.wrongPin()) {
|
||||
TokenData newToken = TokenData.withResponse(kbsTokenData, processor.getTokenResponse());
|
||||
return Single.just(new VerifyCodeWithRegistrationLockResponseProcessor(processor.getResponse(), newToken));
|
||||
}
|
||||
return Single.just(processor);
|
||||
})
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSuccess(processor -> {
|
||||
if (processor.wrongPin()) {
|
||||
setKeyBackupTokenData(processor.getToken());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract Single<ServiceResponse<VerifyAccountResponse>> verifyAccountWithoutRegistrationLock();
|
||||
|
||||
protected abstract Single<ServiceResponse<VerifyAccountWithRegistrationLockResponse>> verifyAccountWithRegistrationLock(@NonNull String pin, @NonNull TokenData kbsTokenData);
|
||||
|
||||
protected abstract Single<VerifyAccountResponseProcessor> onVerifySuccess(@NonNull VerifyAccountResponseProcessor processor);
|
||||
|
||||
protected abstract Single<VerifyCodeWithRegistrationLockResponseProcessor> onVerifySuccessWithRegistrationLock(@NonNull VerifyCodeWithRegistrationLockResponseProcessor processor, String pin);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.AbstractSavedStateViewModelFactory;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.SavedStateHandle;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.savedstate.SavedStateRegistryOwner;
|
||||
@@ -16,42 +15,22 @@ import org.thoughtcrime.securesms.registration.RegistrationData;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationRepository;
|
||||
import org.thoughtcrime.securesms.registration.RequestVerificationCodeResponseProcessor;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository.Mode;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseProcessor;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseWithFailedKbs;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseWithSuccessfulKbs;
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseWithoutKbs;
|
||||
import org.thoughtcrime.securesms.registration.VerifyCodeWithRegistrationLockResponseProcessor;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
|
||||
public final class RegistrationViewModel extends ViewModel {
|
||||
public final class RegistrationViewModel extends BaseRegistrationViewModel {
|
||||
|
||||
private static final long FIRST_CALL_AVAILABLE_AFTER_MS = TimeUnit.SECONDS.toMillis(64);
|
||||
private static final long SUBSEQUENT_CALL_AVAILABLE_AFTER_MS = TimeUnit.SECONDS.toMillis(300);
|
||||
private static final String STATE_FCM_TOKEN = "FCM_TOKEN";
|
||||
private static final String STATE_RESTORE_FLOW_SHOWN = "RESTORE_FLOW_SHOWN";
|
||||
private static final String STATE_IS_REREGISTER = "IS_REREGISTER";
|
||||
|
||||
private static final String STATE_REGISTRATION_SECRET = "REGISTRATION_SECRET";
|
||||
private static final String STATE_NUMBER = "NUMBER";
|
||||
private static final String STATE_VERIFICATION_CODE = "TEXT_CODE_ENTERED";
|
||||
private static final String STATE_CAPTCHA = "CAPTCHA";
|
||||
private static final String STATE_FCM_TOKEN = "FCM_TOKEN";
|
||||
private static final String STATE_RESTORE_FLOW_SHOWN = "RESTORE_FLOW_SHOWN";
|
||||
private static final String STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS = "SUCCESSFUL_CODE_REQUEST_ATTEMPTS";
|
||||
private static final String STATE_REQUEST_RATE_LIMITER = "REQUEST_RATE_LIMITER";
|
||||
private static final String STATE_KBS_TOKEN = "KBS_TOKEN";
|
||||
private static final String STATE_TIME_REMAINING = "TIME_REMAINING";
|
||||
private static final String STATE_CAN_CALL_AT_TIME = "CAN_CALL_AT_TIME";
|
||||
private static final String STATE_IS_REREGISTER = "IS_REREGISTER";
|
||||
|
||||
private final SavedStateHandle registrationState;
|
||||
private final VerifyAccountRepository verifyAccountRepository;
|
||||
private final KbsRepository kbsRepository;
|
||||
private final RegistrationRepository registrationRepository;
|
||||
private final RegistrationRepository registrationRepository;
|
||||
|
||||
public RegistrationViewModel(@NonNull SavedStateHandle savedStateHandle,
|
||||
boolean isReregister,
|
||||
@@ -59,83 +38,18 @@ public final class RegistrationViewModel extends ViewModel {
|
||||
@NonNull KbsRepository kbsRepository,
|
||||
@NonNull RegistrationRepository registrationRepository)
|
||||
{
|
||||
this.registrationState = savedStateHandle;
|
||||
this.verifyAccountRepository = verifyAccountRepository;
|
||||
this.kbsRepository = kbsRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
super(savedStateHandle, verifyAccountRepository, kbsRepository, Util.getSecret(18));
|
||||
|
||||
setInitialDefaultValue(this.registrationState, STATE_REGISTRATION_SECRET, Util.getSecret(18));
|
||||
setInitialDefaultValue(this.registrationState, STATE_NUMBER, NumberViewState.INITIAL);
|
||||
setInitialDefaultValue(this.registrationState, STATE_VERIFICATION_CODE, "");
|
||||
setInitialDefaultValue(this.registrationState, STATE_RESTORE_FLOW_SHOWN, false);
|
||||
setInitialDefaultValue(this.registrationState, STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS, 0);
|
||||
setInitialDefaultValue(this.registrationState, STATE_REQUEST_RATE_LIMITER, new LocalCodeRequestRateLimiter(60_000));
|
||||
this.registrationRepository = registrationRepository;
|
||||
|
||||
this.registrationState.set(STATE_IS_REREGISTER, isReregister);
|
||||
}
|
||||
setInitialDefaultValue(STATE_RESTORE_FLOW_SHOWN, false);
|
||||
|
||||
private static <T> void setInitialDefaultValue(@NonNull SavedStateHandle savedStateHandle, @NonNull String key, @NonNull T initialValue) {
|
||||
if (!savedStateHandle.contains(key) || savedStateHandle.get(key) == null) {
|
||||
savedStateHandle.set(key, initialValue);
|
||||
}
|
||||
this.savedState.set(STATE_IS_REREGISTER, isReregister);
|
||||
}
|
||||
|
||||
public boolean isReregister() {
|
||||
//noinspection ConstantConditions
|
||||
return registrationState.get(STATE_IS_REREGISTER);
|
||||
}
|
||||
|
||||
public @NonNull NumberViewState getNumber() {
|
||||
//noinspection ConstantConditions
|
||||
return registrationState.get(STATE_NUMBER);
|
||||
}
|
||||
|
||||
public @NonNull String getTextCodeEntered() {
|
||||
//noinspection ConstantConditions
|
||||
return registrationState.get(STATE_VERIFICATION_CODE);
|
||||
}
|
||||
|
||||
private @Nullable String getCaptchaToken() {
|
||||
return registrationState.get(STATE_CAPTCHA);
|
||||
}
|
||||
|
||||
public boolean hasCaptchaToken() {
|
||||
return getCaptchaToken() != null;
|
||||
}
|
||||
|
||||
private @NonNull String getRegistrationSecret() {
|
||||
//noinspection ConstantConditions
|
||||
return registrationState.get(STATE_REGISTRATION_SECRET);
|
||||
}
|
||||
|
||||
public void setCaptchaResponse(@Nullable String captchaToken) {
|
||||
registrationState.set(STATE_CAPTCHA, captchaToken);
|
||||
}
|
||||
|
||||
private void clearCaptchaResponse() {
|
||||
setCaptchaResponse(null);
|
||||
}
|
||||
|
||||
public void onCountrySelected(@Nullable String selectedCountryName, int countryCode) {
|
||||
setViewState(getNumber().toBuilder()
|
||||
.selectedCountryDisplayName(selectedCountryName)
|
||||
.countryCode(countryCode).build());
|
||||
}
|
||||
|
||||
public void setNationalNumber(String number) {
|
||||
NumberViewState numberViewState = getNumber().toBuilder().nationalNumber(number).build();
|
||||
setViewState(numberViewState);
|
||||
}
|
||||
|
||||
private void setViewState(NumberViewState numberViewState) {
|
||||
if (!numberViewState.equals(getNumber())) {
|
||||
registrationState.set(STATE_NUMBER, numberViewState);
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void onVerificationCodeEntered(String code) {
|
||||
registrationState.set(STATE_VERIFICATION_CODE, code);
|
||||
return savedState.get(STATE_IS_REREGISTER);
|
||||
}
|
||||
|
||||
public void onNumberDetected(int countryCode, String nationalNumber) {
|
||||
@@ -145,165 +59,67 @@ public final class RegistrationViewModel extends ViewModel {
|
||||
.build());
|
||||
}
|
||||
|
||||
private @Nullable String getFcmToken() {
|
||||
return registrationState.get(STATE_FCM_TOKEN);
|
||||
public @Nullable String getFcmToken() {
|
||||
return savedState.get(STATE_FCM_TOKEN);
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void setFcmToken(@Nullable String fcmToken) {
|
||||
registrationState.set(STATE_FCM_TOKEN, fcmToken);
|
||||
savedState.set(STATE_FCM_TOKEN, fcmToken);
|
||||
}
|
||||
|
||||
public void setWelcomeSkippedOnRestore() {
|
||||
registrationState.set(STATE_RESTORE_FLOW_SHOWN, true);
|
||||
savedState.set(STATE_RESTORE_FLOW_SHOWN, true);
|
||||
}
|
||||
|
||||
public boolean hasRestoreFlowBeenShown() {
|
||||
//noinspection ConstantConditions
|
||||
return registrationState.get(STATE_RESTORE_FLOW_SHOWN);
|
||||
}
|
||||
|
||||
private void markASuccessfulAttempt() {
|
||||
//noinspection ConstantConditions
|
||||
registrationState.set(STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS, (Integer) registrationState.get(STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS) + 1);
|
||||
}
|
||||
|
||||
public LiveData<Integer> getSuccessfulCodeRequestAttempts() {
|
||||
return registrationState.getLiveData(STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS, 0);
|
||||
}
|
||||
|
||||
private @NonNull LocalCodeRequestRateLimiter getRequestLimiter() {
|
||||
//noinspection ConstantConditions
|
||||
return registrationState.get(STATE_REQUEST_RATE_LIMITER);
|
||||
}
|
||||
|
||||
private void updateLimiter() {
|
||||
registrationState.set(STATE_REQUEST_RATE_LIMITER, registrationState.get(STATE_REQUEST_RATE_LIMITER));
|
||||
}
|
||||
|
||||
public @Nullable TokenData getKeyBackupCurrentToken() {
|
||||
return registrationState.get(STATE_KBS_TOKEN);
|
||||
}
|
||||
|
||||
public void setKeyBackupTokenData(@Nullable TokenData tokenData) {
|
||||
registrationState.set(STATE_KBS_TOKEN, tokenData);
|
||||
}
|
||||
|
||||
public LiveData<Long> getLockedTimeRemaining() {
|
||||
return registrationState.getLiveData(STATE_TIME_REMAINING, 0L);
|
||||
}
|
||||
|
||||
public LiveData<Long> getCanCallAtTime() {
|
||||
return registrationState.getLiveData(STATE_CAN_CALL_AT_TIME, 0L);
|
||||
}
|
||||
|
||||
public void setLockedTimeRemaining(long lockedTimeRemaining) {
|
||||
registrationState.set(STATE_TIME_REMAINING, lockedTimeRemaining);
|
||||
}
|
||||
|
||||
public void onStartEnterCode() {
|
||||
registrationState.set(STATE_CAN_CALL_AT_TIME, System.currentTimeMillis() + FIRST_CALL_AVAILABLE_AFTER_MS);
|
||||
}
|
||||
|
||||
private void onCallRequested() {
|
||||
registrationState.set(STATE_CAN_CALL_AT_TIME, System.currentTimeMillis() + SUBSEQUENT_CALL_AVAILABLE_AFTER_MS);
|
||||
return savedState.get(STATE_RESTORE_FLOW_SHOWN);
|
||||
}
|
||||
|
||||
public void setIsReregister(boolean isReregister) {
|
||||
registrationState.set(STATE_IS_REREGISTER, isReregister);
|
||||
savedState.set(STATE_IS_REREGISTER, isReregister);
|
||||
}
|
||||
|
||||
public Single<RequestVerificationCodeResponseProcessor> requestVerificationCode(@NonNull Mode mode) {
|
||||
String captcha = getCaptchaToken();
|
||||
clearCaptchaResponse();
|
||||
|
||||
if (mode == Mode.PHONE_CALL) {
|
||||
onCallRequested();
|
||||
} else if (!getRequestLimiter().canRequest(mode, getNumber().getE164Number(), System.currentTimeMillis())) {
|
||||
return Single.just(RequestVerificationCodeResponseProcessor.forLocalRateLimit());
|
||||
}
|
||||
|
||||
return verifyAccountRepository.requestVerificationCode(getNumber().getE164Number(),
|
||||
getRegistrationSecret(),
|
||||
mode,
|
||||
captcha)
|
||||
.map(RequestVerificationCodeResponseProcessor::new)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSuccess(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
markASuccessfulAttempt();
|
||||
setFcmToken(processor.getResult().getFcmToken().orNull());
|
||||
getRequestLimiter().onSuccessfulRequest(mode, getNumber().getE164Number(), System.currentTimeMillis());
|
||||
} else {
|
||||
getRequestLimiter().onUnsuccessfulRequest();
|
||||
}
|
||||
updateLimiter();
|
||||
});
|
||||
@Override
|
||||
public Single<RequestVerificationCodeResponseProcessor> requestVerificationCode(@NonNull VerifyAccountRepository.Mode mode) {
|
||||
return super.requestVerificationCode(mode)
|
||||
.doOnSuccess(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
setFcmToken(processor.getResult().getFcmToken().orNull());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Single<VerifyAccountResponseProcessor> verifyCodeAndRegisterAccountWithoutRegistrationLock(@NonNull String code) {
|
||||
onVerificationCodeEntered(code);
|
||||
|
||||
RegistrationData registrationData = new RegistrationData(getTextCodeEntered(),
|
||||
getNumber().getE164Number(),
|
||||
getRegistrationSecret(),
|
||||
registrationRepository.getRegistrationId(),
|
||||
registrationRepository.getProfileKey(getNumber().getE164Number()),
|
||||
getFcmToken());
|
||||
|
||||
return verifyAccountRepository.verifyAccount(registrationData)
|
||||
.map(VerifyAccountResponseWithoutKbs::new)
|
||||
.flatMap(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
return registrationRepository.registerAccountWithoutRegistrationLock(registrationData, processor.getResult())
|
||||
.map(VerifyAccountResponseWithoutKbs::new);
|
||||
} else if (processor.registrationLock() && !processor.isKbsLocked()) {
|
||||
return kbsRepository.getToken(processor.getLockedException().getBasicStorageCredentials())
|
||||
.map(r -> r.getResult().isPresent() ? new VerifyAccountResponseWithSuccessfulKbs(processor.getResponse(), r.getResult().get())
|
||||
: new VerifyAccountResponseWithFailedKbs(r));
|
||||
}
|
||||
return Single.just(processor);
|
||||
})
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSuccess(processor -> {
|
||||
if (processor.registrationLock() && !processor.isKbsLocked()) {
|
||||
setLockedTimeRemaining(processor.getLockedException().getTimeRemaining());
|
||||
setKeyBackupTokenData(processor.getTokenData());
|
||||
} else if (processor.isKbsLocked()) {
|
||||
setLockedTimeRemaining(processor.getLockedException().getTimeRemaining());
|
||||
}
|
||||
});
|
||||
|
||||
@Override
|
||||
protected Single<ServiceResponse<VerifyAccountResponse>> verifyAccountWithoutRegistrationLock() {
|
||||
return verifyAccountRepository.verifyAccount(getRegistrationData());
|
||||
}
|
||||
|
||||
public Single<VerifyCodeWithRegistrationLockResponseProcessor> verifyCodeAndRegisterAccountWithRegistrationLock(@NonNull String pin) {
|
||||
RegistrationData registrationData = new RegistrationData(getTextCodeEntered(),
|
||||
getNumber().getE164Number(),
|
||||
getRegistrationSecret(),
|
||||
registrationRepository.getRegistrationId(),
|
||||
registrationRepository.getProfileKey(getNumber().getE164Number()),
|
||||
getFcmToken());
|
||||
@Override
|
||||
protected Single<ServiceResponse<VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse>> verifyAccountWithRegistrationLock(@NonNull String pin, @NonNull TokenData kbsTokenData) {
|
||||
return verifyAccountRepository.verifyAccountWithPin(getRegistrationData(), pin, kbsTokenData);
|
||||
}
|
||||
|
||||
TokenData kbsTokenData = Objects.requireNonNull(getKeyBackupCurrentToken());
|
||||
@Override
|
||||
protected Single<VerifyAccountResponseProcessor> onVerifySuccess(@NonNull VerifyAccountResponseProcessor processor) {
|
||||
return registrationRepository.registerAccountWithoutRegistrationLock(getRegistrationData(), processor.getResult())
|
||||
.map(VerifyAccountResponseWithoutKbs::new);
|
||||
}
|
||||
|
||||
return verifyAccountRepository.verifyAccountWithPin(registrationData, pin, kbsTokenData)
|
||||
.map(r -> new VerifyCodeWithRegistrationLockResponseProcessor(r, kbsTokenData))
|
||||
.flatMap(processor -> {
|
||||
if (processor.hasResult()) {
|
||||
return registrationRepository.registerAccountWithRegistrationLock(registrationData, processor.getResult(), pin)
|
||||
.map(processor::updatedIfRegistrationFailed);
|
||||
} else if (processor.wrongPin()) {
|
||||
TokenData newToken = TokenData.withResponse(kbsTokenData, processor.getTokenResponse());
|
||||
return Single.just(new VerifyCodeWithRegistrationLockResponseProcessor(processor.getResponse(), newToken));
|
||||
}
|
||||
return Single.just(processor);
|
||||
})
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSuccess(processor -> {
|
||||
if (processor.wrongPin()) {
|
||||
setKeyBackupTokenData(processor.getToken());
|
||||
}
|
||||
});
|
||||
@Override
|
||||
protected Single<VerifyCodeWithRegistrationLockResponseProcessor> onVerifySuccessWithRegistrationLock(@NonNull VerifyCodeWithRegistrationLockResponseProcessor processor, String pin) {
|
||||
return registrationRepository.registerAccountWithRegistrationLock(getRegistrationData(), processor.getResult(), pin)
|
||||
.map(processor::updatedIfRegistrationFailed);
|
||||
}
|
||||
|
||||
private RegistrationData getRegistrationData() {
|
||||
return new RegistrationData(getTextCodeEntered(),
|
||||
getNumber().getE164Number(),
|
||||
getRegistrationSecret(),
|
||||
registrationRepository.getRegistrationId(),
|
||||
registrationRepository.getProfileKey(getNumber().getE164Number()),
|
||||
getFcmToken());
|
||||
}
|
||||
|
||||
public static final class Factory extends AbstractSavedStateViewModelFactory {
|
||||
|
||||
@@ -6,8 +6,6 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
|
||||
@@ -99,8 +97,9 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
||||
boolean preferContactAvatars = remote.isPreferContactAvatars();
|
||||
int universalExpireTimer = remote.getUniversalExpireTimer();
|
||||
boolean primarySendsSms = local.isPrimarySendsSms();
|
||||
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms);
|
||||
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms);
|
||||
String e164 = local.getE164();
|
||||
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164);
|
||||
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164);
|
||||
|
||||
if (matchesRemote) {
|
||||
return remote;
|
||||
@@ -127,6 +126,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
||||
.setPayments(payments.isEnabled(), payments.getEntropy().orNull())
|
||||
.setUniversalExpireTimer(universalExpireTimer)
|
||||
.setPrimarySendsSms(primarySendsSms)
|
||||
.setE164(e164)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -164,13 +164,15 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
||||
boolean preferContactAvatars,
|
||||
SignalAccountRecord.Payments payments,
|
||||
int universalExpireTimer,
|
||||
boolean primarySendsSms)
|
||||
boolean primarySendsSms,
|
||||
String e164)
|
||||
{
|
||||
return Arrays.equals(contact.serializeUnknownFields(), unknownFields) &&
|
||||
Objects.equals(contact.getGivenName().or(""), givenName) &&
|
||||
Objects.equals(contact.getFamilyName().or(""), familyName) &&
|
||||
Objects.equals(contact.getAvatarUrlPath().or(""), avatarUrlPath) &&
|
||||
Objects.equals(contact.getPayments(), payments) &&
|
||||
Objects.equals(contact.getE164(), e164) &&
|
||||
Arrays.equals(contact.getProfileKey().orNull(), profileKey) &&
|
||||
contact.isNoteToSelfArchived() == noteToSelfArchived &&
|
||||
contact.isNoteToSelfForcedUnread() == noteToSelfForcedUnread &&
|
||||
|
||||
@@ -129,6 +129,7 @@ public final class StorageSyncHelper {
|
||||
.setPayments(SignalStore.paymentsValues().mobileCoinPaymentsEnabled(), Optional.fromNullable(SignalStore.paymentsValues().getPaymentsEntropy()).transform(Entropy::getBytes).orNull())
|
||||
.setPrimarySendsSms(Util.isDefaultSmsProvider(context))
|
||||
.setUniversalExpireTimer(SignalStore.settings().getUniversalExpireTimer())
|
||||
.setE164(TextSecurePreferences.getLocalNumber(context))
|
||||
.build();
|
||||
|
||||
return SignalStorageRecord.forAccount(account);
|
||||
|
||||
@@ -83,6 +83,7 @@ public final class FeatureFlags {
|
||||
private static final String SUGGEST_SMS_BLACKLIST = "android.suggestSmsBlacklist";
|
||||
private static final String MAX_GROUP_CALL_RING_SIZE = "global.calling.maxGroupCallRingSize";
|
||||
private static final String GROUP_CALL_RINGING = "android.calling.groupCallRinging";
|
||||
private static final String CHANGE_NUMBER_ENABLED = "android.changeNumber";
|
||||
|
||||
/**
|
||||
* We will only store remote values for flags in this set. If you want a flag to be controllable
|
||||
@@ -124,7 +125,8 @@ public final class FeatureFlags {
|
||||
|
||||
@VisibleForTesting
|
||||
static final Set<String> NOT_REMOTE_CAPABLE = SetUtil.newHashSet(
|
||||
PHONE_NUMBER_PRIVACY_VERSION
|
||||
PHONE_NUMBER_PRIVACY_VERSION,
|
||||
CHANGE_NUMBER_ENABLED
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -392,6 +394,11 @@ public final class FeatureFlags {
|
||||
return getBoolean(GROUP_CALL_RINGING, false);
|
||||
}
|
||||
|
||||
/** Weather or not to show change number in the UI. */
|
||||
public static boolean changeNumber() {
|
||||
return getBoolean(CHANGE_NUMBER_ENABLED, false);
|
||||
}
|
||||
|
||||
/** Only for rendering debug info. */
|
||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
||||
return new TreeMap<>(REMOTE_VALUES);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.util.rx
|
||||
|
||||
import com.google.android.gms.tasks.Task
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
|
||||
/**
|
||||
* Convert a [Task] into a [Single].
|
||||
*/
|
||||
fun <T : Any> Task<T>.toSingle(): Single<T> {
|
||||
return Single.create { emitter ->
|
||||
addOnCompleteListener {
|
||||
if (it.isSuccessful && !emitter.isDisposed) {
|
||||
emitter.onSuccess(it.result)
|
||||
} else if (!emitter.isDisposed) {
|
||||
emitter.onError(it.exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user