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