From ff06880d70f8ae50493c084d51794e2763cf334c Mon Sep 17 00:00:00 2001 From: jignesh-dimagi Date: Fri, 10 Apr 2026 16:08:43 +0530 Subject: [PATCH 1/5] Add email - tech spec, implementation plan and its tickets --- ...erpowers-add-email-to-personalid-signup.md | 1962 +++++++++++++++++ 1 file changed, 1962 insertions(+) create mode 100644 docs/superpowers/plans/superpowers-add-email-to-personalid-signup.md diff --git a/docs/superpowers/plans/superpowers-add-email-to-personalid-signup.md b/docs/superpowers/plans/superpowers-add-email-to-personalid-signup.md new file mode 100644 index 0000000000..a832abe843 --- /dev/null +++ b/docs/superpowers/plans/superpowers-add-email-to-personalid-signup.md @@ -0,0 +1,1962 @@ +# Add Email to PersonalID Signup / Recovery Flow — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Insert an optional email-entry + OTP-verification step into the PersonalID signup flow (between Name and Backup Code), and offer a non-blocking email-collection prompt to existing users who completed signup without an email. + +**Architecture:** New Kotlin fragments (`PersonalIdEmailFragment`, `PersonalIdEmailVerificationFragment`) extend the existing `BasePersonalIdFragment` pattern. Email state is stored in `PersonalIdSessionData` during signup and persisted to `ConnectUserRecord` (new DB columns) upon verification. The legacy prompt uses a two-offer-with-30-day-gap policy tracked via `emailOfferCount` (int) and `lastEmailOfferDate` (Date) on `ConnectUserRecord`. + +**Tech Stack:** Kotlin (new files), Java (existing files modified), AndroidX Navigation Safe Args, Retrofit 2 / OkHttp, SQLCipher (versioned migration), Robolectric unit tests. + +--- + +## File Structure + +### Files to Create +| File | Purpose | +|------|---------| +| `app/src/org/commcare/android/database/connect/models/ConnectUserRecordV24.java` | Snapshot of v24 ConnectUserRecord (for DB migration) | +| `app/src/org/commcare/fragments/personalId/PersonalIdEmailFragment.kt` | Email entry UI fragment | +| `app/src/org/commcare/fragments/personalId/PersonalIdEmailVerificationFragment.kt` | Email OTP verification UI fragment | +| `app/res/layout/fragment_personalid_email.xml` | Layout for email entry screen | +| `app/res/layout/fragment_personalid_email_verification.xml` | Layout for email OTP screen | +| `app/src/org/commcare/connect/network/connectId/parser/SendEmailOtpResponseParser.kt` | Parses send-email-OTP server response | +| `app/src/org/commcare/connect/network/connectId/parser/VerifyEmailOtpResponseParser.kt` | Parses verify-email-OTP server response | +| `app/unit-tests/src/org/commcare/connect/PersonalIdEmailOfferTest.kt` | Unit tests for evaluateEmailOffer logic | +| `app/unit-tests/src/org/commcare/connect/PersonalIdEmailValidationTest.kt` | Unit tests for client-side email format validation | + +### Files to Modify +| File | Change | +|------|--------| +| `app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java` | Add email, emailVerified, emailOfferCount, lastEmailOfferDate fields | +| `app/src/org/commcare/android/database/connect/models/PersonalIdSessionData.kt` | Add email and emailVerified fields | +| `app/src/org/commcare/fragments/personalId/PersonalIdPhotoCaptureFragment.java` | Read email/emailVerified from session data when constructing ConnectUserRecord | +| `app/src/org/commcare/fragments/personalId/PersonalIdBackupCodeFragment.java` | Read email/emailVerified from session data in handleSuccessfulRecovery() | +| `app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java` | Bump CONNECT_DB_VERSION to 25 | +| `app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java` | Add upgradeTwentyFourTwentyFive() | +| `app/src/org/commcare/connect/network/ApiEndPoints.java` | Add sendEmailOtp and verifyEmailOtp endpoints | +| `app/src/org/commcare/connect/network/ApiService.java` | Add sendEmailOtp and verifyEmailOtp service methods | +| `app/src/org/commcare/connect/network/ApiPersonalId.java` | Add sendEmailOtp() and verifyEmailOtp() static methods | +| `app/src/org/commcare/connect/network/connectId/PersonalIdApiHandler.java` | Add sendEmailOtpCall() and verifyEmailOtpCall() | +| `app/res/navigation/nav_graph_personalid.xml` | Add email and email-OTP destinations; reroute Name → Email | +| `app/src/org/commcare/fragments/personalId/PersonalIdNameFragment.java` | Update navigate action to go to email instead of backup code | +| `app/src/org/commcare/activities/connect/PersonalIdActivity.java` | Handle EXTRA_LEGACY_EMAIL_FLOW intent extra | +| `app/src/org/commcare/connect/PersonalIdManager.java` | Add checkEmailCollection() and evaluateEmailOffer() | +| `app/src/org/commcare/activities/LoginActivity.java` | Call checkEmailCollection() after PersonalID login | + +--- + +## Chunk 1: Database Layer + +### Task 1: Create ConnectUserRecordV24 (migration snapshot) + +**Files:** +- Create: `app/src/org/commcare/android/database/connect/models/ConnectUserRecordV24.java` + +- [ ] **Step 1.1: Write the failing unit test** + + Create `app/unit-tests/src/org/commcare/connect/ConnectUserRecordMigrationV24Test.kt`: + + ```kotlin + package org.commcare.connect + + import org.junit.Assert.assertEquals + import org.junit.Assert.assertNull + import org.junit.Test + + class ConnectUserRecordMigrationV24Test { + + @Test + fun `fromV24 copies all fields and sets email defaults`() { + val old = ConnectUserRecordV24().apply { + // Verify V24 compiles with fields 1-16 only (no email fields) + } + val new = ConnectUserRecord.fromV24(old) + assertNull(new.email) + assertEquals(false, new.emailVerified) + assertNull(new.emailOfferDate1) + assertNull(new.emailOfferDate2) + } + } + ``` + + Run: `./gradlew testCommcareDebug --tests "org.commcare.connect.ConnectUserRecordMigrationV24Test"` + Expected: FAIL — `ConnectUserRecordV24` and `ConnectUserRecord.fromV24` don't exist yet. + +- [ ] **Step 1.2: Create ConnectUserRecordV24.java** + + This is a verbatim copy of the current `ConnectUserRecord.java` with the class name changed and `STORAGE_KEY` kept. It will only ever be read, not written. + + ```java + package org.commcare.android.database.connect.models; + + import org.commcare.android.storage.framework.Persisted; + import org.commcare.connect.ConnectConstants; + import org.commcare.models.framework.Persisting; + import org.commcare.modern.database.Table; + import org.commcare.modern.models.MetaField; + import java.util.Date; + + @Table(ConnectUserRecordV24.STORAGE_KEY) + public class ConnectUserRecordV24 extends Persisted { + public static final String STORAGE_KEY = "user_info"; + public static final String META_PIN = "pin"; + + @Persisting(1) private String userId; + @Persisting(2) private String password; + @Persisting(3) private String name; + @Persisting(4) private String primaryPhone; + @Deprecated @Persisting(5) private String alternatePhone; + @Persisting(6) private int registrationPhase; + @Persisting(7) private Date lastPasswordDate; + @Persisting(value = 8, nullable = true) private String connectToken; + @Persisting(value = 9, nullable = true) private Date connectTokenExpiration; + @Persisting(value = 10, nullable = true) @MetaField(META_PIN) private String pin; + @Deprecated @Persisting(11) private boolean secondaryPhoneVerified; + @Deprecated @Persisting(12) private Date verifySecondaryPhoneByDate; + @Persisting(value = 13, nullable = true) private String photo; + @Persisting(value = 14) private boolean isDemo; + @Persisting(value = 15) private String requiredLock = PersonalIdSessionData.PIN; + @Persisting(value = 16) private boolean hasConnectAccess; + + public ConnectUserRecordV24() { + registrationPhase = ConnectConstants.PERSONALID_NO_ACTIVITY; + lastPasswordDate = new Date(); + connectTokenExpiration = new Date(); + secondaryPhoneVerified = true; + verifySecondaryPhoneByDate = new Date(); + alternatePhone = ""; + } + + // Getters for all fields (copy from ConnectUserRecord) + public String getUserId() { return userId; } + public String getPassword() { return password; } + public String getName() { return name; } + public String getPrimaryPhone() { return primaryPhone; } + public int getRegistrationPhase() { return registrationPhase; } + public Date getLastPasswordDate() { return lastPasswordDate; } + public String getConnectToken() { return connectToken; } + public Date getConnectTokenExpiration() { return connectTokenExpiration; } + public String getPin() { return pin; } + public String getPhoto() { return photo; } + public boolean isDemo() { return isDemo; } + public String getRequiredLock() { return requiredLock; } + public boolean hasConnectAccess() { return hasConnectAccess; } + } + ``` + +- [ ] **Step 1.3: Add email fields and fromV24() to ConnectUserRecord.java** + + In `ConnectUserRecord.java`, after field `@Persisting(value = 16)`: + + ```java + public static final String META_EMAIL = "email"; + public static final String META_EMAIL_VERIFIED = "email_verified"; + public static final String META_EMAIL_OFFER_COUNT = "email_offer_count"; + public static final String META_LAST_EMAIL_OFFER_DATE = "last_email_offer_date"; + + @Persisting(value = 17, nullable = true) + @MetaField(META_EMAIL) + private String email; + + @Persisting(value = 18) + @MetaField(META_EMAIL_VERIFIED) + private boolean emailVerified; + + @Persisting(value = 19) + @MetaField(META_EMAIL_OFFER_COUNT) + private int emailOfferCount; // 0 = never offered, 1 = first offer shown, 2 = both offers shown + + @Persisting(value = 20, nullable = true) + @MetaField(META_LAST_EMAIL_OFFER_DATE) + private Date lastEmailOfferDate; // when the most recent offer was shown + ``` + + Add getters and setters: + + ```java + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public boolean isEmailVerified() { return emailVerified; } + public void setEmailVerified(boolean emailVerified) { this.emailVerified = emailVerified; } + + public int getEmailOfferCount() { return emailOfferCount; } + public void setEmailOfferCount(int count) { this.emailOfferCount = count; } + + public Date getLastEmailOfferDate() { return lastEmailOfferDate; } + public void setLastEmailOfferDate(Date date) { this.lastEmailOfferDate = date; } + ``` + + Add the migration factory method: + + ```java + public static ConnectUserRecord fromV24(ConnectUserRecordV24 oldRecord) { + ConnectUserRecord newRecord = new ConnectUserRecord(); + newRecord.userId = oldRecord.getUserId(); + newRecord.password = oldRecord.getPassword(); + newRecord.name = oldRecord.getName(); + newRecord.primaryPhone = oldRecord.getPrimaryPhone(); + newRecord.alternatePhone = ""; + newRecord.registrationPhase = oldRecord.getRegistrationPhase(); + newRecord.lastPasswordDate = oldRecord.getLastPasswordDate(); + newRecord.connectToken = oldRecord.getConnectToken(); + newRecord.connectTokenExpiration = oldRecord.getConnectTokenExpiration(); + newRecord.secondaryPhoneVerified = true; + newRecord.photo = oldRecord.getPhoto(); + newRecord.isDemo = oldRecord.isDemo(); + newRecord.requiredLock = oldRecord.getRequiredLock(); + newRecord.hasConnectAccess = oldRecord.hasConnectAccess(); + // email defaults to null, emailVerified to false, emailOfferCount to 0, lastEmailOfferDate to null + return newRecord; + } + ``` + +- [ ] **Step 1.4: Run the test to verify it passes** + + Run: `./gradlew testCommcareDebug --tests "org.commcare.connect.ConnectUserRecordMigrationV24Test"` + Expected: PASS + +- [ ] **Step 1.5: Commit** + + ```bash + git add app/src/org/commcare/android/database/connect/models/ConnectUserRecordV24.java \ + app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java \ + app/unit-tests/src/org/commcare/connect/ConnectUserRecordMigrationV24Test.kt + git commit -m "[AI] Add email/emailVerified fields to ConnectUserRecord with V24 snapshot for migration" + ``` + +--- + +### Task 2: Add DB migration v24 → v25 + +**Files:** +- Modify: `app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java` +- Modify: `app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java` + +- [ ] **Step 2.1: Bump CONNECT_DB_VERSION to 25** + + In `DatabaseConnectOpenHelper.java`, line 70: + + ```java + // Before: + private static final int CONNECT_DB_VERSION = 24; + + // After: + private static final int CONNECT_DB_VERSION = 25; + ``` + + Also add a version comment in the header block: + ```java + // V.25 - Added email, emailVerified, emailOfferCount, lastEmailOfferDate to ConnectUserRecord + ``` + +- [ ] **Step 2.2: Add import for ConnectUserRecordV24 in ConnectDatabaseUpgrader.java** + + In `ConnectDatabaseUpgrader.java`, in the imports block, add: + ```java + import org.commcare.android.database.connect.models.ConnectUserRecordV24; + ``` + +- [ ] **Step 2.3: Add upgradeTwentyFourTwentyFive() to ConnectDatabaseUpgrader.java** + + First, add the call in the `upgrade()` method after the existing v23→v24 block: + + ```java + if (oldVersion == 24) { + upgradeTwentyFourTwentyFive(db); + oldVersion = 25; + } + ``` + + Then add the method: + + ```java + private void upgradeTwentyFourTwentyFive(IDatabase db) { + db.beginTransaction(); + try { + DbUtil.addColumnToTable(db, ConnectUserRecord.STORAGE_KEY, + ConnectUserRecord.META_EMAIL, "TEXT"); + DbUtil.addColumnToTable(db, ConnectUserRecord.STORAGE_KEY, + ConnectUserRecord.META_EMAIL_VERIFIED, "INTEGER DEFAULT 0"); + DbUtil.addColumnToTable(db, ConnectUserRecord.STORAGE_KEY, + ConnectUserRecord.META_EMAIL_OFFER_COUNT, "INTEGER DEFAULT 0"); + DbUtil.addColumnToTable(db, ConnectUserRecord.STORAGE_KEY, + ConnectUserRecord.META_LAST_EMAIL_OFFER_DATE, "TEXT"); + + // Migrate existing records: read V24, write new ConnectUserRecord + SqlStorage oldStorage = new SqlStorage<>( + ConnectUserRecordV24.STORAGE_KEY, ConnectUserRecordV24.class, + new ConcreteAndroidDbHelper(c, db)); + SqlStorage newStorage = new SqlStorage<>( + ConnectUserRecord.STORAGE_KEY, ConnectUserRecord.class, + new ConcreteAndroidDbHelper(c, db)); + + for (ConnectUserRecordV24 old : oldStorage) { + ConnectUserRecord newRecord = ConnectUserRecord.fromV24(old); + newRecord.setID(old.getID()); + newStorage.write(newRecord); + } + db.setTransactionSuccessful(); + } catch (Exception e) { + CrashUtil.reportException(e); + } finally { + db.endTransaction(); + } + } + ``` + + > **Note:** Check how `DbUtil.addColumnToTable(IDatabase, String, String, String)` is called in other migration methods in this file and match the exact signature. Some versions take the db object directly; others call it differently. + +- [ ] **Step 2.4: Build to verify compilation** + + Run: `./gradlew assembleCommcareDebug` + Expected: BUILD SUCCESSFUL + +- [ ] **Step 2.5: Commit** + + ```bash + git add app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java \ + app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java + git commit -m "[AI] Add DB migration v24→v25 for email fields on ConnectUserRecord" + ``` + +--- + +## Chunk 2: Session Data & API Layer + +### Task 3: Add email to PersonalIdSessionData + +**Files:** +- Modify: `app/src/org/commcare/android/database/connect/models/PersonalIdSessionData.kt` + +- [ ] **Step 3.1: Add email property** + + Open `PersonalIdSessionData.kt` and add to the data class: + + ```kotlin + var email: String? = null + var emailVerified: Boolean = false + var emailSkippedDuringSignup: Boolean = false + ``` + + Add all three alongside the existing fields (e.g., after `userName`). + + > **Why these live in session data:** `ConnectUserRecord` is not created until `PersonalIdPhotoCaptureFragment` (new signup) or `PersonalIdBackupCodeFragment` (recovery). When the user acts on the email screen — which comes *before* those steps — the record does not exist yet. Session data is the correct carrier until the record is written at signup completion. + + > **Why `emailSkippedDuringSignup`:** A user who explicitly skips email during signup has already been presented the offer once. If `emailOfferCount` were left at 0, `shouldOfferEmail()` would show the dialog again on the very next login, immediately after they just declined during signup. Setting `emailOfferCount = 1` and `lastEmailOfferDate = now` when recording the skip treats the signup screen as the first offer, so the dialog only appears 30 days later (second and final offer). Legacy users migrated from v24 have `emailOfferCount = 0` and do not have this flag set, so their first dialog appears on the next login as intended. + +- [ ] **Step 3.2: Build to verify compilation** + + Run: `./gradlew assembleCommcareDebug` + Expected: BUILD SUCCESSFUL + +- [ ] **Step 3.3: Commit** + + ```bash + git add app/src/org/commcare/android/database/connect/models/PersonalIdSessionData.kt + git commit -m "[AI] Add email field to PersonalIdSessionData" + ``` + +--- + +### Task 4: Add email API endpoints + +> **Important:** The server-side endpoints `/users/send_email_otp` and `/users/verify_email_otp` are provisional. Confirm actual endpoint paths with the backend team before finalising. The parameter names `email` and `otp` are also provisional. + +**Files:** +- Modify: `app/src/org/commcare/connect/network/ApiEndPoints.java` +- Modify: `app/src/org/commcare/connect/network/ApiService.java` +- Modify: `app/src/org/commcare/connect/network/ApiPersonalId.java` +- Modify: `app/src/org/commcare/connect/network/connectId/PersonalIdApiHandler.java` +- Create: `app/src/org/commcare/connect/network/connectId/parser/SendEmailOtpResponseParser.kt` +- Create: `app/src/org/commcare/connect/network/connectId/parser/VerifyEmailOtpResponseParser.kt` + +- [ ] **Step 4.1: Write failing unit test for evaluateEmailOffer** + + Create `app/unit-tests/src/org/commcare/connect/PersonalIdEmailOfferTest.kt`: + + ```kotlin + package org.commcare.connect + + import org.commcare.android.database.connect.models.ConnectUserRecord + import org.junit.Assert.assertFalse + import org.junit.Assert.assertTrue + import org.junit.Test + import java.util.Calendar + import java.util.Date + + class PersonalIdEmailOfferTest { + + private fun dateMinusDays(days: Int): Date = + Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -days) }.time + + @Test + fun `should offer when never shown before (count=0)`() { + val user = ConnectUserRecord().apply { emailVerified = false } + assertTrue(PersonalIdManager.shouldOfferEmail(user)) + } + + @Test + fun `should not offer when both offers already shown (count=2)`() { + val user = ConnectUserRecord().apply { + emailVerified = false + emailOfferCount = 2 + lastEmailOfferDate = dateMinusDays(5) + } + assertFalse(PersonalIdManager.shouldOfferEmail(user)) + } + + @Test + fun `should offer second time after 30 days (count=1, date old)`() { + val user = ConnectUserRecord().apply { + emailVerified = false + emailOfferCount = 1 + lastEmailOfferDate = dateMinusDays(31) + } + assertTrue(PersonalIdManager.shouldOfferEmail(user)) + } + + @Test + fun `should not offer second time before 30 days (count=1, date recent)`() { + val user = ConnectUserRecord().apply { + emailVerified = false + emailOfferCount = 1 + lastEmailOfferDate = dateMinusDays(15) + } + assertFalse(PersonalIdManager.shouldOfferEmail(user)) + } + + @Test + fun `should not offer when email already verified`() { + val user = ConnectUserRecord().apply { emailVerified = true } + assertFalse(PersonalIdManager.shouldOfferEmail(user)) + } + + @Test + fun `should not offer immediately after signup skip (count=1, date just set)`() { + // Simulates a user who skipped email during signup: count=1, date=now + val user = ConnectUserRecord().apply { + emailVerified = false + emailOfferCount = 1 + lastEmailOfferDate = Date() + } + assertFalse(PersonalIdManager.shouldOfferEmail(user)) + } + } + ``` + + Run: `./gradlew testCommcareDebug --tests "org.commcare.connect.PersonalIdEmailOfferTest"` + Expected: FAIL — `PersonalIdManager.shouldOfferEmail` does not exist yet. + +- [ ] **Step 4.2: Add email API endpoints to ApiEndPoints.java** + + ```java + public static final String sendEmailOtp = "/users/send_email_otp"; + public static final String verifyEmailOtp = "/users/verify_email_otp"; + ``` + +- [ ] **Step 4.3: Add email service methods to ApiService.java** + + ```java + @POST(ApiEndPoints.sendEmailOtp) + Call sendEmailOtp(@Header("Authorization") String token, + @Body Map emailRequest); + + @POST(ApiEndPoints.verifyEmailOtp) + Call verifyEmailOtp(@Header("Authorization") String token, + @Body Map otpRequest); + ``` + +- [ ] **Step 4.4: Create SendEmailOtpResponseParser.kt** + + ```kotlin + package org.commcare.connect.network.connectId.parser + + import org.commcare.android.database.connect.models.PersonalIdSessionData + import org.json.JSONObject + + class SendEmailOtpResponseParser : PersonalIdApiResponseParser { + override fun parse(json: JSONObject, sessionData: PersonalIdSessionData) { + // Server may return additional info; extend as needed when API is finalised. + } + } + ``` + +- [ ] **Step 4.5: Create VerifyEmailOtpResponseParser.kt** + + ```kotlin + package org.commcare.connect.network.connectId.parser + + import org.commcare.android.database.connect.models.PersonalIdSessionData + import org.json.JSONObject + + class VerifyEmailOtpResponseParser : PersonalIdApiResponseParser { + override fun parse(json: JSONObject, sessionData: PersonalIdSessionData) { + // Confirmation only; extend when server API is finalised. + } + } + ``` + +- [ ] **Step 4.6: Add static methods to ApiPersonalId.java** + + ```java + public static void sendEmailOtp(Context context, String email, String token, IApiCallback callback) { + AuthInfo authInfo = new AuthInfo.TokenAuth(token); + String tokenAuth = HttpUtils.getCredential(authInfo); + Objects.requireNonNull(tokenAuth); + + HashMap params = new HashMap<>(); + params.put("email", email); + + ApiService apiService = PersonalIdApiClient.getClientApi(); + Call call = apiService.sendEmailOtp(tokenAuth, params); + BaseApi.Companion.callApi(context, call, callback, ApiEndPoints.sendEmailOtp); + } + + public static void verifyEmailOtp(Context context, String email, String otp, + String token, IApiCallback callback) { + AuthInfo authInfo = new AuthInfo.TokenAuth(token); + String tokenAuth = HttpUtils.getCredential(authInfo); + Objects.requireNonNull(tokenAuth); + + HashMap params = new HashMap<>(); + params.put("email", email); + params.put("otp", otp); + + ApiService apiService = PersonalIdApiClient.getClientApi(); + Call call = apiService.verifyEmailOtp(tokenAuth, params); + BaseApi.Companion.callApi(context, call, callback, ApiEndPoints.verifyEmailOtp); + } + ``` + +- [ ] **Step 4.7: Add handler methods to PersonalIdApiHandler.java** + + Add imports for new parsers at top of file: + ```java + import org.commcare.connect.network.connectId.parser.SendEmailOtpResponseParser; + import org.commcare.connect.network.connectId.parser.VerifyEmailOtpResponseParser; + ``` + + Add handler methods: + ```java + public void sendEmailOtpCall(Activity activity, String email, PersonalIdSessionData sessionData) { + sessionData.setEmail(email); + ApiPersonalId.sendEmailOtp( + activity, + email, + sessionData.getToken(), + createCallback(sessionData, new SendEmailOtpResponseParser()) + ); + } + + public void verifyEmailOtpCall(Activity activity, String otp, PersonalIdSessionData sessionData) { + ApiPersonalId.verifyEmailOtp( + activity, + sessionData.getEmail(), + otp, + sessionData.getToken(), + createCallback(sessionData, new VerifyEmailOtpResponseParser()) + ); + } + ``` + + > **Critical:** `PersonalIdApiHandler` has two distinct `createCallback` overloads. Use the **private** overload defined directly in `PersonalIdApiHandler` — `createCallback(PersonalIdSessionData, PersonalIdApiResponseParser)`. Do NOT use the parent `BaseApiHandler.createCallback`, which takes a different parser interface and does not populate `sessionData`. Both compile without error but the parent overload leaves session data unpopulated. + +- [ ] **Step 4.8: Build to verify compilation** + + Run: `./gradlew assembleCommcareDebug` + Expected: BUILD SUCCESSFUL + +- [ ] **Step 4.9: Commit** + + ```bash + git add app/src/org/commcare/connect/network/ApiEndPoints.java \ + app/src/org/commcare/connect/network/ApiService.java \ + app/src/org/commcare/connect/network/ApiPersonalId.java \ + app/src/org/commcare/connect/network/connectId/PersonalIdApiHandler.java \ + app/src/org/commcare/connect/network/connectId/parser/SendEmailOtpResponseParser.kt \ + app/src/org/commcare/connect/network/connectId/parser/VerifyEmailOtpResponseParser.kt + git commit -m "[AI] Add email OTP API endpoints, service calls, parsers, and handler methods" + ``` + +--- + +## Chunk 3: Email Entry Fragment + +### Task 5: Create email entry layout and fragment + +**Files:** +- Create: `app/res/layout/fragment_personalid_email.xml` +- Create: `app/src/org/commcare/fragments/personalId/PersonalIdEmailFragment.kt` + +- [ ] **Step 5.1: Create fragment_personalid_email.xml** + + Model closely after `app/res/layout/screen_personalid_name.xml`. The layout must include: + - `ScrollView` root with ID `personalid_email_scroll_view` + - `TextInputLayout` + `TextInputEditText` with ID `emailTextValue` (inputType="textEmailAddress") + - Continue button with ID `personalidEmailContinueButton` + - "Skip" text button with ID `personalidEmailSkipButton` (styled as secondary action) + - Error `TextView` with ID `personalidEmailError` (visibility gone by default) + + ```xml + + + + + + + + + + + + + + + + +