Flesh out registration lock support for Registration V2.

This commit is contained in:
Nicholas Tinsley
2024-05-28 12:29:05 -04:00
committed by Cody Henthorne
parent 54cd84b842
commit b71ba79b8a
7 changed files with 415 additions and 40 deletions

View File

@@ -20,6 +20,7 @@ enum class RegistrationCheckpoint {
CHALLENGE_COMPLETED,
VERIFICATION_CODE_REQUESTED,
VERIFICATION_CODE_ENTERED,
PIN_ENTERED,
VERIFICATION_CODE_VALIDATED,
SERVICE_REGISTRATION_COMPLETED,
LOCAL_REGISTRATION_COMPLETE

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.pin.PinRestoreActivity
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
@@ -40,35 +41,43 @@ class RegistrationV2Activity : BaseActivity() {
}
private fun handleSuccessfulVerify() {
// TODO [regv2]: add functionality of [RegistrationCompleteFragment]
val isProfileNameEmpty = Recipient.self().profileName.isEmpty
val isAvatarEmpty = !AvatarHelper.hasAvatar(this, Recipient.self().id)
val needsProfile = isProfileNameEmpty || isAvatarEmpty
val needsPin = !sharedViewModel.hasPin()
Log.i(TAG, "Pin restore flow not required. Profile name: $isProfileNameEmpty | Profile avatar: $isAvatarEmpty | Needs PIN: $needsPin")
SignalStore.internalValues().setForceEnterRestoreV2Flow(true)
if (!needsProfile && !needsPin) {
sharedViewModel.completeRegistration()
}
sharedViewModel.setInProgress(false)
val startIntent = MainActivity.clearTop(this).apply {
if (needsPin) {
putExtra("next_intent", CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationV2Activity))
}
if (needsProfile) {
putExtra("next_intent", CreateProfileActivity.getIntentForUserProfile(this@RegistrationV2Activity))
}
if (SignalStore.misc().hasLinkedDevices) {
SignalStore.misc().shouldShowLinkedDevicesReminder = sharedViewModel.isReregister
}
Log.d(TAG, "Launching ${startIntent.component}")
startActivity(startIntent)
finish()
ActivityNavigator.applyPopAnimationsToPendingTransition(this)
if (SignalStore.storageService().needsAccountRestore()) {
Log.i(TAG, "Performing pin restore.")
startActivity(Intent(this, PinRestoreActivity::class.java))
} else {
val isProfileNameEmpty = Recipient.self().profileName.isEmpty
val isAvatarEmpty = !AvatarHelper.hasAvatar(this, Recipient.self().id)
val needsProfile = isProfileNameEmpty || isAvatarEmpty
val needsPin = !sharedViewModel.hasPin()
Log.i(TAG, "Pin restore flow not required. Profile name: $isProfileNameEmpty | Profile avatar: $isAvatarEmpty | Needs PIN: $needsPin")
SignalStore.internalValues().setForceEnterRestoreV2Flow(true)
if (!needsProfile && !needsPin) {
sharedViewModel.completeRegistration()
}
sharedViewModel.setInProgress(false)
val startIntent = MainActivity.clearTop(this).apply {
if (needsPin) {
putExtra("next_intent", CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationV2Activity))
}
if (needsProfile) {
putExtra("next_intent", CreateProfileActivity.getIntentForUserProfile(this@RegistrationV2Activity))
}
}
Log.d(TAG, "Launching ${startIntent.component}")
startActivity(startIntent)
finish()
ActivityNavigator.applyPopAnimationsToPendingTransition(this)
}
}
companion object {

View File

@@ -600,6 +600,25 @@ class RegistrationV2ViewModel : ViewModel() {
}
}
fun verifyCodeAndRegisterAccountWithRegistrationLock(context: Context, pin: String, submissionErrorHandler: (RegistrationResult) -> Unit, registrationErrorHandler: (RegisterAccountResult) -> Unit) {
Log.v(TAG, "verifyCodeAndRegisterAccountWithRegistrationLock()")
store.update {
it.copy(
inProgress = true,
registrationCheckpoint = RegistrationCheckpoint.PIN_ENTERED
)
}
viewModelScope.launch {
verifyCodeInternal(
context = context,
pin = pin,
reglockEnabled = true,
submissionErrorHandler = submissionErrorHandler,
registrationErrorHandler = registrationErrorHandler
)
}
}
private suspend fun verifyCodeInternal(context: Context, reglockEnabled: Boolean, pin: String?, submissionErrorHandler: (RegistrationResult) -> Unit, registrationErrorHandler: (RegisterAccountResult) -> Unit) {
val sessionId = getOrCreateValidSession(context, submissionErrorHandler)?.body?.id ?: return
val registrationData = getRegistrationData()

View File

@@ -0,0 +1,341 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.registrationlock
import android.os.Bundle
import android.text.InputType
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.Stopwatch
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationLockBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.v2.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.v2.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.SvrNoDataException
import java.io.IOException
import java.util.concurrent.TimeUnit
class RegistrationLockV2Fragment : LoggingFragment(R.layout.fragment_registration_lock) {
companion object {
private val TAG = Log.tag(RegistrationLockV2Fragment::class.java)
}
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind)
private val viewModel by activityViewModels<RegistrationV2ViewModel>()
private var timeRemaining: Long = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title))
val args: RegistrationLockV2FragmentArgs = RegistrationLockV2FragmentArgs.fromBundle(requireArguments())
timeRemaining = args.getTimeRemaining()
binding.kbsLockForgotPin.visibility = View.GONE
binding.kbsLockForgotPin.setOnClickListener { handleForgottenPin(timeRemaining) }
binding.kbsLockPinInput.setImeOptions(EditorInfo.IME_ACTION_DONE)
binding.kbsLockPinInput.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
ViewUtil.hideKeyboard(requireContext(), v!!)
handlePinEntry()
return@setOnEditorActionListener true
}
false
}
enableAndFocusPinEntry()
binding.kbsLockPinConfirm.setOnClickListener {
ViewUtil.hideKeyboard(requireContext(), binding.kbsLockPinInput)
handlePinEntry()
}
binding.kbsLockKeyboardToggle.setOnClickListener {
val keyboardType: PinKeyboardType = getPinEntryKeyboardType()
updateKeyboard(keyboardType.other)
binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource)
}
val keyboardType: PinKeyboardType = getPinEntryKeyboardType().getOther()
binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource)
viewModel.lockedTimeRemaining.observe(viewLifecycleOwner) { t: Long -> timeRemaining = t }
viewModel.checkpoint.observe(viewLifecycleOwner) {
if (it >= RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED) {
handleSuccessfulPinEntry()
}
}
val triesRemaining: Int = viewModel.svrTriesRemaining
if (triesRemaining <= 3) {
val daysRemaining = getLockoutDays(timeRemaining)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__not_many_tries_left)
.setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() }
.show()
}
if (triesRemaining < 5) {
binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining)
}
}
private fun handlePinEntry() {
binding.kbsLockPinInput.setEnabled(false)
val pin: String = binding.kbsLockPinInput.getText().toString()
val trimmedLength = pin.replace(" ", "").length
if (trimmedLength == 0) {
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
if (trimmedLength < SvrConstants.MINIMUM_PIN_LENGTH) {
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
binding.kbsLockPinConfirm.setSpinning()
viewModel.verifyCodeAndRegisterAccountWithRegistrationLock(requireContext(), pin, ::handleSessionErrorResponse, ::handleRegistrationErrorResponse)
}
private fun handleSessionErrorResponse(requestResult: RegistrationResult) {
when (requestResult) {
is VerificationCodeRequestResult.Success -> Unit
is VerificationCodeRequestResult.RateLimited -> onRateLimited()
is VerificationCodeRequestResult.AttemptsExhausted,
is VerificationCodeRequestResult.RegistrationLocked -> onKbsAccountLocked()
else -> when (val cause = requestResult.getCause()) {
is SvrWrongPinException -> {
Log.w(TAG, "TODO figure out which Result class this results in and create a concrete class.")
onIncorrectKbsRegistrationLockPin(cause.triesRemaining)
}
is SvrNoDataException -> {
Log.w(TAG, "TODO figure out which Result class this results in and create a concrete class.")
onKbsAccountLocked()
}
else -> {
Log.w(TAG, "Unable to verify code with registration lock", cause)
onError()
}
}
}
}
private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
when (result) {
is RegisterAccountResult.Success -> Unit
is RegisterAccountResult.RateLimited -> onRateLimited()
is RegisterAccountResult.AttemptsExhausted,
is RegisterAccountResult.RegistrationLocked -> onKbsAccountLocked()
else -> when (val cause = result.getCause()) {
is SvrWrongPinException -> {
Log.w(TAG, "TODO figure out which Result class this results in and create a concrete class.")
onIncorrectKbsRegistrationLockPin(cause.triesRemaining)
}
is SvrNoDataException -> {
Log.w(TAG, "TODO figure out which Result class this results in and create a concrete class.")
onKbsAccountLocked()
}
else -> {
Log.w(TAG, "Unable to register account with registration lock", cause)
onError()
}
}
}
}
private fun onIncorrectKbsRegistrationLockPin(svrTriesRemaining: Int) {
binding.kbsLockPinConfirm.cancelSpinning()
binding.kbsLockPinInput.getText().clear()
enableAndFocusPinEntry()
if (svrTriesRemaining == 0) {
Log.w(TAG, "Account locked. User out of attempts on KBS.")
onAccountLocked()
return
}
if (svrTriesRemaining == 3) {
val daysRemaining = getLockoutDays(timeRemaining)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__incorrect_pin)
.setMessage(getTriesRemainingDialogMessage(svrTriesRemaining, daysRemaining))
.setPositiveButton(android.R.string.ok, null)
.show()
}
if (svrTriesRemaining > 5) {
binding.kbsLockPinInputLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again)
} else {
binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, svrTriesRemaining, svrTriesRemaining)
binding.kbsLockForgotPin.visibility = View.VISIBLE
}
}
private fun onRateLimited() {
binding.kbsLockPinConfirm.cancelSpinning()
enableAndFocusPinEntry()
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationActivity_too_many_attempts)
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun onKbsAccountLocked() {
onAccountLocked()
}
fun onError() {
binding.kbsLockPinConfirm.cancelSpinning()
enableAndFocusPinEntry()
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show()
}
private fun handleForgottenPin(timeRemainingMs: Long) {
val lockoutDays = getLockoutDays(timeRemainingMs)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__forgot_your_pin)
.setMessage(requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays))
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() }
.show()
}
private fun getLockoutDays(timeRemainingMs: Long): Int {
return TimeUnit.MILLISECONDS.toDays(timeRemainingMs).toInt() + 1
}
private fun onAccountLocked() {
navigateToAccountLocked()
}
private fun getTriesRemainingDialogMessage(triesRemaining: Int, daysRemaining: Int): String {
val resources = requireContext().resources
val tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining)
val days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining)
return "$tries $days"
}
private fun enableAndFocusPinEntry() {
binding.kbsLockPinInput.setEnabled(true)
binding.kbsLockPinInput.setFocusable(true)
ViewUtil.focusAndShowKeyboard(binding.kbsLockPinInput)
}
private fun getPinEntryKeyboardType(): PinKeyboardType {
val isNumeric = (binding.kbsLockPinInput.inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER
return if (isNumeric) PinKeyboardType.NUMERIC else PinKeyboardType.ALPHA_NUMERIC
}
private fun updateKeyboard(keyboard: PinKeyboardType) {
val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC
binding.kbsLockPinInput.setInputType(
if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
)
binding.kbsLockPinInput.getText().clear()
}
private fun navigateToAccountLocked() {
findNavController().safeNavigate(RegistrationLockV2FragmentDirections.actionAccountLocked())
}
private fun handleSuccessfulPinEntry() {
SignalStore.pinValues().keyboardType = getPinEntryKeyboardType()
SimpleTask.run<Any?>({
SignalStore.onboarding().clearAll()
val stopwatch = Stopwatch("RegistrationLockRestore")
AppDependencies.jobManager.runSynchronously(StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN)
stopwatch.split("AccountRestore")
AppDependencies.jobManager
.startChain(StorageSyncJob())
.then(ReclaimUsernameAndLinkJob())
.enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10))
stopwatch.split("ContactRestore")
try {
FeatureFlags.refreshSync()
} catch (e: IOException) {
Log.w(TAG, "Failed to refresh flags.", e)
}
stopwatch.split("FeatureFlags")
stopwatch.stop(TAG)
null
}, { none: Any? ->
binding.kbsLockPinConfirm.cancelSpinning()
findNavController().safeNavigate(RegistrationLockV2FragmentDirections.actionSuccessfulRegistration())
})
}
private fun sendEmailToSupport() {
val subject = R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v2_pin
val body = SupportEmailUtil.generateSupportEmailBody(
requireContext(),
subject,
null,
null
)
CommunicationActions.openEmail(
requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getString(subject),
body
)
}
}

