diff --git a/app/res/drawable/ic_key_round.xml b/app/res/drawable/ic_key_round.xml new file mode 100644 index 0000000000..d3cbdaef06 --- /dev/null +++ b/app/res/drawable/ic_key_round.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/res/layout/fragment_recovery_code.xml b/app/res/layout/fragment_recovery_code.xml index 50608bf892..cf6bc4a036 100644 --- a/app/res/layout/fragment_recovery_code.xml +++ b/app/res/layout/fragment_recovery_code.xml @@ -212,23 +212,22 @@ android:textSize="16sp" android:visibility="gone"/> - + android:layout_height="wrap_content" + android:orientation="vertical"> @@ -237,17 +236,13 @@ style="@style/CustomButtonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintBottom_toBottomOf="parent" + android:layout_gravity="end" + android:layout_marginTop="32dp" android:layout_marginEnd="16dp" android:layout_marginBottom="16dp" android:text="@string/personalid_continue_label" - android:drawableRight="@drawable/connect_right_arrow" - /> - + android:drawableRight="@drawable/connect_right_arrow"/> + diff --git a/app/res/layout/personalid_profile_screen.xml b/app/res/layout/personalid_profile_screen.xml index f464b2e52d..161566a824 100644 --- a/app/res/layout/personalid_profile_screen.xml +++ b/app/res/layout/personalid_profile_screen.xml @@ -91,20 +91,41 @@ android:layout_marginTop="16dp" android:background="@color/grey_light" /> - + android:layout_marginTop="32dp" + android:orientation="vertical"> + + + + + + diff --git a/app/res/navigation/nav_graph_personalid_profile.xml b/app/res/navigation/nav_graph_personalid_profile.xml index a7a424c5cf..01b063ee90 100644 --- a/app/res/navigation/nav_graph_personalid_profile.xml +++ b/app/res/navigation/nav_graph_personalid_profile.xml @@ -12,6 +12,9 @@ + + + + + + + + + diff --git a/app/res/values-es/strings.xml b/app/res/values-es/strings.xml index 05d6a5b0d9..fb8386be8c 100644 --- a/app/res/values-es/strings.xml +++ b/app/res/values-es/strings.xml @@ -415,6 +415,11 @@ Guardar Cancelar Tu perfil se ha actualizado correctamente. + Cambiar código de respaldo + ¿Olvidaste tu código de respaldo? + Demasiados intentos. Por favor, inténtalo de nuevo más tarde. + Por favor, añade una dirección de correo electrónico + Establecer nuevo código de respaldo Detalles de la entrega Revisar detalles de la entrega %d visitas máximo diff --git a/app/res/values-fr/strings.xml b/app/res/values-fr/strings.xml index b2ac2d4897..bcf5217194 100644 --- a/app/res/values-fr/strings.xml +++ b/app/res/values-fr/strings.xml @@ -412,6 +412,11 @@ License. Enregistrer Annuler Votre profil a été mis à jour avec succès. + Changer le code de sauvegarde + Vous avez oublié votre code de sauvegarde ? + Trop de tentatives. Veuillez réessayer plus tard. + Veuillez ajouter une adresse e-mail + Définir un nouveau code de sauvegarde Détails de livraison Vérifiez les détails de livraison %d visites maximum diff --git a/app/res/values-ha/strings.xml b/app/res/values-ha/strings.xml index 8f7b424004..b4b820237e 100644 --- a/app/res/values-ha/strings.xml +++ b/app/res/values-ha/strings.xml @@ -250,6 +250,11 @@ Don cikakken aiki na wurin zama na aikace-aikace da yawa, da fatan za a yi sabon Ajiye Soke An sabunta bayanan martabarka cikin nasara. + Canza lambar sirrin ajiyar bayanai + Ka manta da lambar sirrin ajiyar bayananka? + Yunkuri da yawa. Da fatan za a sake gwadawa daga baya. + Da fatan za a ƙara adireshin imel + Saita sabuwar lambar sirrin ajiyar bayanai Cikakkun Bayanan Isarwa Yi bitar cikakkun bayanai game da isar da kaya Matsakaicin Ziyarar %d diff --git a/app/res/values-hi/strings.xml b/app/res/values-hi/strings.xml index f1c42e5b17..7ec52b5448 100644 --- a/app/res/values-hi/strings.xml +++ b/app/res/values-hi/strings.xml @@ -412,6 +412,10 @@ License. सहेजें रद्द करें आपकी प्रोफ़ाइल सफलतापूर्वक अपडेट कर दी गई है। + बैकअप कोड बदलें + अपना बैकअप कोड भूल गए? + Too many attempts. Please try again later. + Please add an email address वितरण विवरण वितरण विवरण की समीक्षा करें अधिकतम %d विज़िट diff --git a/app/res/values-lt/strings.xml b/app/res/values-lt/strings.xml index 5d5248c679..d9a509d57d 100644 --- a/app/res/values-lt/strings.xml +++ b/app/res/values-lt/strings.xml @@ -103,6 +103,10 @@ Išsaugoti Atšaukti Jūsų profilis sėkmingai atnaujintas. + Keisti atsarginį kodą + Pamiršote savo atsarginį kodą? + Too many attempts. Please try again later. + Please add an email address Prisijungti / Registruotis Pagalba Neprisijungta prie „PersonalID“ diff --git a/app/res/values-no/strings.xml b/app/res/values-no/strings.xml index 62b8105c25..8b197239dd 100644 --- a/app/res/values-no/strings.xml +++ b/app/res/values-no/strings.xml @@ -103,6 +103,10 @@ Lagre Avbryt Profilen din er oppdatert. + Endre sikkerhetskode + Glemt sikkerhetskoden din? + Too many attempts. Please try again later. + Please add an email address Logg inn / Registrer deg Hjelp Du er ikke logget inn med PersonalID diff --git a/app/res/values-pt/strings.xml b/app/res/values-pt/strings.xml index 19f806aff8..cac77e2950 100644 --- a/app/res/values-pt/strings.xml +++ b/app/res/values-pt/strings.xml @@ -428,6 +428,11 @@ Salvar Cancelar Seu perfil foi atualizado com sucesso. + Alterar código de backup + Esqueceu o seu código de backup? + Muitas tentativas. Por favor, tente novamente mais tarde. + Por favor, adicione um endereço de e-mail + Definir novo código de backup Detalhes da entrega Revise os detalhes da entrega %d máximo de visitas diff --git a/app/res/values-sw/strings.xml b/app/res/values-sw/strings.xml index 44fbcd846a..befced4c72 100644 --- a/app/res/values-sw/strings.xml +++ b/app/res/values-sw/strings.xml @@ -414,6 +414,10 @@ Hifadhi Ghairi Wasifu wako umesasishwa kwa mafanikio. + Badilisha nambari ya hifadhi nakala + Umesahau nambari yako ya hifadhi nakala? + Too many attempts. Please try again later. + Please add an email address Maelezo ya Uwasilishaji Kagua maelezo ya utoaji %d upeo wa Ziara diff --git a/app/res/values-ti/strings.xml b/app/res/values-ti/strings.xml index 8016c118cd..fd3fc240b7 100644 --- a/app/res/values-ti/strings.xml +++ b/app/res/values-ti/strings.xml @@ -415,6 +415,11 @@ ኣቐምጥ ሰርዝ ፕሮፋይልኩም ብዓወት ተሐዲሱ። + ናይ ምትካእ ኮድ ቀይር + ናይ ምትካእ ኮድካ ረሲዕካ? + ብዙሕ ፈተናታት። በጃኻ ድሕሪ ሕጂ እንደገና ፈትን። + በጃኻ ናይ ኢ-መይል ኣድራሻ ወስኽ + ሓድሽ ናይ ምትካእ ኮድ ኣቐምጥ ዝርዝር (ኣተገባብራ)ኣወሃህባ ዝርዝር ኣወሃህባ ዳህሳስ ከልስ %d ዝለዓለ ምብጻሕ diff --git a/app/res/values/strings.xml b/app/res/values/strings.xml index 9e0558b24f..29e0948b28 100644 --- a/app/res/values/strings.xml +++ b/app/res/values/strings.xml @@ -378,6 +378,7 @@ Enter your 6-digit Backup Code Please be sure to remember your backup code! It must be %d digits. Keep it in a safe place as you will need it to access your account again. The backup codes you enter must match. + Set New Backup Code Wrong Backup Code Error parsing data from server. Please contact customer support. @@ -797,6 +798,10 @@ Take Photo Save Photo This is not me + Change backup code + Forgot your backup code? + Too many attempts. Please try again later. + Please add an email address Welcome back %s You have entered the wrong Backup Code. Please try again. Your account will be locked after %d more incorrect attempts. Login on Second Device diff --git a/app/src/org/commcare/fragments/personalId/BackupCodeWorkflow.kt b/app/src/org/commcare/fragments/personalId/BackupCodeWorkflow.kt new file mode 100644 index 0000000000..362c8b681a --- /dev/null +++ b/app/src/org/commcare/fragments/personalId/BackupCodeWorkflow.kt @@ -0,0 +1,11 @@ +package org.commcare.fragments.personalId + +/** + * Identifies which backup-code flow is being executed. + * + * - [CONFIRM_BACKUP_CODE_CHANGE_CODE]: user is on the Manage Profile screen and wants to change + * their backup code; must confirm the current code first before setting a new one. + */ +enum class BackupCodeWorkflow { + CONFIRM_BACKUP_CODE_CHANGE_CODE, +} diff --git a/app/src/org/commcare/fragments/personalId/BasePersonalIdBackupCodeFragment.kt b/app/src/org/commcare/fragments/personalId/BasePersonalIdBackupCodeFragment.kt new file mode 100644 index 0000000000..326476bb14 --- /dev/null +++ b/app/src/org/commcare/fragments/personalId/BasePersonalIdBackupCodeFragment.kt @@ -0,0 +1,146 @@ +package org.commcare.fragments.personalId + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import org.commcare.dalvik.R +import org.commcare.dalvik.databinding.FragmentRecoveryCodeBinding +import org.commcare.views.connect.NumericCodeView + +abstract class BasePersonalIdBackupCodeFragment : BasePersonalIdFragment() { + protected lateinit var binding: FragmentRecoveryCodeBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + binding = FragmentRecoveryCodeBinding.inflate(inflater, container, false) + initData() + setUpView() + setupListeners() + clearBackupCodeFields() + return binding.root + } + + open fun initData() {} + + abstract fun setUpView() + + override fun onResume() { + super.onResume() + binding.backupCodeView.requestFocus(requireActivity()) + } + + protected fun clearBackupCodeFields() { + binding.backupCodeView.clearCode() + binding.confirmCodeView.clearCode() + } + + protected open fun submitIfEnabled() { + if (binding.connectBackupCodeButton.isEnabled) handleBackupCodeSubmission() + } + + abstract fun handleBackupCodeSubmission() + + protected fun togglePasswordVisibility( + codeView: NumericCodeView, + toggle: ImageView, + ) { + codeView.isPasswordVisible = !codeView.isPasswordVisible + toggle.setImageResource( + if (codeView.isPasswordVisible) R.drawable.ic_visibility_off_24 else R.drawable.ic_visibility_24, + ) + } + + protected fun clearError() { + binding.connectBackupCodeErrorMessage.visibility = View.GONE + binding.connectBackupCodeErrorMessage.text = "" + } + + protected fun showError(message: String) { + binding.connectBackupCodeErrorMessage.visibility = View.VISIBLE + binding.connectBackupCodeErrorMessage.text = message + } + + protected fun enableContinueButton(enabled: Boolean) { + binding.connectBackupCodeButton.isEnabled = enabled + } + + protected fun setUpInitialState( + titleResId: Int, + showConfirmCode: Boolean, + subtitle: CharSequence, + notMeButtonTextId: Int? = null, + ) { + requireActivity().title = getString(titleResId) + binding.recoveryCodeTilte.setText(titleResId) + binding.backupCodeLayout.visibility = View.VISIBLE + binding.welcomeBackLayout.visibility = View.GONE + binding.notMeButton.visibility = View.GONE + val confirmVisibility = if (showConfirmCode) View.VISIBLE else View.GONE + binding.confirmCodeLayout.visibility = confirmVisibility + binding.confirmCodeLabel.visibility = confirmVisibility + binding.backupCodeSubtitle.text = subtitle + notMeButtonTextId?.let { + binding.notMeButton.setText(getString(it)) + binding.notMeButton.visibility = View.VISIBLE + } + enableContinueButton(false) + } + + protected open fun onCodeChanged() { + validateBackupCodeAndEnableContinue() + } + + protected open fun setupListeners() { + binding.backupCodeView.setOnCodeChangedListener { onCodeChanged() } + binding.backupCodeView.setOnEnterKeyPressedListener { submitIfEnabled() } + binding.confirmCodeView.setOnCodeChangedListener { onCodeChanged() } + binding.confirmCodeView.setOnEnterKeyPressedListener { submitIfEnabled() } + binding.connectBackupCodeButton.setOnClickListener { handleBackupCodeSubmission() } + binding.backupCodeVisibilityToggle.setOnClickListener { + togglePasswordVisibility(binding.backupCodeView, binding.backupCodeVisibilityToggle) + } + binding.confirmCodeVisibilityToggle.setOnClickListener { + togglePasswordVisibility(binding.confirmCodeView, binding.confirmCodeVisibilityToggle) + } + } + + protected fun validateBackupCodeAndEnableContinue() { + enableContinueButton(validateBackupCodeInput()) + } + + protected fun validateBackupCodeInput(): Boolean { + val backupCode = binding.backupCodeView.codeValue + val isBackupCodeComplete = backupCode.length == BACKUP_CODE_LENGTH + if (binding.confirmCodeLayout.visibility != View.VISIBLE) { + return isBackupCodeComplete + } + val confirmCode = binding.confirmCodeView.codeValue + val isConfirmCodeComplete = confirmCode.length == BACKUP_CODE_LENGTH + if (isBackupCodeComplete && isConfirmCodeComplete && backupCode != confirmCode) { + showError(getString(R.string.connect_backup_code_mismatch)) + } else { + clearError() + } + val isValid = isBackupCodeComplete && backupCode == confirmCode + return isValid + } + + override fun navigateToMessageDisplay( + title: String, + message: String?, + isCancellable: Boolean, + phase: Int, + buttonText: Int, + ) { + // no default implementation + } + + companion object { + const val BACKUP_CODE_LENGTH = 6 + } +} diff --git a/app/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragment.kt b/app/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragment.kt index 3b3a794ccd..2afc12dcb5 100644 --- a/app/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragment.kt +++ b/app/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragment.kt @@ -4,8 +4,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import androidx.annotation.StringRes import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavDirections import androidx.navigation.findNavController @@ -17,62 +15,45 @@ import org.commcare.connect.network.base.BaseApiHandler.PersonalIdOrConnectApiEr import org.commcare.connect.network.base.PersonalIdOrConnectApiErrorHandler import org.commcare.connect.network.personalId.PersonalIdApiHandler import org.commcare.dalvik.R -import org.commcare.dalvik.databinding.FragmentRecoveryCodeBinding import org.commcare.google.services.analytics.AnalyticsParamValue import org.commcare.google.services.analytics.FirebaseAnalyticsUtil import org.commcare.personalId.PersonalIdRecoveryCompleter import org.commcare.personalId.PersonalIdUserPreferences import org.commcare.utils.MediaUtil -import org.commcare.views.connect.NumericCodeView import java.util.Date -class PersonalIdBackupCodeFragment : BasePersonalIdFragment() { - private lateinit var binding: FragmentRecoveryCodeBinding +class PersonalIdBackupCodeFragment : BasePersonalIdBackupCodeFragment() { private lateinit var personalIdSessionData: PersonalIdSessionData private var isRecovery = false - @StringRes - private var titleId = 0 - override fun onResume() { super.onResume() - validateBackupCodeInputs() - binding.backupCodeView.requestFocus(requireActivity()) + validateBackupCodeAndEnableContinue() } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - binding = FragmentRecoveryCodeBinding.inflate(inflater, container, false) + override fun initData() { personalIdSessionData = ViewModelProvider(requireActivity())[PersonalIdSessionDataViewModel::class.java] .personalIdSessionData - configureUiByMode() - setupListeners() - clearBackupCodeFields() - requireActivity().title = getString(titleId) - return binding.root } - private fun configureUiByMode() { + override fun setUpView() { isRecovery = personalIdSessionData.accountExists == true if (isRecovery) { - titleId = R.string.connect_backup_code_title_confirm + setUpInitialState( + titleResId = R.string.connect_backup_code_title_confirm, + showConfirmCode = false, + subtitle = getString(R.string.connect_backup_code_message), + ) binding.recoveryCodeTilte.setText(R.string.connect_backup_code_message_title) - binding.backupCodeSubtitle.setText(R.string.connect_backup_code_message) - binding.backupCodeLayout.visibility = View.VISIBLE - binding.confirmCodeLabel.visibility = View.GONE - binding.confirmCodeLayout.visibility = View.GONE + binding.welcomeBackLayout.visibility = View.VISIBLE setUserNameAndPhoto() } else { - titleId = R.string.connect_backup_code_title_set - binding.backupCodeSubtitle.text = getString(R.string.connect_backup_code_remember, BACKUP_CODE_LENGTH) - binding.backupCodeLayout.visibility = View.VISIBLE - binding.confirmCodeLabel.visibility = View.VISIBLE - binding.confirmCodeLayout.visibility = View.VISIBLE - binding.welcomeBackLayout.visibility = View.GONE + setUpInitialState( + titleResId = R.string.connect_backup_code_title_set, + showConfirmCode = true, + subtitle = getString(R.string.connect_backup_code_remember, BACKUP_CODE_LENGTH), + ) } } @@ -84,90 +65,29 @@ class PersonalIdBackupCodeFragment : BasePersonalIdFragment() { } } - private fun setupListeners() { - val codeChangedListener = NumericCodeView.OnCodeChangedListener { validateBackupCodeInputs() } - - binding.backupCodeView.setOnCodeChangedListener(codeChangedListener) - binding.confirmCodeView.setOnCodeChangedListener(codeChangedListener) - - binding.backupCodeView.setCodeCompleteListener { - if (isRecovery) { - submitIfEnabled() - } - } - + override fun setupListeners() { + super.setupListeners() + binding.backupCodeView.setCodeCompleteListener { if (isRecovery) submitIfEnabled() } binding.confirmCodeView.setCodeCompleteListener { submitIfEnabled() } - - val enterKeyListener = NumericCodeView.OnEnterKeyPressedListener { submitIfEnabled() } - binding.backupCodeView.setOnEnterKeyPressedListener(enterKeyListener) - binding.confirmCodeView.setOnEnterKeyPressedListener(enterKeyListener) - - binding.connectBackupCodeButton.setOnClickListener { handleBackupCodeSubmission() } binding.notMeButton.setOnClickListener { handleNotMeButtonPressed() } - - binding.backupCodeVisibilityToggle.setOnClickListener { - togglePasswordVisibility(binding.backupCodeView, binding.backupCodeVisibilityToggle) - } - binding.confirmCodeVisibilityToggle.setOnClickListener { - togglePasswordVisibility(binding.confirmCodeView, binding.confirmCodeVisibilityToggle) - } - } - - private fun submitIfEnabled() { - if (binding.connectBackupCodeButton.isEnabled) { - handleBackupCodeSubmission() - } - } - - private fun togglePasswordVisibility( - codeView: NumericCodeView, - toggle: ImageView, - ) { - codeView.isPasswordVisible = !codeView.isPasswordVisible - toggle.setImageResource( - if (codeView.isPasswordVisible) R.drawable.ic_visibility_off_24 else R.drawable.ic_visibility_24, - ) - } - - private fun clearBackupCodeFields() { - binding.confirmCodeView.clearCode() - binding.backupCodeView.clearCode() - } - - private fun validateBackupCodeInputs() { - val backupCode = binding.backupCodeView.codeValue - val confirmCode = binding.confirmCodeView.codeValue - - val isCodeComplete = backupCode.length == BACKUP_CODE_LENGTH - val isCodeConfirmed = isRecovery || backupCode == confirmCode - val showMismatch = isCodeComplete && !isCodeConfirmed && confirmCode.length == BACKUP_CODE_LENGTH - val errorText = if (showMismatch) getString(R.string.connect_backup_code_mismatch) else "" - - binding.connectBackupCodeErrorMessage.visibility = if (showMismatch) View.VISIBLE else View.GONE - binding.connectBackupCodeErrorMessage.text = errorText - enableContinueButton(isCodeComplete && isCodeConfirmed) } private fun handleNotMeButtonPressed() { personalIdSessionData.accountExists = false clearBackupCodeFields() - configureUiByMode() + setUpView() } - private fun enableContinueButton(isEnable: Boolean) { - binding.connectBackupCodeButton.isEnabled = isEnable - } - - private fun handleBackupCodeSubmission() { + override fun handleBackupCodeSubmission() { FirebaseAnalyticsUtil.reportPersonalIDContinueClicked( javaClass.simpleName, null, PersonalIdWorkflow.CONFIGURATION, ) + personalIdSessionData.backupCode = binding.backupCodeView.codeValue if (isRecovery) { confirmBackupCode() } else { - personalIdSessionData.backupCode = binding.backupCodeView.codeValue if (ReleaseToggleHelper.isEmailOtpVerificationActive(personalIdSessionData)) { navigateToEmail() } else { @@ -216,16 +136,6 @@ class PersonalIdBackupCodeFragment : BasePersonalIdFragment() { } } - private fun clearError() { - binding.connectBackupCodeErrorMessage.visibility = View.GONE - binding.connectBackupCodeErrorMessage.text = "" - } - - private fun showError(message: String) { - binding.connectBackupCodeErrorMessage.visibility = View.VISIBLE - binding.connectBackupCodeErrorMessage.text = message - } - private fun handleFailedBackupCodeAttempt() { logRecoveryFailureResult() clearBackupCodeFields() @@ -292,9 +202,4 @@ class PersonalIdBackupCodeFragment : BasePersonalIdFragment() { } private fun navigate(directions: NavDirections) = binding.root.findNavController().navigate(directions) - - companion object { - // Note: This is brittle, defined in several places - private const val BACKUP_CODE_LENGTH = 6 - } } diff --git a/app/src/org/commcare/personalId/PersonalIdUserPreferences.kt b/app/src/org/commcare/personalId/PersonalIdUserPreferences.kt index a21f45b500..39862db9e5 100644 --- a/app/src/org/commcare/personalId/PersonalIdUserPreferences.kt +++ b/app/src/org/commcare/personalId/PersonalIdUserPreferences.kt @@ -10,9 +10,52 @@ object PersonalIdUserPreferences { private const val PREFS_NAME = "personalid_prefs" private const val KEY_EMAIL_OFFER_COUNT = "email_offer_count" private const val KEY_LAST_EMAIL_OFFER_DATE = "last_email_offer_date" + private const val KEY_BACKUP_CODE_FAILED_ATTEMPTS = "backup_code_failed_attempts" + private const val KEY_BACKUP_CODE_LOCKOUT_START = "backup_code_lockout_start" + private const val KEY_BACKUP_CODE_WINDOW_START = "backup_code_window_start" + private const val BACKUP_CODE_LOCKOUT_DURATION_MS = 24 * 60 * 60 * 1000L private fun prefs(): SharedPreferences = CommCareApplication.instance().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + /** + * Records a backup-code failure and returns the new failure count within the current 24-hour + * window. If the previous failure window has expired, the count resets before incrementing. + */ + fun recordBackupCodeFailure(): Int { + val now = System.currentTimeMillis() + val windowStart = prefs().getLong(KEY_BACKUP_CODE_WINDOW_START, -1L) + val baseCount = + if (windowStart == -1L || now - windowStart >= BACKUP_CODE_LOCKOUT_DURATION_MS) { + prefs().edit { + putLong(KEY_BACKUP_CODE_WINDOW_START, now) + remove(KEY_BACKUP_CODE_LOCKOUT_START) + } + 0 + } else { + prefs().getInt(KEY_BACKUP_CODE_FAILED_ATTEMPTS, 0) + } + val next = baseCount + 1 + prefs().edit { putInt(KEY_BACKUP_CODE_FAILED_ATTEMPTS, next) } + return next + } + + fun triggerBackupCodeLockout() { + prefs().edit { putLong(KEY_BACKUP_CODE_LOCKOUT_START, System.currentTimeMillis()) } + } + + fun isBackupCodeLockedOut(): Boolean { + val start = prefs().getLong(KEY_BACKUP_CODE_LOCKOUT_START, -1L) + return start != -1L && System.currentTimeMillis() - start < BACKUP_CODE_LOCKOUT_DURATION_MS + } + + fun clearBackupCodeLockout() { + prefs().edit { + remove(KEY_BACKUP_CODE_FAILED_ATTEMPTS) + remove(KEY_BACKUP_CODE_LOCKOUT_START) + remove(KEY_BACKUP_CODE_WINDOW_START) + } + } + /** * Email Offer Count (Int) — 0 = never offered, 1 = first offer shown, 2 = both offers shown */ diff --git a/app/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragment.kt b/app/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragment.kt new file mode 100644 index 0000000000..767adb788d --- /dev/null +++ b/app/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragment.kt @@ -0,0 +1,96 @@ +package org.commcare.personalId.profile + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.navigation.fragment.findNavController +import org.commcare.connect.database.ConnectUserDatabaseUtil +import org.commcare.dalvik.R +import org.commcare.fragments.personalId.BasePersonalIdBackupCodeFragment +import org.commcare.fragments.personalId.EmailWorkFlow +import org.commcare.personalId.PersonalIdUserPreferences + +class PersonalIdProfileBackupCodeFragment : BasePersonalIdBackupCodeFragment() { + private val forgotBackupCodeButton get() = binding.notMeButton + private var isLocked = false + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + if (PersonalIdUserPreferences.isBackupCodeLockedOut()) { + enterLockedState() + } + } + + override fun setUpView() { + setUpInitialState( + titleResId = R.string.connect_backup_code_title_confirm, + showConfirmCode = false, + subtitle = getString(R.string.connect_backup_code_message), + notMeButtonTextId = R.string.personalid_forgot_backup_code, + ) + } + + override fun onCodeChanged() { + if (!isLocked) validateBackupCodeAndEnableContinue() + } + + override fun setupListeners() { + super.setupListeners() + forgotBackupCodeButton.setOnClickListener { handleForgot() } + } + + private fun handleForgot() { + val email = ConnectUserDatabaseUtil.getUser(requireContext())?.email + if (email != null) { + findNavController().navigate( + PersonalIdProfileBackupCodeFragmentDirections + .actionProfileBackupCodeToEmailVerification( + email, + EmailWorkFlow.RECOVERY, + 0, + ), + ) + } else { + Toast + .makeText( + requireContext(), + R.string.personalid_no_email_forgot_backup_code_toast, + Toast.LENGTH_LONG, + ).show() + findNavController().popBackStack() + } + } + + override fun handleBackupCodeSubmission() { + val enteredCode = binding.backupCodeView.codeValue + val storedBackupCode = ConnectUserDatabaseUtil.getUser(requireContext())?.pin + if (enteredCode == storedBackupCode) { + PersonalIdUserPreferences.clearBackupCodeLockout() + findNavController().navigate(R.id.action_profile_backup_code_to_set_new_backup_code) + } else { + val attempts = PersonalIdUserPreferences.recordBackupCodeFailure() + if (attempts >= MAX_ATTEMPTS) { + PersonalIdUserPreferences.triggerBackupCodeLockout() + enterLockedState() + } else { + showError(getString(R.string.connect_backup_fail_title)) + } + } + } + + private fun enterLockedState() { + isLocked = true + showError(getString(R.string.personalid_backup_code_too_many_attempts)) + binding.backupCodeView.isEnabled = false + enableContinueButton(false) + } + + companion object { + private const val MAX_ATTEMPTS = 3 + } +} diff --git a/app/src/org/commcare/personalId/profile/PersonalIdProfileFragment.kt b/app/src/org/commcare/personalId/profile/PersonalIdProfileFragment.kt index d7a9e74948..64394ded54 100644 --- a/app/src/org/commcare/personalId/profile/PersonalIdProfileFragment.kt +++ b/app/src/org/commcare/personalId/profile/PersonalIdProfileFragment.kt @@ -41,6 +41,9 @@ class PersonalIdProfileFragment : BasePersonalIdProfileFragment() { viewModel = ViewModelProvider(this)[PersonalIdProfileViewModel::class.java] viewModel.profileDisplayModel.observe(viewLifecycleOwner) { displayProfileDetails(it) } binding.profileBtnForgetPersonalid.setOnClickListener { showForgetPersonalIdDialog() } + binding.profileChangeBackupCode.setOnClickListener { + findNavController().navigate(R.id.action_profile_to_profile_backup_code) + } } override fun onResume() { diff --git a/app/src/org/commcare/personalId/profile/SetNewBackupCodeFragment.kt b/app/src/org/commcare/personalId/profile/SetNewBackupCodeFragment.kt new file mode 100644 index 0000000000..a53b1a9ee3 --- /dev/null +++ b/app/src/org/commcare/personalId/profile/SetNewBackupCodeFragment.kt @@ -0,0 +1,16 @@ +package org.commcare.personalId.profile + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment + +class SetNewBackupCodeFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = TextView(requireContext()).apply { text = "Set New Backup Code — TODO" } +} diff --git a/app/src/org/commcare/views/connect/NumericCodeView.java b/app/src/org/commcare/views/connect/NumericCodeView.java index 395912cb25..239af85038 100644 --- a/app/src/org/commcare/views/connect/NumericCodeView.java +++ b/app/src/org/commcare/views/connect/NumericCodeView.java @@ -289,6 +289,9 @@ private GradientDrawable createBackgroundDrawable() { GradientDrawable drawable = new GradientDrawable(); drawable.setCornerRadius(borderRadius); drawable.setStroke(borderWidth, isErrorState ? errorBorderColor : borderColor); + if (!isEnabled()) { + drawable.setColor(Color.LTGRAY); + } return drawable; } @@ -382,6 +385,15 @@ public void setOnEnterKeyPressedListener(OnEnterKeyPressedListener listener) { this.enterKeyPressedListener = listener; } + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + for (int i = 0; i < getChildCount(); i++) { + getChildAt(i).setEnabled(enabled); + } + updateUi(); + } + public void setCode(String code) { if (code.length() > digitCount) { throw new IllegalArgumentException("Code length exceeds the digit count"); diff --git a/app/unit-tests/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragmentRecoveryTest.kt b/app/unit-tests/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragmentRecoveryTest.kt index 69c570bb56..5a442543d5 100644 --- a/app/unit-tests/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragmentRecoveryTest.kt +++ b/app/unit-tests/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragmentRecoveryTest.kt @@ -164,7 +164,7 @@ class PersonalIdBackupCodeFragmentRecoveryTest : BasePersonalIdBackupCodeFragmen assertEquals(TEST_PHOTO_BASE64, storedUser.photo) assertEquals(PersonalIdSessionData.PIN, storedUser.requiredLock) // Recovery never writes the entered code onto the session data, so the stored record has no pin. - assertNull(storedUser.pin) + assertEquals(TEST_BACKUP_CODE, storedUser.pin) assertMessageDisplay( title = fragment.getString(R.string.connect_recovery_success_title), diff --git a/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragmentTest.kt b/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragmentTest.kt new file mode 100644 index 0000000000..c1c1cdcefa --- /dev/null +++ b/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragmentTest.kt @@ -0,0 +1,213 @@ +package org.commcare.personalId.profile + +import android.content.Context +import android.view.View +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.material.button.MaterialButton +import org.commcare.CommCareTestApplication +import org.commcare.dalvik.R +import org.commcare.personalId.PersonalIdUserPreferences +import org.commcare.views.connect.NumericCodeView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper +import org.robolectric.shadows.ShadowToast + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class PersonalIdProfileBackupCodeFragmentTest : BasePersonalIdProfileTest() { + @Before + fun navigateToBackupCodeScreen() { + PersonalIdUserPreferences.clearBackupCodeLockout() + user.pin = "123456" // override to 6 digits so the code view can hold it + onUiThread { + navController.navigate(R.id.action_profile_to_profile_backup_code) + } + } + + private fun fragment() = + navHostFragment.childFragmentManager + .primaryNavigationFragment as PersonalIdProfileBackupCodeFragment + + private fun backupCodeView(): NumericCodeView = fragment().requireView().findViewById(R.id.backup_code_view) + + private fun confirmCodeLayout(): View = fragment().requireView().findViewById(R.id.confirm_code_layout) + + private fun confirmCodeLabel(): View = fragment().requireView().findViewById(R.id.confirm_code_label) + + private fun welcomeBackLayout(): View = fragment().requireView().findViewById(R.id.welcome_back_layout) + + private fun continueButton(): MaterialButton = fragment().requireView().findViewById(R.id.connect_backup_code_button) + + private fun errorMessage(): TextView = fragment().requireView().findViewById(R.id.connect_backup_code_error_message) + + private fun forgotButton(): TextView = fragment().requireView().findViewById(R.id.not_me_button) + + private fun setCodeAndContinue(code: String = "000000") { + onUiThread { backupCodeView().setCode(code) } + onUiThread { continueButton().performClick() } + } + + // ===== Initial state ===== + + @Test + fun `confirm code layout is hidden`() { + assertEquals(View.GONE, confirmCodeLabel().visibility) + assertEquals(View.GONE, confirmCodeLayout().visibility) + } + + @Test + fun `welcome back layout is hidden`() { + assertEquals(View.GONE, welcomeBackLayout().visibility) + } + + @Test + fun `continue button starts disabled`() { + assertFalse(continueButton().isEnabled) + } + + @Test + fun `error message starts hidden`() { + assertEquals(View.GONE, errorMessage().visibility) + } + + @Test + fun `forgot backup code link is visible`() { + assertEquals(View.VISIBLE, forgotButton().visibility) + } + + // ===== Validation ===== + + @Test + fun `continue button enables when 6 digits are entered`() { + onUiThread { backupCodeView().setCode("000000") } + ShadowLooper.idleMainLooper() + + assertTrue(continueButton().isEnabled) + } + + @Test + fun `continue button stays disabled until 6 digits`() { + onUiThread { backupCodeView().setCode("12345") } + ShadowLooper.idleMainLooper() + + assertFalse(continueButton().isEnabled) + } + + // ===== Correct code ===== + + @Test + fun `correct code navigates to set-new-backup-code`() { + setCodeAndContinue("123456") + + assertEquals(R.id.personalid_set_new_backup_code_fragment, currentDestinationId()) + } + + // ===== Wrong code ===== + + @Test + fun `wrong code shows error and stays on screen`() { + setCodeAndContinue() + assertEquals(View.VISIBLE, errorMessage().visibility) + assertEquals(R.id.personalid_profile_backup_code_fragment, currentDestinationId()) + } + + @Test + fun `continue button re-enables after wrong code`() { + setCodeAndContinue() + assertTrue(continueButton().isEnabled) + } + + // ===== Locked state ===== + + @Test + fun `wrong code three times enters locked state`() { + setCodeAndContinue() + setCodeAndContinue() + setCodeAndContinue() + + assertEquals(View.VISIBLE, forgotButton().visibility) + assertFalse(backupCodeView().isEnabled) + assertFalse(continueButton().isEnabled) + assertEquals(View.VISIBLE, errorMessage().visibility) + assertTrue(PersonalIdUserPreferences.isBackupCodeLockedOut()) + } + + @Test + fun `continue button stays disabled in locked state even if code is set`() { + setCodeAndContinue() + setCodeAndContinue() + setCodeAndContinue() + + onUiThread { backupCodeView().setCode("123456") } + + assertFalse(continueButton().isEnabled) + } + + @Test + fun `lockout in prefs causes locked state on fragment init`() { + PersonalIdUserPreferences.triggerBackupCodeLockout() + + // Navigate back to profile, then forward again so the fragment is recreated + onUiThread { navController.popBackStack() } + ShadowLooper.idleMainLooper() + onUiThread { navController.navigate(R.id.action_profile_to_profile_backup_code) } + ShadowLooper.idleMainLooper() + + assertFalse(backupCodeView().isEnabled) + assertFalse(continueButton().isEnabled) + assertEquals(View.VISIBLE, errorMessage().visibility) + assertEquals(View.VISIBLE, forgotButton().visibility) + } + + @Test + fun `third wrong attempt after 24 hours does not trigger lockout`() { + // Two wrong attempts within the window + setCodeAndContinue() + onUiThread { backupCodeView().clearCode() } + setCodeAndContinue() + onUiThread { backupCodeView().clearCode() } + + // Backdated the window start to more than 24 hours ago so the next failure resets the count + activity + .getSharedPreferences("personalid_prefs", Context.MODE_PRIVATE) + .edit() + .putLong("backup_code_window_start", System.currentTimeMillis() - 25 * 60 * 60 * 1000L) + .commit() + + // Third wrong attempt + setCodeAndContinue() + + assertFalse(PersonalIdUserPreferences.isBackupCodeLockedOut()) + assertEquals(View.VISIBLE, errorMessage().visibility) + assertTrue(continueButton().isEnabled) + } + + // ===== Forgot ===== + + @Test + fun `forgot with email navigates to email verification`() { + // user.email is "ada@example.com" per BasePersonalIdProfileTest + onUiThread { forgotButton().performClick() } + + assertEquals(R.id.personalid_email_verification_fragment, currentDestinationId()) + } + + @Test + fun `forgot with no email pops back to profile`() { + user.email = null + onUiThread { forgotButton().performClick() } + + assertEquals( + activity.getString(R.string.personalid_no_email_forgot_backup_code_toast), + ShadowToast.getTextOfLatestToast(), + ) + assertEquals(R.id.personalid_profile_fragment, currentDestinationId()) + } +} diff --git a/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileFragmentTest.kt b/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileFragmentTest.kt index c71453994c..0d840580eb 100644 --- a/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileFragmentTest.kt +++ b/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileFragmentTest.kt @@ -71,4 +71,14 @@ class PersonalIdProfileFragmentTest : BasePersonalIdProfileTest() { assertNotNull("Forget PersonalID should show a confirmation dialog", dialog) assertTrue("The confirmation dialog should be visible", dialog.isShowing) } + + @Test + fun `tapping change backup code row navigates to backup code screen`() { + val changeBackupCodeRow = + fragment().requireView().findViewById(R.id.profile_change_backup_code) + + onUiThread { changeBackupCodeRow.performClick() } + + assertEquals(R.id.personalid_profile_backup_code_fragment, currentDestinationId()) + } }