Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ import org.json.JSONObject
import java.lang.String.format
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import java.security.PrivateKey
import java.security.cert.X509Certificate

Expand Down Expand Up @@ -768,9 +767,17 @@ open class LoginActivity : FragmentActivity() {
* Launches the current login server in a Custom Tab. This allows Admins
* to authenticate with a Passkey or by other methods that require
* Advanced/Browser Authentication.
*
* This function is a no-op for Welcome Discovery.
*/
@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.

w(TAG, "launchLoginForAdminsAction is a no-op for Welcome Discovery; " +
"LFA requires an app-unique OAuth callback")
return
}
val loginUrl = viewModel.browserCustomTabUrl.value ?: return
loadLoginPageInCustomTab(loginUrl, adminLoginCustomTabLauncher)
}
Expand Down Expand Up @@ -1034,31 +1041,6 @@ open class LoginActivity : FragmentActivity() {
return isWelcomeDiscoveryUrl || hasLoginHint
}

/**
* Creates a Salesforce Welcome Discovery mobile URL using the provided
* Salesforce Welcome Discovery host and path URL.
* @param salesforceWelcomeDiscoveryHostAndPathUrl The Salesforce Welcome
* Discovery host and path URL
* @return A Salesforce Welcome Discovery mobile URL with all required
* parameters
*/
private fun generateSalesforceWelcomeDiscoveryMobileUrl(
salesforceWelcomeDiscoveryHostAndPathUrl: Uri
) = salesforceWelcomeDiscoveryHostAndPathUrl.buildUpon()
.appendQueryParameter(
SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID,
viewModel.oAuthConfig.consumerKey,
)
.appendQueryParameter(
SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION,
URLEncoder.encode(SalesforceSDKManager.getInstance().appVersion, "utf8")
)
.appendQueryParameter(
SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CALLBACK_URL,
SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL
)
.build()

/**
* Switches between default or Salesforce Welcome Discovery log in as needed
* using the provided pending login server URL.
Expand All @@ -1084,7 +1066,7 @@ open class LoginActivity : FragmentActivity() {
this,
SalesforceSDKManager.getInstance().webViewLoginActivityClass
).apply {
data = generateSalesforceWelcomeDiscoveryMobileUrl(pendingLoginServerUri)
data = viewModel.generateSalesforceWelcomeDiscoveryMobileUrl(pendingLoginServerUri)
flags = FLAG_ACTIVITY_SINGLE_TOP
})
true
Expand Down Expand Up @@ -1496,7 +1478,7 @@ open class LoginActivity : FragmentActivity() {
// region Salesforce Welcome Login Private Implementation

/** The default Salesforce Welcome Discovery mobile callback URL. This value is fixed until future Salesforce Welcome updates */
private const val SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL = "sfdc://discocallback"
internal const val SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL = "sfdc://discocallback"

/** The Salesforce Welcome Discovery mobile callback URL's "login hint" parameter key */
private const val SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL_QUERY_PARAMETER_KEY_LOGIN_HINT = "login_hint"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.URI
import java.net.URLEncoder
import kotlin.coroutines.CoroutineContext

/**
Expand Down Expand Up @@ -299,8 +300,69 @@ open class LoginViewModel(
loginUrl.addSource(selectedServer, LoginUrlSource())
}

/**
* Creates a Salesforce Welcome Discovery mobile URL using the provided
* Salesforce Welcome Discovery host and path URL.
* @param salesforceWelcomeDiscoveryHostAndPathUrl The Salesforce Welcome
* Discovery host and path URL
* @return A Salesforce Welcome Discovery mobile URL with all required
* parameters
*/
internal fun generateSalesforceWelcomeDiscoveryMobileUrl(
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

LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL
)
.build()