View File

@@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationPinRestoreEntryV2Binding
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.registration.fragments.BaseRegistrationLockFragment
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
import org.thoughtcrime.securesms.registration.v2.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationCheckpoint
@@ -84,7 +83,7 @@ class ReRegisterWithPinV2Fragment : LoggingFragment(R.layout.fragment_registrati
if (state.networkError != null) {
genericErrorDialog()
} else if (!state.canSkipSms) {
abortSkipSmsFlow()
findNavController().safeNavigate(ReRegisterWithPinV2FragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberV2Fragment())
} else if (state.isRegistrationLockEnabled && state.svrTriesRemaining == 0) {
Log.w(TAG, "Unable to continue skip flow, KBS is locked")
onAccountLocked()
@@ -94,10 +93,6 @@ class ReRegisterWithPinV2Fragment : LoggingFragment(R.layout.fragment_registrati
}
}
private fun abortSkipSmsFlow() {
findNavController().safeNavigate(ReRegisterWithPinV2FragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberV2Fragment())
}
private fun presentProgress(inProgress: Boolean) {
if (inProgress) {
ViewUtil.hideKeyboard(requireContext(), binding.pinRestorePinInput)
@@ -118,8 +113,8 @@ class ReRegisterWithPinV2Fragment : LoggingFragment(R.layout.fragment_registrati
return
}
if (pin.trim().length < BaseRegistrationLockFragment.MINIMUM_PIN_LENGTH) {
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, BaseRegistrationLockFragment.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
if (pin.trim().length < SvrConstants.MINIMUM_PIN_LENGTH) {
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
@@ -134,8 +129,6 @@ class ReRegisterWithPinV2Fragment : LoggingFragment(R.layout.fragment_registrati
},
registrationErrorHandler = ::registrationErrorHandler
)
// TODO [regv2]: check for registration lock + wrong pin and decrement SVR tries remaining
}
private fun presentTriesRemaining(triesRemaining: Int) {

View File

@@ -150,7 +150,7 @@
<fragment
android:id="@+id/registrationLockV2Fragment"
android:name="org.thoughtcrime.securesms.registration.fragments.RegistrationLockFragment"
android:name="org.thoughtcrime.securesms.registration.v2.ui.registrationlock.RegistrationLockV2Fragment"
android:label="fragment_kbs_lock"
tools:layout="@layout/fragment_registration_lock">
@@ -209,13 +209,13 @@
<fragment
android:id="@+id/accountLockedV2Fragment"
android:name="org.thoughtcrime.securesms.registration.fragments.AccountLockedFragment"
android:name="org.thoughtcrime.securesms.registration.v2.ui.accountlocked.AccountLockedV2Fragment"
android:label="fragment_account_locked"
tools:layout="@layout/account_locked_fragment" />
<fragment
android:id="@+id/captchaV2Fragment"
android:name="org.thoughtcrime.securesms.registration.v2.ui.captcha.CaptchaFragment"
android:name="org.thoughtcrime.securesms.registration.v2.ui.captcha.RegistrationCaptchaV2Fragment"
android:label="fragment_captcha"
tools:layout="@layout/fragment_registration_captcha" />

View File

@@ -7,6 +7,7 @@ package org.whispersystems.signalservice.api.registration
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest
import org.whispersystems.signalservice.api.account.PreKeyCollection
import org.whispersystems.signalservice.internal.push.BackupAuthCheckResponse
import org.whispersystems.signalservice.internal.push.PushServiceSocket
@@ -111,4 +112,15 @@ class RegistrationApi(
pushServiceSocket.checkBackupAuthCredentials(e164, usernamePasswords)
}
}
/**
* Changes the phone number that an account is associated with.
*
* `PUT /v2/accounts/number`
*/
fun changeNumber(requestBody: ChangePhoneNumberRequest): NetworkResult<VerifyAccountResponse> {
return NetworkResult.fromFetch {
pushServiceSocket.changeNumber(requestBody)
}
}
}