Skip to content

@W-23290467: [MSDK Android] Push deregistration for one user can deregister all users after app upgrade from a pre-14.0 build - #2957

Merged
JohnsonEricAtSalesforce merged 1 commit into
forcedotcom:devfrom
JohnsonEricAtSalesforce:bugfix/W-23290467_android-push-worker-legacy-payload-upgrade
Jul 10, 2026
Merged

@W-23290467: [MSDK Android] Push deregistration for one user can deregister all users after app upgrade from a pre-14.0 build#2957
JohnsonEricAtSalesforce merged 1 commit into
forcedotcom:devfrom
JohnsonEricAtSalesforce:bugfix/W-23290467_android-push-worker-legacy-payload-upgrade

Conversation

@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor

What & why

PushNotificationsRegistrationChangeWorker resolves its target account from discrete ORG_ID / USER_ID input keys, treating both absent as "all authenticated users" (the periodic re-register path). But an app upgrading in place from a pre-14.0 build can carry a queued push work request whose payload uses the old USER_ACCOUNT blob key — with no ORG_ID / USER_ID. When the new worker runs that job, it hits the absent-identifiers branch and widens scope: a single-user deregister enqueued by the old build deregisters every authenticated user.

The fix

Account selection is extracted into a @VisibleForTesting internal fun resolveTargetAccounts(): TargetAccounts that runs before the register/deregister action. It migrates a legacy USER_ACCOUNT payload by reading only the org id and user id from the blob — never the auth token, refresh token, or session cookies it also carries — then re-resolves the account from secure storage. This preserves the worker's data-minimization design: no plaintext credentials are re-persisted to WorkManager's unencrypted storage.

Resolution outcomes are a small sealed type:

  • Absent identifiers (no discrete keys, no legacy blob) → all authenticated users (periodic re-register — unchanged).
  • Present/migrated identifiers that resolve → exactly that one account.
  • Present identifiers that no longer resolve (e.g., the user logged out before the job ran) or an unparseable legacy payloadResult.failure(). The work fails rather than silently widening scope; this is safe because the SDK re-enqueues registration work with REPLACE on the next foreground/login.

Testing

Instrumented suite (PushNotificationsRegistrationChangeWorkerTest, emulator, no FCM) — resolveTargetAccounts() + TargetAccounts at 100% line/branch/instruction coverage:

  • Legacy USER_ACCOUNT-only deregister, users A+B seeded → targets only A (written first; fails against pre-fix code).
  • Legacy USER_ACCOUNT-only register → resolves to the one user.
  • Malformed/unparseable USER_ACCOUNTfailure() (no widening).
  • Absent all identifiers → all-users periodic path unchanged.
  • Plus partial-identifier and all-users-deregister cases for full branch coverage.

