Skip to content

@W-23201591: [Android] Surface RTR state in developer info screen - #2974

Merged
JohnsonEricAtSalesforce merged 2 commits into
forcedotcom:devfrom
JohnsonEricAtSalesforce:feature/W-23201591_android-surface-rtr-state-in-developer-info-screen
Aug 1, 2026
Merged

@W-23201591: [Android] Surface RTR state in developer info screen#2974
JohnsonEricAtSalesforce merged 2 commits into
forcedotcom:devfrom
JohnsonEricAtSalesforce:feature/W-23201591_android-surface-rtr-state-in-developer-info-screen

Conversation

@JohnsonEricAtSalesforce

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Refresh Token Rotation (RTR) is a server-driven security feature: when the token endpoint
issues a new refresh token in place of the one that was presented, the old refresh token is
retired. This pull request surfaces that RTR state in the Salesforce Mobile SDK developer info
screen
(the on-device diagnostics screen reachable from the developer support menu) so that
developers can confirm, on-device, whether Refresh Token Rotation is active for the currently
logged-in user and when the refresh token last rotated.

It adds a new "RTR" section to the developer info screen with two rows:

  • RTR Activetrue or false, reflecting whether the Refresh Token Rotation per-user
    feature flag (an entry the SDK records per user under the code RT) is registered for the
    current user; shows N/A when no user is logged in.
  • Last Rotation — the date and time of the most recent confirmed rotation, formatted as an
    ISO 8601 timestamp (the internationally standardized date-time format, for example
    2026-07-30T12:34:56Z); shows Never until the first rotation, or N/A when no user is
    logged in.

To make the rotation timestamp durable across app restarts, a new lastTokenRotationTime field
is persisted on the UserAccount object. It travels through the full account persistence path,
including the encrypt-on-write and decrypt-on-read handling used to store account data in
Android's AccountManager. The timestamp is stamped at the existing point in ClientManager
where a rotation is confirmed.

This work builds on the Refresh Token Rotation per-user feature flag (feature code RT) that
landed in work item W-23195021. It is the Android counterpart to the iOS work item W-23201588,
and uses the same field labels and display semantics.

Work item: W-23201591

Acceptance criteria

  • AC1 — Per-user fields show N/A when no user is logged in. (Verified by unit test and on-device.)
  • AC2 — Before any token rotation, the section shows RTR Active: false and Last Rotation: Never. (Verified by unit test and by the on-device demo below.)
  • AC3 — After a confirmed rotation, RTR Active shows true and Last Rotation shows a valid timestamp. (Verified by unit test and by the on-device demo below.)
  • AC4 — The rotation timestamp survives an app restart, because it is persisted on the UserAccount object rather than held only in memory. (Verified by an encrypt/decrypt round-trip unit test.)

Demo: A live end-to-end run against a Refresh Token Rotation–enabled org — showing the
false/Never before-state, the Force Token Refresh action, and the resulting true +
timestamp after-state — is attached in a follow-up comment on this pull request, along with
before/after stills.

Changes

Production (7 files)

  • UserAccount.java — new lastTokenRotationTime field carried across the full persistence path (the string constant, the field, the JSON constructor, the Bundle constructor, the getter and setter, toJson, and toBundle), mirroring how the existing tokenType field is handled.
  • UserAccountBuilder.kt — a lastTokenRotationTime(...) builder method, plus the populate-from-existing-account and allow-unset wiring.
  • UserAccountManager.java — encrypt on write and decrypt on read when storing the field in Android's AccountManager user data.
  • AuthenticatorService.java — a new KEY_LAST_TOKEN_ROTATION_TIME user-data key.
  • ClientManager.java — stamps setLastTokenRotationTime at the point where a rotation is confirmed (before the primary updateAccount call), then registers the Refresh Token Rotation feature.
  • SalesforceSDKManager.kt — an isUserFeatureRegistered(...) helper; the RTR section wired into the devSupportInfo getter; a debug-only "Force Token Refresh" developer action; and an extracted forceTokenRefresh(user) function to make that action testable. forceTokenRefresh takes an optional restClient parameter that defaults to the current user's client, so tests can inject a mock without any production-visibility change (matching the existing invokeServerNotificationAction pattern).
  • DevSupportInfo.kt — a parseRtrSection(...) function that builds the "RTR" section.

