Skip to content

QA-8627 Don't Write Connect Data After Signing Out Of PersonalID (2/2: make Connect storage safe to reach around sign-out) - #3860

Draft
OrangeAndGreen wants to merge 1 commit into
masterfrom
QA-8627-stale-connect-db-write-after-signout
Draft

QA-8627 Don't Write Connect Data After Signing Out Of PersonalID (2/2: make Connect storage safe to reach around sign-out)#3860
OrangeAndGreen wants to merge 1 commit into
masterfrom
QA-8627-stale-connect-db-write-after-signout

Conversation

@OrangeAndGreen

@OrangeAndGreen OrangeAndGreen commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

QA-8627

Second of two PRs, stacked on #3863review that one first, and merge it first. The diff here is only the follow-up work; retarget to commcare_2.64 once #3863 lands.

Product Description

Same user-visible symptom as #3863: signing out of PersonalID could leave the nav drawer showing "Logged out of PersonalID — A problem occurred, please configure your PersonalID account again". In the worst case the error also reached the uncaught exception handler as a LoginInvalidatedException, which wipes the account and exits the process. This PR removes that outcome entirely.

Technical Summary

#3863 gates the Connect write sites on isloggedIn(). That closes the ordinary paths, but a status check is check-then-act: a caller already past its check when sign-out lands still reaches a deleted DB, and the storage layer treated that as a corrupt DB. So sign-out now drops the work it can, and what still gets through is no longer fatal.

Drop more of the in-flight work

  • forgetUser cancels the periodic release-toggles worker and drops in-flight requests via ConnectRequestManager.cancelAll() — which existed for exactly this but was never called — before deleting the DB.
  • NotificationsSyncWorker no longer raises an FCM notification while signed out.

Stop treating the lost race as corruption

  • ConnectUserDatabaseUtil.forgetUser was racing connectDbHandleLock on its own: deleteDb() and the passphrase removal ran outside the lock while teardown() took it, so a reader could open a handle mid-teardown and re-flag the DB as broken after forgetUser had just cleared the flag. Moved into ConnectDatabaseHelper.clearConnectData(), which holds the lock across teardown, deletion and passphrase removal. Deleting a local DB touches no network, so the critical section stays short enough to hold on the UI thread forgetUser already runs on.
  • An absent passphrase means there's no account to open a DB for — the expected state after sign-out, not corruption. getConnectDbOpenHelper now throws the typed ConnectDatabaseUnavailableException instead of a bare IllegalStateException, and getHandle rethrows it without setting dbBroken or raising the global error. Only a DB that won't open despite having a passphrase is still treated as broken. Losing the race now degrades to a logged, catchable no-op instead of an account wipe and process restart.
  • dbBroken is private and volatile; it was package-private and read from threads other than the one writing it.

Contain the notification path

The storage/broadcast/acknowledge block in callPushNotificationApi is wrapped in runCatching: a failure is logged and returned as Result.failure rather than escaping to the uncaught exception handler, and the continuation is resumed exactly once either way (previously a throw there left the caller suspended forever). A failed acknowledgement is logged but not treated as a failure — the notifications are stored and the server resends unacknowledged ones on the next sync. acknowledgeNotificationsReceipt also returns false on a null user instead of NPEing.

Safety Assurance

Safety story

What gives me confidence:

  • The worst outcome this addresses — LoginInvalidatedException reaching the uncaught handler and wiping the account — is now unreachable from a missing passphrase, which is the only way sign-out can produce it.
  • clearConnectData strictly widens an existing critical section; every operation it performs was already happening, just partly unlocked.
  • ConnectDatabaseHelperTest asserts the invariant directly, including under real thread interleaving.

Risks to review:

  • clearConnectData holds connectDbHandleLock across the DB deletion on the UI thread. Local file I/O with no network in the critical section, and forgetUser already ran on that thread, but it is a longer hold than before. Worth a look if anyone knows of a Connect storage call that can block for a long time under that lock.
  • ConnectRequestManager.cancelAll() is now called for the first time. It cancels in-flight deferreds, so a caller awaiting one sees a CancellationException at sign-out. That is what the method was written for, but it has never actually run in production.
  • The runCatching on the notification path swallows more than the sign-out race. Any storage failure in processParsedDataIntoDB — not just a deleted DB — now becomes a logged Result.failure instead of an uncaught exception. That is the intent (a background notification sync should not take down the process), but it makes a genuine storage bug quieter than it used to be. The failure is also logged and returned after partial work may have happened; there is no rollback.
  • A missing passphrase is no longer flagged as a broken DB. If some path can lose the passphrase while an account genuinely exists, that state now degrades quietly instead of prompting recovery. PERSONALID_DB_STARTUP_ERROR on a null user in init still covers the startup case.
  • The concurrency test asserts an invariant, not a schedule. It reads Connect storage from a background thread across a concurrent forgetUser and asserts what must hold under any interleaving — never LoginInvalidatedException, never dbBroken — rather than trying to hit one specific ordering. It will not reliably reproduce the original failure if the fix regresses in a timing-dependent way.

