diff --git a/app/build.gradle b/app/build.gradle index 510014716..211272df5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -419,12 +419,12 @@ dependencies { implementation 'androidx.exifinterface:exifinterface:1.0.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.multidex:multidex:2.0.1' - implementation 'androidx.navigation:navigation-fragment:2.1.0' - implementation 'androidx.navigation:navigation-ui:2.1.0' - implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:1.0.0-alpha05' - implementation 'androidx.lifecycle:lifecycle-common-java8:2.1.0' - implementation 'androidx.lifecycle:lifecycle-reactivestreams-ktx:2.1.0' + implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' + implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.1' + implementation 'androidx.lifecycle:lifecycle-common-java8:2.3.1' + implementation 'androidx.lifecycle:lifecycle-reactivestreams-ktx:2.3.1' implementation "androidx.camera:camera-core:1.0.0-beta11" implementation "androidx.camera:camera-camera2:1.0.0-beta11" implementation "androidx.camera:camera-lifecycle:1.0.0-beta11" diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt index d4e236dde..68c25a1b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt @@ -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() } } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt index e775d04b0..37c6e733c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt index 85d0753e6..9b8a633dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberAccountLockedFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberAccountLockedFragment.kt new file mode 100644 index 000000000..f17ca452e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberAccountLockedFragment.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberConfirmFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberConfirmFragment.kt new file mode 100644 index 000000000..3b5fb79d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberConfirmFragment.kt @@ -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) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberEnterCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberEnterCodeFragment.kt new file mode 100644 index 000000000..713f64836 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberEnterCodeFragment.kt @@ -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(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(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()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberEnterPhoneNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberEnterPhoneNumberFragment.kt new file mode 100644 index 000000000..239abdd99 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberEnterPhoneNumberFragment.kt @@ -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(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() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberFragment.kt new file mode 100644 index 000000000..e322da387 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberFragment.kt @@ -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(R.id.change_phone_number_continue).setOnClickListener { + findNavController().navigate(R.id.action_changePhoneNumberFragment_to_enterPhoneNumberChangeFragment) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberPinDiffersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberPinDiffersFragment.kt new file mode 100644 index 000000000..53f98ab9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberPinDiffersFragment.kt @@ -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(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(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() + } + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRegistrationLockFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRegistrationLockFragment.kt new file mode 100644 index 000000000..16a078923 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRegistrationLockFragment.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt new file mode 100644 index 000000000..0dd487a5c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt @@ -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> { + return Single.fromCallable { accountManager.changeNumber(code, newE164, null) } + .subscribeOn(Schedulers.io()) + } + + fun changeNumber( + code: String, + newE164: String, + pin: String, + tokenData: TokenData + ): Single> { + 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 = 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 { + TextSecurePreferences.setLocalNumber(context, e164) + + DatabaseFactory.getRecipientDatabase(context).updateSelfPhone(e164) + + ApplicationDependencies.closeConnections() + ApplicationDependencies.getIncomingMessageObserver() + + return rotateCertificates() + } + + @Suppress("UsePropertyAccessSyntax") + private fun rotateCertificates(): Single { + 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()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberUtil.kt new file mode 100644 index 000000000..b06a7b389 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberUtil.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberVerifyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberVerifyFragment.kt new file mode 100644 index 000000000..ed22e3580 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberVerifyFragment.kt @@ -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() + } + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberViewModel.kt new file mode 100644 index 000000000..205fe98e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberViewModel.kt @@ -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 { + return liveOldNumberState + } + + fun getLiveNewNumber(): LiveData { + 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> { + return changeNumberRepository.changeNumber(textCodeEntered, number.e164Number) + } + + override fun verifyAccountWithRegistrationLock(pin: String, kbsTokenData: TokenData): Single> { + return changeNumberRepository.changeNumber(textCodeEntered, number.e164Number, pin, kbsTokenData) + } + + @WorkerThread + override fun onVerifySuccess(processor: VerifyAccountResponseProcessor): Single { + 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 { + 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 create(key: String, modelClass: Class, 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 31427d7d3..377252ffc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -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 existingUsername = getByUsername(username); diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index fa6c0c752..fef68986a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationNavigationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationNavigationActivity.java index 5e7385a70..8d806bba3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationNavigationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationNavigationActivity.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/AccountLockedFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/AccountLockedFragment.java index e7c87ffdb..d9637424f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/AccountLockedFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/AccountLockedFragment.java @@ -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(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseAccountLockedFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseAccountLockedFragment.java new file mode 100644 index 000000000..2544e4143 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseAccountLockedFragment.java @@ -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(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseEnterCodeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseEnterCodeFragment.java new file mode 100644 index 000000000..9b202a018 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseEnterCodeFragment.java @@ -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 - The concrete view model used by the subclasses, for ease of access in said subclass + */ +public abstract class BaseEnterCodeFragment 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() { + @Override + public void onSuccess(Boolean result) { + runAfterAnimation.run(); + } + }); + } + + protected void handleRateLimited() { + keyboard.displayFailure().addListener(new AssertedSuccessListener() { + @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() { + @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() { + @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() { + @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 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 convertVerificationCodeToDigits(@Nullable String code) { + if (code == null || code.length() != 6) { + return Collections.emptyList(); + } + + List 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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseRegistrationLockFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseRegistrationLockFragment.java new file mode 100644 index 000000000..98cf0d430 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseRegistrationLockFragment.java @@ -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(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CaptchaFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CaptchaFragment.java index ac78f750f..1c2a583ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CaptchaFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CaptchaFragment.java @@ -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); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CountryPickerFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CountryPickerFragment.java index 628b5dc7b..0db1656f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CountryPickerFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CountryPickerFragment.java @@ -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>> { + 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>> 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> 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 }; diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterCodeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterCodeFragment.java index bbcae9082..07b4f6e0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterCodeFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterCodeFragment.java @@ -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 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() { - @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() { - @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() { - @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() { - @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() { - @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 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 convertVerificationCodeToDigits(@Nullable String code) { - if (code == null || code.length() != 6) { - return Collections.emptyList(); - } - - List 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(); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java index 2e6df34c7..c2a767b73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java @@ -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 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java index 19aff24ff..e2eca0b51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationNumberInputController.java b/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationNumberInputController.java new file mode 100644 index 000000000..ae9e49851 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationNumberInputController.java @@ -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 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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/BaseEnterCodeViewModelDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/BaseEnterCodeViewModelDelegate.java new file mode 100644 index 000000000..367f58a9d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/BaseEnterCodeViewModelDelegate.java @@ -0,0 +1,4 @@ +package org.thoughtcrime.securesms.registration.viewmodel; + +public final class BaseEnterCodeViewModelDelegate { +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/BaseRegistrationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/BaseRegistrationViewModel.java new file mode 100644 index 000000000..461d915df --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/BaseRegistrationViewModel.java @@ -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 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 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 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 getLockedTimeRemaining() { + return savedState.getLiveData(STATE_TIME_REMAINING, 0L); + } + + public LiveData 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 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 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 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> verifyAccountWithoutRegistrationLock(); + + protected abstract Single> verifyAccountWithRegistrationLock(@NonNull String pin, @NonNull TokenData kbsTokenData); + + protected abstract Single onVerifySuccess(@NonNull VerifyAccountResponseProcessor processor); + + protected abstract Single onVerifySuccessWithRegistrationLock(@NonNull VerifyCodeWithRegistrationLockResponseProcessor processor, String pin); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java index 6cefc2bb4..f59da4bd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java @@ -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 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 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 getLockedTimeRemaining() { - return registrationState.getLiveData(STATE_TIME_REMAINING, 0L); - } - - public LiveData 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 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 requestVerificationCode(@NonNull VerifyAccountRepository.Mode mode) { + return super.requestVerificationCode(mode) + .doOnSuccess(processor -> { + if (processor.hasResult()) { + setFcmToken(processor.getResult().getFcmToken().orNull()); + } + }); } - public Single 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> verifyAccountWithoutRegistrationLock() { + return verifyAccountRepository.verifyAccount(getRegistrationData()); } - public Single verifyCodeAndRegisterAccountWithRegistrationLock(@NonNull String pin) { - RegistrationData registrationData = new RegistrationData(getTextCodeEntered(), - getNumber().getE164Number(), - getRegistrationSecret(), - registrationRepository.getRegistrationId(), - registrationRepository.getProfileKey(getNumber().getE164Number()), - getFcmToken()); + @Override + protected Single> verifyAccountWithRegistrationLock(@NonNull String pin, @NonNull TokenData kbsTokenData) { + return verifyAccountRepository.verifyAccountWithPin(getRegistrationData(), pin, kbsTokenData); + } - TokenData kbsTokenData = Objects.requireNonNull(getKeyBackupCurrentToken()); + @Override + protected Single 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 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java index 705abc453..bd431cdf3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java @@ -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 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 getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/rx/ReactiveTask.kt b/app/src/main/java/org/thoughtcrime/securesms/util/rx/ReactiveTask.kt new file mode 100644 index 000000000..3a001472d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/rx/ReactiveTask.kt @@ -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 Task.toSingle(): Single { + return Single.create { emitter -> + addOnCompleteListener { + if (it.isSuccessful && !emitter.isDisposed) { + emitter.onSuccess(it.result) + } else if (!emitter.isDisposed) { + emitter.onError(it.exception) + } + } + } +} diff --git a/app/src/main/res/drawable/change_number_hero_image.xml b/app/src/main/res/drawable/change_number_hero_image.xml new file mode 100644 index 000000000..7d480e4de --- /dev/null +++ b/app/src/main/res/drawable/change_number_hero_image.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/labeled_edit_text_background_active.xml b/app/src/main/res/drawable/labeled_edit_text_background_active.xml index 3bb9c5f1a..95e985867 100644 --- a/app/src/main/res/drawable/labeled_edit_text_background_active.xml +++ b/app/src/main/res/drawable/labeled_edit_text_background_active.xml @@ -7,7 +7,7 @@ diff --git a/app/src/main/res/layout/fragment_change_number_account_locked.xml b/app/src/main/res/layout/fragment_change_number_account_locked.xml new file mode 100644 index 000000000..e178d4523 --- /dev/null +++ b/app/src/main/res/layout/fragment_change_number_account_locked.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_change_number_confirm.xml b/app/src/main/res/layout/fragment_change_number_confirm.xml new file mode 100644 index 000000000..42ec8052b --- /dev/null +++ b/app/src/main/res/layout/fragment_change_number_confirm.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_change_number_enter_code.xml b/app/src/main/res/layout/fragment_change_number_enter_code.xml new file mode 100644 index 000000000..4a9a198e4 --- /dev/null +++ b/app/src/main/res/layout/fragment_change_number_enter_code.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_change_number_enter_phone_number.xml b/app/src/main/res/layout/fragment_change_number_enter_phone_number.xml new file mode 100644 index 000000000..2de821b8e --- /dev/null +++ b/app/src/main/res/layout/fragment_change_number_enter_phone_number.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_change_number_pin_differs.xml b/app/src/main/res/layout/fragment_change_number_pin_differs.xml new file mode 100644 index 000000000..eed39db61 --- /dev/null +++ b/app/src/main/res/layout/fragment_change_number_pin_differs.xml @@ -0,0 +1,61 @@ + + + + + + + + + +