/** Reloads the WebView with a newly generated authorization URL. */
open fun reloadWebView() {
// If the user-selected login server (per LoginServerManager) is Salesforce Welcome
// Discovery, load the Phase 1 discovery mobile URL directly instead of regenerating a
// Phase-2-style auth URL. We intentionally do NOT key off `selectedServer.value` here:
// during Phase 2, the VM's selectedServer is the discovered My Domain, while the
// LoginServerManager retains Welcome Discovery as the user's actual server selection.
// This branch runs BEFORE the isUsingFrontDoorBridge short-circuit by design — Welcome
// Discovery and frontdoor bridge are mutually exclusive in practice, but defense-in-depth
// ordering ensures a stray frontdoor URL cannot suppress the discovery reload.
SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer?.url
?.toUri()?.let { selectedLoginServerUri ->
if (isSalesforceWelcomeDiscoveryUrlPath(selectedLoginServerUri)) {
// Reset the top app bar background to White synchronously so the bar flips
// immediately when the user reloads/re-selects Welcome Discovery from Phase 2.
// dynamicBackgroundColor is otherwise only updated by AuthWebViewClient.
// onPageFinished, which runs after the new page loads — the gap leaves the
// Phase-2 color visible on the bar even though the WebView URL has changed.
dynamicBackgroundColor.value = White
// Set loginUrl first so LoginUrlSource's same-host short-circuit (host of new
// selectedServer == host of current loginUrl) suppresses an auth-URL regen
// when we reset selectedServer below.
loginUrl.value =
generateSalesforceWelcomeDiscoveryMobileUrl(selectedLoginServerUri).toString()
// Reset VM-level selectedServer back to the Welcome URL so the top app bar
// title (via defaultTitleText), menu gating in Compose (LoginView reads
// viewModel.selectedServer to hide "Login for Admin" on Phase 1), and any
// other observers of selectedServer realign with Phase 1. In Phase 2 this
// value had been overwritten with the discovered My Domain by
// 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.

}
return
}
}