Automated test coverage

ConnectDatabaseHelperTest adds three cases covering Connect storage reached around sign-out, including the latch-based one described above.

Carried over from #3863: ConnectReleaseTogglesParserTest covers the release-toggle guard in both directions. The runCatching hardening on the notification path is not directly covered.

@OrangeAndGreen OrangeAndGreen self-assigned this Aug 10, 2026
@OrangeAndGreen

OrangeAndGreen commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Suggested Review Order

  • app/src/org/commcare/connect/PersonalIdManager.java — sign-out ordering and cancellation, plus the volatile status the guards read
  • app/src/org/commcare/utils/PushNotificationApiHelper.kt — the path from the ticket; also fixes single-resume and a null user deref
  • app/src/org/commcare/pn/workers/NotificationsSyncWorker.kt — follow-on guard for the same flow
  • app/src/org/commcare/pn/workers/MessagingChannelsKeySyncWorker.kt — previously read Connect storage with no check at all
  • app/src/org/commcare/connect/network/connect/parser/ConnectReleaseTogglesParser.kt — second independent stale writer
  • app/unit-tests/.../ConnectReleaseTogglesParserTest.kt — covers that guard in both directions
  • app/unit-tests/.../PushNotificationActivityTest.kt — existing click-gating test adjusted for the new load requirement

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (QA-8627-gate-connect-writes-when-signed-out@ff30984). Learn more about missing BASE report.

Additional details and impacted files
@@                              Coverage Diff                               @@
##             QA-8627-gate-connect-writes-when-signed-out    #3860   +/-   ##
==============================================================================
  Coverage                                               ?   27.36%           
  Complexity                                             ?     4793           
==============================================================================
  Files                                                  ?      989           
  Lines                                                  ?    59008           
  Branches                                               ?     7044           
==============================================================================
  Hits                                                   ?    16145           
  Misses                                                 ?    40909           
  Partials                                               ?     1954           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@OrangeAndGreen
OrangeAndGreen changed the base branch from master to commcare_2.64 August 10, 2026 16:02
@OrangeAndGreen
OrangeAndGreen force-pushed the QA-8627-stale-connect-db-write-after-signout branch from 3a6a5d2 to 03821a6 Compare August 10, 2026 16:02
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

PersonalID sign-out now updates account state before cleanup, cancels related work, and clears Connect credentials and database state. Connect database access distinguishes unavailable storage from broken storage. Release toggles, notifications, push handling, and messaging key synchronization now require an active account. Tests cover cleanup, concurrent access, toggle persistence, and notification navigation. Release notes add sign-out and re-sign-in QA checks.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PersonalIdManager
  participant ConnectDatabaseHelper
  participant PushNotificationApiHelper
  participant MessagingChannelsKeySyncWorker
  PersonalIdManager->>PersonalIdManager: mark account absent
  PersonalIdManager->>ConnectDatabaseHelper: clear Connect data
  ConnectDatabaseHelper-->>PersonalIdManager: reset database state
  PushNotificationApiHelper->>PersonalIdManager: check account presence
  PersonalIdManager-->>PushNotificationApiHelper: account absent or active
  PushNotificationApiHelper->>PushNotificationApiHelper: store or discard notification
  MessagingChannelsKeySyncWorker->>PersonalIdManager: check account presence
  PersonalIdManager-->>MessagingChannelsKeySyncWorker: account absent or active
  MessagingChannelsKeySyncWorker->>MessagingChannelsKeySyncWorker: skip or perform key sync
Loading

Possibly related PRs