Manual on-device verification (the path automated tests model but cannot exercise across a real process upgrade) — see the manual-test comment below: a legacy build enqueues a single-user deregister offline (pinned PENDING by the job's CONNECTED constraint), an in-place install -r upgrade preserves the WorkManager DB, and on restored connectivity the fixed worker runs the migrated job → resolveTargetAccounts … → FAIL, Worker result FAILURE, with the bystander account left registered and still receiving pushes.

Notes

  • Public API unchanged — the fix is testable via an internal @VisibleForTesting seam only; PushService's surface is untouched.
  • No deviations from the acceptance criteria.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

Manual verification — on-device legacy→fixed upgrade test

In addition to the automated instrumented tests (PushNotificationsRegistrationChangeWorkerTest, 9 cases, worker + TargetAccounts at 100% line/branch/instruction coverage), the fix was verified end-to-end on a device by reproducing the exact customer scenario: a single-user deregister job enqueued by a pre-14.0 build, surviving an in-place app upgrade, and running against the new worker. This is the path the automated tests model but cannot exercise across a real process upgrade.

Why a special harness was needed

The defect only manifests when a push work request enqueued by an older build (which persisted the full account under the legacy USER_ACCOUNT key) is executed by a newer build's worker after an in-place upgrade. No sample-app UI enqueues a legacy-format request, so the scenario was staged directly:

  • "Legacy" build — the last pre-migration commit, whose enqueuer writes the legacy USER_ACCOUNT payload and whose deregister work request carries a CONNECTED network constraint. Used only to produce the legacy job.
  • "Fixed" build — this PR's change, instrumented with temporary log lines at each decision point in the worker (these logs are not part of the PR).
  • Both built as the RestExplorer sample app wired for real FCM push, installed on the same device so an adb install -r performs a true in-place upgrade that preserves the WorkManager database (and therefore any pending job).

The mechanism that makes the test deterministic: because the legacy deregister request requires network, performing the logout offline leaves the job PENDING — it never runs — so it can be carried across the upgrade and then released to the new worker by restoring connectivity.

Procedure and results

1. Seed two accounts on the legacy build. Logged in User A, then added User B (two distinct users). Both registered for push successfully:

LEGACY worker parsed account=<User A> … userId=<A>
onRegistered registrationId=<redacted> account=<User A>
LEGACY worker parsed account=<User B> … userId=<B>
onRegistered registrationId=<redacted> account=<User B>

2. Queue a single-user deregister for User A, offline. Enabled airplane mode, switched to User A, then logged A out. The legacy build enqueued a deregister work request carrying A's legacy USER_ACCOUNT payload. Verified via dumpsys jobscheduler that the RestExplorer WorkManager job was present but blocked on its network constraint (NET … unsatisfied), and the worker did not run (no worker log line). User A was removed locally; User B remained signed in.

3. In-place upgrade to the fixed build, still offline. adb install -r of the fixed APK. Confirmed the pending deregister job and the account state (B still current) survived the upgrade — the WorkManager DB carried over.

4. Restore connectivity → the fixed worker runs the pending legacy job. Disabled airplane mode. The pending deregister ran against the new worker:

W23290467Push  doWork ENTER action=Deregister
W23290467Push  resolveTargetAccounts orgId=<A-org> userId=<A> → FAIL (work fails, scope NOT widened)
WM-WorkerWrapper  Worker result FAILURE for Work [ …PushNotificationsRegistrationChangeWorker ]

The worker migrated the legacy USER_ACCOUNT blob by reading only A's org id / user id (never tokens), tried to re-resolve A from secure storage, found A gone (it had logged out), and returned Result.failure()instead of falling through to the old "all authenticated users" path. Crucially, there was no performRegistrationChange for User B anywhere in the run.

5. Bystander survived. User B remained authenticated and registered throughout — the failed single-user deregister did not touch it. This is the fix: pre-fix, this job would have widened to every authenticated user and deregistered B.

6. Fixed build's push path remains healthy. On the fixed build, a re-registration resolved to exactly the one authenticated user and succeeded, and a test push was received:

W23290467Push  resolveTargetAccounts orgId=null userId=null → ACCOUNTS(1)=[<B>]
W23290467Push  performRegistrationChange register=true for userId=<B>
WM-WorkerWrapper  Worker result SUCCESS for Work [ …PushNotificationsRegistrationChangeWorker ]
RestExplorerPush  onPushMessageReceived: { … }

Confirming the change does not regress normal register / notification-delivery behavior.

Outcome

Assertion Result
Legacy build registers multiple users for push
Pre-14.0 single-user deregister survives an in-place upgrade as a pending job
New worker migrates the legacy USER_ACCOUNT payload reading only org/user id (no tokens)
Deregister targeting a now-absent user fails rather than widening to all users
Bystander account is left registered and functional
Register + notification receipt still work on the fixed build

The on-device result matches the deterministic instrumented tests: the migration correctly scopes a legacy single-user deregister to the intended user only.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Job Summary for Gradle

Pull Request :: test-android
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
SalesforceMobileSDK-Android libs:SalesforceSDK:lint 9.4.1 Build Scan not published
SalesforceMobileSDK-Android libs:SalesforceSDK:convertCodeCoverage 9.4.1 Build Scan not published
SalesforceMobileSDK-Android libs:SalesforceSDK:assembleAndroidTest 9.4.1 Build Scan not published

@wmathurin wmathurin 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.

Review findings — W-23290467: push deregistration wrong-user bug after upgrade

[suggestion] PushNotificationsRegistrationChangeWorkerTest.kt — The regression test constructs the legacy blob using UserAccount.toJson(). Worth confirming that this method produces the exact same JSON structure that the pre-14.0 SDK wrote into the work request payload — if the field names or structure differ between SDK versions, the test would pass but the production migration path would still fail for real upgraded devices.

[question] resolveTargetAccounts() — If the legacy blob contains a valid orgId/userId but that user no longer exists in UserAccountManager (e.g. they were removed between the upgrade and the worker running), the method returns Fail and the deregistration is silently dropped. Is that the intended behavior, or should it log a warning? A silent no-op means the push token for that user remains registered on the server indefinitely.

@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

Good call-out. Two things pin the fidelity down:

1. The unit test uses the same serializer the legacy SDK used. The pre-14.0 enqueue path built the payload with userAccount.toJson().toString() and stored it under the USER_ACCOUNT key (PushService.enqueuePushNotificationsRegistrationWork, baseline commit d0ed1132d). The regression test constructs its blob the same way — UserAccount.toJson() — so it's not an approximation of the old structure, it's the identical serialization method. The migration reads orgId/userId back out via UserAccount(JSONObject(...)), which is the round-trip counterpart of toJson().

2. The on-device test didn't use a synthetic blob at all — it used a real pre-migration build. For the manual verification (posted above), the legacy job was produced by building and running the last pre-migration commit (d0ed1132d) as the RestExplorer sample app, enqueuing a real single-user deregister offline, then doing an in-place install -r upgrade to the fixed build. The pending job that the new worker migrated was written by actual pre-14.0 enqueue code, not by the test harness — so the exact payload a real upgraded device would carry was exercised end to end, and resolveTargetAccounts read it correctly (orgId=<A-org> userId=<A> → FAIL, bystander untouched).

So the field names/structure match by construction (same toJson() writer, same UserAccount(JSONObject) reader) and are corroborated by a real-build upgrade run.

This wasn't a hands-off verification — there was substantial human-in-the-loop work behind it: reviewing the unit tests, and manually driving the in-app scenario across the old and new builds through the actual upgrade (seeding the accounts, staging the offline deregister, performing the install -r, and confirming the outcome on-device at each step).

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

Intended — and it's actually the central case this fix targets, not just an edge. A single-user deregister is enqueued because that user logged out, so by the time a legacy pre-14.0 deregister job survives an in-place upgrade and runs against the new worker, that account is normally already gone from UserAccountManager. Returning Fail there is the fix: the alternative — falling through to the absent-identifiers "all authenticated users" branch — is the original wrong-user bug. This is exactly the path the on-device test drove: User A logged out, resolveTargetAccounts … → FAIL, Worker result FAILURE, bystander B left registered and functional.

It's worth adding why the client can't just complete the deregister anyway: pre-14.0 it could, but only because it persisted the full account — including the auth/refresh token — in WorkManager's unencrypted storage and rebuilt a REST client straight from that blob. Removing those plaintext credentials was the whole point of the preceding hardening work; once we re-resolve from secure storage instead, a departed user has no token to authenticate a deregister, so fail-closed is the honest outcome. The silent no-op is the accepted cost of not keeping credentials at rest, not a regression.

On the "token stays registered on the server indefinitely" concern — I went and checked the server side (the MobilePushServiceDevice entity definition) rather than assume. It's actually bounded, and the bound is visible in this very file too: the SDK re-registers all authenticated users every six days (delayDays = 6), which exists to stay ahead of the backend's cleanup of registrations that haven't been refreshed within its window. A logged-out user drops out of that periodic re-registration set, so their device registration simply stops being refreshed and is reclaimed server-side once it ages past that window — on the order of the re-registration cadence, not indefinitely. On the server entity itself, MobilePushServiceDevice carries a required LastSubscribed timestamp consistent with exactly this recency-based cleanup, and the record is cascade-bound to its User and connected Application, so it's also reclaimed if either is removed.

Given that, proactive client-side cleanup of a departed user's registration would be redundant with the server's own age-out — so I've kept this fix scoped to the wrong-user defect rather than adding anything there.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

…o deregister targets only the intended user (Resolve target accounts from legacy USER_ACCOUNT blob reading only org/user id; add instrumented migration tests)
@JohnsonEricAtSalesforce
JohnsonEricAtSalesforce force-pushed the bugfix/W-23290467_android-push-worker-legacy-payload-upgrade branch from 8f28659 to ce2c211 Compare July 10, 2026 04:13
@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

Merging with --admin — rationale

This PR is being merged with admin privileges because the one remaining red check, ui-tests-pr / test-android, is failing for reasons unrelated to this change.

The relevant checks pass. unit-tests-pr (SalesforceSDK) / test-android — the suite that covers this change, including the new PushNotificationsRegistrationChangeWorkerTest cases — passes, along with test-orchestrator and salesforce-cla. (An earlier red on this job was a pre-existing ClientManagerMockTest.kt compile break on dev; it was resolved upstream and this branch has been rebased onto current dev, so it now compiles and passes.)

The failing check is a dev-wide, pre-existing issue, not a regression from this PR. ui-tests-pr fails with the same six AuthFlowTester login/auth UI tests on every current PR — the same failures reproduce on PRs 2956, 2958, 2959, 2960, and 2961. The two root causes are:

  • user_creds_section timeouts (BootConfigLoginTests, AdvancedAuthBeaconLoginTests) — a known harness flake (AssertionError: Timed out after 15000ms waiting for node "user_creds_section").
  • BW feature-flag assertions (ECALoginTests, MultiUserLoginTests, BeaconLoginTests) — Expected no 'BW' flag for in-app WebView auth, where the auth user-agent (…ftr_AI.BW.KT.UA… on SalesforceMobileSDK/14.0.0.dev) now carries the flag. This is a test-org / feature-flag configuration condition on dev, not application code.

Every failing test is in the AuthFlowTester login/auth sample app. This change touches only the background push-registration WorkManager path and has no UI or authentication surface, so it cannot affect these tests. The change is reviewed and approved.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@JohnsonEricAtSalesforce
JohnsonEricAtSalesforce merged commit 9616951 into forcedotcom:dev Jul 10, 2026
3 of 4 checks passed
@JohnsonEricAtSalesforce
JohnsonEricAtSalesforce deleted the bugfix/W-23290467_android-push-worker-legacy-payload-upgrade branch July 10, 2026 05:06
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.

2 participants