if (!isUsingFrontDoorBridge) {
selectedServer.value?.let { server ->
// The Web Server Flow code challenge makes the authorization url unique each time,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.core.net.toUri
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
Expand Down Expand Up @@ -147,10 +148,16 @@ fun LoginView() {
val viewModel: LoginViewModel =
viewModel(factory = SalesforceSDKManager.getInstance().loginViewModelFactory)
val frontDoorBridgeUrl = viewModel.frontDoorBridgeUrl.observeAsState()
// Observe selectedServer and loginUrl AS COMPOSE STATE so that recomposition triggers when
// either changes — and so titleText is read inside Compose's snapshot system, not via the
// non-reactive `defaultTitleText` getter chain (which reads LiveData.getValue() directly and
// is invisible to Compose).
val selectedServer = viewModel.selectedServer.observeAsState()
val loginUrl = viewModel.loginUrl.observeAsState()
val titleText = if (frontDoorBridgeUrl.value != null) {
viewModel.frontdoorBridgeServer ?: ""
} else {
viewModel.titleText ?: viewModel.defaultTitleText
viewModel.titleText ?: if (loginUrl.value == LoginActivity.ABOUT_BLANK) "" else selectedServer.value ?: ""
}
val showDevSupport = with(SalesforceSDKManager.getInstance()) {
return@with if (isDebugBuild && isDevSupportEnabled()) {
Expand All @@ -160,6 +167,8 @@ fun LoginView() {
}
}

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.

val topAppBar = viewModel.topAppBar ?: {
DefaultTopAppBar(
backgroundColor = viewModel.topBarColor ?: viewModel.dynamicBackgroundColor.value,
Expand All @@ -172,7 +181,9 @@ fun LoginView() {
shouldShowBackButton = viewModel.shouldShowBackButton,
showDevSupport = showDevSupport,
finish = { activity.handleBackBehavior() },
onLoginForAdmins = { activity.launchLoginForAdminsAction() },
onLoginForAdmins = if (isWelcomeDiscoveryServer) null else {
{ activity.launchLoginForAdminsAction() }
},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,11 @@ import com.salesforce.androidsdk.R.string.sf__server_url_save
import com.salesforce.androidsdk.accounts.UserAccount
import com.salesforce.androidsdk.accounts.UserAccountManager
import com.salesforce.androidsdk.app.SalesforceSDKManager
import androidx.core.net.toUri
import com.salesforce.androidsdk.config.LoginServerManager
import com.salesforce.androidsdk.config.LoginServerManager.LoginServer
import com.salesforce.androidsdk.ui.CORNER_RADIUS
import com.salesforce.androidsdk.ui.LoginActivity
import com.salesforce.androidsdk.ui.LoginViewModel
import com.salesforce.androidsdk.ui.PADDING_SIZE
import com.salesforce.androidsdk.ui.theme.hintTextColor
Expand Down Expand Up @@ -177,6 +179,12 @@ internal fun TestablePickerBottomSheet(
if (newSelectedServer != SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer) {
viewModel.loading.value = true
SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer = newSelectedServer
} else if (LoginActivity.isSalesforceWelcomeDiscoveryUrlPath(newSelectedServer.url.toUri())) {
// Re-selecting Welcome Discovery while it is already the selected server should
// return the WebView to Phase 1 (welcome.salesforce.com/discovery), even though
// LoginServerManager's selection didn't change. This mirrors the reload-button
// behavior the user expects when stuck on a discovered My Domain in Phase 2.
viewModel.reloadWebView()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import com.salesforce.androidsdk.config.LoginServerManager.LoginServer
import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL
import com.salesforce.androidsdk.config.OAuthConfig
import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getSHA256Hash
import com.salesforce.androidsdk.ui.LoginActivity
import com.salesforce.androidsdk.ui.LoginActivity.Companion.ABOUT_BLANK
import com.salesforce.androidsdk.ui.LoginViewModel
import io.mockk.coEvery
Expand Down Expand Up @@ -69,6 +70,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.net.URI
import java.net.URLEncoder

private const val FAKE_SERVER_URL = "shouldMatchNothing.salesforce.com"
private const val FAKE_JWT = "1234"
Expand Down Expand Up @@ -778,6 +780,12 @@ class LoginViewModelTest {
assertTrue("isUsingFrontDoorBridge should be true", viewModel.isUsingFrontDoorBridge)
assertEquals("frontDoorBridgeUrl should be front door URL", frontDoorUrl, viewModel.frontDoorBridgeUrl.value)

// Precondition: invariant ("frontdoor wins over reload") only holds when the
// LoginServerManager-selected server is NOT a Welcome Discovery URL. The Welcome Discovery
// branch in reloadWebView intentionally runs before the frontdoor short-circuit.
assertFalse(LoginActivity.isSalesforceWelcomeDiscoveryUrlPath(
(SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer?.url ?: "").toUri()))

// Call reloadWebView
viewModel.reloadWebView()
testDispatcher.scheduler.advanceUntilIdle()
Expand All @@ -786,6 +794,107 @@ class LoginViewModelTest {
assertEquals("frontDoorBridgeUrl should still be front door URL", frontDoorUrl, viewModel.frontDoorBridgeUrl.value)
}

@Test
fun test_givenLoginServerManagerSelectedServerIsWelcomeDiscovery_whenReloadWebView_thenLoginUrlIsDiscoveryMobileUrl() {
val loginServerManager = SalesforceSDKManager.getInstance().loginServerManager
loginServerManager.addCustomLoginServer("Welcome", WELCOME_LOGIN_URL)
// Simulate Phase 2: VM's selectedServer is the discovered My Domain even though
// LoginServerManager still has Welcome Discovery selected.
viewModel.selectedServer.value = "https://acme.my.salesforce.com"
testDispatcher.scheduler.advanceUntilIdle()

viewModel.reloadWebView()
testDispatcher.scheduler.advanceUntilIdle()

val resultUri = viewModel.loginUrl.value?.toUri()
assertNotNull(resultUri)
assertTrue(LoginActivity.isSalesforceWelcomeDiscoveryMobileUrl(resultUri!!))
assertEquals(viewModel.oAuthConfig.consumerKey,
resultUri.getQueryParameter(LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID))
assertFalse(viewModel.loginUrl.value!!.contains("/services/oauth2/authorize"))
}

@Test
fun test_givenLoginServerManagerSelectedServerIsNotWelcomeDiscovery_whenReloadWebView_thenLoginUrlIsAuthUrl() {
// LoginServerManager defaults to Production (login.salesforce.com).
viewModel.selectedServer.value = "https://login.salesforce.com"
testDispatcher.scheduler.advanceUntilIdle()

viewModel.reloadWebView()
testDispatcher.scheduler.advanceUntilIdle()

val url = viewModel.loginUrl.value
assertNotNull(url)
assertTrue(url!!.contains("/services/oauth2/authorize"))
assertFalse(LoginActivity.isSalesforceWelcomeDiscoveryUrlPath(url.toUri()))
}

@Test
fun test_givenLoginServerManagerSelectedServerIsWelcomeDiscovery_whenIsUsingFrontDoorBridge_andReloadWebView_thenLoginUrlIsDiscoveryMobileUrl() {
val loginServerManager = SalesforceSDKManager.getInstance().loginServerManager
loginServerManager.addCustomLoginServer("Welcome", WELCOME_LOGIN_URL)
val frontdoorUrl = "https://example.my.salesforce.com/secur/frontdoor.jsp?sid=fake"
viewModel.loginWithFrontDoorBridgeUrl(frontdoorUrl, pkceCodeVerifier = null)
assertTrue(viewModel.isUsingFrontDoorBridge)
testDispatcher.scheduler.advanceUntilIdle()

viewModel.reloadWebView()
testDispatcher.scheduler.advanceUntilIdle()

val resultUri = viewModel.loginUrl.value?.toUri()
assertNotNull(resultUri)
assertTrue(LoginActivity.isSalesforceWelcomeDiscoveryMobileUrl(resultUri!!))
assertNotEquals(frontdoorUrl, viewModel.loginUrl.value)
}

/**
* Regression guard for the Phase-2 reload bug: when the user is in Phase 2 of the Welcome
* Discovery flow, viewModel.selectedServer is the discovered My Domain (NOT the Welcome URL),
* but LoginServerManager retains Welcome as the user's actual server selection. Reload must
* return the WebView to Phase 1.
*/
@Test
fun test_givenInPhase2OfWelcomeDiscovery_whenReloadWebView_thenLoginUrlReturnsToPhase1() {
val loginServerManager = SalesforceSDKManager.getInstance().loginServerManager
loginServerManager.addCustomLoginServer("Welcome", WELCOME_LOGIN_URL)
// Phase 2 state: VM mirrors the My Domain from applySalesforceWelcomeLoginHintAndHost.
val myDomainUrl = "https://acme.my.salesforce.com"
viewModel.selectedServer.value = myDomainUrl
testDispatcher.scheduler.advanceUntilIdle()

viewModel.reloadWebView()
testDispatcher.scheduler.advanceUntilIdle()

val resultUri = viewModel.loginUrl.value?.toUri()
assertNotNull(resultUri)
assertEquals("welcome.salesforce.com", resultUri!!.host)
assertEquals(LoginActivity.SALESFORCE_WELCOME_DISCOVERY_URL_PATH, resultUri.path)
assertFalse(viewModel.loginUrl.value!!.contains(myDomainUrl))
assertFalse(viewModel.loginUrl.value!!.contains("/services/oauth2/authorize"))

// Reload must also realign VM-level selectedServer back to the Welcome URL so the top
// app bar title (defaultTitleText reads selectedServer.value) and the Compose menu
// gating ("Login for Admin" hidden when selectedServer is the discovery URL) follow.
assertEquals(WELCOME_LOGIN_URL, viewModel.selectedServer.value)
}

@Test
fun test_givenConsumerKeyAndAppVersion_whenGenerateSalesforceWelcomeDiscoveryMobileUrl_thenReturnsExpectedUrl() {
val input = "https://welcome.salesforce.com/discovery".toUri()
val result = viewModel.generateSalesforceWelcomeDiscoveryMobileUrl(input)

assertEquals("welcome.salesforce.com", result.host)
assertEquals("/discovery", result.path)
assertEquals(viewModel.oAuthConfig.consumerKey,
result.getQueryParameter(LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID))
assertEquals(URLEncoder.encode(SalesforceSDKManager.getInstance().appVersion, "utf8"),
result.getQueryParameter(LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION))
assertEquals("sfdc://discocallback",
result.getQueryParameter(LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CALLBACK_URL))
// Companion validator round-trip:
assertTrue(LoginActivity.isSalesforceWelcomeDiscoveryMobileUrl(result))
}

@Test
fun reloadWebView_WithUserAgentFlow_SetsAboutBlankFirst() {
try {
Expand Down
Loading
Loading