Initial work to support Change Number.

This commit is contained in:
Cody Henthorne
2021-09-03 17:07:05 -04:00
parent e09d162c1e
commit f2ab0b6423
61 changed files with 3177 additions and 1176 deletions

View File

@@ -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() }
}
)
}

View File

@@ -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 {

View File

@@ -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),

View File

@@ -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()
}
}

View File

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

View File

@@ -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())
}
}

View File

@@ -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()
}
}
}
}

View File

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

View File

@@ -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()
}
}
)
}
}

View File

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

View File

@@ -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())
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}
)
}
}

View File

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

View File

@@ -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);

View File

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

View File

@@ -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();

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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();
}
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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);
}
}

View File

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

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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 &&

View File

@@ -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);

View File

@@ -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);

View File

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