Suggested reviewers: jignesh-dimagi, shubham1g5

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: preventing Connect data writes after PersonalID sign-out.
Description check ✅ Passed The description covers the product impact, technical design, safety risks, and automated tests, but omits the Labels and Review checklist.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch QA-8627-stale-connect-db-write-after-signout

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/org/commcare/connect/database/PersonalIdNotConfiguredException.java`:
- Around line 13-17: Rewrite the new PersonalIdNotConfiguredException class in a
Kotlin source file while preserving its public class name, IllegalStateException
inheritance, and message-taking constructor so existing Java callers can import
and use it unchanged.

In `@app/src/org/commcare/connect/PersonalIdManager.java`:
- Around line 197-208: Introduce one shared lifecycle lock or guarded-storage
operation and use it consistently: in
app/src/main/java/org/commcare/connect/PersonalIdManager.java:197-208, acquire
exclusive access before changing personalIdSatus and deleting the database; in
app/src/main/java/org/commcare/connect/network/connect/parser/ConnectReleaseTogglesParser.kt:20-24,
guard the storage write; in
app/src/main/java/org/commcare/pn/workers/NotificationsSyncWorker.kt:231-239,
keep the account check and notification-state read within one shared operation;
and in app/src/main/java/org/commcare/utils/PushNotificationApiHelper.kt:82-121,
acquire access before getUser and retain it through response storage and
acknowledgment. Add a latch-based test that exercises sign-out between the
account check and storage access.

In `@app/src/org/commcare/utils/PushNotificationApiHelper.kt`:
- Around line 110-121: Update the notification processing block around
acknowledgeNotificationsReceipt so a false acknowledgment result throws or
otherwise propagates as a failure within runCatching. Preserve the existing
savedNotifications return for successful acknowledgments, allowing the
continuation to resume with failure and NotificationsSyncWorker to retry when
acknowledgment is rejected.

In `@RELEASES.md`:
- Line 18: Update the release note wording by replacing “afterwards” with the
American English form “afterward,” preserving the rest of the signing,
notifications, and messaging guidance.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 19c05109-2b7b-4054-86a5-e4e33457a24e

📥 Commits

Reviewing files that changed from the base of the PR and between d65c480 and 03821a6.

📒 Files selected for processing (9)
  • RELEASES.md
  • app/src/org/commcare/CommCareApplication.java
  • app/src/org/commcare/connect/PersonalIdManager.java
  • app/src/org/commcare/connect/database/ConnectDatabaseHelper.java
  • app/src/org/commcare/connect/database/PersonalIdNotConfiguredException.java
  • app/src/org/commcare/connect/network/connect/parser/ConnectReleaseTogglesParser.kt
  • app/src/org/commcare/pn/workers/NotificationsSyncWorker.kt
  • app/src/org/commcare/utils/PushNotificationApiHelper.kt
  • app/unit-tests/src/org/commcare/connect/network/connectId/parser/ConnectReleaseTogglesParserTest.kt

Comment thread app/src/org/commcare/connect/database/PersonalIdNotConfiguredException.java Outdated
Comment on lines 197 to +208
personalIdSatus = PersonalIdStatus.NotIntroduced;

// Cancel periodic push notification retrieval when user logs out
NotificationsSyncWorkerManager.cancelPeriodicPushNotificationRetrieval(CommCareApplication.instance());

ConnectReleaseTogglesWorker.Companion.cancelPeriodicFetch(CommCareApplication.instance());

//Drop in-flight API requests, which are held in an app-wide scope and would otherwise
//write stale data from the previous session
ConnectRequestManager.INSTANCE.cancelAll();

ConnectUserDatabaseUtil.forgetUser();

@coderabbitai coderabbitai Bot Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make account checks and Connect storage deletion atomic.

volatile makes personalIdSatus visible. It does not serialize hasAccount() with forgetUser(). A worker can pass an account guard, then sign-out can set NotIntroduced and delete the database, then the worker can read or write Connect storage. In PushNotificationApiHelper, ConnectUserDatabaseUtil.getUser(context) also occurs before the new guard.

Use one shared lifecycle lock or a central guarded-storage operation. Hold shared access across each Connect storage operation. Hold exclusive access in forgetUser() before deleting storage. Add a latch-based test that forces sign-out between the check and storage access.

  • app/src/org/commcare/connect/PersonalIdManager.java#L197-L208: acquire exclusive lifecycle access before status change and database deletion.
  • app/src/org/commcare/connect/network/connect/parser/ConnectReleaseTogglesParser.kt#L20-L24: perform the storage write in the guarded lifecycle operation.
  • app/src/org/commcare/pn/workers/NotificationsSyncWorker.kt#L231-L239: protect the account check and notification-state read as one operation.
  • app/src/org/commcare/utils/PushNotificationApiHelper.kt#L82-L121: acquire guarded access before reading the user and hold it through response storage and acknowledgment.
📍 Affects 4 files
  • app/src/org/commcare/connect/PersonalIdManager.java#L197-L208 (this comment)
  • app/src/org/commcare/connect/network/connect/parser/ConnectReleaseTogglesParser.kt#L20-L24
  • app/src/org/commcare/pn/workers/NotificationsSyncWorker.kt#L231-L239
  • app/src/org/commcare/utils/PushNotificationApiHelper.kt#L82-L121
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/org/commcare/connect/PersonalIdManager.java` around lines 197 - 208,
Introduce one shared lifecycle lock or guarded-storage operation and use it
consistently: in
app/src/main/java/org/commcare/connect/PersonalIdManager.java:197-208, acquire
exclusive access before changing personalIdSatus and deleting the database; in
app/src/main/java/org/commcare/connect/network/connect/parser/ConnectReleaseTogglesParser.kt:20-24,
guard the storage write; in
app/src/main/java/org/commcare/pn/workers/NotificationsSyncWorker.kt:231-239,
keep the account check and notification-state read within one shared operation;
and in app/src/main/java/org/commcare/utils/PushNotificationApiHelper.kt:82-121,
acquire access before getUser and retain it through response storage and
acknowledgment. Add a latch-based test that exercises sign-out between the
account check and storage access.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observation is correct — volatile gives visibility, not serialization, and the caller-side isloggedIn() guards are check-then-act. The getUser(context) call in PushNotificationApiHelper is also outside the guard, as noted.

