Skip to content

Fix Login for Admin and Welcome Discovery incompatibility#2933

Open
brandonpage wants to merge 2 commits into
forcedotcom:devfrom
brandonpage:fix-lfa-welcome-discovery
Open

Fix Login for Admin and Welcome Discovery incompatibility#2933
brandonpage wants to merge 2 commits into
forcedotcom:devfrom
brandonpage:fix-lfa-welcome-discovery

Conversation

@brandonpage

@brandonpage brandonpage commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the interaction between Login for Admin (LFA) and the Welcome Discovery 2-phase login flow. Previously the LFA menu item was offered even on the Welcome Discovery Phase 1 page (welcome.salesforce.com/discovery), where the registered OAuth callback (sfdc://discocallback) is not app-unique — any app on the device that registers the sfdc:// scheme could intercept the response. This change hides LFA in Phase 1, keeps it available in Phase 2 (the discovered My Domain), guards LoginActivity.launchLoginForAdminsAction so a programmatic invocation is also a no-op for Welcome Discovery, and fixes a related reload bug.

Behavior changes

1. LFA hidden on Welcome Discovery Phase 1

  • LoginView (Compose) now passes onLoginForAdmins = null to DefaultTopAppBar when the VM-level selectedServer LiveData matches LoginActivity.isSalesforceWelcomeDiscoveryUrlPath.
  • DefaultTopAppBar already conditionally renders the menu item when onLoginForAdmins != null — the test wrapper signature was widened to nullable to mirror that contract.

2. LFA action is also a no-op for Welcome Discovery (defense-in-depth)

  • LoginActivity.launchLoginForAdminsAction() now short-circuits with a single w(TAG, ...) log when LoginServerManager.selectedLoginServer.url matches the discovery path. Even if a host app calls the (deprecated) public method directly, it cannot launch a Custom Tab against welcome.salesforce.com.
  • The @Deprecated annotation, KDoc, and visibility are unchanged. KDoc gains one line noting the no-op.

3. Reload / Clear Cookies / Clear Cache / re-select Welcome → returns the WebView to Phase 1

  • LoginViewModel.reloadWebView() checks LoginServerManager.selectedLoginServer (the source of truth — distinct from viewModel.selectedServer, which during Phase 2 holds the discovered My Domain). When it is Welcome Discovery, the function:
    • resets dynamicBackgroundColor to white synchronously (so the top app bar background flips immediately, not after onPageFinished),
    • rebuilds the Phase-1 mobile URL via the relocated generateSalesforceWelcomeDiscoveryMobileUrl,
    • realigns viewModel.selectedServer.value back to the Welcome URL so the title and the menu gating recompose,
    • returns early so the Phase-2 auth-URL regen path is skipped.
  • PickerBottomSheet adds an equality-branch handler: re-selecting Welcome Discovery while it is already the selected server now calls viewModel.reloadWebView() (otherwise the picker would dismiss with no observable change).

4. Top app bar title is now reactive

  • LoginView previously read viewModel.defaultTitleText (a plain Kotlin getter that calls LiveData.getValue()), which is invisible to Compose's snapshot system, so the title never recomposed when selectedServer changed. The composable now observeAsState()s selectedServer and loginUrl and reads them directly inside the title computation, so the bar updates in lock-step with reload/re-select.

5. Structural-only relocation

  • generateSalesforceWelcomeDiscoveryMobileUrl moved from a private fun on LoginActivity to an internal fun on LoginViewModel so both the activity (intent dispatch) and the VM (reload) can share one implementation. Bytes-identical Phase-1 URL — verified by S19 in the manual test plan.
  • SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL widened from private to internal to support the relocation. No public API surface change.

Test plan — manual verification checklist

A full manual test plan with 20 numbered scenarios was authored alongside this work (kept locally; not committed). Reviewers should walk through the following scenarios on at least Android API 31 and API 36, running the AuthFlowTester sample app unless noted otherwise.

Menu gating

  • S1 — Welcome Discovery selected, Phase 1 loaded → ⋮ menu does NOT contain "Login for Admin".
  • S2 — Production / Sandbox / custom My Domain selected → ⋮ menu DOES contain "Login for Admin".
  • S3 — Phase-2 (post-discovery) My Domain → ⋮ menu DOES contain "Login for Admin".

Reload bug fix (the WebView returns to Phase 1 on each of these)

  • S4 — Phase 2 → tap Reload → WebView navigates to https://welcome.salesforce.com/discovery?client_id=...&client_version=...&callback_url=sfdc%3A%2F%2Fdiscocallback.
  • S5 — Phase 2 → server picker → re-select "Welcome" → same end state as S4.
  • S6 — Phase 2 → Clear Cookies → Phase 1 reloads, cookies cleared.
  • S7 — Phase 2 → Clear cache → Phase 1 reloads, cache cleared.

Phase-1 → Phase-2 transition (regression guard)

  • S8 — Welcome → enter email → land in Phase 2 → ⋮ menu now CONTAINS "Login for Admin" (no menu reopen needed).

LFA still works in Phase 2

  • S9 — Phase 2 + passkey-enabled user → tap Login for Admin → Custom Tab opens against the My Domain (not welcome.salesforce.com), passkey auth succeeds, app returns to foreground logged in.
  • S10 — Verify across all variations: a Custom Tab is NEVER launched against welcome.salesforce.com. Logcat audit shows zero hits on welcome.salesforce.com in the loadLoginPageInCustomTab path.

Multi-user

  • S11 — User A logged in via Welcome Discovery → add User B via Production → ⋮ menu reflects User B's server (LFA visible).
  • S12 — User A logged in via Welcome Discovery → add User B also via Welcome Discovery → Phase 1 is fresh, email field empty, no auto-advance to a stale Phase 2.

Configuration changes

  • S13 — Phase 1 → rotate device → menu still hides LFA in both orientations; WebView preserves Phase 1.
  • S14 — Phase 2 → rotate → menu still shows LFA → tap Reload → returns to Phase 1; rotate back → menu now hides LFA.
  • S15 — Toggle dark/light theme in either phase → URL unchanged, menu list unchanged, top-bar colors update.

FrontDoor bridge interaction

  • S16 — Frontdoor bridge URL delivered while Welcome is the selected server: WebView loads the frontdoor URL (NOT welcome.salesforce.com); subsequent Reload behaves per the existing isUsingFrontDoorBridge short-circuit.

Hybrid parity

  • S17MobileSyncExplorerHybrid → re-run S1, S2, S4 → identical observable results.

Backward compatibility (apps without Welcome Discovery)

  • S18RestExplorer (no Welcome in servers.xml) → menu list unchanged from prior release; Reload, Clear Cookies, Clear cache, Login for Admin all behave identically to the pre-change build.

URL-builder relocation regression guard

  • S19 — Capture the Phase-1 URL via WebView client log → bytes-identical to a dev-baseline capture: same scheme/host/path, same query parameters in the same order (client_id, client_version, callback_url), client_version UTF-8 encoded with %2F for slashes, callback_url=sfdc%3A%2F%2Fdiscocallback.

Cross-platform parity

  • S20 — Re-run S1, S4, S9 on the iOS twin once it lands; mark as deferred with a linked iOS PR if it has not yet merged.

Sign-off (CLAUDE.md code-review checklist)

  • Backward compatibility — public API unchanged (S18, S19).
  • Both platforms considered — iOS twin tracked separately; S20 covered above.
  • Tests included — 5 new VM tests, 1 new Compose test, 2 contract guards on existing LFA tests.
  • Multi-user works — S11, S12.
  • Localization — no new user-facing strings.
  • Security — S9, S10 (no PII / token leakage; no Custom Tab against welcome.salesforce.com).
  • Performance — Phase-2 → Phase-1 reload completes well under 2s on a mid-tier device; no main-thread blocking.
  • Deprecation warnings — no NEW warnings introduced (verified locally on :libs:SalesforceSDK:compileDebugKotlin).
  • Documentation — LoginViewModel.reloadWebView KDoc reflects the Welcome branch; LoginActivity.launchLoginForAdminsAction KDoc notes the no-op for Welcome Discovery; deprecation message at LoginActivity.kt:771 is still accurate.
  • Sample apps — AuthFlowTester (S1–S15), RestExplorer (S2, S18), MobileSyncExplorerHybrid (S17).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
salesforceWelcomeDiscoveryHostAndPathUrl: Uri
) = salesforceWelcomeDiscoveryHostAndPathUrl.buildUpon()
.appendQueryParameter(
LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID,

@github-actions github-actions Bot Jun 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ This method should only be accessed from tests or within private scope

oAuthConfig.consumerKey,
)
.appendQueryParameter(
LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION,

@github-actions github-actions Bot Jun 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ This method should only be accessed from tests or within private scope

URLEncoder.encode(SalesforceSDKManager.getInstance().appVersion, "utf8")
)
.appendQueryParameter(
LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CALLBACK_URL,

@github-actions github-actions Bot Jun 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ This method should only be accessed from tests or within private scope

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
1 Warning
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt#L674 - Switch statement on an int with known associated constant missing case BiometricManager.BIOMETRIC_ERROR_IDENTITY_CHECK_NOT_ACTIVE

Generated by 🚫 Danger

The guards read viewModel.selectedServer.value on a relaxed mockk,
which returns a String proxy that fails the .toUri() CHECKCAST with
"Object_1_Proxy cannot be cast to String" in CI. The guards were
documentation-only; the underlying tests still exercise the
non-Welcome path because the LoginServerManager default in the
instrumentation environment is not a Welcome Discovery URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
// applySalesforceWelcomeLoginHintAndHost → applyPendingServer.
val welcomeUrl = selectedLoginServerUri.toString()
if (selectedServer.value != welcomeUrl) {
selectedServer.value = welcomeUrl

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.

The ordering of loginUrl.value = (line 350) before selectedServer.value = (line 360) is load-bearing: LoginUrlSource's same-host short-circuit relies on seeing the new loginUrl host before selectedServer changes, so it suppresses a spurious auth-URL regen. This invariant is currently documented only in the large block comment above — consider adding a short inline comment at each of the two assignment lines (e.g. // Must precede selectedServer reset — see LoginUrlSource same-host short-circuit) so a future refactor doesn't accidentally reorder them or extract one without the other.

@Deprecated(message = "This function will be replaced by a permanent solution in 14.0.")
fun launchLoginForAdminsAction() {
val selectedServer = SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer?.url?.toUri()
if (selectedServer?.let { isSalesforceWelcomeDiscoveryUrlPath(it) } == true) {

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.

The Compose-layer visibility gate (isWelcomeDiscoveryServer in LoginView.kt) is keyed off viewModel.selectedServer, while this programmatic guard is keyed off LoginServerManager.selectedLoginServer. These are intentionally different state sources (documented well in reloadWebView), but they create a subtle window: in phase 2 the Compose layer correctly shows the LFA item (VM's selectedServer is the My Domain), yet if this guard fires anyway (e.g. in a race or from a direct external call), the action silently no-ops while the user sees the item as active. A brief inline comment here noting the dual-source-of-truth intent would help future readers understand why the two guards differ rather than assuming one is stale.

}

val isWelcomeDiscoveryServer = selectedServer.value
?.let { LoginActivity.isSalesforceWelcomeDiscoveryUrlPath(it.toUri()) } == true

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.

isWelcomeDiscoveryServer is computed from viewModel.selectedServer, which in phase 2 holds the resolved My Domain — so this correctly evaluates to false and shows the LFA item. However, the LFA guard in LoginActivity.launchLoginForAdminsAction() uses LoginServerManager.selectedLoginServer instead. These are consistent for all described scenarios, but a short comment here (e.g. // During phase 2, selectedServer is the My Domain, so this is false and LFA is shown — see LoginActivity guard for the programmatic defense-in-depth check) would make the dual-source-of-truth explicit to a future reader rather than leaving it implicit.

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