Tests (6 files) — 134 scoped tests, all passing:

  • DevSupportInfoTest.kt, UserAccountTest.java, UserAccountManagerTest.java, SalesforceSDKManagerTests.kt, ClientManagerMockTest.kt, DevInfoActivityTest.kt.

Testing

  • 134 scoped tests pass on an Android API 36 emulator: 124 unit and mock-based tests (SalesforceSDKManagerTests, DevSupportInfoTest, UserAccountTest, UserAccountManagerTest, ClientManagerMockTest) plus 10 instrumented UI tests (DevInfoActivityTest).
  • Patch coverage is 84.9%, which clears the 80% threshold enforced by Codecov (the code-coverage service that reports on each pull request) for the SalesforceSDK library. Here, "patch coverage" means the percentage of the lines changed by this pull request that are exercised by tests. The only uncovered lines are the 8-line block that dispatches work onto a Kotlin coroutine (a background task) and shows an Android Toast (a brief on-screen message); that block is not practically unit-testable, and all of its real logic lives in the tested forceTokenRefresh function.
  • ./gradlew :libs:SalesforceSDK:lintDebug (the Android lint static-analysis check) runs clean, with no new warnings.

Reviewer escalation flags (per the repository's CLAUDE.md "Stop and Flag" guidance)

  • New serialized field on UserAccount, plus the encrypt/decrypt handling in UserAccountManager — this touches the account persistence path.
  • Change to the token-refresh path — the ClientManager rotation block was reordered to stamp the timestamp before the primary account write.
  • New public API (additive and backward-compatible): UserAccount.getLastTokenRotationTime() / setLastTokenRotationTime(...), and UserAccountBuilder.lastTokenRotationTime(...).
  • Debug-only "Force Token Refresh" developer action — reviewer opt-in.
  • No sf__strings.xml changes — the developer-info labels are hardcoded, consistent with the existing sections on that screen.

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

* @param appFeatureCode The app feature code
* @param user The user account, or null to use the current user
*/
internal fun isUserFeatureRegistered(appFeatureCode: String, user: UserAccount? = null): Boolean {

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce Jul 31, 2026

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.

New read accessor for per-user feature flags. RTR-active state is stored as the per-user feature flag RT that ClientManager registers on a confirmed rotation. The backing perUserFeatures map is private and the existing public surface only lets you write (registerUsedAppFeature) or read the whole aggregated user-agent string — there was no narrow "is this one code set for this user?" read.

Rather than widen perUserFeatures visibility, this adds a focused internal accessor. It resolves the target user (explicit arg → current user → false if neither), builds the same "orgId/userId" key the writers use (registerUsedAppFeature/unregisterUsedAppFeature/getUserAgent), and does a null-safe, thread-safe (ConcurrentHashMap/ConcurrentSkipListSet), case-insensitive membership test. The optional user param defaults to the current user for ergonomics; the sole caller today passes the user explicitly, and the fallback branch is covered by its own test.

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

@VisibleForTesting(otherwise = PRIVATE)
internal fun forceTokenRefresh(
user: UserAccount,
restClient: RestClient = clientManager.peekRestClient(user)

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce Jul 31, 2026

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.

Default-argument seam instead of widening production surface. forceTokenRefresh backs the debug-only "Force Token Refresh" dev action; it drives the SDK's standard refresh path so a developer can watch RTR state update without waiting for natural token expiry. It's the testable core — the dev-action lambda only dispatches it on a coroutine and shows the result in a Toast.

To make it testable without a network call, restClient defaults to clientManager.peekRestClient(user) (the production path, unchanged at the forceTokenRefresh(user) call site) and tests pass a mock RestClient. This mirrors the existing invokeServerNotificationAction(..., restClient: RestClient = clientManager.peekRestClient(...)) idiom in this same file. An earlier revision made clientManager open for a test subclass to override — this seam replaces that, so there is no production-visibility change. It's @VisibleForTesting(otherwise = PRIVATE) internal, and it never throws: any failure is caught, logged, and returned as a message.

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

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.

Could peekRestClient throw?

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.

Good catch — yes. peekRestClient(user) throws AccountInfoNotFoundException (a RuntimeException) when there's no account, the user is mid-logout, or auth-token/URL/id data is missing. Because it was a default argument it was evaluated before the try, so it could escape uncaught — contradicting the "never throws" contract. Fixed by resolving the client inside the try ((restClient ?: clientManager.peekRestClient(user))) and correcting the KDoc, plus a regression test covering the default path. Commit: dd055af

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

* when the token last rotated.
*/
val currentUser = userAccountManager.cachedCurrentUser
additionalSections.add(

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce Jul 31, 2026

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.

Why the RTR section is appended via additionalSections rather than a new field. createFromLegacyDevInfos(devSupportInfos) returns a DevSupportInfo that is already fully populated — its five standard sections (basic info, auth config, boot config, current user, runtime config) are immutable vals set at construction. additionalSections is the only mutable member, and it's the designed extension point for sections the fixed schema didn't anticipate. So .apply { additionalSections.add(...) } is the one legal, minimal-touch way to inject RTR post-construction.

Deliberately not done: (a) editing createFromLegacyDevInfos, which is marked // TODO: Remove in 14.0; (b) adding a dedicated rtrSection field, which would change the data-class signature and require wiring into both the legacy path and the future implementation.

⚠️ 14.0 migration note for reviewers: the commented-out future devSupportInfo getter just above builds DevSupportInfo via its structured constructor and does not append this RTR section. Whoever swaps that in when devSupportInfos is removed must carry the RTR additionalSections.add(...) over, or RTR silently drops from the screen.

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

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.

@JohnsonEricAtSalesforce If you cannot add RTR to the new impl comment above, please add a comment noting that RTR needs to be added so it does not get lost.

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.

Done — added a note in the commented-out 14.0 devSupportInfo implementation (and expanded the TODO) that the RTR additionalSections.add(...) from the live getter must be carried over, so it can't be missed when the block is swapped in. Commit: dd055af

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

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
2 Warnings
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java#L112 - Do not place Android context classes in static fields (static reference to UserAccountManager which has field context pointing to Context); this is a memory leak
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java#L56 - Do not place Android context classes in static fields (static reference to Authenticator which has field context pointing to Context); this is a memory leak

Generated by 🚫 Danger

@JohnsonEricAtSalesforce
JohnsonEricAtSalesforce force-pushed the feature/W-23201591_android-surface-rtr-state-in-developer-info-screen branch from 99273ba to bcf8970 Compare July 31, 2026 19:48
@JohnsonEricAtSalesforce

JohnsonEricAtSalesforce commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Demo — RTR state in the developer info screen

Short screen recording of the feature running end to end in RestExplorer against an
RTR-enabled org, plus before/after stills.

Tip

The clip is brief and moves quickly — playing it at half speed (0.5×) makes the
before/after states easy to read.

What it shows:

  1. Before (AC2): the RTR section reads RTR Active: false / Last Rotation: Never
    (User Agent ftr_AI.SP.UA).
  2. Action: tapping the debug Force Token Refresh dev action triggers a token
    refresh; a toast confirms "Token refresh complete — check RTR section in dev info."
  3. After (AC3): the RTR section updates to RTR Active: true / Last Rotation:
    a valid ISO-8601 timestamp, and the User Agent flips to ftr_AI.RT.SP.UA (RT feature
    flag now registered for the user).
rtr_demo_clean.mp4

Note

The Last Rotation timestamp in the before/after stills differs from the one in the
video — the stills and the clip were captured on separate token-rotation runs, and
every rotation stamps the current time. The exact value isn't meaningful; what matters is
that it transitions from Never to a valid timestamp on a confirmed rotation.

rtr_after_true_timestamp_v2 rtr_before_false_never_v2

AC1 (per-user fields show N/A with no user) and AC4 (timestamp persists across app
restart) are covered by the automated tests in this PR.

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

@JohnsonEricAtSalesforce
JohnsonEricAtSalesforce marked this pull request as ready for review July 31, 2026 21:43
@JohnsonEricAtSalesforce

JohnsonEricAtSalesforce commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Note on CodeCov reporting

This pull request has no CodeCov status or coverage comment. I dug into the CI logs, and this is
a pre-existing, repo-wide CI regression — not a gap in this change's coverage, and (correcting an
earlier version of this note) not a fork-permissions issue.

Root cause: CodeCov isn't a standalone check; it's an upload step at the end of each
test-android job (.github/workflows/reusable-lib-workflow.yaml). The upload has nothing to send
because the convertedCodeCoverage Gradle task runs SKIPPED on every job: its Jacoco
executionData input ($rootDir/firebase/**/coverage.ec, per libs/SalesforceSDK/build.gradle.kts)
is empty. When the tests were moved to Firebase Test Lab client-side sharding
(--test-targets-for-shard, added in the Dec 2025 "Shard tests" change), coverage collection was
never adapted — under a sharded matrix FTL doesn't write coverage.ec back to the pulled /sdcard
path (the only artifact that comes back per shard is googletest/internal_use/test_args.dat). So
Jacoco has no execution data → task skipped → no convertedCodeCoverage.xml → the CodeCov CLI logs
No coverage reports found and Failed to run upload-coverage. fail_ci_if_error: false makes it
silent rather than a red check.

Evidence it's not this PR: the identical SKIPPED → No coverage reports found → Failed to run upload-coverage chain appears on recent internal-branch PRs (#2969 dpop, #2973 codex/*), across
all libraries (SalesforceSDK, MobileSync, SalesforceHybrid), and on the oldest CI run still
available (early July). It has been broken for every PR since sharding landed.

Coverage for this change was measured locally: 84.9% patch coverage on the changed lines,
clearing the 80% threshold. 134 scoped tests pass on an API 36 emulator. The only uncovered lines are
the ~8-line coroutine + Toast dispatch block, whose real logic lives in the separately-tested
forceTokenRefresh function. Happy to share the local Jacoco report.

The CI fix (collect the per-shard .ec files, or merge sharded execution data before the Jacoco
convert step) is out of scope for this PR — flagging so the absent CodeCov comment isn't misread and
so the team can track the coverage-pipeline break separately.

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

@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

🔎 Root-cause analysis: CodeCov coverage has been silently broken repo-wide since test sharding

Posting this separately from the coverage note above because it's a CI-pipeline issue that affects
every PR and every library
, not something specific to this change — hoping it saves the next person
the dig.

Symptom: No CodeCov status or coverage comment appears on PRs. There's no red check, because the
failure is swallowed.

Root cause — the sharded Firebase Test Lab run never produces the coverage.ec the Jacoco convert
step consumes:

  1. CodeCov upload is a step at the end of each test-android job
    (.github/workflows/reusable-lib-workflow.yaml), not a standalone check.
  2. It uploads libs/<LIB>/build/reports/jacoco/convertedCodeCoverage/convertedCodeCoverage.xml,
    produced by the Gradle convertedCodeCoverage Jacoco task
    (libs/SalesforceSDK/build.gradle.kts), whose executionData input is
    $rootDir/firebase/**/coverage.ec.
  3. Since tests moved to Firebase Test Lab client-side sharding
    (--test-targets-for-shard; commit 4c9d84433 "Shard tests", 2025-12-01), the sharded matrix no
    longer writes coverage.ec back to the pulled /sdcard path. The only per-shard artifact that
    returns under artifacts/sdcard/ is googletest/internal_use/test_args.datno .ec file at
    all
    . (The --environment-variables coverage=true,coverageFile=/sdcard/coverage.ec +
    --directories-to-pull=/sdcard flags predate sharding and no longer collect anything.)
  4. With empty executionData, Gradle marks the task SKIPPED, so no XML is written.
  5. The CodeCov CLI then logs
    not_found_files: [...convertedCodeCoverage.xml]Error: No coverage reports found
    Failed to run upload-coverage.
  6. fail_ci_if_error: false on the codecov/codecov-action step makes all of the above silent
    the job still passes and nothing surfaces on the PR.

Evidence it's environment-wide (not one branch/lib/fork): the identical
SKIPPED → No coverage reports found → Failed to run upload-coverage chain appears on

So it has been broken for every PR since sharding landed.

Suggested fix (out of scope for this PR): adapt coverage collection to the sharded layout —
pull each shard's coverage file (.../shard_*/artifacts/.../coverage.ec, or whatever FTL emits per
shard now) into ./firebase/ and point the Jacoco task's executionData at all of them, so the
merge covers every shard. Worth also flipping fail_ci_if_error (or adding a guard) so a future
coverage break is loud instead of silent.

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

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

LGTM!

But please add a comment to the new dev info impl so RTR status does not get lost. I also would not mind seeing this added to the AuthFlowTester app's UI, but we can do that another day 😉

Comment on lines +811 to +813
public void setLastTokenRotationTime(String lastTokenRotationTime) {
this.lastTokenRotationTime = lastTokenRotationTime;
}

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.

It will be nice when everything is Kotlin and we can make things like this internal. Not necessary for this PR, but curious if you have an opinion on RestrictTo? It does not actually prevent someone from using the API, but adds a stern lint warning/error indicating that we do not want them to.

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce Jul 31, 2026

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.

Agree that's the right direction for library-internal API that has to stay public from Java. I'd rather not add it to just these two accessors here, since the sibling UserAccount accessors (e.g. tokenType) aren't annotated and a partial application would be inconsistent — better as a uniform sweep across the account accessors. Filed W-23667824 to track that. Out of scope for this PR.

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

* menu is only shown when isDevSupportEnabled() is true (debug
* builds by default).
*/
actions["Force Token Refresh"] = object : DevActionHandler {

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.

Great idea.

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.

@sfdctaka Should we add this to iOS as well?

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.

@brandonpage Ditto. Let me file a ticket for it and we can take care of it next week.

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.

Sounds good — thanks for filing the iOS ticket, @sfdctaka. Happy to help with the parity work when it's up. Likewise the AuthFlowTester RTR UI is a nice follow-up for another day.

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

* when the token last rotated.
*/
val currentUser = userAccountManager.cachedCurrentUser
additionalSections.add(

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.

@JohnsonEricAtSalesforce If you cannot add RTR to the new impl comment above, please add a comment noting that RTR needs to be added so it does not get lost.

…s in forceTokenRefresh; note RTR carry-over for 14.0

- forceTokenRefresh: resolve peekRestClient inside the try so
  AccountInfoNotFoundException (no account / logging out / missing token
  data) is caught rather than thrown; fix the KDoc that claimed it never
  throws. Add a regression test for the default (production) path.
- devSupportInfo: note in the commented-out 14.0 implementation that the
  RTR additionalSections.add(...) must be carried over, or RTR silently
  drops from the dev info screen.

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

LGTM!

@JohnsonEricAtSalesforce
JohnsonEricAtSalesforce merged commit 5b64e9c into forcedotcom:dev Aug 1, 2026
5 of 6 checks passed
@JohnsonEricAtSalesforce
JohnsonEricAtSalesforce deleted the feature/W-23201591_android-surface-rtr-state-in-developer-info-screen branch August 1, 2026 03:44
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.

3 participants