From c71a4953a6890ea59225e0662eb4b4bc76aeece8 Mon Sep 17 00:00:00 2001 From: jignesh-dimagi Date: Thu, 8 Oct 2026 11:28:55 +0530 Subject: [PATCH 1/6] CCCT-2790 Add the account security analytics event [AI] One event covers the backup-code and email journeys, with workflow and event_type params separating them, mirroring how otp_requested already handles the same problem. Only the event name is new; every param constant already existed. Nothing reports it yet. Co-Authored-By: Claude Opus 5 (1M context) --- .../analytics/AnalyticsParamValue.java | 45 +++++++++++++++++++ .../services/analytics/CCAnalyticsEvent.java | 2 + .../analytics/FirebaseAnalyticsUtil.java | 25 +++++++++++ .../utils/AccountSecurityAnalyticsMapper.kt | 42 +++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt diff --git a/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java b/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java index 43ecebaf47..5d5a9ef253 100644 --- a/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java +++ b/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java @@ -247,6 +247,51 @@ public class AnalyticsParamValue { "email_verification_failure_retry"; public static final String USER_PROMPT_INFO_MANAGE_PROFILE_EMAIL_UPDATE = "manage_profile_email_update"; + public static final String USER_PROMPT_TYPE_BACKUP_CODE = "backup_code"; + public static final String USER_PROMPT_ACTION_FORGOT = "forgot"; + public static final String USER_PROMPT_INFO_BACKUP_CODE_REMINDER = "backup_code_reminder"; + public static final String USER_PROMPT_INFO_SET_BACKUP_CODE_REMINDER = + "set_backup_code_reminder"; + public static final String USER_PROMPT_INFO_SET_NEW_BACKUP_CODE_ABANDON = + "set_new_backup_code_abandon"; + + // Param values for account security analytics + public static final String ACCOUNT_SECURITY_WORKFLOW_CHANGE_BACKUP_CODE = "change_backup_code"; + public static final String ACCOUNT_SECURITY_WORKFLOW_RECOVERY_ACCOUNT_CONFIG = + "recovery_account_config"; + public static final String ACCOUNT_SECURITY_WORKFLOW_RECOVERY_EXISTING_USER = + "recovery_existing_user"; + public static final String ACCOUNT_SECURITY_WORKFLOW_RECOVERY_PENDING_CODE = + "recovery_pending_code"; + public static final String ACCOUNT_SECURITY_WORKFLOW_BACKUP_CODE_REMINDER = + "backup_code_reminder"; + public static final String ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE_BACKUP_CODE = + "email_change_backup_code"; + public static final String ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE_PHONE_OTP = + "email_change_phone_otp"; + public static final String ACCOUNT_SECURITY_WORKFLOW_REGISTRATION = "registration"; + public static final String ACCOUNT_SECURITY_WORKFLOW_ACCOUNT_RECOVERY = "account_recovery"; + + public static final String ACCOUNT_SECURITY_EVENT_CONFIRM_CODE_SHOWN = "confirm_code_shown"; + public static final String ACCOUNT_SECURITY_EVENT_CONFIRM_CODE_ATTEMPT = "confirm_code_attempt"; + public static final String ACCOUNT_SECURITY_EVENT_CONFIRM_CODE_MAX_ATTEMPTS = + "confirm_code_max_attempts"; + public static final String ACCOUNT_SECURITY_EVENT_FORGOT_CODE_STARTED = "forgot_code_started"; + public static final String ACCOUNT_SECURITY_EVENT_SET_NEW_CODE_SHOWN = "set_new_code_shown"; + public static final String ACCOUNT_SECURITY_EVENT_SET_NEW_CODE_ATTEMPT = "set_new_code_attempt"; + public static final String ACCOUNT_SECURITY_EVENT_SEND_EMAIL_OTP_SHOWN = "send_email_otp_shown"; + public static final String ACCOUNT_SECURITY_EVENT_VERIFY_EMAIL_OTP_SHOWN = + "verify_email_otp_shown"; + public static final String ACCOUNT_SECURITY_EVENT_VERIFY_EMAIL_OTP_MAX_ATTEMPTS = + "verify_email_otp_max_attempts"; + public static final String ACCOUNT_SECURITY_EVENT_EMAIL_PROMPT_SHOWN = "email_prompt_shown"; + public static final String ACCOUNT_SECURITY_EVENT_PHONE_OTP_REQUIRED = "phone_otp_required"; + public static final String ACCOUNT_SECURITY_EVENT_PHONE_OTP_MAX_ATTEMPTS = + "phone_otp_max_attempts"; + public static final String ACCOUNT_SECURITY_EVENT_SET_CODE_LAUNCHED_ON_STARTUP = + "set_code_launched_on_startup"; + public static final String ACCOUNT_SECURITY_EVENT_RECOVERY_COMPLETED = "recovery_completed"; + public static final String ACCOUNT_SECURITY_EVENT_EMAIL_CHANGED = "email_changed"; // Param values for SMS invite link analytics public static final String OPP_INVITE_LINK = "opp_invite_link"; diff --git a/app/src/org/commcare/google/services/analytics/CCAnalyticsEvent.java b/app/src/org/commcare/google/services/analytics/CCAnalyticsEvent.java index 622fad27bb..1a296bf768 100644 --- a/app/src/org/commcare/google/services/analytics/CCAnalyticsEvent.java +++ b/app/src/org/commcare/google/services/analytics/CCAnalyticsEvent.java @@ -58,4 +58,6 @@ public class CCAnalyticsEvent { static final String PERSONAL_ID_RELEASE_TOGGLES = "personal_id_release_toggles"; static final String USER_PROMPT = "user_prompt"; static final String EXTERNAL_APP_LAUNCH = "external_app_launch"; + static final String PERSONAL_ID_ACCOUNT_SECURITY_ACTION = + "personalid_account_security_action"; } diff --git a/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java b/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java index 4b226d2717..bcf7146053 100644 --- a/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java +++ b/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java @@ -685,6 +685,31 @@ public static void reportPersonalIDContinueClicked(String screenName, @Nullable reportEvent(CCAnalyticsEvent.PERSONAL_ID_CONTINUE_CLICKED, params); } + /** + * Reports a step in a PersonalID backup-code or email journey. {@code workflow} says which + * journey (see {@link org.commcare.utils.AccountSecurityAnalyticsMapper}), {@code eventType} + * which step; the remaining params are omitted when null. + */ + public static void reportPersonalIdAccountSecurityAction(String workflow, + String eventType, + @Nullable String outcome, + @Nullable String reason, + @Nullable Integer failedAttempts) { + Bundle params = new Bundle(); + params.putString(CCAnalyticsParam.OTP_WORKFLOW, workflow); + params.putString(CCAnalyticsParam.OTP_EVENT_TYPE, eventType); + if (outcome != null) { + params.putString(CCAnalyticsParam.OTP_OUTCOME, outcome); + } + if (reason != null) { + params.putString(CCAnalyticsParam.REASON, reason); + } + if (failedAttempts != null) { + params.putLong(CCAnalyticsParam.OTP_FAILED_ATTEMPTS, failedAttempts); + } + reportEvent(CCAnalyticsEvent.PERSONAL_ID_ACCOUNT_SECURITY_ACTION, params); + } + public static void reportUserPromptEvent(String type, String action, String info) { Bundle params = new Bundle(); params.putString(CCAnalyticsParam.USER_PROMPT_TYPE, type); diff --git a/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt b/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt new file mode 100644 index 0000000000..7bf4f05984 --- /dev/null +++ b/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt @@ -0,0 +1,42 @@ +package org.commcare.utils + +import org.commcare.fragments.personalId.EmailWorkFlow +import org.commcare.google.services.analytics.AnalyticsParamValue + +/** + * Normalizes the PersonalID backup-code and email journeys into the stable analytics strings + * emitted with the {@code personalid_account_security_action} event. + */ +object AccountSecurityAnalyticsMapper { + /** + * Maps an [EmailWorkFlow] to the `workflow` param, which says which journey the user is in + * when the shared email and backup-code screens report an event. + */ + @JvmStatic + fun workflowParam(workflow: EmailWorkFlow): String = + when (workflow) { + EmailWorkFlow.REGISTRATION -> { + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_REGISTRATION + } + + EmailWorkFlow.RECOVERY -> { + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_ACCOUNT_RECOVERY + } + + EmailWorkFlow.EXISTING_USER -> { + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE_BACKUP_CODE + } + + EmailWorkFlow.FORGOT_BACKUP_CODE_RECOVERY -> { + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_RECOVERY_ACCOUNT_CONFIG + } + + EmailWorkFlow.FORGOT_BACKUP_CODE_EXISTING_USER -> { + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_RECOVERY_EXISTING_USER + } + + EmailWorkFlow.PENDING_BACKUP_CODE -> { + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_RECOVERY_PENDING_CODE + } + } +} From 0ac20fb86fc1633263721a4244fff0c3c0a72e5d Mon Sep 17 00:00:00 2001 From: jignesh-dimagi Date: Thu, 8 Oct 2026 11:29:12 +0530 Subject: [PATCH 2/6] CCCT-2790 Add analytics to the change backup code flow [AI] Reports the confirm-code screen, its attempts and lockout, the forgot-code tap, the set-new-code screen and its outcome. The abandon dialog goes to user_prompt, matching how the app already reports prompts. The confirm and set-new-code screens are shared by several journeys, and their existing arguments cannot say which one the user is in - emailWorkflow carries the forgot route available, not the reason for being there. So BackupCodeWorkflow, until now unused, becomes a nav argument that each entry point sets explicitly. The set-new-code base class spans two nav graphs, so it takes the workflow through an abstract method rather than Safe Args. Co-Authored-By: Claude Opus 5 (1M context) --- .../nav_graph_personalid_profile.xml | 9 +++- .../personalId/BackupCodeWorkflow.kt | 13 ++++-- ...IdAccountConfigSetNewBackupCodeFragment.kt | 3 ++ ...ailVerificationForgotBackupCodeFragment.kt | 4 +- .../BasePersonalIdSetNewBackupCodeFragment.kt | 37 +++++++++++++++ .../PersonalIdProfileBackupCodeFragment.kt | 45 ++++++++++++++++++- .../profile/PersonalIdProfileEditFragment.kt | 7 ++- .../profile/PersonalIdProfileFragment.kt | 6 ++- ...rsonalIdProfileSetNewBackupCodeFragment.kt | 5 +++ .../utils/AccountSecurityAnalyticsMapper.kt | 21 +++++++++ 10 files changed, 140 insertions(+), 10 deletions(-) diff --git a/app/res/navigation/nav_graph_personalid_profile.xml b/app/res/navigation/nav_graph_personalid_profile.xml index b3f433dc93..b3fd1b9cd9 100644 --- a/app/res/navigation/nav_graph_personalid_profile.xml +++ b/app/res/navigation/nav_graph_personalid_profile.xml @@ -77,6 +77,9 @@ + @@ -140,6 +143,10 @@ + android:label="@string/personalid_set_new_backup_code_title"> + + diff --git a/app/src/org/commcare/fragments/personalId/BackupCodeWorkflow.kt b/app/src/org/commcare/fragments/personalId/BackupCodeWorkflow.kt index 362c8b681a..140b17f0aa 100644 --- a/app/src/org/commcare/fragments/personalId/BackupCodeWorkflow.kt +++ b/app/src/org/commcare/fragments/personalId/BackupCodeWorkflow.kt @@ -1,11 +1,16 @@ package org.commcare.fragments.personalId /** - * Identifies which backup-code flow is being executed. + * Why the user is on a backup-code screen. Passed as a nav argument so the confirm and + * set-new-code screens can report which journey they belong to, which their other arguments + * cannot tell them. * - * - [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. + * - [CHANGE_BACKUP_CODE]: user chose to change their backup code from Manage Profile. + * - [EMAIL_CHANGE]: user is changing their email and must confirm the current code first. + * - [FORGOT_BACKUP_CODE]: user forgot their code and recovered via email OTP. */ enum class BackupCodeWorkflow { - CONFIRM_BACKUP_CODE_CHANGE_CODE, + CHANGE_BACKUP_CODE, + EMAIL_CHANGE, + FORGOT_BACKUP_CODE, } diff --git a/app/src/org/commcare/fragments/personalId/PersonalIdAccountConfigSetNewBackupCodeFragment.kt b/app/src/org/commcare/fragments/personalId/PersonalIdAccountConfigSetNewBackupCodeFragment.kt index 750b4498d8..dc2391fa71 100644 --- a/app/src/org/commcare/fragments/personalId/PersonalIdAccountConfigSetNewBackupCodeFragment.kt +++ b/app/src/org/commcare/fragments/personalId/PersonalIdAccountConfigSetNewBackupCodeFragment.kt @@ -2,10 +2,13 @@ package org.commcare.fragments.personalId import org.commcare.connect.ConnectConstants import org.commcare.dalvik.R +import org.commcare.google.services.analytics.AnalyticsParamValue import org.commcare.personalId.profile.BasePersonalIdSetNewBackupCodeFragment import org.commcare.views.dialogs.StandardAlertDialog class PersonalIdAccountConfigSetNewBackupCodeFragment : BasePersonalIdSetNewBackupCodeFragment() { + override fun analyticsWorkflow(): String = AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_RECOVERY_ACCOUNT_CONFIG + override fun navigateToMessageDisplay( title: String, message: String?, diff --git a/app/src/org/commcare/fragments/personalId/PersonalIdEmailVerificationForgotBackupCodeFragment.kt b/app/src/org/commcare/fragments/personalId/PersonalIdEmailVerificationForgotBackupCodeFragment.kt index 7498ae5c3a..d81b2fee87 100644 --- a/app/src/org/commcare/fragments/personalId/PersonalIdEmailVerificationForgotBackupCodeFragment.kt +++ b/app/src/org/commcare/fragments/personalId/PersonalIdEmailVerificationForgotBackupCodeFragment.kt @@ -21,7 +21,9 @@ class PersonalIdEmailVerificationForgotBackupCodeFragment : BasePersonalIdEmailV .findNavController() .navigate( PersonalIdEmailVerificationForgotBackupCodeFragmentDirections - .actionEmailVerificationForgotBackupCodeToSetNewBackupCode(), + .actionEmailVerificationForgotBackupCodeToSetNewBackupCode( + BackupCodeWorkflow.FORGOT_BACKUP_CODE, + ), ) } } diff --git a/app/src/org/commcare/personalId/profile/BasePersonalIdSetNewBackupCodeFragment.kt b/app/src/org/commcare/personalId/profile/BasePersonalIdSetNewBackupCodeFragment.kt index 0d700a7d51..5569764772 100644 --- a/app/src/org/commcare/personalId/profile/BasePersonalIdSetNewBackupCodeFragment.kt +++ b/app/src/org/commcare/personalId/profile/BasePersonalIdSetNewBackupCodeFragment.kt @@ -12,11 +12,16 @@ import org.commcare.connect.network.base.PersonalIdOrConnectApiErrorHandler import org.commcare.connect.network.personalId.PersonalIdApiHandler import org.commcare.dalvik.R import org.commcare.fragments.personalId.BasePersonalIdBackupCodeFragment +import org.commcare.google.services.analytics.AnalyticsParamValue +import org.commcare.google.services.analytics.FirebaseAnalyticsUtil import org.commcare.personalId.PersonalIdUnlocker import org.commcare.personalId.UnlockPolicy import org.commcare.views.dialogs.StandardAlertDialog abstract class BasePersonalIdSetNewBackupCodeFragment : BasePersonalIdBackupCodeFragment() { + /** The `workflow` analytics param, which differs per graph and per entry into this screen. */ + abstract fun analyticsWorkflow(): String + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -43,9 +48,11 @@ abstract class BasePersonalIdSetNewBackupCodeFragment : BasePersonalIdBackupCode getString(R.string.personalid_set_new_backup_code_abandon_message), ) dialog.setPositiveButton(getString(R.string.personalid_set_new_backup_code_abandon_positive)) { d, _ -> + reportAbandonPrompt(AnalyticsParamValue.USER_PROMPT_ACTION_CANCEL) d.dismiss() } dialog.setNegativeButton(getString(R.string.personalid_set_new_backup_code_abandon_negative)) { d, _ -> + reportAbandonPrompt(AnalyticsParamValue.USER_PROMPT_ACTION_ACCEPT) d.dismiss() onAbandon() } @@ -53,6 +60,14 @@ abstract class BasePersonalIdSetNewBackupCodeFragment : BasePersonalIdBackupCode dialog.showNonPersistentDialog(requireActivity()) } + private fun reportAbandonPrompt(action: String) { + FirebaseAnalyticsUtil.reportUserPromptEvent( + AnalyticsParamValue.USER_PROMPT_TYPE_BACKUP_CODE, + action, + AnalyticsParamValue.USER_PROMPT_INFO_SET_NEW_BACKUP_CODE_ABANDON, + ) + } + protected open fun onAbandon() { findNavController().popBackStack() if (findNavController().currentDestination == null) { @@ -71,6 +86,13 @@ abstract class BasePersonalIdSetNewBackupCodeFragment : BasePersonalIdBackupCode showConfirmCode = true, subtitle = getString(R.string.connect_backup_code_remember, BACKUP_CODE_LENGTH), ) + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + analyticsWorkflow(), + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_SET_NEW_CODE_SHOWN, + null, + null, + null, + ) } override fun handleBackupCodeSubmission() { @@ -112,6 +134,7 @@ abstract class BasePersonalIdSetNewBackupCodeFragment : BasePersonalIdBackupCode ) { user.pin = backupCode ConnectUserDatabaseUtil.storeUser(user) + reportSetNewCodeAttempt(AnalyticsParamValue.OTP_OUTCOME_SUCCESS, null) showSuccess() } @@ -121,6 +144,7 @@ abstract class BasePersonalIdSetNewBackupCodeFragment : BasePersonalIdBackupCode errorCode: PersonalIdOrConnectApiErrorCodes, t: Throwable?, ) { + reportSetNewCodeAttempt(AnalyticsParamValue.OTP_OUTCOME_FAILURE, errorCode.name) showError( PersonalIdOrConnectApiErrorHandler.handle( requireActivity(), @@ -130,4 +154,17 @@ abstract class BasePersonalIdSetNewBackupCodeFragment : BasePersonalIdBackupCode ) enableContinueButton(true) } + + private fun reportSetNewCodeAttempt( + outcome: String, + reason: String?, + ) { + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + analyticsWorkflow(), + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_SET_NEW_CODE_ATTEMPT, + outcome, + reason, + null, + ) + } } diff --git a/app/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragment.kt b/app/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragment.kt index e83e59c44b..19f0208615 100644 --- a/app/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragment.kt +++ b/app/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragment.kt @@ -7,7 +7,10 @@ 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.google.services.analytics.AnalyticsParamValue +import org.commcare.google.services.analytics.FirebaseAnalyticsUtil import org.commcare.personalId.PersonalIdUserPreferences +import org.commcare.utils.AccountSecurityAnalyticsMapper class PersonalIdProfileBackupCodeFragment : BasePersonalIdBackupCodeFragment() { private val args by lazy { PersonalIdProfileBackupCodeFragmentArgs.fromBundle(requireArguments()) } @@ -15,6 +18,8 @@ class PersonalIdProfileBackupCodeFragment : BasePersonalIdBackupCodeFragment() { private var isLocked = false + private val analyticsWorkflow get() = AccountSecurityAnalyticsMapper.workflowParam(args.backupCodeWorkflow) + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -32,6 +37,13 @@ class PersonalIdProfileBackupCodeFragment : BasePersonalIdBackupCodeFragment() { subtitle = getString(R.string.connect_backup_code_message), ) binding.personalidForgotBackupCode.visibility = View.VISIBLE + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + analyticsWorkflow, + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_CONFIRM_CODE_SHOWN, + null, + null, + null, + ) } override fun onCodeChanged() { @@ -39,6 +51,13 @@ class PersonalIdProfileBackupCodeFragment : BasePersonalIdBackupCodeFragment() { } override fun handleForgotBackupCode() { + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + analyticsWorkflow, + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_FORGOT_CODE_STARTED, + null, + null, + null, + ) val email = ConnectUserDatabaseUtil.getUser().email when { !email.isNullOrEmpty() -> { @@ -77,6 +96,7 @@ class PersonalIdProfileBackupCodeFragment : BasePersonalIdBackupCodeFragment() { val storedBackupCode = ConnectUserDatabaseUtil.getUser()?.pin if (enteredCode == storedBackupCode) { PersonalIdUserPreferences.clearBackupCodeLockout() + reportConfirmAttempt(AnalyticsParamValue.OTP_OUTCOME_SUCCESS, null) if (args.emailWorkflow == EmailWorkFlow.EXISTING_USER) { navigateToEmailOtpFragment() } else { @@ -84,7 +104,15 @@ class PersonalIdProfileBackupCodeFragment : BasePersonalIdBackupCodeFragment() { } } else { val attempts = PersonalIdUserPreferences.recordBackupCodeFailure() + reportConfirmAttempt(AnalyticsParamValue.OTP_OUTCOME_FAILURE, attempts) if (attempts >= MAX_ATTEMPTS) { + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + analyticsWorkflow, + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_CONFIRM_CODE_MAX_ATTEMPTS, + null, + null, + attempts, + ) PersonalIdUserPreferences.triggerBackupCodeLockout() enterLockedState() } else { @@ -101,8 +129,23 @@ class PersonalIdProfileBackupCodeFragment : BasePersonalIdBackupCodeFragment() { findNavController().navigate(directions) } + private fun reportConfirmAttempt( + outcome: String, + failedAttempts: Int?, + ) { + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + analyticsWorkflow, + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_CONFIRM_CODE_ATTEMPT, + outcome, + null, + failedAttempts, + ) + } + private fun navigateToSetNewBackupCode() { - val directions = PersonalIdProfileBackupCodeFragmentDirections.actionProfileBackupCodeToSetNewBackupCode() + val directions = + PersonalIdProfileBackupCodeFragmentDirections + .actionProfileBackupCodeToSetNewBackupCode(args.backupCodeWorkflow) findNavController().navigate(directions) } diff --git a/app/src/org/commcare/personalId/profile/PersonalIdProfileEditFragment.kt b/app/src/org/commcare/personalId/profile/PersonalIdProfileEditFragment.kt index 1f1a89d8ab..91c77788af 100644 --- a/app/src/org/commcare/personalId/profile/PersonalIdProfileEditFragment.kt +++ b/app/src/org/commcare/personalId/profile/PersonalIdProfileEditFragment.kt @@ -14,6 +14,7 @@ import org.commcare.connect.network.base.PersonalIdOrConnectApiErrorHandler import org.commcare.connect.network.personalId.PersonalIdApiHandler import org.commcare.dalvik.R import org.commcare.dalvik.databinding.PersonalidProfileEditScreenBinding +import org.commcare.fragments.personalId.BackupCodeWorkflow import org.commcare.fragments.personalId.EmailWorkFlow import org.commcare.google.services.analytics.AnalyticsParamValue import org.commcare.google.services.analytics.FirebaseAnalyticsUtil @@ -168,8 +169,10 @@ class PersonalIdProfileEditFragment : BasePersonalIdProfileFragment() { .actionProfileEditToProfileSendPhoneOtp(pendingEmail) } else { PersonalIdProfileEditFragmentDirections - .actionProfileEditToBackupCode(EmailWorkFlow.EXISTING_USER) - .setPendingEmail(pendingEmail) + .actionProfileEditToBackupCode( + EmailWorkFlow.EXISTING_USER, + BackupCodeWorkflow.EMAIL_CHANGE, + ).setPendingEmail(pendingEmail) } findNavController().navigate(directions) } diff --git a/app/src/org/commcare/personalId/profile/PersonalIdProfileFragment.kt b/app/src/org/commcare/personalId/profile/PersonalIdProfileFragment.kt index f5c3846437..2ba2fdfdc1 100644 --- a/app/src/org/commcare/personalId/profile/PersonalIdProfileFragment.kt +++ b/app/src/org/commcare/personalId/profile/PersonalIdProfileFragment.kt @@ -14,6 +14,7 @@ import androidx.navigation.fragment.findNavController import org.commcare.connect.database.ConnectUserDatabaseUtil import org.commcare.dalvik.R import org.commcare.dalvik.databinding.PersonalidProfileScreenBinding +import org.commcare.fragments.personalId.BackupCodeWorkflow import org.commcare.fragments.personalId.EmailWorkFlow class PersonalIdProfileFragment : BasePersonalIdProfileFragment() { @@ -69,7 +70,10 @@ class PersonalIdProfileFragment : BasePersonalIdProfileFragment() { private fun navigateToProfileBackupCode() { findNavController().navigate( PersonalIdProfileFragmentDirections - .actionProfileToProfileBackupCode(EmailWorkFlow.FORGOT_BACKUP_CODE_EXISTING_USER), + .actionProfileToProfileBackupCode( + EmailWorkFlow.FORGOT_BACKUP_CODE_EXISTING_USER, + BackupCodeWorkflow.CHANGE_BACKUP_CODE, + ), ) } diff --git a/app/src/org/commcare/personalId/profile/PersonalIdProfileSetNewBackupCodeFragment.kt b/app/src/org/commcare/personalId/profile/PersonalIdProfileSetNewBackupCodeFragment.kt index 37d9d2edca..b62d5d8be4 100644 --- a/app/src/org/commcare/personalId/profile/PersonalIdProfileSetNewBackupCodeFragment.kt +++ b/app/src/org/commcare/personalId/profile/PersonalIdProfileSetNewBackupCodeFragment.kt @@ -3,8 +3,13 @@ package org.commcare.personalId.profile import android.widget.Toast import androidx.navigation.fragment.findNavController import org.commcare.dalvik.R +import org.commcare.utils.AccountSecurityAnalyticsMapper class PersonalIdProfileSetNewBackupCodeFragment : BasePersonalIdSetNewBackupCodeFragment() { + private val args by lazy { PersonalIdProfileSetNewBackupCodeFragmentArgs.fromBundle(requireArguments()) } + + override fun analyticsWorkflow(): String = AccountSecurityAnalyticsMapper.workflowParam(args.backupCodeWorkflow) + override fun showSuccess() { Toast .makeText( diff --git a/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt b/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt index 7bf4f05984..60041159f3 100644 --- a/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt +++ b/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt @@ -1,5 +1,6 @@ package org.commcare.utils +import org.commcare.fragments.personalId.BackupCodeWorkflow import org.commcare.fragments.personalId.EmailWorkFlow import org.commcare.google.services.analytics.AnalyticsParamValue @@ -39,4 +40,24 @@ object AccountSecurityAnalyticsMapper { AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_RECOVERY_PENDING_CODE } } + + /** + * Maps a [BackupCodeWorkflow] to the `workflow` param, for the backup-code screens that + * several journeys share. + */ + @JvmStatic + fun workflowParam(workflow: BackupCodeWorkflow): String = + when (workflow) { + BackupCodeWorkflow.CHANGE_BACKUP_CODE -> { + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_CHANGE_BACKUP_CODE + } + + BackupCodeWorkflow.EMAIL_CHANGE -> { + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE_BACKUP_CODE + } + + BackupCodeWorkflow.FORGOT_BACKUP_CODE -> { + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_RECOVERY_EXISTING_USER + } + } } From e8f1894a25b05f9f19b06b7f58dbebc34eac0208 Mon Sep 17 00:00:00 2001 From: jignesh-dimagi Date: Thu, 8 Oct 2026 11:34:07 +0530 Subject: [PATCH 3/6] CCCT-2790 Add analytics to the backup code recovery flows [AI] Covers the three recovery journeys - account configuration, existing user and pending backup code - which share the same email screens and are told apart by the workflow param. Only the navigation and limit moments are added. Sending and verifying the email OTP already report through otp_requested in EmailHelper, so repeating them here would give two sources for the same action. Co-Authored-By: Claude Opus 5 (1M context) --- .../BasePersonalIdEmailVerificationFragment.kt | 15 +++++++++++++++ .../BasePersonalIdSendEmailOtpFragment.kt | 10 ++++++++++ .../personalId/PersonalIdBackupCodeFragment.kt | 15 +++++++++++++++ ...IdEmailVerificationForgotBackupCodeFragment.kt | 10 ++++++++++ .../PersonalIdProfileSendEmailOtpFragment.kt | 10 ++++++++++ 5 files changed, 60 insertions(+) diff --git a/app/src/org/commcare/fragments/personalId/BasePersonalIdEmailVerificationFragment.kt b/app/src/org/commcare/fragments/personalId/BasePersonalIdEmailVerificationFragment.kt index d25c036f85..59c72bc882 100644 --- a/app/src/org/commcare/fragments/personalId/BasePersonalIdEmailVerificationFragment.kt +++ b/app/src/org/commcare/fragments/personalId/BasePersonalIdEmailVerificationFragment.kt @@ -19,6 +19,7 @@ import org.commcare.dalvik.databinding.FragmentPersonalidEmailVerificationBindin import org.commcare.fragments.extensions.hasLiveView import org.commcare.google.services.analytics.AnalyticsParamValue import org.commcare.google.services.analytics.FirebaseAnalyticsUtil +import org.commcare.utils.AccountSecurityAnalyticsMapper import org.commcare.utils.CommCareAttemptCounter import org.commcare.utils.OtpWaitFormatter import org.commcare.views.dialogs.StandardAlertDialog @@ -118,6 +119,13 @@ abstract class BasePersonalIdEmailVerificationFragment : BasePersonalIdFragment( activity = requireActivity() activity.setTitle(R.string.personalid_email_verification_appbar_title) activity.window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AccountSecurityAnalyticsMapper.workflowParam(workflow), + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_VERIFY_EMAIL_OTP_SHOWN, + null, + null, + null, + ) binding.emailVerificationDescription.text = getString(R.string.personalid_email_verification_description, displayEmail()) @@ -256,6 +264,13 @@ abstract class BasePersonalIdEmailVerificationFragment : BasePersonalIdFragment( private fun onOtpLimitExceeded(throwable: Throwable?) { otpLimitExceeded = true + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AccountSecurityAnalyticsMapper.workflowParam(workflow), + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_VERIFY_EMAIL_OTP_MAX_ATTEMPTS, + null, + null, + emailOtpTracker.failedAttempts, + ) binding.otpCodeView.clearCode() binding.otpCodeView.isEnabled = false enableVerifyButton(false) diff --git a/app/src/org/commcare/fragments/personalId/BasePersonalIdSendEmailOtpFragment.kt b/app/src/org/commcare/fragments/personalId/BasePersonalIdSendEmailOtpFragment.kt index 3160c94483..4a122bfb8f 100644 --- a/app/src/org/commcare/fragments/personalId/BasePersonalIdSendEmailOtpFragment.kt +++ b/app/src/org/commcare/fragments/personalId/BasePersonalIdSendEmailOtpFragment.kt @@ -10,6 +10,9 @@ import org.commcare.dalvik.R import org.commcare.dalvik.databinding.FragmentPersonalidSendOtpBinding import org.commcare.fragments.extensions.hasLiveView import org.commcare.fragments.personalId.EmailHelper.maskEmail +import org.commcare.google.services.analytics.AnalyticsParamValue +import org.commcare.google.services.analytics.FirebaseAnalyticsUtil +import org.commcare.utils.AccountSecurityAnalyticsMapper import org.commcare.utils.CommCareAttemptCounter /** @@ -48,6 +51,13 @@ abstract class BasePersonalIdSendEmailOtpFragment : BasePersonalIdFragment() { binding.sendOtpAddress.text = if (masked) maskEmail(email) else email binding.sendOtpButton.setOnClickListener { sendCode() } clearError() + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AccountSecurityAnalyticsMapper.workflowParam(workflow), + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_SEND_EMAIL_OTP_SHOWN, + null, + null, + null, + ) } open fun setAppBarTitle() { diff --git a/app/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragment.kt b/app/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragment.kt index 63b6bdf6a2..743c89cfef 100644 --- a/app/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragment.kt +++ b/app/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragment.kt @@ -13,6 +13,7 @@ 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.AccountSecurityAnalyticsMapper import org.commcare.utils.MediaUtil import java.util.Date @@ -70,6 +71,13 @@ class PersonalIdBackupCodeFragment : BasePersonalIdBackupCodeFragment() { } override fun handleForgotBackupCode() { + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AccountSecurityAnalyticsMapper.workflowParam(EmailWorkFlow.FORGOT_BACKUP_CODE_RECOVERY), + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_FORGOT_CODE_STARTED, + null, + null, + null, + ) navigate( PersonalIdBackupCodeFragmentDirections .actionPersonalidBackupCodeToSendEmailOtp( @@ -160,6 +168,13 @@ class PersonalIdBackupCodeFragment : BasePersonalIdBackupCodeFragment() { private fun navigateToEmail() { PersonalIdUserPreferences.setLastEmailOfferDate(Date()) val emailWorkFlow = if (isRecovery) EmailWorkFlow.RECOVERY else EmailWorkFlow.REGISTRATION + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AccountSecurityAnalyticsMapper.workflowParam(emailWorkFlow), + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_EMAIL_PROMPT_SHOWN, + null, + null, + null, + ) navigate( PersonalIdBackupCodeFragmentDirections .actionPersonalidBackupcodeToPersonalidEmail(emailWorkFlow), diff --git a/app/src/org/commcare/fragments/personalId/PersonalIdEmailVerificationForgotBackupCodeFragment.kt b/app/src/org/commcare/fragments/personalId/PersonalIdEmailVerificationForgotBackupCodeFragment.kt index d81b2fee87..cbfc0f1597 100644 --- a/app/src/org/commcare/fragments/personalId/PersonalIdEmailVerificationForgotBackupCodeFragment.kt +++ b/app/src/org/commcare/fragments/personalId/PersonalIdEmailVerificationForgotBackupCodeFragment.kt @@ -1,6 +1,9 @@ package org.commcare.fragments.personalId import androidx.navigation.findNavController +import org.commcare.google.services.analytics.AnalyticsParamValue +import org.commcare.google.services.analytics.FirebaseAnalyticsUtil +import org.commcare.utils.AccountSecurityAnalyticsMapper /** * Email verification fragment for the forgot backup code flow for already signed-in users. @@ -17,6 +20,13 @@ class PersonalIdEmailVerificationForgotBackupCodeFragment : BasePersonalIdEmailV override fun resolveEmailOtpRequestCount(): Int = args().emailOtpRequestCount override fun onEmailVerified() { + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AccountSecurityAnalyticsMapper.workflowParam(resolveWorkflow()), + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_RECOVERY_COMPLETED, + AnalyticsParamValue.OTP_OUTCOME_SUCCESS, + null, + null, + ) binding.root .findNavController() .navigate( diff --git a/app/src/org/commcare/fragments/personalId/PersonalIdProfileSendEmailOtpFragment.kt b/app/src/org/commcare/fragments/personalId/PersonalIdProfileSendEmailOtpFragment.kt index 86727cedac..583eadcfaf 100644 --- a/app/src/org/commcare/fragments/personalId/PersonalIdProfileSendEmailOtpFragment.kt +++ b/app/src/org/commcare/fragments/personalId/PersonalIdProfileSendEmailOtpFragment.kt @@ -5,6 +5,9 @@ import androidx.appcompat.app.AppCompatActivity import androidx.navigation.findNavController import org.commcare.android.database.connect.models.PersonalIdSessionData import org.commcare.dalvik.R +import org.commcare.google.services.analytics.AnalyticsParamValue +import org.commcare.google.services.analytics.FirebaseAnalyticsUtil +import org.commcare.utils.AccountSecurityAnalyticsMapper /** * Screen that sends an email OTP to the user during the profile (existing-user) flow and @@ -20,6 +23,13 @@ class PersonalIdProfileSendEmailOtpFragment : BasePersonalIdSendEmailOtpFragment binding.sendOtpTitle.setText(R.string.personalid_send_email_otp_pending_backup_code_title) binding.sendOtpSubtitle.setText(R.string.personalid_send_email_otp_pending_backup_code_subtitle) binding.sendOtpSubtitle.visibility = View.VISIBLE + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AccountSecurityAnalyticsMapper.workflowParam(workflow), + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_SET_CODE_LAUNCHED_ON_STARTUP, + null, + null, + null, + ) } else if (workflow == EmailWorkFlow.EXISTING_USER) { binding.sendOtpTitle.setText(R.string.personalid_email_verification_title) } From 1fef9263b5ddfe8098b78d17185c6b1ee1762bd6 Mon Sep 17 00:00:00 2001 From: jignesh-dimagi Date: Thu, 8 Oct 2026 11:36:55 +0530 Subject: [PATCH 4/6] CCCT-2790 Add analytics to the periodic backup code reminders [AI] Both reminder dialogs report their button taps through user_prompt, matching how the app already reports prompts, with the info param separating them. The code confirmation itself carries an outcome and an attempt count, which user_prompt has no params for, so it goes to the account security event and stays comparable with the same step in the other flows. The Forgot tap is reported from the click listener rather than inside onForgotClicked, which also runs automatically on lockout and would otherwise inflate the count. Co-Authored-By: Claude Opus 5 (1M context) --- .../analytics/AnalyticsParamValue.java | 1 + .../personalId/BackupCodeReminderDialog.kt | 75 ++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java b/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java index 5d5a9ef253..b964fda397 100644 --- a/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java +++ b/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java @@ -248,6 +248,7 @@ public class AnalyticsParamValue { public static final String USER_PROMPT_INFO_MANAGE_PROFILE_EMAIL_UPDATE = "manage_profile_email_update"; public static final String USER_PROMPT_TYPE_BACKUP_CODE = "backup_code"; + public static final String USER_PROMPT_ACTION_SHOWN = "shown"; public static final String USER_PROMPT_ACTION_FORGOT = "forgot"; public static final String USER_PROMPT_INFO_BACKUP_CODE_REMINDER = "backup_code_reminder"; public static final String USER_PROMPT_INFO_SET_BACKUP_CODE_REMINDER = diff --git a/app/src/org/commcare/personalId/BackupCodeReminderDialog.kt b/app/src/org/commcare/personalId/BackupCodeReminderDialog.kt index 5cd9feaeb3..4e126391d8 100644 --- a/app/src/org/commcare/personalId/BackupCodeReminderDialog.kt +++ b/app/src/org/commcare/personalId/BackupCodeReminderDialog.kt @@ -14,6 +14,8 @@ import org.commcare.connect.database.ConnectUserDatabaseUtil import org.commcare.dalvik.R import org.commcare.dalvik.databinding.DialogBackupCodeReminderBinding import org.commcare.fragments.personalId.BasePersonalIdBackupCodeFragment.Companion.BACKUP_CODE_LENGTH +import org.commcare.google.services.analytics.AnalyticsParamValue +import org.commcare.google.services.analytics.FirebaseAnalyticsUtil import org.commcare.views.connect.bindVisibilityToggle import org.commcare.views.dialogs.CustomViewAlertDialog import org.commcare.views.dialogs.StandardAlertDialog @@ -32,9 +34,18 @@ class BackupCodeReminderDialog private constructor( private fun setupListeners() { binding.backupCodeView.setOnCodeChangedListener { onCodeChanged(it) } - binding.confirmButton.setOnClickListener { onConfirmClicked() } - binding.skipButton.setOnClickListener { onSkipClicked() } - binding.forgotButton.setOnClickListener { onForgotClicked() } + binding.confirmButton.setOnClickListener { + reportReminderPrompt(AnalyticsParamValue.USER_PROMPT_ACTION_ACCEPT) + onConfirmClicked() + } + binding.skipButton.setOnClickListener { + reportReminderPrompt(AnalyticsParamValue.USER_PROMPT_ACTION_SKIP) + onSkipClicked() + } + binding.forgotButton.setOnClickListener { + reportReminderPrompt(AnalyticsParamValue.USER_PROMPT_ACTION_FORGOT) + onForgotClicked() + } binding.backupCodeView.bindVisibilityToggle(binding.backupCodeVisibilityToggle) } @@ -48,11 +59,18 @@ class BackupCodeReminderDialog private constructor( private fun onConfirmClicked() { val entered = binding.backupCodeView.codeValue if (entered == user.pin) { + reportConfirmAttempt(AnalyticsParamValue.OTP_OUTCOME_SUCCESS, null) dismiss() Toast.makeText(context, R.string.personalid_backup_code_reminder_success_toast, Toast.LENGTH_SHORT).show() } else { failedAttempts++ + reportConfirmAttempt(AnalyticsParamValue.OTP_OUTCOME_FAILURE, failedAttempts) if (failedAttempts >= MAX_ATTEMPTS) { + reportReminderEvent( + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_CONFIRM_CODE_MAX_ATTEMPTS, + null, + failedAttempts, + ) Toast.makeText(context, R.string.personalid_backup_code_reminder_max_attempts_toast, Toast.LENGTH_LONG).show() onForgotClicked() } else { @@ -70,6 +88,35 @@ class BackupCodeReminderDialog private constructor( ConnectNavHelper.goToProfile(context, initiateBackupCodeRecovery = true) } + private fun reportReminderPrompt(action: String) { + reportPrompt(action, AnalyticsParamValue.USER_PROMPT_INFO_BACKUP_CODE_REMINDER) + } + + private fun reportConfirmAttempt( + outcome: String, + failedAttempts: Int?, + ) { + reportReminderEvent( + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_CONFIRM_CODE_ATTEMPT, + outcome, + failedAttempts, + ) + } + + private fun reportReminderEvent( + eventType: String, + outcome: String?, + failedAttempts: Int?, + ) { + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_BACKUP_CODE_REMINDER, + eventType, + outcome, + null, + failedAttempts, + ) + } + private fun showError(attemptsRemaining: Int) { binding.errorBanner.visibility = View.VISIBLE binding.errorMessage.text = context.getString(R.string.personalid_backup_code_reminder_incorrect, attemptsRemaining) @@ -99,6 +146,10 @@ class BackupCodeReminderDialog private constructor( showSetBackupCodePrompt(activity) } else { val binding = DialogBackupCodeReminderBinding.inflate(LayoutInflater.from(activity)) + reportPrompt( + AnalyticsParamValue.USER_PROMPT_ACTION_SHOWN, + AnalyticsParamValue.USER_PROMPT_INFO_BACKUP_CODE_REMINDER, + ) activity.showAlertDialog(BackupCodeReminderDialog(binding, activity, user)) } } @@ -110,13 +161,31 @@ class BackupCodeReminderDialog private constructor( activity.getString(R.string.personalid_set_backup_code_reminder_message), ) dialog.setPositiveButton(activity.getString(R.string.personalid_backup_code_reminder_confirm)) { _, _ -> + reportSetBackupCodePrompt(AnalyticsParamValue.USER_PROMPT_ACTION_ACCEPT) activity.dismissAlertDialog() ConnectNavHelper.goToProfileForPendingBackupCode(activity) } dialog.setNegativeButton(activity.getString(R.string.personalid_backup_code_reminder_skip)) { _, _ -> + reportSetBackupCodePrompt(AnalyticsParamValue.USER_PROMPT_ACTION_SKIP) activity.dismissAlertDialog() } + reportSetBackupCodePrompt(AnalyticsParamValue.USER_PROMPT_ACTION_SHOWN) activity.showAlertDialog(dialog) } + + private fun reportSetBackupCodePrompt(action: String) { + reportPrompt(action, AnalyticsParamValue.USER_PROMPT_INFO_SET_BACKUP_CODE_REMINDER) + } + + private fun reportPrompt( + action: String, + info: String, + ) { + FirebaseAnalyticsUtil.reportUserPromptEvent( + AnalyticsParamValue.USER_PROMPT_TYPE_BACKUP_CODE, + action, + info, + ) + } } } From 30354fdbd09bd810fc9ae1ee00adccafbe760099 Mon Sep 17 00:00:00 2001 From: jignesh-dimagi Date: Thu, 8 Oct 2026 11:47:06 +0530 Subject: [PATCH 5/6] CCCT-2790 Add analytics to the email change flow [AI] Both email-change routes converge on the shared email screens carrying EmailWorkFlow.EXISTING_USER, so those screens cannot tell which auth step the user came through. EXISTING_USER now maps to a neutral email_change workflow, and the two route-specific workflows are emitted only from the auth steps, where the route is known. email_changed goes on the account security event rather than personalid_manage_profile_action, keeping the multi-screen funnel in one table. Co-Authored-By: Claude Opus 5 (1M context) --- .../BasePersonalIdPhoneVerificationFragment.kt | 3 +++ .../PersonalIdProfileEmailVerificationFragment.kt | 10 ++++++++++ .../services/analytics/AnalyticsParamValue.java | 1 + .../profile/PersonalIdProfileEditFragment.kt | 7 +++++++ .../PersonalIdProfilePhoneVerificationFragment.kt | 12 ++++++++++++ .../commcare/utils/AccountSecurityAnalyticsMapper.kt | 2 +- 6 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/org/commcare/fragments/personalId/BasePersonalIdPhoneVerificationFragment.kt b/app/src/org/commcare/fragments/personalId/BasePersonalIdPhoneVerificationFragment.kt index a12ecaf82e..c861743fff 100644 --- a/app/src/org/commcare/fragments/personalId/BasePersonalIdPhoneVerificationFragment.kt +++ b/app/src/org/commcare/fragments/personalId/BasePersonalIdPhoneVerificationFragment.kt @@ -93,6 +93,8 @@ abstract class BasePersonalIdPhoneVerificationFragment : BasePersonalIdFragment( protected open fun onChangeNumberClicked() = Unit + protected open fun onOtpAttemptsExhausted() = Unit + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { @@ -309,6 +311,7 @@ abstract class BasePersonalIdPhoneVerificationFragment : BasePersonalIdFragment( private fun onOtpLimitExceeded(throwable: Throwable?) { otpLimitExceeded = true + onOtpAttemptsExhausted() binding.customOtpView.clearCode() binding.customOtpView.isEnabled = false binding.connectPhoneVerifyButton.isEnabled = false diff --git a/app/src/org/commcare/fragments/personalId/PersonalIdProfileEmailVerificationFragment.kt b/app/src/org/commcare/fragments/personalId/PersonalIdProfileEmailVerificationFragment.kt index 8faca3ffd1..1b1560772b 100644 --- a/app/src/org/commcare/fragments/personalId/PersonalIdProfileEmailVerificationFragment.kt +++ b/app/src/org/commcare/fragments/personalId/PersonalIdProfileEmailVerificationFragment.kt @@ -4,6 +4,9 @@ import androidx.navigation.findNavController import org.commcare.activities.CommCareActivity import org.commcare.connect.database.ConnectUserDatabaseUtil import org.commcare.dalvik.R +import org.commcare.google.services.analytics.AnalyticsParamValue +import org.commcare.google.services.analytics.FirebaseAnalyticsUtil +import org.commcare.utils.AccountSecurityAnalyticsMapper import org.commcare.views.dialogs.StandardAlertDialog /** @@ -24,6 +27,13 @@ class PersonalIdProfileEmailVerificationFragment : BasePersonalIdEmailVerificati val user = ConnectUserDatabaseUtil.getUser() user.email = enteredEmail ConnectUserDatabaseUtil.storeUser(user) + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AccountSecurityAnalyticsMapper.workflowParam(resolveWorkflow()), + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_EMAIL_CHANGED, + AnalyticsParamValue.OTP_OUTCOME_SUCCESS, + null, + null, + ) showEmailAddedSuccessDialog() } diff --git a/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java b/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java index b964fda397..0d878145e0 100644 --- a/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java +++ b/app/src/org/commcare/google/services/analytics/AnalyticsParamValue.java @@ -266,6 +266,7 @@ public class AnalyticsParamValue { "recovery_pending_code"; public static final String ACCOUNT_SECURITY_WORKFLOW_BACKUP_CODE_REMINDER = "backup_code_reminder"; + public static final String ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE = "email_change"; public static final String ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE_BACKUP_CODE = "email_change_backup_code"; public static final String ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE_PHONE_OTP = diff --git a/app/src/org/commcare/personalId/profile/PersonalIdProfileEditFragment.kt b/app/src/org/commcare/personalId/profile/PersonalIdProfileEditFragment.kt index 91c77788af..a0955548f9 100644 --- a/app/src/org/commcare/personalId/profile/PersonalIdProfileEditFragment.kt +++ b/app/src/org/commcare/personalId/profile/PersonalIdProfileEditFragment.kt @@ -165,6 +165,13 @@ class PersonalIdProfileEditFragment : BasePersonalIdProfileFragment() { _binding ?: return val directions = if (viewModel.user.pin.isNullOrEmpty()) { + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE_PHONE_OTP, + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_PHONE_OTP_REQUIRED, + null, + null, + null, + ) PersonalIdProfileEditFragmentDirections .actionProfileEditToProfileSendPhoneOtp(pendingEmail) } else { diff --git a/app/src/org/commcare/personalId/profile/PersonalIdProfilePhoneVerificationFragment.kt b/app/src/org/commcare/personalId/profile/PersonalIdProfilePhoneVerificationFragment.kt index c82d05eb2f..0acae7cdf2 100644 --- a/app/src/org/commcare/personalId/profile/PersonalIdProfilePhoneVerificationFragment.kt +++ b/app/src/org/commcare/personalId/profile/PersonalIdProfilePhoneVerificationFragment.kt @@ -8,6 +8,8 @@ import org.commcare.core.network.AuthInfo import org.commcare.fragments.personalId.BasePersonalIdPhoneVerificationFragment import org.commcare.fragments.personalId.EmailWorkFlow import org.commcare.fragments.personalId.PersonalIdWorkflow +import org.commcare.google.services.analytics.AnalyticsParamValue +import org.commcare.google.services.analytics.FirebaseAnalyticsUtil import org.commcare.utils.AttemptCounter import org.commcare.utils.CommCareAttemptCounter import org.commcare.utils.OtpManager @@ -53,6 +55,16 @@ class PersonalIdProfilePhoneVerificationFragment : BasePersonalIdPhoneVerificati override fun showChangeNumberLink(): Boolean = false + override fun onOtpAttemptsExhausted() { + FirebaseAnalyticsUtil.reportPersonalIdAccountSecurityAction( + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE_PHONE_OTP, + AnalyticsParamValue.ACCOUNT_SECURITY_EVENT_PHONE_OTP_MAX_ATTEMPTS, + null, + null, + attemptTracker.failedAttempts, + ) + } + override fun onOtpVerified() { findNavController().navigate( PersonalIdProfilePhoneVerificationFragmentDirections diff --git a/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt b/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt index 60041159f3..33ada7cf79 100644 --- a/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt +++ b/app/src/org/commcare/utils/AccountSecurityAnalyticsMapper.kt @@ -25,7 +25,7 @@ object AccountSecurityAnalyticsMapper { } EmailWorkFlow.EXISTING_USER -> { - AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE_BACKUP_CODE + AnalyticsParamValue.ACCOUNT_SECURITY_WORKFLOW_EMAIL_CHANGE } EmailWorkFlow.FORGOT_BACKUP_CODE_RECOVERY -> { From 04fde8b85e535fb06f8d6a5dbed3638bcdc788d3 Mon Sep 17 00:00:00 2001 From: jignesh-dimagi Date: Thu, 8 Oct 2026 12:16:55 +0530 Subject: [PATCH 6/6] CCCT-2790 Fix test call sites for the new backup code nav argument [AI] The backupCodeWorkflow argument is required, so Safe Args stopped generating the single-argument builders these tests used. Each call site now passes what its real counterpart passes. Co-Authored-By: Claude Opus 5 (1M context) --- .../BasePersonalIdSetNewBackupCodeFragmentTest.kt | 6 +++++- ...sonalIdProfileBackupCodeChangeEmailFragmentTest.kt | 3 ++- .../PersonalIdProfileBackupCodeFragmentTest.kt | 11 +++++++++-- .../PersonalIdProfilePhoneVerificationFragmentTest.kt | 3 ++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/unit-tests/src/org/commcare/personalId/profile/BasePersonalIdSetNewBackupCodeFragmentTest.kt b/app/unit-tests/src/org/commcare/personalId/profile/BasePersonalIdSetNewBackupCodeFragmentTest.kt index e31968aee5..800eb0dd1f 100644 --- a/app/unit-tests/src/org/commcare/personalId/profile/BasePersonalIdSetNewBackupCodeFragmentTest.kt +++ b/app/unit-tests/src/org/commcare/personalId/profile/BasePersonalIdSetNewBackupCodeFragmentTest.kt @@ -12,6 +12,7 @@ import org.commcare.CommCareTestApplication import org.commcare.connect.PersonalIdManager import org.commcare.connect.database.ConnectUserDatabaseUtil import org.commcare.dalvik.R +import org.commcare.fragments.personalId.BackupCodeWorkflow import org.commcare.fragments.personalId.EmailWorkFlow import org.commcare.personalId.PersonalIdUnlocker import org.commcare.personalId.PersonalIdUserPreferences @@ -43,7 +44,10 @@ class BasePersonalIdSetNewBackupCodeFragmentTest : BasePersonalIdProfileTest() { onUiThread { navController.navigate( PersonalIdProfileFragmentDirections - .actionProfileToProfileBackupCode(EmailWorkFlow.FORGOT_BACKUP_CODE_EXISTING_USER), + .actionProfileToProfileBackupCode( + EmailWorkFlow.FORGOT_BACKUP_CODE_EXISTING_USER, + BackupCodeWorkflow.CHANGE_BACKUP_CODE, + ), ) } // Enter the current backup code on the confirm screen and click continue diff --git a/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeChangeEmailFragmentTest.kt b/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeChangeEmailFragmentTest.kt index b6367005b6..846e3d9b17 100644 --- a/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeChangeEmailFragmentTest.kt +++ b/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeChangeEmailFragmentTest.kt @@ -8,6 +8,7 @@ import org.commcare.CommCareTestApplication import org.commcare.android.shadows.ShadowPhoneAuthProvider import org.commcare.android.util.FirebaseTestUtils import org.commcare.dalvik.R +import org.commcare.fragments.personalId.BackupCodeWorkflow import org.commcare.fragments.personalId.EmailWorkFlow import org.commcare.fragments.personalId.PersonalIdProfileSendEmailOtpFragmentArgs import org.commcare.personalId.PersonalIdUserPreferences @@ -29,7 +30,7 @@ import org.robolectric.shadows.ShadowLooper class PersonalIdProfileBackupCodeChangeEmailFragmentTest : BasePersonalIdProfileTest() { private val fragmentArgs = PersonalIdProfileBackupCodeFragmentArgs - .Builder(EmailWorkFlow.EXISTING_USER) + .Builder(EmailWorkFlow.EXISTING_USER, BackupCodeWorkflow.EMAIL_CHANGE) .setPendingEmail("grace@example.com") .build() .toBundle() diff --git a/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragmentTest.kt b/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragmentTest.kt index c9d5665a65..97c822d6f2 100644 --- a/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragmentTest.kt +++ b/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfileBackupCodeFragmentTest.kt @@ -7,6 +7,7 @@ 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.fragments.personalId.BackupCodeWorkflow import org.commcare.fragments.personalId.EmailWorkFlow import org.commcare.personalId.PersonalIdUserPreferences import org.commcare.views.connect.NumericCodeView @@ -30,7 +31,10 @@ class PersonalIdProfileBackupCodeFragmentTest : BasePersonalIdProfileTest() { onUiThread { navController.navigate( PersonalIdProfileFragmentDirections - .actionProfileToProfileBackupCode(EmailWorkFlow.FORGOT_BACKUP_CODE_EXISTING_USER), + .actionProfileToProfileBackupCode( + EmailWorkFlow.FORGOT_BACKUP_CODE_EXISTING_USER, + BackupCodeWorkflow.CHANGE_BACKUP_CODE, + ), ) } } @@ -171,7 +175,10 @@ class PersonalIdProfileBackupCodeFragmentTest : BasePersonalIdProfileTest() { onUiThread { navController.navigate( PersonalIdProfileFragmentDirections - .actionProfileToProfileBackupCode(EmailWorkFlow.FORGOT_BACKUP_CODE_EXISTING_USER), + .actionProfileToProfileBackupCode( + EmailWorkFlow.FORGOT_BACKUP_CODE_EXISTING_USER, + BackupCodeWorkflow.CHANGE_BACKUP_CODE, + ), ) } ShadowLooper.idleMainLooper() diff --git a/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfilePhoneVerificationFragmentTest.kt b/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfilePhoneVerificationFragmentTest.kt index 6f10055ef2..9989a20e34 100644 --- a/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfilePhoneVerificationFragmentTest.kt +++ b/app/unit-tests/src/org/commcare/personalId/profile/PersonalIdProfilePhoneVerificationFragmentTest.kt @@ -24,6 +24,7 @@ import org.commcare.android.util.FirebaseTestUtils import org.commcare.connect.network.personalId.PersonalIdApiEndpoints import org.commcare.core.network.AuthInfo import org.commcare.dalvik.R +import org.commcare.fragments.personalId.BackupCodeWorkflow import org.commcare.fragments.personalId.EmailWorkFlow import org.commcare.fragments.personalId.PersonalIdProfileSendEmailOtpFragmentArgs import org.commcare.network.HttpUtils @@ -293,7 +294,7 @@ class PersonalIdProfilePhoneVerificationFragmentTest : BasePersonalIdProfileTest private fun openFromForgotBackupCode() { val backupCodeArgs = PersonalIdProfileBackupCodeFragmentArgs - .Builder(EmailWorkFlow.EXISTING_USER) + .Builder(EmailWorkFlow.EXISTING_USER, BackupCodeWorkflow.EMAIL_CHANGE) .setPendingEmail(pendingEmail) .build() .toBundle()