The consequence is addressed at the source rather than by threading a lifecycle lock through the callers (that work is in an incoming push, so it isn't visible in the commit under review):

  • ConnectDatabaseHelper.clearConnectData() now holds connectDbHandleLock across teardown, deleteDb(), and key removal — the same lock getConnectStorage().getHandle() acquires. This is the "hold exclusive access in forgetUser() before deleting storage" half of the suggestion. ConnectUserDatabaseUtil.forgetUser() delegates to it instead of deleting the DB itself.
  • The missing-passphrase path now throws a typed ConnectDatabaseUnavailableException instead of setting dbBroken and calling triggerGlobalError(PERSONALID_GENERIC_ERROR). Losing the race no longer persists a GlobalErrorRecord, which was the actual bug.

So the check-then-act windows still exist, but they are no longer load-bearing: a storage operation racing with sign-out either completes against the live DB or sees it already gone, and the latter is a caught/typed exception rather than a persisted global error.

Declining the remaining part of the suggestion — acquiring guarded access before getUser and holding it "through response storage and acknowledgment." acknowledgeNotificationsReceipt makes a network round-trip, and forgetUser() is called from the UI thread. Holding a lifecycle lock across that span would block sign-out behind in-flight HTTP, trading a race whose failure mode is now benign for an ANR. Per-handle-acquisition granularity is the right scope here.

One residual item this does surface, which is the strongest surviving form of the point: ConnectDatabaseUnavailableException is a RuntimeException with no production catch site beyond the rethrow. It is contained on the notification path by the runCatching block, but the call sites listed as unguarded in the PR description (ConnectOpportunitiesParser, the remaining connectId parsers) would throw it from a background thread with no handler. Better than the persistent banner, but a crash rather than a no-op — tracking that separately rather than widening this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Candidate database and exception files =='
fd -t f -E .git 'ConnectDatabaseHelper|ConnectUserDatabaseUtil|ConnectDatabaseUnavailableException|PersonalIdNotConfiguredException' app

printf '%s\n' '== Locking, teardown, and unavailable-exception paths =='
rg -n -C 4 \
  'connectDbHandleLock|clearConnectData\s*\(|deleteDb\s*\(|ConnectDatabaseUnavailableException|PersonalIdNotConfiguredException|triggerGlobalError' \
  app/src app/unit-tests

printf '%s\n' '== Production catch sites for the unavailable exception =='
rg -n -C 3 \
  'catch\s*\([^)]*ConnectDatabaseUnavailableException|ConnectDatabaseUnavailableException' \
  app/src

Length of output: 12458


@OrangeAndGreen, I agree with the design rationale. A per-handle lock is the correct scope. A caller-spanning lock across acknowledgeNotificationsReceipt could block UI-thread sign-out on network I/O.

I could not verify the described fix in the current review snapshot:

  • ConnectUserDatabaseUtil.forgetUser() still performs deleteDb(), key removal, and teardown() as separate operations.
  • ConnectDatabaseHelper.clearConnectData() is not present.
  • ConnectDatabaseUnavailableException is not present.
  • ConnectDatabaseHelper.getHandle() still catches broad Exception, sets dbBroken, and triggers PERSONALID_GENERIC_ERROR.

Therefore, I will leave this finding unresolved until the incoming change is available on the review branch. Once it lands, the caller-side lock portion of this finding should be withdrawn if teardown and handle acquisition share the same lock and the unavailable-database path cannot persist a global error.

The unhandled ConnectDatabaseUnavailableException paths are a separate follow-up concern, as you described.

🐇🔍


✏️ Learnings added
Learnt from: OrangeAndGreen
Repo: dimagi/commcare-android PR: 3860
File: app/src/org/commcare/connect/PersonalIdManager.java:181-191
Timestamp: 2026-08-11T15:05:42.898Z
Learning: For PersonalID sign-out, do not hold a lifecycle lock across `PushNotificationApiHelper.acknowledgeNotificationsReceipt`, because it performs network I/O and `PersonalIdManager.forgetUser(String)` can run on the UI thread. Prefer synchronization at Connect database-handle acquisition and database teardown granularity.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Comment thread app/src/org/commcare/utils/PushNotificationApiHelper.kt
Comment thread RELEASES.md Outdated
@OrangeAndGreen
OrangeAndGreen force-pushed the QA-8627-stale-connect-db-write-after-signout branch from 03821a6 to 2440f84 Compare August 10, 2026 18:11
@OrangeAndGreen OrangeAndGreen changed the title QA-8627 Stop Stale Connect Writes Reporting A Broken PersonalID DB QA-8627 Don't Write Connect Data After PersonalID Is Forgotten Aug 10, 2026
@OrangeAndGreen OrangeAndGreen changed the title QA-8627 Don't Write Connect Data After PersonalID Is Forgotten QA-8627 Don't Write Connect Data After Signing Out Of PersonalID Aug 10, 2026
@OrangeAndGreen
OrangeAndGreen force-pushed the QA-8627-stale-connect-db-write-after-signout branch 3 times, most recently from e6fffea to 7a97735 Compare August 11, 2026 15:06
@OrangeAndGreen

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@OrangeAndGreen
OrangeAndGreen force-pushed the QA-8627-stale-connect-db-write-after-signout branch from 7a97735 to 47ccba8 Compare August 11, 2026 15:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app/src/org/commcare/connect/database/ConnectDatabaseUnavailableException.kt`:
- Around line 15-17: Update the Connect parser and worker flows, especially
ConnectReleaseTogglesParser and MessagingChannelsKeySyncWorker, to catch
ConnectDatabaseUnavailableException around database-dependent operations and
return their existing expected no-op result. Do not rely solely on isloggedIn(),
since sign-out can occur concurrently; preserve normal behavior for successful
operations and other failures.

In
`@app/unit-tests/src/org/commcare/connect/database/ConnectDatabaseHelperTest.kt`:
- Around line 64-96: Update testStorageReadRacingSignOutNeverInvalidatesLogin to
seed a valid Connect user and passphrase, and replace the readerReady start
barrier with a test hook or latch at storage-handle acquisition so forgetUser()
runs only after the reader reaches that point. Await reader completion before
asserting fatal and ConnectDatabaseHelper.isDbBroken(), while preserving the
race assertions and expected unavailable-storage handling.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b094f9b8-d5bb-4fe5-bd05-9a9e2b15ad6e

📥 Commits

Reviewing files that changed from the base of the PR and between 03821a6 and 7a97735.

📒 Files selected for processing (13)
  • RELEASES.md
  • app/src/org/commcare/CommCareApplication.java
  • app/src/org/commcare/connect/PersonalIdManager.java
  • app/src/org/commcare/connect/database/ConnectDatabaseHelper.java
  • app/src/org/commcare/connect/database/ConnectDatabaseUnavailableException.kt
  • app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java
  • app/src/org/commcare/connect/network/connect/parser/ConnectReleaseTogglesParser.kt
  • app/src/org/commcare/pn/workers/MessagingChannelsKeySyncWorker.kt
  • app/src/org/commcare/pn/workers/NotificationsSyncWorker.kt
  • app/src/org/commcare/utils/PushNotificationApiHelper.kt
  • app/unit-tests/src/org/commcare/activities/PushNotificationActivityTest.kt
  • app/unit-tests/src/org/commcare/connect/database/ConnectDatabaseHelperTest.kt
  • app/unit-tests/src/org/commcare/connect/network/connectId/parser/ConnectReleaseTogglesParserTest.kt
🚧 Files skipped from review as they are similar to previous changes (5)
  • RELEASES.md
  • app/src/org/commcare/connect/network/connect/parser/ConnectReleaseTogglesParser.kt
  • app/src/org/commcare/pn/workers/NotificationsSyncWorker.kt
  • app/src/org/commcare/utils/PushNotificationApiHelper.kt
  • app/unit-tests/src/org/commcare/connect/network/connectId/parser/ConnectReleaseTogglesParserTest.kt

Comment on lines +15 to +17
class ConnectDatabaseUnavailableException(
message: String,
) : RuntimeException(message)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline app/src/org/commcare/connect --items all --type function

printf '%s\n' '== Connect storage access sites =='
rg -n -C 3 'getConnectStorage\s*\(|getConnectDbOpenHelper\s*\(' app/src

printf '%s\n' '== Explicit unavailable-database handling =='
rg -n -C 3 'ConnectDatabaseUnavailableException' app/src

Repository: dimagi/commcare-android

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Database helper behavior =='
sed -n '45,90p' app/src/org/commcare/connect/database/ConnectDatabaseHelper.java

printf '%s\n' '== All storage call sites with enclosing declarations =='
python3 - <<'PY'
from pathlib import Path
import re

root = Path("app/src")
call = re.compile(r'\b(?:ConnectDatabaseHelper\.)?getConnectStorage\s*\(')
decl = re.compile(
    r'\b(?:public|private|protected|internal|override|static|suspend|fun|'
    r'final|abstract|open|class|object|void|[A-Za-z_][\w<>,.? ]*)\s+'
    r'([A-Za-z_]\w*)\s*\([^;{}]*\)\s*(?::\s*[^{=]+)?\s*(?:\{|=)'
)
for path in sorted(root.rglob("*")):
    if path.suffix not in {".java", ".kt"}:
        continue
    text = path.read_text(errors="replace")
    lines = text.splitlines()
    starts = []
    for i, line in enumerate(lines):
        m = decl.search(line)
        if m:
            starts.append((i, m.group(1)))
    for i, line in enumerate(lines):
        if call.search(line):
            candidates = [x for x in starts if x[0] <= i]
            name = candidates[-1][1] if candidates else "<unknown>"
            print(f"{path}:{i+1}: {name}: {line.strip()}")
PY

printf '%s\n' '== Unavailable exception references and catches =='
rg -n -C 2 'ConnectDatabaseUnavailableException' app/src --glob '*.{java,kt}'

printf '%s\n' '== Connect worker implementations =='
rg -n -C 8 'class .*Worker|override fun doWork|Result\.(success|retry|failure)|ConnectJobUtils|ConnectAppDatabaseUtil|ConnectTaskUtils|ConnectMessagingDatabaseHelper' app/src/org/commcare/connect/workers app/src/org/commcare/connect --glob '*Worker*.{java,kt}' --glob '*.kt' --glob '*.java' | head -n 500

Repository: dimagi/commcare-android

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Parser implementations and storage-related calls =='
for f in $(fd -t f -e kt -e java app/src/org/commcare/connect/network); do
    if rg -q 'class .*Parser|object .*Parser|fun parse|ConnectDatabaseHelper|getConnectStorage|ConnectJobUtils|ConnectAppDatabaseUtil|ConnectTaskUtils|ConnectMessagingDatabaseHelper' "$f"; then
        echo "---- $f"
        rg -n -C 6 'class .*Parser|object .*Parser|fun parse|ConnectDatabaseHelper|getConnectStorage|ConnectJobUtils|ConnectAppDatabaseUtil|ConnectTaskUtils|ConnectMessagingDatabaseHelper' "$f"
    fi
done

printf '%s\n' '== Worker source files =='
for f in $(fd -t f -e kt -e java app/src/org/commcare/connect/workers); do
    echo "---- $f"
    cat -n "$f"
done

printf '%s\n' '== Callers of parser classes and database utility entry points =='
rg -n -C 5 'ConnectOpportunitiesParser|LearningAppProgressResponseParser|DeliveryAppProgressResponseParser|ConnectReleaseTogglesParser|RetrieveHqTokenResponseParser|ConnectTokenResponseParser|LinkHqWorkerResponseParser|RetrieveNotificationsResponseParser|RetrieveWorkHistoryResponseParser|RetrieveChannelEncryptionKeyResponseParser|ConnectJobUtils\.|ConnectAppDatabaseUtil\.|ConnectTaskUtils\.|ConnectMessagingDatabaseHelper\.' app/src --glob '*.{java,kt}' | head -n 1000

Repository: dimagi/commcare-android

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Focused parser source excerpts =='
for f in \
  app/src/org/commcare/connect/network/connect/parser/ConnectOpportunitiesParser.kt \
  app/src/org/commcare/connect/network/connect/parser/LearningAppProgressResponseParser.kt \
  app/src/org/commcare/connect/network/connect/parser/DeliveryAppProgressResponseParser.kt \
  app/src/org/commcare/connect/network/connect/parser/ConnectReleaseTogglesParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/RetrieveHqTokenResponseParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/ConnectTokenResponseParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/LinkHqWorkerResponseParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/RetrieveNotificationsResponseParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/RetrieveWorkHistoryResponseParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/RetrieveChannelEncryptionKeyResponseParser.kt; do
  if [ -f "$f" ]; then
    echo "---- $f"
    cat -n "$f"
  fi
done

printf '%s\n' '== Focused worker source excerpts =='
for f in $(fd -t f -e kt -e java app/src/org/commcare/connect/workers); do
  echo "---- $f"
  cat -n "$f"
done

Repository: dimagi/commcare-android

Length of output: 26957


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Direct storage callers grouped by file =='
python3 - <<'PY'
from pathlib import Path
import re

pat = re.compile(r'\bgetConnectStorage\s*\(')
for path in sorted(Path("app/src").rglob("*")):
    if path.suffix not in {".java", ".kt"}:
        continue
    lines = path.read_text(errors="replace").splitlines()
    hits = [i + 1 for i, line in enumerate(lines) if pat.search(line)]
    if hits:
        print(f"{path}: {','.join(map(str, hits))}")

print("== Exception catch blocks by file ==")
catch = re.compile(r'\bcatch\s*\([^)]*ConnectDatabaseUnavailableException[^)]*\)')
for path in sorted(Path("app/src").rglob("*")):
    if path.suffix not in {".java", ".kt"}:
        continue
    lines = path.read_text(errors="replace").splitlines()
    for i, line in enumerate(lines):
        if catch.search(line):
            print(f"{path}:{i+1}: {line.strip()}")
PY

printf '%s\n' '== Worker and parser names referenced by production code =='
rg -n 'class [A-Za-z0-9_]*(Worker|Parser)|object [A-Za-z0-9_]*(Worker|Parser)|[A-Za-z0-9_]*(Worker|Parser)\(' app/src --glob '*.{java,kt}' | head -n 500

Repository: dimagi/commcare-android

Length of output: 24805


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

root = Path("app/src")
call = re.compile(r'\bConnectDatabaseHelper\s*\.\s*getConnectStorage\s*\(')
decl = re.compile(
    r'(?:(?:public|private|protected|internal|override|static|final|suspend|open)\s+)*'
    r'(?:[\w.<>,?\[\]]+\s+)+([A-Za-z_]\w*)\s*\([^;{}]*\)\s*(?:[:][^{=]+)?(?:\{|=)'
)

def enclosing(lines, line_no):
    found = []
    for i, line in enumerate(lines[:line_no + 1]):
        m = decl.search(line)
        if m:
            found.append((i + 1, m.group(1)))
    return found[-1] if found else (None, "<unknown>")

for path in sorted(root.rglob("*")):
    if path.suffix not in {".java", ".kt"}:
        continue
    lines = path.read_text(errors="replace").splitlines()
    for i, line in enumerate(lines):
        if call.search(line):
            line_no, method = enclosing(lines, i)
            print(f"{path}:{i+1}: {method} (declared at {line_no}): {line.strip()}")

print("== Catch sites ==")
catch = re.compile(r'\bcatch\s*\([^)]*ConnectDatabaseUnavailableException[^)]*\)')
for path in sorted(root.rglob("*")):
    if path.suffix not in {".java", ".kt"}:
        continue
    for i, line in enumerate(path.read_text(errors="replace").splitlines()):
        if catch.search(line):
            print(f"{path}:{i+1}: {line.strip()}")
PY

Repository: dimagi/commcare-android

Length of output: 10157


Handle the sign-out race in Connect parsers and workers.

ConnectDatabaseHelper is the only catch site, but parsers and MessagingChannelsKeySyncWorker can still propagate ConnectDatabaseUnavailableException. The isloggedIn() check in ConnectReleaseTogglesParser does not prevent a concurrent sign-out. Return the expected no-op result when this exception occurs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/org/commcare/connect/database/ConnectDatabaseUnavailableException.kt`
around lines 15 - 17, Update the Connect parser and worker flows, especially
ConnectReleaseTogglesParser and MessagingChannelsKeySyncWorker, to catch
ConnectDatabaseUnavailableException around database-dependent operations and
return their existing expected no-op result. Do not rely solely on isloggedIn(),
since sign-out can occur concurrently; preserve normal behavior for successful
operations and other failures.

Comment on lines +64 to +96
fun testStorageReadRacingSignOutNeverInvalidatesLogin() {
val readerReady = CountDownLatch(1)
val signOutDone = CountDownLatch(1)
val fatal = AtomicReference<Throwable?>(null)

val reader =
Thread {
readerReady.countDown()
// keep hammering storage across the sign-out so at least one read lands on either
// side of it, and ideally one lands mid-teardown
repeat(200) {
try {
readUserStorage()
} catch (expected: ConnectDatabaseUnavailableException) {
// the read can't succeed once the account is gone, it just has to fail
// survivably
} catch (t: Throwable) {
if (t is LoginInvalidatedException) {
fatal.compareAndSet(null, t)
}
}
}
signOutDone.countDown()
}

reader.start()
assertTrue(readerReady.await(5, TimeUnit.SECONDS))
ConnectUserDatabaseUtil.forgetUser()
reader.join(TimeUnit.SECONDS.toMillis(30))

assertNull("A read racing sign-out invalidated the login", fatal.get())
assertFalse("A read racing sign-out flagged the Connect DB as broken",
ConnectDatabaseHelper.isDbBroken())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the race test use a live database and a deterministic interleaving.

The test creates no passphrase or user before Line 91. readerReady only confirms that the thread started. It does not confirm that a storage read reached handle acquisition. The test can pass after executing only the expected unavailable-storage path.

Seed a Connect user and passphrase before starting the reader. Add a test hook or latch that pauses the reader at handle acquisition. Trigger forgetUser() only after that latch. Assert that the reader completes before checking the fatal error and dbBroken.

As per coding guidelines, unit tests must “provide comprehensive coverage of all public methods in the tested class.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/unit-tests/src/org/commcare/connect/database/ConnectDatabaseHelperTest.kt`
around lines 64 - 96, Update testStorageReadRacingSignOutNeverInvalidatesLogin
to seed a valid Connect user and passphrase, and replace the readerReady start
barrier with a test hook or latch at storage-handle acquisition so forgetUser()
runs only after the reader reaches that point. Await reader completion before
asserting fatal and ConnectDatabaseHelper.isDbBroken(), while preserving the
race assertions and expected unavailable-storage handling.

Source: Coding guidelines

Follow-up to the sign-in gates on the Connect write sites. Those close the
ordinary paths, but a status check is check-then-act: a caller already past its
check when sign-out lands still reaches a deleted DB, and the storage layer
treated that as a corrupt DB. So sign-out drops the work it can, and what still
gets through is no longer fatal.

- forgetUser cancels the periodic release-toggles worker and drops in-flight
  requests via ConnectRequestManager.cancelAll() (which existed for this but was
  never called) before deleting the DB.
- ConnectUserDatabaseUtil.forgetUser was racing connectDbHandleLock on its own:
  deleteDb() and the passphrase removal ran outside the lock while teardown()
  took it, so a reader could open a handle mid-teardown and re-flag the DB as
  broken after forgetUser had just cleared the flag. Moved into
  ConnectDatabaseHelper.clearConnectData(), which holds the lock across
  teardown, deletion and passphrase removal. Deleting a local DB touches no
  network, so the critical section stays short enough to hold on the UI thread
  that forgetUser already runs on.
- An absent passphrase means there's no account to open a DB for, which is
  simply the state after sign-out, not corruption. getConnectDbOpenHelper now
  reports it as ConnectDatabaseUnavailableException rather than a bare
  IllegalStateException, and getHandle rethrows that without setting dbBroken or
  raising the global error. Only a DB that won't open despite having a passphrase
  is still treated as broken. Losing the race now degrades to a logged,
  catchable no-op instead of an account wipe and process restart.
- dbBroken is private and volatile; it was package-private and read from threads
  other than the one writing it.
- The notification-processing coroutine resumes its continuation exactly once
  rather than letting a storage failure escape to the uncaught exception
  handler, acknowledgeNotificationsReceipt null-checks the user, and no FCM
  notification is raised while signed out.

ConnectDatabaseHelperTest covers storage reached around sign-out, including a
latch-based case that reads Connect storage from a background thread across a
concurrent forgetUser and asserts the invariant that holds under any
interleaving: never LoginInvalidatedException, never dbBroken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OrangeAndGreen
OrangeAndGreen force-pushed the QA-8627-stale-connect-db-write-after-signout branch from 47ccba8 to 8c6588f Compare August 11, 2026 17:26
@OrangeAndGreen OrangeAndGreen changed the title QA-8627 Don't Write Connect Data After Signing Out Of PersonalID QA-8627 Don't Write Connect Data After Signing Out Of PersonalID (2/2: make Connect storage safe to reach around sign-out) Aug 11, 2026
@OrangeAndGreen
OrangeAndGreen changed the base branch from commcare_2.64 to QA-8627-gate-connect-writes-when-signed-out August 11, 2026 17:27
Base automatically changed from QA-8627-gate-connect-writes-when-signed-out to commcare_2.64 August 13, 2026 06:21
Base automatically changed from commcare_2.64 to master August 13, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant