Skip to content

Conversation

@OrangeAndGreen
Copy link
Contributor

@OrangeAndGreen OrangeAndGreen commented Jan 26, 2026

https://dimagi.atlassian.net/browse/CCCT-2025

Product Description

If a user attempts to access PersonalID on a device after logging into the same account on a different device, the following will happen:

  • The app will immediately navigate out of the current workflow and navigate back to the initial landing page (Setup or Login)
  • The "global errors" message will appear on the page, but now with text informing the user about the reason that they were logged out on this device and what steps they should take next
  • The PersonalID account will be "forgotten" on the device
image

Technical Summary

This change modifies the existing TokenDeniedException and GlobalErrors handling.
First, TokenDeniedException was renamed to LoginInvalidatedException, and I added an optional GlobalErrors input to provide a reason. The exception gets thrown when the base API callback sees a 400 error with the LOGIN_FROM_DIFFERENT_DEVICE error code.

In a change to how these errors are handled, we now let the exception bubble all the way up to the last-resort CommCareExceptionHandler, where it is caught and handled as described in the user-facing steps above (delete DB, navigate to landing page, show error message). Note that achieving this required the async CommCareTask to catch and rethrow the exception. Also, the handling code in CommCareExceptionHandler checks for the LoginInvalidatedException to be wrapped (possibly multiple times) in other exceptions (i.e. due to the original wrapping in a RuntimeException so calling code doesn't have to handle it explicitly, and possible additional wrapping from the async task rethrow).

I also noticed the translations for the existing generic logout error were missing so I went ahead and added those.

Feature Flag

None

Safety Assurance

Safety story

The server isn't sending the LOGIN_FROM_DIFFERENT_DEVICE error code yet, so for testing I modified the code temporarily to interpret any 400 response on a PersonalID token request to be treated as that error.

In order to speed up testing, I also disabled both HQ and PersonalID token caching (in PersonalIdManager) so the code always requested fresh tokens when attempting relevant API calls.

Test procedure:

  • Login to PersonalID on device A, then login to a learn/deliver app
  • Login to PersonalID on device B
  • Attempt to access an API call and verify that the desired logout behavior occurs and message is shown

Spots tested:

  • Refreshing jobs list
  • Refreshing learn/deliver progress
  • App home: sync
  • Login: Direct login to learn/deliver app

Automated test coverage

None

QA Plan

Once the server functionality is ready, follow the tests described above and verify that in every case the user is logged out of PersonalID and shown the error message about a new device logging into the account.

Using exception bubble-up to CommCareExceptionHandler to handle global error storage, DB cleanup, and redirect to login.
Helper method to detect LOGIN_FROM_DIFFERENT_DEVICE error, used in two relevant places (for async and sync calls).
Added new GlobalErrors value to represent login from second device.
Added error message for second device login with translations.
Filled in missing translations for the generic error message when login gets invalidated.
…nto ccct-2025/lost_config_reason

Handled conflicts in:
#	app/src/org/commcare/connect/PersonalIdManager.java
#	app/src/org/commcare/connect/network/ApiPersonalId.java
#	app/src/org/commcare/connect/network/ConnectNetworkHelper.java
#	app/src/org/commcare/connect/network/ConnectSsoHelper.java
@coderabbitai
Copy link

coderabbitai bot commented Jan 26, 2026

📝 Walkthrough

Walkthrough

This PR introduces a refactored error handling mechanism for PersonalID authentication failures. It renames TokenDeniedException to LoginInvalidatedException (with an optional reason field), replaces exception-based token denial handling with a centralized triggerGlobalError() method in ConnectDatabaseHelper, and adds detection for "login from different device" scenarios via a new checkForLoginFromDifferentDevice() utility. The exception handler chain is enhanced to detect LoginInvalidatedException and trigger appropriate recovery flows. New localized strings in nine language variants provide user-facing messages for token rejection and device-mismatch scenarios.

Sequence Diagram

sequenceDiagram
    participant Client as HTTP Client
    participant BaseApi as BaseApiCallback
    participant Helper as ConnectNetworkHelper
    participant DbHelper as ConnectDatabaseHelper
    participant Handler as CommCareExceptionHandler

    Client->>BaseApi: HTTP 400 Bad Request
    BaseApi->>Helper: checkForLoginFromDifferentDevice(errorBody)
    Helper-->>BaseApi: true (if LOGIN_FROM_DIFFERENT_DEVICE code)
    alt Login from Different Device Detected
        BaseApi->>DbHelper: triggerGlobalError(PERSONALID_LOGIN_FROM_DIFFERENT_DEVICE_ERROR)
        DbHelper->>DbHelper: throw LoginInvalidatedException(reason)
    else Token Request Denied
        BaseApi->>DbHelper: triggerGlobalError(PERSONALID_LOST_CONFIGURATION_ERROR)
        DbHelper->>DbHelper: throw LoginInvalidatedException(reason)
    end
    
    DbHelper-->>Handler: uncaught LoginInvalidatedException
    Handler->>Handler: findRootLoginInvalidatedException(cause chain)
    Handler->>Handler: Derive crash reason from exception
    Handler->>DbHelper: Store crash record
    Handler->>Handler: startDispatchActivity()
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

QA Note

Suggested reviewers

  • shubham1g5
  • Jignesh-dimagi
  • avazirna
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: recognizing and informing users when login is invalidated due to login on another device, which aligns with the PR's core objective and implementation.
Description check ✅ Passed The pull request description comprehensively covers all required sections: product description with user-facing effects, technical summary with link to ticket and design rationale, feature flag status, detailed safety assurance including testing approach, automated test coverage acknowledgment, and QA plan.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ccct-2025/lost_config_reason

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 and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/src/org/commcare/network/HttpCalloutTask.java (1)

79-110: Don’t swallow LoginInvalidatedException in callout handling.
Since LoginInvalidatedException now extends IOException, it will be mapped to NetworkFailure here, preventing the global invalidation flow from triggering. Add an explicit catch before the generic IOException branch and let it bubble (wrapped) for the global handler.

🛠 Proposed fix
 import org.commcare.connect.network.TokenUnavailableException;
+import org.commcare.connect.network.LoginInvalidatedException;

 ...
             } catch (CaptivePortalRedirectException e) {
                 e.printStackTrace();
                 outcome = HttpCalloutOutcomes.CaptivePortal;
+            } catch (LoginInvalidatedException e) {
+                throw new RuntimeException(e);
             } catch (TokenUnavailableException e) {
                 e.printStackTrace();
                 outcome = HttpCalloutOutcomes.TokenUnavailable;
             } catch (IOException e) {
app/src/org/commcare/tasks/DataPullTask.java (1)

296-321: Let LoginInvalidatedException escape to the global handler.
With the new exception type, the broad IOException catch will mask login invalidation. Handle it explicitly and rethrow (wrapped) so the global recovery path can run.

🛠 Proposed fix
         } catch (TokenUnavailableException e) {
             responseError = PullTaskResult.TOKEN_UNAVAILABLE;
+        } catch (LoginInvalidatedException e) {
+            throw new RuntimeException(e);
         } catch (IOException e) {
             e.printStackTrace();
             Logger.log(LogTypes.TYPE_WARNING_NETWORK, "Couldn't sync due to IO Error|" + e.getMessage());
         } catch (UnknownSyncError e) {
🤖 Fix all issues with AI agents
In `@app/src/org/commcare/utils/CommCareExceptionHandler.java`:
- Around line 47-54: The call to CrashUtil.reportException currently passes
ex.getCause(), which can be an intermediate wrapper and may omit the full
context; update the reporting to pass either the full exception (ex) for
complete stack trace or the resolved root LoginInvalidatedException
(loginInvalidated) if you only want that specific error; locate the logic in
CommCareExceptionHandler where findRootLoginInvalidatedException(ex) is used and
replace CrashUtil.reportException(ex.getCause()) with
CrashUtil.reportException(ex) or CrashUtil.reportException(loginInvalidated) as
appropriate for the desired diagnostics.
🧹 Nitpick comments (2)
app/src/org/commcare/connect/PersonalIdManager.java (1)

544-552: Potential null appRecord if database state changes between checks.

The isSeatedAppLinkedToPersonalId(username) check at line 544 queries the database, but getConnectLinkedAppRecord at line 548-549 queries it again. In a rare race condition, the record could be deleted between these calls, resulting in appRecord being null when passed to retrieveHqSsoTokenSync.

Consider adding a null check or reusing the record from the validation check:

♻️ Suggested defensive check
     ConnectLinkedAppRecord appRecord = ConnectAppDatabaseUtil.getConnectLinkedAppRecord(manager.parentActivity,
             seatedAppId, username);
 
+    if (appRecord == null) {
+        return null;
+    }
+
     return ConnectSsoSyncHelper.INSTANCE.retrieveHqSsoTokenSync(CommCareApplication.instance(), user, appRecord, username,
             false);
app/src/org/commcare/connect/network/LoginInvalidatedException.kt (1)

15-17: Consider making reason immutable.

The reason field is declared as var, making it mutable. For an exception class, immutability is generally preferred to ensure the exception state remains consistent after creation.

Suggested fix
-class LoginInvalidatedException(`@JvmField` var reason: GlobalErrors?) : IOException() {
+class LoginInvalidatedException(`@JvmField` val reason: GlobalErrors?) : IOException() {

Comment on lines 47 to 54
LoginInvalidatedException loginInvalidated = findRootLoginInvalidatedException(ex);
if(loginInvalidated != null) {
CrashUtil.reportException(ex.getCause());
GlobalErrors reason = loginInvalidated.reason != null ? loginInvalidated.reason :
GlobalErrors.PERSONALID_LOST_CONFIGURATION_ERROR;
ConnectDatabaseHelper.crashDb(reason);
startDispatchActivity();
System.exit(0);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential issue: ex.getCause() may not report the intended exception.

On line 49, CrashUtil.reportException(ex.getCause()) reports the immediate cause of the original exception. However, LoginInvalidatedException could be nested several levels deep in the exception chain. This may result in reporting an intermediate wrapper exception rather than the full context or the LoginInvalidatedException itself.

Consider reporting the full exception ex for complete stack trace context, or loginInvalidated if only the root cause is desired.

Suggested fix
         if(loginInvalidated != null) {
-            CrashUtil.reportException(ex.getCause());
+            CrashUtil.reportException(ex);
             GlobalErrors reason = loginInvalidated.reason != null ? loginInvalidated.reason :
                     GlobalErrors.PERSONALID_LOST_CONFIGURATION_ERROR;
🤖 Prompt for AI Agents
In `@app/src/org/commcare/utils/CommCareExceptionHandler.java` around lines 47 -
54, The call to CrashUtil.reportException currently passes ex.getCause(), which
can be an intermediate wrapper and may omit the full context; update the
reporting to pass either the full exception (ex) for complete stack trace or the
resolved root LoginInvalidatedException (loginInvalidated) if you only want that
specific error; locate the logic in CommCareExceptionHandler where
findRootLoginInvalidatedException(ex) is used and replace
CrashUtil.reportException(ex.getCause()) with CrashUtil.reportException(ex) or
CrashUtil.reportException(loginInvalidated) as appropriate for the desired
diagnostics.

Copy link
Contributor

@shubham1g5 shubham1g5 left a comment

Choose a reason for hiding this comment

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

Looking quite good overall, left some minor questions/suggestions.

public static void crashDb(GlobalErrors error) {
crashDb(error, null);
public static void triggerGlobalError(GlobalErrors error) {
throw new RuntimeException(new LoginInvalidatedException(error));
Copy link
Contributor

Choose a reason for hiding this comment

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

I would imagine that you can instead extend LoginInvalidatedException from RuntimeException and throw it directly instead of wrapping it

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah I like that, makes it a bit simpler. I think in previous code having the exception be an IOException helped with routing it correctly, but no need for that now. f28de33

* Corrective action is to recover the ConnectID account on the device.
*/
class TokenDeniedException: IOException() {
class LoginInvalidatedException(@JvmField var reason: GlobalErrors?) : IOException() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Little curious what is @JvmField meant to achieve here, also should reason be defined as non null instead ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That makes the field accessible from Java code, i.e. loginInvalidated.reason in CommCareExceptionHandler.

Copy link
Contributor

@shubham1g5 shubham1g5 Jan 27, 2026

Choose a reason for hiding this comment

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

Got it, think Java way to do that would be loginInvalidated.getReason() without needing to add a JvmField

…on PR comments.

Not throwing exception if error body isn't JSON format.
Changed LoginInvalidatedExceptoin to a RuntimeException (to avoid having to wrap when throwing) and made reason non-nullable.
Copy link
Contributor

@conroy-ricketts conroy-ricketts left a comment

Choose a reason for hiding this comment

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

LGTM! The biggest thing is a clarifying question I had in ConnectSsoHelper.java.

<string name="recovery_network_outdated">This app is outdated. Please update the app on the Google Play Store in order to proceed.</string>
<string name="recovery_network_token_unavailable">There was a network issue. Please try again.</string>
<string name="recovery_network_token_request_rejected">Lost PersonalID configuration with server, please recover your Personal ID account and retry</string>
<string name="recovery_network_token_request_rejected">Lost PersonalID configuration with server, please recover your PersonalID account and retry</string>
Copy link
Contributor

Choose a reason for hiding this comment

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

Side note: is "PersonalID" the correct branding? For some reason I always thought it was "Personal ID" with a space

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh wow, this is a great point to bring up! I've always gone with PersonalID (no space) but we should make sure we're using the right one and then be consistent. Fortunately I currently only see five instances of UI strings using "Personal ID", while the majority don't include a space.

Copy link
Contributor

Choose a reason for hiding this comment

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

@OrangeAndGreen Judging from Slack, it looks like the consensus is that the correct branding is "PersonalID". Do you want to include that as part of this PR? I noticed that there a few code comments that also say "Personal ID". I don't mind making a quick PR to get rid of the spaces everywhere in the code

Copy link
Contributor

@shubham1g5 shubham1g5 left a comment

Choose a reason for hiding this comment

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

One other question, What happens when user is inside a form and this error gets triggered on a async background call ? I think we would still want the user to be able to continue form submission in that case without getting logged out.

return doTaskBackground(params);
} catch (Exception e) {
if (!(e instanceof UserCausedRuntimeException)) {
if (e.getCause() instanceof LoginInvalidatedException) {
Copy link
Contributor

Choose a reason for hiding this comment

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

should this now change change to if (e instanceof LoginInvalidatedException) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Even better! Now that LoginInvalidatedException is a RuntimeException, there's less handling required (i.e. wrapping, catching and rethrowing). Here, the unchecked exception isn't handled by the AsyncTask so now bubbles up directly to CommCareExceptionHandler. 71fc6e3

if (RecoveryMeasuresHelper.recoveryMeasuresPending()) {
LoginInvalidatedException loginInvalidated = findRootLoginInvalidatedException(ex);
if (loginInvalidated != null) {
CrashUtil.reportException(ex.getCause());
Copy link
Contributor

Choose a reason for hiding this comment

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

similarly, should this just be CrashUtil.reportException(ex);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This improves too thanks to LoginInvalidatedException becoming a RuntimeException. We can just check the main ex exception here and cast it if appropriate (no recursive looping into getCause to search for it). 71fc6e3

Copy link
Contributor

@conroy-ricketts conroy-ricketts left a comment

Choose a reason for hiding this comment

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

LGTM, and I'm also curious to see what the Connect team says on the "Personal ID" vs "PersonalID" point, but I don't think that's blocking

@OrangeAndGreen
Copy link
Contributor Author

The last thing to do here is avoid bumping the user out of form entry (good catch @shubham1g5)! I'm looking for a reliable way to detect when the user is in that state, then we won't throw LoginInvalidatedExceptions when that's the case (fallback behavior to TokenUnavailableExceptions will happen instead).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants