diff --git a/libs/SalesforceSDK/res/values/sf__strings.xml b/libs/SalesforceSDK/res/values/sf__strings.xml
index 3cf6cb9b23..20d2dac991 100644
--- a/libs/SalesforceSDK/res/values/sf__strings.xml
+++ b/libs/SalesforceSDK/res/values/sf__strings.xml
@@ -27,6 +27,7 @@
Clear cache
Reload
Log In with IDP App
+ Login for Admins
Log In with Biometric
Setup Biometric Unlock
Back
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
index d2a707604a..4e0c742ebc 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
@@ -108,7 +108,6 @@ import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Observer
import com.salesforce.androidsdk.R.color.sf__background
import com.salesforce.androidsdk.R.color.sf__background_dark
-import com.salesforce.androidsdk.R.color.sf__primary_color
import com.salesforce.androidsdk.R.drawable.sf__action_back
import com.salesforce.androidsdk.R.string.cannot_use_another_apps_login_qr_code
import com.salesforce.androidsdk.R.string.sf__biometric_opt_in_title
@@ -175,9 +174,27 @@ import java.security.cert.X509Certificate
*/
open class LoginActivity : FragmentActivity() {
- /** The activity result launcher used when browser-based authentication loads the OAuth authorization URL in the external browser custom tab activity */
+ /**
+ * The activity result launcher used when browser-based authentication loads
+ * the OAuth authorization URL in the external browser custom tab activity
+ * */
+ @VisibleForTesting
+ internal val customTabLauncher = registerForActivityResult(
+ /* contract = */ StartActivityForResult(),
+ /* callback = */ CustomTabActivityResult(),
+ )
+
+ /**
+ * The activity result launcher used by the "Login for Admin" flow.
+ *
+ * Unlike [customTabLauncher], cancelling here leaves the existing WebView
+ * login page intact and does not reveal the server picker.
+ */
@VisibleForTesting
- internal val customTabLauncher = registerForActivityResult(StartActivityForResult(), CustomTabActivityResult())
+ internal val adminLoginCustomTabLauncher = registerForActivityResult(
+ /* contract = */ StartActivityForResult(),
+ /* callback = */ AdminCustomTabActivityResult(),
+ )
// View Model
@VisibleForTesting(otherwise = PROTECTED)
@@ -276,6 +293,13 @@ open class LoginActivity : FragmentActivity() {
certAuthOrLogin()
}
+ // Observe front door bridge URL to load in the WebView when present.
+ viewModel.frontDoorBridgeUrl.observe(this) { url ->
+ if (url != null) {
+ viewModel.loading.value = true
+ }
+ }
+
// Take control of the back logic if the device is locked.
// TODO: Remove SDK_INT check when min API > 33
if (SDK_INT >= TIRAMISU && biometricAuthenticationManager?.locked == true) {
@@ -283,9 +307,13 @@ open class LoginActivity : FragmentActivity() {
}
// Add view model observers.
- viewModel.browserCustomTabUrl.observe(this, BrowserCustomTabUrlObserver())
viewModel.pendingServer.observe(this, PendingServerObserver())
+ // Set callback for when browser custom tab URL is ready to launch.
+ viewModel.onBrowserCustomTabReady = { url ->
+ loadLoginPageInCustomTab(url, customTabLauncher)
+ }
+
// Support magic links
if (viewModel.jwt != null) {
swapJWTForAccessToken()
@@ -751,7 +779,19 @@ open class LoginActivity : FragmentActivity() {
)
}
- private fun loadLoginPageInCustomTab(loginUrl: String, customTabLauncher: ActivityResultLauncher) {
+ /**
+ * 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.
+ */
+ @Deprecated(message = "This function will be replaced by a permanent solution in 14.0.")
+ fun launchLoginForAdminsAction() {
+ val loginUrl = viewModel.browserCustomTabUrl.value ?: return
+ loadLoginPageInCustomTab(loginUrl, adminLoginCustomTabLauncher)
+ }
+
+ @VisibleForTesting
+ internal fun loadLoginPageInCustomTab(loginUrl: String, customTabLauncher: ActivityResultLauncher) {
val customTabsIntent = CustomTabsIntent.Builder().apply {
/*
* Set a custom animation to slide in and out for Chrome custom tab
@@ -763,8 +803,11 @@ open class LoginActivity : FragmentActivity() {
// Replace the default 'Close Tab' button with a custom back arrow instead of 'x'
setCloseButtonIcon(decodeResource(resources, sf__action_back))
setShareState(CustomTabsIntent.SHARE_STATE_OFF)
+
+ // Use app provided color if set. Fallback to dynamic color.
+ val background: Color = viewModel.topBarColor ?: viewModel.dynamicBackgroundColor.value
setDefaultColorSchemeParams(
- CustomTabColorSchemeParams.Builder().setToolbarColor(getColor(sf__primary_color)).build()
+ CustomTabColorSchemeParams.Builder().setToolbarColor(background.toArgb()).build()
)
}.build()
@@ -874,7 +917,7 @@ open class LoginActivity : FragmentActivity() {
// Choose front door bridge use by verifying intent data and such that only front door bridge URLs with matching consumer keys are used.
val uiBridgeApiParametersFrontDoorBridgeUrlMismatchedConsumerKey = uiBridgeApiParametersConsumerKey != null && uiBridgeApiParametersConsumerKey != viewModel.bootConfig.remoteAccessConsumerKey
- viewModel.isUsingFrontDoorBridge = (isFrontdoorBridgeUrlIntent(intent) || isQrCodeLoginUrlIntent(intent)) && !uiBridgeApiParametersFrontDoorBridgeUrlMismatchedConsumerKey
+ val shouldUseFrontDoorBridge = (isFrontdoorBridgeUrlIntent(intent) || isQrCodeLoginUrlIntent(intent)) && !uiBridgeApiParametersFrontDoorBridgeUrlMismatchedConsumerKey
// Alert the user if the front door bridge URL is not for this app and was discarded.
if (uiBridgeApiParametersFrontDoorBridgeUrlMismatchedConsumerKey) {
@@ -888,7 +931,7 @@ open class LoginActivity : FragmentActivity() {
}
// Use the front door URL as the login page if applicable.
- if (viewModel.isUsingFrontDoorBridge && uiBridgeApiParameters?.frontdoorBridgeUrl != null) {
+ if (shouldUseFrontDoorBridge && uiBridgeApiParameters?.frontdoorBridgeUrl != null) {
loginWithFrontdoorBridgeUrl(
uiBridgeApiParameters.frontdoorBridgeUrl,
uiBridgeApiParameters.pkceCodeVerifier
@@ -1079,35 +1122,6 @@ open class LoginActivity : FragmentActivity() {
applyUiBridgeApiFrontDoorUrl(intent)
}
- /**
- * Starts a browser custom tab for the OAuth authorization URL according to
- * the authentication configuration. The activity only takes action when
- * browser-based authentication requires a browser custom tab to be started.
- * UI front-door bridge use bypasses the need for browser custom tab.
- * @param authorizationUrl The selected login server's OAuth authorization
- * URL
- * @param activityResultLauncher The activity result launcher to use when
- * browser-based authentication requires a browser custom tab
- * @param isBrowserLoginEnabled Indicates if browser-based authentication is
- * enabled
- * @param isUsingFrontDoorBridge Indicates if a UI bridge API front door
- * bridge URL is in use
- * @param singleServerCustomTabActivity Indicates single server custom
- * browser tab authentication is active
- */
- @VisibleForTesting
- internal open fun startBrowserCustomTabAuthorization(
- authorizationUrl: String,
- activityResultLauncher: ActivityResultLauncher,
- isBrowserLoginEnabled: Boolean = SalesforceSDKManager.getInstance().isBrowserLoginEnabled,
- isUsingFrontDoorBridge: Boolean = viewModel.isUsingFrontDoorBridge,
- singleServerCustomTabActivity: Boolean = viewModel.singleServerCustomTabActivity,
- ) {
- if ((singleServerCustomTabActivity.or(isBrowserLoginEnabled)).and(!isUsingFrontDoorBridge)) {
- loadLoginPageInCustomTab(authorizationUrl, activityResultLauncher)
- }
- }
-
/**
* A web view client which intercepts the redirect to the OAuth callback URL. That redirect marks the end of
* the user facing portion of the authentication flow.
@@ -1591,32 +1605,19 @@ open class LoginActivity : FragmentActivity() {
}
}
- // endregion
- // region Observer Classes
-
/**
- * An observer for browser custom tab URL that continues the authentication
- * flow by loading the login URL in a web browser custom tab when browser-
- * based authentication is required.
- * @param activity The login activity. This parameter is intended for
- * testing purposes only. Defaults to this inner class receiver
+ * Activity result callback for the "Login for Admin" custom tab.
*/
- internal inner class BrowserCustomTabUrlObserver(
- private val activity: LoginActivity = this@LoginActivity
- ) : Observer {
- override fun onChanged(value: String) {
- if (value == "about:blank") {
- return
- }
-
- activity.startBrowserCustomTabAuthorization(
- authorizationUrl = value,
- activityResultLauncher = activity.customTabLauncher,
- isBrowserLoginEnabled = SalesforceSDKManager.getInstance().isBrowserLoginEnabled,
- )
+ @VisibleForTesting
+ internal inner class AdminCustomTabActivityResult : ActivityResultCallback {
+ override fun onActivityResult(result: ActivityResult) {
+ // Intentional no-op: keep the existing WebView visible on cancel.
}
}
+ // endregion
+ // region Observer Classes
+
/**
* An observer for pending login server that continues the authentication
* flow by determining the switch between default login and Salesforce
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt
index 03c72549be..6bae43cc3e 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt
@@ -72,6 +72,7 @@ import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getSHA256Hash
import com.salesforce.androidsdk.ui.LoginActivity.Companion.ABOUT_BLANK
import com.salesforce.androidsdk.ui.LoginActivity.Companion.isSalesforceWelcomeDiscoveryUrlPath
import com.salesforce.androidsdk.util.SalesforceSDKLogger.e
+import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
@@ -125,9 +126,25 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
/** The selected login server's OAuth authorization URL for use in the web view when browser-based authentication is inactive */
val loginUrl = MediatorLiveData()
- /** The selected login server's OAuth authorization URL for use in the external browser custom tab when browser-based authentication is active. This provides the login flow with the requirements for advanced authentication, such as client certificates */
+ /**
+ * The selected login server's OAuth authorization URL for use in the
+ * external browser custom tab when browser-based authentication is active.
+ * This provides the login flow with the requirements for advanced
+ * authentication, such as client certificates.
+ *
+ * Future enhancement: Use CustomTabsClient.warmup() and
+ * CustomTabsSession.mayLaunchUrl() to pre-warm the browser and
+ * speculatively pre-load this URL whenever it changes, improving custom
+ * tab launch performance.
+ */
val browserCustomTabUrl = MediatorLiveData()
+ /** The Salesforce Identity API UI Bridge front door bridge URL for use in the web view when overriding the standard login URL */
+ val frontDoorBridgeUrl = MediatorLiveData()
+
+ /** Callback invoked when [browserCustomTabUrl] is ready and should be launched in a browser custom tab. Set by the Activity. */
+ internal var onBrowserCustomTabReady: ((String) -> Unit)? = null
+
var showServerPicker = mutableStateOf(false)
var loading = mutableStateOf(false)
@@ -206,7 +223,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
}
// Second, when not using a front-door bridge URL, the app's preferences can be used.
else {
- useWebServerAuthentication || isBrowserLoginEnabled
+ useWebServerAuthentication || isBrowserLoginEnabled || singleServerCustomTabActivity
}
}
@@ -236,7 +253,8 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
internal var authCodeForJwtFlow: String? = null
// For Salesforce Identity API UI Bridge support, indicates use of an overriding front door bridge URL.
- internal var isUsingFrontDoorBridge = false
+ internal val isUsingFrontDoorBridge: Boolean
+ get() = frontDoorBridgeUrl.value != null
// The optional server used for code exchange.
internal var frontdoorBridgeServer: String? = null
@@ -262,9 +280,6 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
// Update loginUrl when selectedServer updates so webview automatically reloads
loginUrl.addSource(selectedServer, LoginUrlSource())
-
- // When selecting a login server, update the browser custom tab URL to match the OAuth authorization URL when browser-based authentication is active.
- browserCustomTabUrl.addSource(selectedServer, BrowserCustomTabUrlSource())
}
/** Reloads the WebView with a newly generated authorization URL. */
@@ -278,7 +293,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
}
viewModelScope.launch {
- loginUrl.value = getAuthorizationUrl(server)
+ generateAuthorizationUrl(server)
}
}
}
@@ -302,10 +317,9 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
frontdoorBridgeUrl: String,
pkceCodeVerifier: String?,
) {
- isUsingFrontDoorBridge = true
frontdoorBridgeServer = with(URI(frontdoorBridgeUrl)) { "${scheme}://${host}" }
frontdoorBridgeCodeVerifier = pkceCodeVerifier
- loginUrl.value = frontdoorBridgeUrl
+ frontDoorBridgeUrl.value = frontdoorBridgeUrl
}
/**
@@ -397,7 +411,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
* its default inactive state.
*/
internal fun resetFrontDoorBridgeUrl() {
- isUsingFrontDoorBridge = false
+ frontDoorBridgeUrl.value = null
frontdoorBridgeServer = null
frontdoorBridgeCodeVerifier = null
}
@@ -428,68 +442,136 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
}
/**
- * Generates an OAuth authorization URL for the provided server.
+ * Generates an OAuth authorization URL for token migration.
* @param server The login server URL
- * @param sdkManager The Salesforce SDK manager. This parameter is intended
- * for testing purposes only. Defaults to the shared instance
- * @param scope The Coroutine scope. This parameter is intended for testing
- * purposes only. Defaults to the IO scope
+ * @param migrationOAuthConfig The OAuth config to use for migration
*/
- internal suspend fun getAuthorizationUrl(
+ internal fun generateMigrationAuthorizationPath(
server: String,
+ migrationOAuthConfig: OAuthConfig,
sdkManager: SalesforceSDKManager = SalesforceSDKManager.getInstance(),
- scope: CoroutineScope = CoroutineScope(IO),
- migrationOAuthConfig: OAuthConfig? = null,
- ) = withContext(scope.coroutineContext) {
- // Show loading indicator because appConfigForLoginHost could take a noticeable amount of time.
- loading.value = true
-
- with(sdkManager) {
- oAuthConfig = when {
- // Used by UserAccountManager.migrateRefreshToken/TokenMigrationActivity
- migrationOAuthConfig != null -> migrationOAuthConfig
- // Used by LoginOptions
- isDebugBuild && debugOverrideAppConfig != null -> debugOverrideAppConfig!!
- // Check if app has a config and fallback to bootconfig file.
- else -> appConfigForLoginHost(server) ?: OAuthConfig(bootConfig)
- }
- }
-
- val jwtFlow = !jwt.isNullOrBlank() && !authCodeForJwtFlow.isNullOrBlank()
- val additionalParams = when {
- jwtFlow -> null
- else -> additionalParameters
- }
-
+ ): String {
val codeVerifier = getRandom128ByteKey().also { codeVerifier = it }
val codeChallenge = getSHA256Hash(codeVerifier)
val authorizationUrl = OAuth2.getAuthorizationUrl(
- useWebServerFlow,
+ /* useWebServerAuthentication = */ true,
sdkManager.useHybridAuthentication,
URI(server),
- consumerKey,
- oAuthConfig.redirectUri,
- oAuthConfig.scopes?.toTypedArray(),
- loginHint,
+ migrationOAuthConfig.consumerKey,
+ migrationOAuthConfig.redirectUri,
+ migrationOAuthConfig.scopes?.toTypedArray(),
+ /* loginHint = */ null,
authorizationDisplayType,
codeChallenge,
- additionalParams
+ /* addlParams = */ emptyMap(),
)
- // The Salesforce Welcome login hint is only used once.
- loginHint = null
+ return with(authorizationUrl) { "$path?$query" }
+ }
+
+ /**
+ * Generates an OAuth authorization URL for the provided server.
+ * @param server The login server URL
+ * @param sdkManager The Salesforce SDK manager. This parameter is intended
+ * for testing purposes only. Defaults to the shared instance
+ * @param coroutineContext The coroutine context. This parameter is intended
+ * for testing purposes only. Defaults to the IO dispatcher
+ */
+ internal suspend fun generateAuthorizationUrl(
+ server: String,
+ sdkManager: SalesforceSDKManager = SalesforceSDKManager.getInstance(),
+ coroutineContext: CoroutineContext = IO,
+ ) {
+ // Show loading indicator.
+ loading.value = true
+
+ // Perform heavy work (config fetch, URL generation) on the IO dispatcher.
+ val (browserTabUrl, webViewUrl) = withContext(coroutineContext) {
+ with(sdkManager) {
+ oAuthConfig = when {
+ // Used by LoginOptions
+ isDebugBuild && debugOverrideAppConfig != null -> debugOverrideAppConfig!!
+ // Check if app has a config and fallback to bootconfig file.
+ else -> appConfigForLoginHost(server) ?: OAuthConfig(bootConfig)
+ }
+ }
- return@withContext when {
- jwtFlow -> getFrontdoorUrl(
- authorizationUrl,
- authCodeForJwtFlow,
- selectedServer.value,
- mapOf()
+ val jwtFlow = !jwt.isNullOrBlank() && !authCodeForJwtFlow.isNullOrBlank()
+ val additionalParams = when {
+ jwtFlow -> null
+ else -> additionalParameters
+ }
+
+ val codeVerifier = getRandom128ByteKey().also { codeVerifier = it }
+ val codeChallenge = getSHA256Hash(codeVerifier)
+
+ val webServerAuthorizationUrl = OAuth2.getAuthorizationUrl(
+ /* useWebServerAuthentication = */ true,
+ sdkManager.useHybridAuthentication,
+ URI(server),
+ consumerKey,
+ oAuthConfig.redirectUri,
+ oAuthConfig.scopes?.toTypedArray(),
+ loginHint,
+ authorizationDisplayType,
+ codeChallenge,
+ additionalParams
)
- else -> authorizationUrl
- }.toString()
+ val browserTabUrlValue = when {
+ jwtFlow -> getFrontdoorUrl(
+ webServerAuthorizationUrl,
+ authCodeForJwtFlow,
+ selectedServer.value,
+ mapOf()
+ )
+
+ else -> webServerAuthorizationUrl.toString()
+ }.toString()
+
+ val webViewUrlValue = if (useWebServerFlow) {
+ browserTabUrlValue
+ } else {
+ val userAgentAuthorizationUrl = OAuth2.getAuthorizationUrl(
+ false,
+ sdkManager.useHybridAuthentication,
+ URI(server),
+ consumerKey,
+ oAuthConfig.redirectUri,
+ oAuthConfig.scopes?.toTypedArray(),
+ loginHint,
+ authorizationDisplayType,
+ codeChallenge,
+ additionalParams
+ )
+
+ when {
+ jwtFlow -> getFrontdoorUrl(
+ userAgentAuthorizationUrl,
+ authCodeForJwtFlow,
+ selectedServer.value,
+ mapOf(),
+ )
+
+ else -> userAgentAuthorizationUrl
+ }.toString()
+ }
+
+ // The Salesforce Welcome login hint is only used once.
+ loginHint = null
+
+ Pair(browserTabUrlValue, webViewUrlValue)
+ }
+
+ // Set LiveData values on the main thread.
+ browserCustomTabUrl.value = browserTabUrl
+ loginUrl.value = webViewUrl
+
+ // Launch the browser custom tab when applicable.
+ if (sdkManager.isBrowserLoginEnabled || singleServerCustomTabActivity) {
+ onBrowserCustomTabReady?.invoke(browserTabUrl)
+ }
}
@VisibleForTesting
@@ -573,8 +655,6 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
/**
* An observer used to set the web view's OAuth authorization URL from the
* selected login server.
- * @param sdkManager The Salesforce SDK manager. This parameter is intended
- * for testing purposes only. Defaults to the shared instance
* @param viewModel The login activity view model. This parameter is
* intended for testing purposes only. Defaults to this inner class receiver
* @param scope The Coroutine scope. This parameter is intended for testing
@@ -582,21 +662,22 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
*/
@VisibleForTesting
inner class LoginUrlSource(
- private val sdkManager: SalesforceSDKManager = SalesforceSDKManager.getInstance(),
private val viewModel: LoginViewModel = this@LoginViewModel,
private val scope: CoroutineScope = viewModelScope,
) : Observer {
override fun onChanged(value: String?) {
- if (!sdkManager.isBrowserLoginEnabled && !singleServerCustomTabActivity && !viewModel.isUsingFrontDoorBridge && value != null) {
- val valueUrl = value.toUri()
- val loginUrl = viewModel.loginUrl.value?.toUri()
-
- val isNewServer = (loginUrl?.host?.equals(valueUrl.host) == false).or(!loginUrl?.path.equals(valueUrl.path))
- if (isNewServer) {
- scope.launch {
- viewModel.loginUrl.value = viewModel.getAuthorizationUrl(value)
- }
- }
+ if (value == null) return
+
+ // Ensure the server has a scheme so it parses as a hierarchical URI.
+ val server = if (value.startsWith("http")) value else "https://$value"
+
+ // Avoid unnecessary reloads when the server host hasn't changed.
+ val newHost = server.toUri().host
+ val currentHost = viewModel.loginUrl.value?.toUri()?.host
+ if (newHost != null && newHost == currentHost) return
+
+ scope.launch {
+ viewModel.generateAuthorizationUrl(server = server)
}
}
}
@@ -620,33 +701,4 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
}
}
}
-
- /**
- * An observer used to set the external browser custom tab's OAuth
- * authorization URL from the selected login server.
- * @param sdkManager The Salesforce SDK manager. This parameter is intended
- * for testing purposes only. Defaults to the shared instance
- * @param viewModel The login activity view model. This parameter is
- * intended for testing purposes only. Defaults to this inner class receiver
- * @param scope The Coroutine scope. This parameter is intended for testing
- * purposes only. Defaults to the IO scope
- */
- @VisibleForTesting
- inner class BrowserCustomTabUrlSource(
- private val sdkManager: SalesforceSDKManager = SalesforceSDKManager.getInstance(),
- private val viewModel: LoginViewModel = this@LoginViewModel,
- private val scope: CoroutineScope = viewModelScope,
- ) : Observer {
- override fun onChanged(value: String) {
- val useBrowserCustomTab = sdkManager.isBrowserLoginEnabled || singleServerCustomTabActivity
- if (useBrowserCustomTab && !viewModel.isUsingFrontDoorBridge) {
- scope.launch {
- viewModel.browserCustomTabUrl.value = viewModel.getAuthorizationUrl(
- server = value,
- scope = scope
- )
- }
- }
- }
- }
}
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt
index 3b15081437..f106457c12 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt
@@ -130,11 +130,10 @@ internal class TokenMigrationActivity : ComponentActivity() {
lifecycleScope.launch {
val frontDoorUrl = withContext(IO) {
runCatching {
- val authorizationUrl = viewModel.getAuthorizationUrl(
+ val authorizationPath = viewModel.generateMigrationAuthorizationPath(
server = user.instanceServer,
migrationOAuthConfig = oAuthConfig,
)
- val authorizationPath = with(authorizationUrl.toUri()) { "$path?$query" }
val request = RestRequest.getRequestForSingleAccess(authorizationPath)
val singleAccessResponse = client.sendSync(request)
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt
index 26f7e7ad31..1f292b69ef 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt
@@ -110,12 +110,14 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.viewmodel.compose.viewModel
import com.salesforce.androidsdk.R.string.sf__back_button_content_description
import com.salesforce.androidsdk.R.string.sf__clear_cache
import com.salesforce.androidsdk.R.string.sf__clear_cookies
import com.salesforce.androidsdk.R.string.sf__dev_support_title_menu_item
import com.salesforce.androidsdk.R.string.sf__launch_idp
+import com.salesforce.androidsdk.R.string.sf__login_for_admins
import com.salesforce.androidsdk.R.string.sf__loading_indicator
import com.salesforce.androidsdk.R.string.sf__more_options
import com.salesforce.androidsdk.R.string.sf__pick_server
@@ -145,7 +147,8 @@ fun LoginView() {
val activity: LoginActivity = LocalContext.current.getActivity() as LoginActivity
val viewModel: LoginViewModel =
viewModel(factory = SalesforceSDKManager.getInstance().loginViewModelFactory)
- val titleText = if (viewModel.isUsingFrontDoorBridge) {
+ val frontDoorBridgeUrl = viewModel.frontDoorBridgeUrl.observeAsState()
+ val titleText = if (frontDoorBridgeUrl.value != null) {
viewModel.frontdoorBridgeServer ?: ""
} else {
viewModel.titleText ?: viewModel.defaultTitleText
@@ -170,6 +173,7 @@ fun LoginView() {
shouldShowBackButton = viewModel.shouldShowBackButton,
showDevSupport = showDevSupport,
finish = { activity.handleBackBehavior() },
+ onLoginForAdmins = { activity.launchLoginForAdminsAction() },
)
}
@@ -208,6 +212,7 @@ fun LoginView() {
LoginView(
dynamicBackgroundColor = viewModel.dynamicBackgroundColor,
loginUrlData = viewModel.loginUrl,
+ frontDoorBridgeUrlData = viewModel.frontDoorBridgeUrl,
topAppBar = topAppBar,
webView = activity.webView,
loading = viewModel.loading.value,
@@ -221,6 +226,7 @@ fun LoginView() {
internal fun LoginView(
dynamicBackgroundColor: MutableState,
loginUrlData: LiveData,
+ frontDoorBridgeUrlData: LiveData = MediatorLiveData(),
topAppBar: @Composable () -> Unit,
webView: WebView,
loading: Boolean,
@@ -229,6 +235,7 @@ internal fun LoginView(
showServerPicker: MutableState,
) {
val loginUrl = loginUrlData.observeAsState()
+ val frontDoorBridgeUrl = frontDoorBridgeUrlData.observeAsState()
val alpha: Float by animateFloatAsState(
targetValue = if (loading) LOADING_ALPHA else VISIBLE_ALPHA,
animationSpec = tween(durationMillis = SLOW_ANIMATION_MS),
@@ -249,7 +256,9 @@ internal fun LoginView(
.applyImePaddingConditionally()
.graphicsLayer(alpha = alpha),
factory = { webView },
- update = { it.loadUrl(loginUrl.value ?: "") },
+ update = {
+ it.loadUrl(frontDoorBridgeUrl.value ?: loginUrl.value ?: "")
+ },
)
if (loading) {
@@ -277,6 +286,7 @@ internal fun DefaultTopAppBar(
shouldShowBackButton: Boolean,
showDevSupport: (() -> Unit)?,
finish: () -> Unit,
+ onLoginForAdmins: (() -> Unit)? = null,
) {
var showMenu by remember { mutableStateOf(false) }
@@ -332,6 +342,12 @@ internal fun DefaultTopAppBar(
reloadWebView()
showMenu = false
}
+ onLoginForAdmins?.let {
+ MenuItem(stringResource(sf__login_for_admins)) {
+ it.invoke()
+ showMenu = false
+ }
+ }
showDevSupport?.let {
MenuItem(stringResource(sf__dev_support_title_menu_item)) {
it.invoke()
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt
index 1f3ee9540f..25bdf6d01b 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt
@@ -147,16 +147,211 @@ class LoginViewModelTest {
assertNotEquals(FAKE_SERVER_URL, viewModel.selectedServer.value)
assertTrue(viewModel.loginUrl.value!!.startsWith(viewModel.selectedServer.value!!))
- assertFalse(viewModel.loginUrl.value!!.startsWith(FAKE_SERVER_URL))
+ assertFalse(viewModel.loginUrl.value!!.contains(FAKE_SERVER_URL))
viewModel.selectedServer.value = FAKE_SERVER_URL
// Wait for loginUrl to update after selectedServer change (async coroutine)
Thread.sleep(200)
assertNotNull(viewModel.loginUrl.value)
- assertTrue(viewModel.loginUrl.value!!.startsWith(FAKE_SERVER_URL))
+ // LoginUrlSource prepends https:// to scheme-less servers before URL generation.
+ assertTrue(viewModel.loginUrl.value!!.startsWith("https://$FAKE_SERVER_URL"))
}
+ // region Login for Admin (browserCustomTabUrl) Tests
+
+ @Test
+ fun browserCustomTabUrl_IsPopulated_AfterAuthorizationUrlGeneration() {
+ // Observe so the MediatorLiveData actually propagates in the test environment.
+ viewModel.browserCustomTabUrl.observeForever { }
+
+ // The setup() already triggers URL generation; wait for async completion.
+ Thread.sleep(200)
+
+ val browserCustomTabUrl = viewModel.browserCustomTabUrl.value
+ assertNotNull("browserCustomTabUrl should be populated for the admin flow", browserCustomTabUrl)
+ assertTrue(
+ "browserCustomTabUrl should start with the selected server",
+ browserCustomTabUrl!!.startsWith(viewModel.selectedServer.value!!)
+ )
+ assertTrue(
+ "browserCustomTabUrl should point at the OAuth authorize endpoint",
+ browserCustomTabUrl.contains("/services/oauth2/authorize")
+ )
+ }
+
+ @Test
+ fun browserCustomTabUrl_UsesWebServerFlow_EvenWhenUserAgentFlowIsActive() {
+ viewModel.browserCustomTabUrl.observeForever { }
+ viewModel.loginUrl.observeForever { }
+
+ try {
+ // Force User Agent flow for the WebView.
+ SalesforceSDKManager.getInstance().useWebServerAuthentication = false
+
+ viewModel.reloadWebView()
+ Thread.sleep(200)
+
+ val browserCustomTabUrl = viewModel.browserCustomTabUrl.value
+ val loginUrl = viewModel.loginUrl.value
+ assertNotNull("browserCustomTabUrl should always be generated", browserCustomTabUrl)
+ assertNotNull("loginUrl should be generated", loginUrl)
+ assertFalse( browserCustomTabUrl == loginUrl)
+
+ // browserCustomTabUrl is the Web Server Flow URL, so it must carry a PKCE code challenge
+ // and request a 'code' response type (this is what the admin custom tab needs to complete
+ // via the onNewIntent -> completeAdvAuthFlow path).
+ assertTrue(
+ "browserCustomTabUrl should use response_type=code. URL: $browserCustomTabUrl",
+ browserCustomTabUrl!!.contains("response_type=code")
+ )
+ assertTrue(
+ "browserCustomTabUrl should include a PKCE code_challenge. URL: $browserCustomTabUrl",
+ browserCustomTabUrl.contains("code_challenge=")
+ )
+
+ // In User Agent mode the WebView URL differs from the browser tab URL: it must not be
+ // the Web Server Flow (i.e., no response_type=code). The exact response_type depends on
+ // whether hybrid authentication is enabled (`token` vs `hybrid_token`), which is not
+ // relevant to the admin-flow contract we're validating here.
+ assertFalse(
+ "loginUrl should NOT use response_type=code when User Agent flow is active. URL: $loginUrl",
+ loginUrl!!.contains("response_type=code")
+ )
+ } finally {
+ SalesforceSDKManager.getInstance().useWebServerAuthentication = true
+ }
+ }
+
+ @Test
+ fun browserCustomTabUrl_UpdatesOn_selectedServerChange() {
+ viewModel.browserCustomTabUrl.observeForever { }
+
+ // Wait for initial generation.
+ Thread.sleep(200)
+ val initialUrl = viewModel.browserCustomTabUrl.value
+ assertNotNull(initialUrl)
+ assertFalse(
+ "Initial browserCustomTabUrl should not reference the fake server",
+ initialUrl!!.contains(FAKE_SERVER_URL)
+ )
+
+ viewModel.selectedServer.value = FAKE_SERVER_URL
+ Thread.sleep(200)
+
+ val updatedUrl = viewModel.browserCustomTabUrl.value
+ assertNotNull(updatedUrl)
+ assertFalse(initialUrl == updatedUrl)
+ // LoginUrlSource prepends https:// to scheme-less servers before URL generation.
+ assertTrue(
+ "browserCustomTabUrl should start with the new server after selectedServer change",
+ updatedUrl!!.startsWith("https://$FAKE_SERVER_URL")
+ )
+ }
+
+ @Test
+ fun generateAuthorizationUrl_InvokesOnBrowserCustomTabReady_WhenBrowserLoginEnabled() {
+ val sdkManagerMock = mockk(relaxed = true)
+ every { sdkManagerMock.isDebugBuild } returns false
+ every { sdkManagerMock.useHybridAuthentication } returns false
+ every { sdkManagerMock.isBrowserLoginEnabled } returns true
+ every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null }
+ every { sdkManagerMock.debugOverrideAppConfig } returns null
+
+ val capturedUrls = mutableListOf()
+ viewModel.onBrowserCustomTabReady = { url -> capturedUrls.add(url) }
+
+ runBlocking { viewModel.generateAuthorizationUrl("test.salesforce.com", sdkManagerMock) }
+
+ assertEquals(1, capturedUrls.size)
+ assertEquals(viewModel.browserCustomTabUrl.value, capturedUrls.single())
+ }
+
+ @Test
+ fun generateAuthorizationUrl_InvokesOnBrowserCustomTabReady_WhenSingleServerCustomTabActivity() {
+ val vm = object : LoginViewModel(bootConfig) {
+ override val singleServerCustomTabActivity = true
+ }
+
+ val sdkManagerMock = mockk(relaxed = true)
+ every { sdkManagerMock.isDebugBuild } returns false
+ every { sdkManagerMock.useHybridAuthentication } returns false
+ // Explicitly NOT browser-login-enabled — singleServerCustomTabActivity alone must trigger.
+ every { sdkManagerMock.isBrowserLoginEnabled } returns false
+ every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null }
+ every { sdkManagerMock.debugOverrideAppConfig } returns null
+
+ val capturedUrls = mutableListOf()
+ vm.onBrowserCustomTabReady = { url -> capturedUrls.add(url) }
+
+ runBlocking { vm.generateAuthorizationUrl("test.salesforce.com", sdkManagerMock) }
+
+ assertEquals(1, capturedUrls.size)
+ }
+
+ @Test
+ fun generateAuthorizationUrl_DoesNotInvokeOnBrowserCustomTabReady_ByDefault() {
+ val sdkManagerMock = mockk(relaxed = true)
+ every { sdkManagerMock.isDebugBuild } returns false
+ every { sdkManagerMock.useHybridAuthentication } returns false
+ every { sdkManagerMock.isBrowserLoginEnabled } returns false
+ every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null }
+ every { sdkManagerMock.debugOverrideAppConfig } returns null
+
+ val capturedUrls = mutableListOf()
+ viewModel.onBrowserCustomTabReady = { url -> capturedUrls.add(url) }
+
+ runBlocking { viewModel.generateAuthorizationUrl("test.salesforce.com", sdkManagerMock) }
+
+ assertTrue(
+ "onBrowserCustomTabReady must NOT fire when neither browser login nor single-server custom tab is active",
+ capturedUrls.isEmpty(),
+ )
+ }
+
+ // endregion
+
+ // region frontDoorBridgeUrl Tests
+
+ @Test
+ fun isUsingFrontDoorBridge_FalseByDefault() {
+ // A fresh ViewModel must report isUsingFrontDoorBridge=false when no frontdoor URL is set.
+ val vm = LoginViewModel(bootConfig)
+ assertFalse(vm.isUsingFrontDoorBridge)
+ assertNull(vm.frontDoorBridgeUrl.value)
+ }
+
+ @Test
+ fun loginWithFrontDoorBridgeUrl_SetsFrontDoorBridgeUrl_AndIsUsingFrontDoorBridge() {
+ val vm = LoginViewModel(bootConfig)
+ val frontDoorUrl = "https://test.salesforce.com/frontdoor.jsp?sid=test_session"
+
+ vm.loginWithFrontDoorBridgeUrl(frontDoorUrl, pkceCodeVerifier = "__VERIFIER__")
+
+ assertEquals(frontDoorUrl, vm.frontDoorBridgeUrl.value)
+ assertTrue(vm.isUsingFrontDoorBridge)
+ assertEquals("__VERIFIER__", vm.frontdoorBridgeCodeVerifier)
+ }
+
+ @Test
+ fun resetFrontDoorBridgeUrl_ClearsFrontDoorBridgeUrl() {
+ val vm = LoginViewModel(bootConfig)
+ vm.loginWithFrontDoorBridgeUrl(
+ frontdoorBridgeUrl = "https://test.salesforce.com/frontdoor.jsp?sid=test_session",
+ pkceCodeVerifier = "__VERIFIER__",
+ )
+ assertTrue("Precondition: isUsingFrontDoorBridge should be true", vm.isUsingFrontDoorBridge)
+
+ vm.resetFrontDoorBridgeUrl()
+
+ assertNull(vm.frontDoorBridgeUrl.value)
+ assertFalse(vm.isUsingFrontDoorBridge)
+ assertNull(vm.frontdoorBridgeServer)
+ assertNull(vm.frontdoorBridgeCodeVerifier)
+ }
+
+ // endregion
+
@Test
fun selectedServer_Changes_GenerateCorrectAuthorizationUrl() {
val originalServer = viewModel.selectedServer.value!!
@@ -169,7 +364,8 @@ class LoginViewModelTest {
Thread.sleep(200)
val newCodeChallenge = getSHA256Hash(viewModel.codeVerifier)
assertNotEquals(originalCodeChallenge, newCodeChallenge)
- val newAuthUrl = generateExpectedAuthorizationUrl(FAKE_SERVER_URL, newCodeChallenge)
+ // LoginUrlSource prepends https:// to scheme-less servers before URL generation.
+ val newAuthUrl = generateExpectedAuthorizationUrl("https://$FAKE_SERVER_URL", newCodeChallenge)
assertEquals(newAuthUrl, viewModel.loginUrl.value)
}
@@ -236,7 +432,7 @@ class LoginViewModelTest {
}
@Test
- fun getAuthorizationUrl_UsesDebugOverrideAppConfig_WhenSet() {
+ fun generateAuthorizationUrl_UsesDebugOverrideAppConfig_WhenSet() {
// Set custom OAuth config via debugOverrideAppConfig
val customConsumerKey = "custom_consumer_key_123"
val customRedirectUri = "custom://redirect"
@@ -259,7 +455,7 @@ class LoginViewModelTest {
}
@Test
- fun getAuthorizationUrl_UsesBootConfig_WhenDebugOverrideAppConfigIsNull() {
+ fun generateAuthorizationUrl_UsesBootConfig_WhenDebugOverrideAppConfigIsNull() {
// Ensure debugOverrideAppConfig is null
SalesforceSDKManager.getInstance().debugOverrideAppConfig = null
@@ -276,7 +472,7 @@ class LoginViewModelTest {
}
@Test
- fun getAuthorizationUrl_UsesAppConfigForLoginHost_WhenDebugOverrideIsNull() {
+ fun generateAuthorizationUrl_UsesAppConfigForLoginHost_WhenDebugOverrideIsNull() {
val sdkManager = SalesforceSDKManager.getInstance()
val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost
@@ -312,7 +508,7 @@ class LoginViewModelTest {
}
@Test
- fun getAuthorizationUrl_PrefersDebugOverrideAppConfig_OverAppConfigForLoginHost() {
+ fun generateAuthorizationUrl_PrefersDebugOverrideAppConfig_OverAppConfigForLoginHost() {
val sdkManager = SalesforceSDKManager.getInstance()
val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost
@@ -360,12 +556,15 @@ class LoginViewModelTest {
}
@Test
- fun getAuthorizationUrl_ReleaseBuildIgnoresDebugOverrideAppConfig_OverAppConfigForLoginHost() {
+ fun generateAuthorizationUrl_ReleaseBuildIgnoresDebugOverrideAppConfig_OverAppConfigForLoginHost() {
val sdkManagerMock = mockk(relaxed = false)
val appConfigConsumerKey = "app_config_key_should_not_be_used"
val appConfigRedirectUri = "appconfig://should_not_be_used"
every { sdkManagerMock.isDebugBuild } returns false
every { sdkManagerMock.useHybridAuthentication } returns false
+ // generateAuthorizationUrl reads isBrowserLoginEnabled to decide whether to invoke
+ // the onBrowserCustomTabReady callback; not relevant to this assertion but must be stubbed.
+ every { sdkManagerMock.isBrowserLoginEnabled } returns false
every { sdkManagerMock.appConfigForLoginHost } returns { _ ->
OAuthConfig(
consumerKey = appConfigConsumerKey,
@@ -383,30 +582,31 @@ class LoginViewModelTest {
)
// Verify the URL contains the app config values, not the debug override config values
- val loginUrl = runBlocking { viewModel.getAuthorizationUrl("test.salesforce.com", sdkManagerMock) }
+ runBlocking { viewModel.generateAuthorizationUrl("test.salesforce.com", sdkManagerMock) }
+ val loginUrlValue = viewModel.loginUrl.value!!
assertFalse(
"URL should not contain debug override consumer key",
- loginUrl.contains(debugConsumerKey)
+ loginUrlValue.contains(debugConsumerKey)
)
assertFalse(
"URL should not contain debug override redirect URI",
- loginUrl.contains("redirect_uri=debug://redirect")
+ loginUrlValue.contains("redirect_uri=debug://redirect")
)
- assertFalse("URL should not contain debug scope", loginUrl.contains("debug_scope"))
+ assertFalse("URL should not contain debug scope", loginUrlValue.contains("debug_scope"))
// Verify app config values are in the URL
assertTrue(
"URL should contain app config consumer key",
- loginUrl.contains(appConfigConsumerKey)
+ loginUrlValue.contains(appConfigConsumerKey)
)
assertTrue(
"URL should contain app config redirect URI",
- loginUrl.contains("should_not_be_used")
+ loginUrlValue.contains("should_not_be_used")
)
}
@Test
- fun getAuthorizationUrl_UsesMigrationConfig_OverAppConfigForLoginHost() {
+ fun generateMigrationAuthorizationPath_UsesMigrationConfig_OverAppConfigForLoginHost() {
val sdkManagerMock = mockk(relaxed = false)
val appConfigConsumerKey = "app_config_key_should_not_be_used"
val appConfigRedirectUri = "appconfig://should_not_be_used"
@@ -431,17 +631,14 @@ class LoginViewModelTest {
val migrationScopes = listOf("api", "migration_scope")
// Verify the URL contains the app config values, not the debug override config values
- val loginUrl = runBlocking {
- viewModel.getAuthorizationUrl(
- server = "test.salesforce.com",
- sdkManagerMock,
- migrationOAuthConfig = OAuthConfig(
- migrationConsumerKey,
- migrationRedirectUri,
- migrationScopes,
- )
+ val loginUrl = viewModel.generateMigrationAuthorizationPath(
+ server = "test.salesforce.com",
+ migrationOAuthConfig = OAuthConfig(
+ migrationConsumerKey,
+ migrationRedirectUri,
+ migrationScopes,
)
- }
+ )
assertFalse(
"URL should not contain debug override consumer key",
loginUrl.contains(debugConsumerKey)
@@ -463,7 +660,7 @@ class LoginViewModelTest {
}
@Test
- fun getAuthorizationUrl_UsesServerSpecificConfig_FromAppConfigForLoginHost() {
+ fun generateAuthorizationUrl_UsesServerSpecificConfig_FromAppConfigForLoginHost() {
val sdkManager = SalesforceSDKManager.getInstance()
val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost
@@ -515,7 +712,7 @@ class LoginViewModelTest {
}
@Test
- fun getAuthorizationUrl_HandlesNullScopes_InOAuthConfig() {
+ fun generateAuthorizationUrl_HandlesNullScopes_InOAuthConfig() {
// Set OAuth config with null scopes
val customConsumerKey = "no_scopes_consumer_key"
val customRedirectUri = "noscopes://redirect"
@@ -547,14 +744,14 @@ class LoginViewModelTest {
// Verify front door bridge is active
assertTrue("isUsingFrontDoorBridge should be true", viewModel.isUsingFrontDoorBridge)
- assertEquals("loginUrl should be front door URL", frontDoorUrl, viewModel.loginUrl.value)
+ assertEquals("frontDoorBridgeUrl should be front door URL", frontDoorUrl, viewModel.frontDoorBridgeUrl.value)
// Call reloadWebView
viewModel.reloadWebView()
Thread.sleep(200)
// Verify URL did not change
- assertEquals("loginUrl should still be front door URL", frontDoorUrl, viewModel.loginUrl.value)
+ assertEquals("frontDoorBridgeUrl should still be front door URL", frontDoorUrl, viewModel.frontDoorBridgeUrl.value)
}
@Test
@@ -642,7 +839,7 @@ class LoginViewModelTest {
}
@Test
- fun getAuthorizationUrl_UsesBootConfig_WhenAppConfigForLoginHostReturnsNull() {
+ fun generateAuthorizationUrl_UsesBootConfig_WhenAppConfigForLoginHostReturnsNull() {
val sdkManager = SalesforceSDKManager.getInstance()
val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost
@@ -807,122 +1004,25 @@ class LoginViewModelTest {
}
@Test
- fun loginViewModel_loginUrlObserver_setsLoginUrl() = runTest {
+ fun loginViewModel_loginUrlObserver_generatesUrlForNewServer() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(dispatcher)
- val valueOld = null
- val valueNew = "https://www.example.com" // IETF-Reserved Test Domain
-
- val sdkManager = mockk(relaxed = true)
val viewModel = mockk(relaxed = true)
val loginUrl = mockk>(relaxed = true)
- every { loginUrl.value } returns valueOld
+ every { loginUrl.value } returns null
every { viewModel.loginUrl } returns loginUrl
- val observer = viewModel.LoginUrlSource(sdkManager, viewModel, scope)
-
- observer.onChanged(valueNew)
-
- advanceUntilIdle()
-
- coVerify(exactly = 1) {
- viewModel.getAuthorizationUrl(
- valueNew,
- any(),
- any(),
- )
- }
- }
-
- @Test
- fun loginViewModel_loginUrlObserver_ignoresLoginUrlWhenBrowserLoginEnabledAndSingleServerCustomTabActivityEnabled() = runTest {
-
- val dispatcher = StandardTestDispatcher(testScheduler)
- val scope = CoroutineScope(dispatcher)
-
- val valueOld = null
- val valueNew = "https://www.example.com" // IETF-Reserved Test Domain
-
- val sdkManager = mockk(relaxed = true)
- every { sdkManager.isBrowserLoginEnabled } returns true
- val viewModel = mockk(relaxed = true)
- val loginUrl = mockk>(relaxed = true)
- every { loginUrl.value } returns valueOld
- every { viewModel.singleServerCustomTabActivity } returns true
- every { viewModel.loginUrl } returns loginUrl
- val observer = viewModel.LoginUrlSource(sdkManager, viewModel, scope)
-
- observer.onChanged(valueNew)
-
- advanceUntilIdle()
-
- coVerify(exactly = 0) {
- viewModel.getAuthorizationUrl(
- valueNew,
- any(),
- any(),
- )
- }
- }
-
- @Test
- fun loginViewModel_loginUrlObserver_ignoresLoginUrlWhenBrowserLoginDisabledAndSingleServerCustomTabActivityEnabled() = runTest {
-
- val dispatcher = StandardTestDispatcher(testScheduler)
- val scope = CoroutineScope(dispatcher)
-
- val valueOld = null
- val valueNew = "https://www.example.com" // IETF-Reserved Test Domain
-
- val sdkManager = mockk(relaxed = true)
- every { sdkManager.isBrowserLoginEnabled } returns false
- val viewModel = mockk(relaxed = true)
- val loginUrl = mockk>(relaxed = true)
- every { loginUrl.value } returns valueOld
- every { viewModel.singleServerCustomTabActivity } returns true
- every { viewModel.loginUrl } returns loginUrl
- val observer = viewModel.LoginUrlSource(sdkManager, viewModel, scope)
-
- observer.onChanged(valueNew)
-
- advanceUntilIdle()
-
- coVerify(exactly = 0) {
- viewModel.getAuthorizationUrl(
- valueNew,
- any(),
- any(),
- )
- }
- }
-
- @Test
- fun loginViewModel_loginUrlObserver_ignoresLoginUrlWhenBrowserLoginDisabledAndSingleServerCustomTabActivityEnabledAndFrontDoorBridgeActive() = runTest {
-
- val dispatcher = StandardTestDispatcher(testScheduler)
- val scope = CoroutineScope(dispatcher)
+ val observer = viewModel.LoginUrlSource(viewModel, scope)
- val valueOld = null
val valueNew = "https://www.example.com" // IETF-Reserved Test Domain
-
- val sdkManager = mockk(relaxed = true)
- every { sdkManager.isBrowserLoginEnabled } returns false
- val viewModel = mockk(relaxed = true)
- val loginUrl = mockk>(relaxed = true)
- every { loginUrl.value } returns valueOld
- every { viewModel.singleServerCustomTabActivity } returns true
- every { viewModel.isUsingFrontDoorBridge } returns true
- every { viewModel.loginUrl } returns loginUrl
- val observer = viewModel.LoginUrlSource(sdkManager, viewModel, scope)
-
observer.onChanged(valueNew)
advanceUntilIdle()
- coVerify(exactly = 0) {
- viewModel.getAuthorizationUrl(
- valueNew,
+ coVerify(exactly = 1) {
+ viewModel.generateAuthorizationUrl(
+ server = valueNew,
any(),
any(),
)
@@ -930,91 +1030,26 @@ class LoginViewModelTest {
}
@Test
- fun loginViewModel_loginUrlObserver_ignoresLoginUrlWhenBrowserLoginDisabledAndSingleServerCustomTabActivityEnabledAndFrontDoorBridgeActiveAndValueNull() = runTest {
+ fun loginViewModel_loginUrlObserver_generatesUrlWhenHostChanges() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(dispatcher)
- val valueOld = null
- val valueNew = "https://www.example.com" // IETF-Reserved Test Domain
-
- val sdkManager = mockk(relaxed = true)
- every { sdkManager.isBrowserLoginEnabled } returns false
val viewModel = mockk(relaxed = true)
val loginUrl = mockk>(relaxed = true)
- every { loginUrl.value } returns valueOld
- every { viewModel.singleServerCustomTabActivity } returns true
- every { viewModel.isUsingFrontDoorBridge } returns true
+ // loginUrl currently has a URL with host "other.example.com"
+ every { loginUrl.value } returns "https://other.example.com/services/oauth2/authorize?display=touch"
every { viewModel.loginUrl } returns loginUrl
- val observer = viewModel.LoginUrlSource(sdkManager, viewModel, scope)
-
- observer.onChanged(null)
-
- advanceUntilIdle()
-
- coVerify(exactly = 0) {
- viewModel.getAuthorizationUrl(
- valueNew,
- any(),
- any(),
- )
- }
- }
-
- @Test
- fun loginViewModel_loginUrlObserver_ignoresWhenBrowserLoginEnabled() = runTest {
+ val observer = viewModel.LoginUrlSource(viewModel, scope)
- val dispatcher = StandardTestDispatcher(testScheduler)
- val scope = CoroutineScope(dispatcher)
-
- val valueOld = "https://other.example.com" // IETF-Reserved Test Domain
val valueNew = "https://www.example.com" // IETF-Reserved Test Domain
-
- val sdkManager = mockk(relaxed = true)
- every { sdkManager.isBrowserLoginEnabled } returns true
- val viewModel = mockk(relaxed = true)
- val loginUrl = mockk>(relaxed = true)
- every { loginUrl.value } returns valueOld
- every { viewModel.loginUrl } returns loginUrl
- val observer = viewModel.LoginUrlSource(sdkManager, viewModel, scope)
-
observer.onChanged(valueNew)
advanceUntilIdle()
- coVerify(exactly = 0) {
- viewModel.getAuthorizationUrl(
- valueNew,
- any(),
- any(),
- )
- }
- }
-
- @Test
- fun loginViewModel_loginUrlObserver_ignoresWhenUsingFrontDoorBridge() = runTest {
-
- val dispatcher = StandardTestDispatcher(testScheduler)
- val scope = CoroutineScope(dispatcher)
-
- val valueOld = "https://other.example.com" // IETF-Reserved Test Domain
- val valueNew = "https://www.example.com" // IETF-Reserved Test Domain
-
- val sdkManager = mockk(relaxed = true)
- val viewModel = mockk(relaxed = true)
- every { viewModel.isUsingFrontDoorBridge } returns true
- val loginUrl = mockk>(relaxed = true)
- every { loginUrl.value } returns valueOld
- every { viewModel.loginUrl } returns loginUrl
- val observer = viewModel.LoginUrlSource(sdkManager, viewModel, scope)
-
- observer.onChanged(valueNew)
-
- advanceUntilIdle()
-
- coVerify(exactly = 0) {
- viewModel.getAuthorizationUrl(
- valueNew,
+ coVerify(exactly = 1) {
+ viewModel.generateAuthorizationUrl(
+ server = valueNew,
any(),
any(),
)
@@ -1022,30 +1057,25 @@ class LoginViewModelTest {
}
@Test
- fun loginViewModel_loginUrlObserver_ignoresRepeatValues() = runTest {
+ fun loginViewModel_loginUrlObserver_ignoresWhenSameHost() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(dispatcher)
- val value = "https://www.example.com" // IETF-Reserved Test Domain
-
- val sdkManager = mockk(relaxed = true)
val viewModel = mockk(relaxed = true)
val loginUrl = mockk>(relaxed = true)
- every { loginUrl.value } returns value
+ // loginUrl currently has a URL with host "www.example.com"
+ every { loginUrl.value } returns "https://www.example.com/services/oauth2/authorize?display=touch"
every { viewModel.loginUrl } returns loginUrl
- val observer = viewModel.LoginUrlSource(sdkManager, viewModel, scope)
+ val observer = viewModel.LoginUrlSource(viewModel, scope)
- observer.onChanged(value)
+ // Same host
+ observer.onChanged("https://www.example.com")
advanceUntilIdle()
coVerify(exactly = 0) {
- viewModel.getAuthorizationUrl(
- value,
- any(),
- any(),
- )
+ viewModel.generateAuthorizationUrl(any(), any(), any())
}
}
@@ -1055,148 +1085,24 @@ class LoginViewModelTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(dispatcher)
- val valueOld = "https://other.example.com" // IETF-Reserved Test Domain
-
- val sdkManager = mockk(relaxed = true)
val viewModel = mockk(relaxed = true)
val loginUrl = mockk>(relaxed = true)
- every { loginUrl.value } returns valueOld
+ every { loginUrl.value } returns "https://other.example.com/services/oauth2/authorize"
every { viewModel.loginUrl } returns loginUrl
- val observer = viewModel.LoginUrlSource(sdkManager, viewModel, scope)
+ val observer = viewModel.LoginUrlSource(viewModel, scope)
observer.onChanged(null)
advanceUntilIdle()
coVerify(exactly = 0) {
- viewModel.getAuthorizationUrl(
- any(),
- any(),
- any(),
- )
- }
- }
-
- @Test
- fun loginViewModel_browserCustomTabObserver_setsBrowserCustomTabUrl_whenIsBrowserLoginEnabledAndNotUsingFrontDoorBridge() = runTest {
-
- val dispatcher = StandardTestDispatcher(testScheduler)
- val scope = CoroutineScope(dispatcher)
-
- val sdkManager = mockk(relaxed = true)
- every { sdkManager.isBrowserLoginEnabled } returns true
- val viewModel = mockk(relaxed = true)
- val observer = viewModel.BrowserCustomTabUrlSource(sdkManager, viewModel, scope)
-
- val value = "https://www.example.com" // IETF-Reserved Test Domain
-
- observer.onChanged(value)
-
- advanceUntilIdle()
-
- coVerify(exactly = 1) { viewModel.getAuthorizationUrl(
- value,
- any(),
- any(),
- ) }
- }
-
- @Test
- fun loginViewModel_browserCustomTabObserver_setsBrowserCustomTabUrl_whenSingleServerCustomTabActivityEnabledAndNotUsingFrontDoorBridge() = runTest {
-
- val dispatcher = StandardTestDispatcher(testScheduler)
- val scope = CoroutineScope(dispatcher)
-
- val sdkManager = mockk(relaxed = true)
- every { sdkManager.isBrowserLoginEnabled } returns false
- val viewModel = mockk(relaxed = true)
- every { viewModel.singleServerCustomTabActivity } returns true
- val observer = viewModel.BrowserCustomTabUrlSource(sdkManager, viewModel, scope)
-
- val value = "https://www.example.com" // IETF-Reserved Test Domain
-
- observer.onChanged(value)
-
- advanceUntilIdle()
-
- coVerify(exactly = 1) {
- viewModel.getAuthorizationUrl(
- value,
- any(),
- any(),
- )
- }
- }
-
- @Test
- fun loginViewModel_browserCustomTabObserver_ignoresBrowserCustomTabUrl_whenBrowserLoginDisabledAndSingleServerCustomTabActivityDisabledAndNotUsingFrontDoorBridge() = runTest {
-
- val dispatcher = StandardTestDispatcher(testScheduler)
- val scope = CoroutineScope(dispatcher)
-
- val sdkManager = mockk(relaxed = true)
- every { sdkManager.isBrowserLoginEnabled } returns false
- val viewModel = mockk(relaxed = true)
- every { viewModel.singleServerCustomTabActivity } returns false
- val observer = viewModel.BrowserCustomTabUrlSource(sdkManager, viewModel, scope)
-
- val value = "https://www.example.com" // IETF-Reserved Test Domain
-
- observer.onChanged(value)
-
- advanceUntilIdle()
-
- coVerify(exactly = 0) {
- viewModel.getAuthorizationUrl(
- value,
+ viewModel.generateAuthorizationUrl(any(),
any(),
any(),
)
}
}
- @Test
- fun loginViewModel_browserCustomTabObserver_ignoresBrowserCustomTabUrl_whenBrowserLoginDisabledAndSingleServerCustomTabActivityDisabledAndUsingFrontDoorBridge() = runTest {
-
- val dispatcher = StandardTestDispatcher(testScheduler)
- val scope = CoroutineScope(dispatcher)
-
- val sdkManager = mockk(relaxed = true)
- every { sdkManager.isBrowserLoginEnabled } returns false
- val viewModel = mockk(relaxed = true)
- every { viewModel.singleServerCustomTabActivity } returns false
- every { viewModel.isUsingFrontDoorBridge } returns true
- val observer = viewModel.BrowserCustomTabUrlSource(sdkManager, viewModel, scope)
-
- val value = "https://www.example.com" // IETF-Reserved Test Domain
-
- observer.onChanged(value)
-
- advanceUntilIdle()
-
- coVerify(exactly = 0) {
- viewModel.getAuthorizationUrl(
- value,
- any(),
- any(),
- )
- }
- }
-
- @Test
- fun loginViewModel_browserCustomTabObserver_ignoresBrowserCustomTabUrl_whenIsBrowserLoginEnabledAndUsingFrontDoorBridge() {
-
- val sdkManager = mockk(relaxed = true)
- every { sdkManager.isBrowserLoginEnabled } returns true
- viewModel.isUsingFrontDoorBridge = true
- val observer = viewModel.BrowserCustomTabUrlSource(sdkManager, viewModel)
-
- val value = "https://www.example.com" // IETF-Reserved Test Domain
- observer.onChanged(value)
-
- assertTrue(viewModel.browserCustomTabUrl.value == null)
- }
-
@Test
fun loginViewModel_buildAccountName_returnsExpectedValue() {
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityScenarioTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityScenarioTest.kt
index 5eac91c7d0..13a4ababd9 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityScenarioTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityScenarioTest.kt
@@ -96,6 +96,27 @@ class LoginActivityScenarioTest {
}
}
+ @Test
+ fun onBrowserCustomTabReady_IsSetOnCreate() {
+ // The activity wires the callback as part of onCreate so that the ViewModel can push
+ // the browser-custom-tab URL back up when it's ready (replacing the deleted
+ // BrowserCustomTabUrlObserver). Verifying the callback is non-null after onCreate
+ // protects against someone accidentally removing the wiring. The *behavior* of the
+ // lambda (routing through the regular customTabLauncher, not the admin one) is
+ // exercised by the `onLoginForAdminsClick_*` tests in LoginActivityTest which verify
+ // the two launchers are used by the right code paths.
+ launch(
+ Intent(getApplicationContext(), LoginActivity::class.java)
+ ).use { activityScenario ->
+ activityScenario.onActivity { activity ->
+ assertNotNull(
+ "onBrowserCustomTabReady should be set in LoginActivity.onCreate",
+ activity.viewModel.onBrowserCustomTabReady,
+ )
+ }
+ }
+ }
+
@Test
fun viewModelFrontDoorBridgeCodeVerifier_UpdatesOn_onCreateWithQrCodeLoginIntent() {
val uri = "app://android/login/qr/?bridgeJson=%7B%22pkce_code_verifier%22%3A%22__CODE_VERIFIER__%22%2C%22frontdoor_bridge_url%22%3A%22https%3A%2F%2Fmobilesdk.my.salesforce.com%2Fsecur%2Ffrontdoor.jsp%3Fotp%3D__OTP__%26startURL%3D%252Fservices%252Foauth2%252Fauthorize%253Fresponse_type%253Dcode%2526client_id%253D__CONSUMER_KEY__%2526redirect_uri%253Dtestsfdc%25253A%25252F%25252F%25252Fmobilesdk%25252Fdetect%25252Foauth%25252Fdone%2526code_challenge%253D__CODE_CHALLENGE__%26cshc%3D__CSHC__%22%7D".toUri()
@@ -113,7 +134,7 @@ class LoginActivityScenarioTest {
assertTrue(activity.viewModel.isUsingFrontDoorBridge)
assertEquals("__CODE_VERIFIER__", activity.viewModel.frontdoorBridgeCodeVerifier)
assertEquals("https://mobilesdk.my.salesforce.com", activity.viewModel.frontdoorBridgeServer)
- assertEquals("https://mobilesdk.my.salesforce.com/secur/frontdoor.jsp?otp=__OTP__&startURL=%2Fservices%2Foauth2%2Fauthorize%3Fresponse_type%3Dcode%26client_id%3D__CONSUMER_KEY__%26redirect_uri%3Dtestsfdc%253A%252F%252F%252Fmobilesdk%252Fdetect%252Foauth%252Fdone%26code_challenge%3D__CODE_CHALLENGE__&cshc=__CSHC__", activity.viewModel.loginUrl.value)
+ assertEquals("https://mobilesdk.my.salesforce.com/secur/frontdoor.jsp?otp=__OTP__&startURL=%2Fservices%2Foauth2%2Fauthorize%3Fresponse_type%3Dcode%26client_id%3D__CONSUMER_KEY__%26redirect_uri%3Dtestsfdc%253A%252F%252F%252Fmobilesdk%252Fdetect%252Foauth%252Fdone%26code_challenge%3D__CODE_CHALLENGE__&cshc=__CSHC__", activity.viewModel.frontDoorBridgeUrl.value)
}
}
}
@@ -250,114 +271,6 @@ class LoginActivityScenarioTest {
// region Salesforce Welcome Discovery
- @Test
- fun loginActivity_startBrowserCustomTabAuthorization_launchesActivityResultLauncherWhenIsBrowserLoginEnabled() {
-
- val activityScenario = launch(
- Intent(
- getApplicationContext(),
- LoginActivity::class.java
- )
- )
-
- activityScenario.onActivity { activity ->
-
- val sdkManager = mockk()
- every { sdkManager.isBrowserLoginEnabled } returns true
- val activityResultLauncher = mockk>()
-
- activity.startBrowserCustomTabAuthorization(
- authorizationUrl = "_authorization_url_",
- activityResultLauncher = activityResultLauncher,
- isBrowserLoginEnabled = true,
- isUsingFrontDoorBridge = false,
- singleServerCustomTabActivity = false,
- )
- verify(exactly = 1) { activityResultLauncher.launch(any()) }
- }
- }
-
- @Test
- fun loginActivity_startBrowserCustomTabAuthorization_launchesActivityResultLauncherWhenSingleServerCustomTabActivity() {
-
- val activityScenario = launch(
- Intent(
- getApplicationContext(),
- LoginActivity::class.java
- )
- )
-
- activityScenario.onActivity { activity ->
-
- val sdkManager = mockk()
- every { sdkManager.isBrowserLoginEnabled } returns true
- val activityResultLauncher = mockk>()
-
- activity.startBrowserCustomTabAuthorization(
- authorizationUrl = "_authorization_url_",
- activityResultLauncher = activityResultLauncher,
- isBrowserLoginEnabled = false,
- isUsingFrontDoorBridge = false,
- singleServerCustomTabActivity = true,
- )
- verify(exactly = 1) { activityResultLauncher.launch(any()) }
- }
- }
-
- @Test
- fun loginActivity_startBrowserCustomTabAuthorization_returnsActivityResultLauncherWhenBothBrowserLoginDisabledAndIsUsingFrontDoorBridge() {
-
- val activityScenario = launch(
- Intent(
- getApplicationContext(),
- LoginActivity::class.java
- )
- )
-
- activityScenario.onActivity { activity ->
-
- val sdkManager = mockk()
- every { sdkManager.isBrowserLoginEnabled } returns true
- val activityResultLauncher = mockk>()
-
- activity.startBrowserCustomTabAuthorization(
- authorizationUrl = "_authorization_url_",
- activityResultLauncher = activityResultLauncher,
- isBrowserLoginEnabled = false,
- isUsingFrontDoorBridge = true,
- singleServerCustomTabActivity = false,
- )
- verify(exactly = 0) { activityResultLauncher.launch(any()) }
- }
- }
-
- @Test
- fun loginActivity_startBrowserCustomTabAuthorization_returnsActivityResultLauncherWhenIsUsingFrontDoorBridge() {
-
- val activityScenario = launch(
- Intent(
- getApplicationContext(),
- LoginActivity::class.java
- )
- )
-
- activityScenario.onActivity { activity ->
-
- val sdkManager = mockk()
- every { sdkManager.isBrowserLoginEnabled } returns true
- val activityResultLauncher = mockk>()
-
- activity.startBrowserCustomTabAuthorization(
- authorizationUrl = "_authorization_url_",
- activityResultLauncher = activityResultLauncher,
- isBrowserLoginEnabled = true,
- isUsingFrontDoorBridge = true,
- singleServerCustomTabActivity = true,
- )
- verify(exactly = 0) { activityResultLauncher.launch(any()) }
- }
- }
-
@Test
fun loginActivity_startsWscDiscovery_onCreateWithSelectedServer() {
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt
index 8c53867860..eb024cf7a5 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt
@@ -32,7 +32,6 @@ import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
import androidx.activity.result.ActivityResult
-import androidx.activity.result.ActivityResultLauncher
import androidx.core.net.toUri
import androidx.lifecycle.MediatorLiveData
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -118,6 +117,66 @@ class LoginActivityTest {
verify(exactly = 0) { activity.clearWebView(any()) }
}
+ // region Login for Admin
+
+ @Test
+ fun adminLoginCustomTabLauncher_onCancel_doesNothing() {
+ val loginUrl = mockk>(relaxed = true)
+ val viewModel = mockk(relaxed = true)
+ every { viewModel.loginUrl } returns loginUrl
+ val activity = mockk(relaxed = true)
+ every { activity.viewModel } returns viewModel
+
+ val adminResult = activity.AdminCustomTabActivityResult()
+ adminResult.onActivityResult(ActivityResult(RESULT_CANCELED, Intent()))
+
+ // Contrast with CustomTabActivityResult which calls these on cancel; the admin
+ // launcher must preserve the existing WebView login page.
+ verify(exactly = 0) { activity.clearWebView(any()) }
+ verify(exactly = 0) { loginUrl.value = any() }
+ verify(exactly = 0) { activity.finish() }
+ }
+
+ @Test
+ fun onLoginForAdminsClick_withNullBrowserCustomTabUrl_doesNotLaunchCustomTab() {
+ val browserCustomTabUrl = mockk>()
+ every { browserCustomTabUrl.value } returns null
+ val viewModel = mockk(relaxed = true)
+ every { viewModel.browserCustomTabUrl } returns browserCustomTabUrl
+
+ val activity = mockk(relaxed = true)
+ every { activity.viewModel } returns viewModel
+ every { activity.launchLoginForAdminsAction() } answers { callOriginal() }
+
+ activity.launchLoginForAdminsAction()
+
+ verify(exactly = 0) { activity.loadLoginPageInCustomTab(any(), any()) }
+ }
+
+ @Test
+ fun onLoginForAdminsClick_withBrowserCustomTabUrl_launchesCustomTab() {
+ val testUrl = "https://example.com/services/oauth2/authorize"
+ val browserCustomTabUrl = mockk>()
+ every { browserCustomTabUrl.value } returns testUrl
+ val viewModel = mockk(relaxed = true)
+ every { viewModel.browserCustomTabUrl } returns browserCustomTabUrl
+
+ val activity = mockk(relaxed = true)
+ every { activity.viewModel } returns viewModel
+ every { activity.launchLoginForAdminsAction() } answers { callOriginal() }
+
+ activity.launchLoginForAdminsAction()
+
+ // `loadLoginPageInCustomTab` is invoked with the URL from `browserCustomTabUrl.value`.
+ // Note: we can't verify the launcher argument via mockk here because Kotlin emits
+ // direct field access (GETFIELD) for same-class property reads, bypassing the mocked
+ // getter for `adminLoginCustomTabLauncher`. The admin-vs-regular launcher routing is
+ // enforced structurally by the 2-line body of `onLoginForAdminsClick`.
+ verify(exactly = 1) { activity.loadLoginPageInCustomTab(eq(testUrl), any()) }
+ }
+
+ // endregion
+
@Test
fun testIsWelcomeDiscoveryUri() {
val validUrl = "https://welcome.salesforce.com$SALESFORCE_WELCOME_DISCOVERY_URL_PATH?$SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID=X&$SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION=Y&$SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CALLBACK_URL=Z"
@@ -153,36 +212,6 @@ class LoginActivityTest {
// region Salesforce Welcome Discovery
- @Test
- fun loginActivityBrowserCustomTabObserver_startsBrowserCustomTabAuthorization_onChange() {
-
- val exampleUrl = "https://www.example.com" // IETF-Reserved Test Domain
-
- val activity = mockk(relaxed = true)
- val activityResultLauncher = mockk>(relaxed = true)
- every { activity.customTabLauncher } returns activityResultLauncher
-
- val observer = activity.BrowserCustomTabUrlObserver(activity)
-
- observer.onChanged(exampleUrl)
- verify {
- activity.startBrowserCustomTabAuthorization(
- match { it == exampleUrl },
- match { it == activityResultLauncher }
- )
- }
- }
-
- @Test
- fun loginActivityBrowserCustomTabObserver_returns_onChangeWithAboutBlank() {
-
- val activity = mockk(relaxed = true)
- val observer = activity.BrowserCustomTabUrlObserver(activity)
-
- observer.onChanged(ABOUT_BLANK)
- verify(exactly = 0) { activity.startBrowserCustomTabAuthorization(any(), any(), any()) }
- }
-
@Test
fun loginActivityPendingServerObserver_appliesPendingServer_onChange() {
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt
index 2d615d430f..15a7a7d301 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt
@@ -324,6 +324,34 @@ class LoginViewActivityTest {
devSupportButton.assertDoesNotExist()
}
+ @Test
+ fun topAppBar_LoginForAdminsButton_CallsCallback() {
+ var loginForAdminsCalled = false
+ androidComposeTestRule.setContent {
+ DefaultTopAppBarTestWrapper(
+ onLoginForAdmins = { loginForAdminsCalled = true },
+ )
+ }
+
+ val menu = androidComposeTestRule.onNodeWithContentDescription(
+ androidComposeTestRule.activity.getString(R.string.sf__more_options)
+ )
+ val loginForAdminsButton = androidComposeTestRule.onNodeWithText(
+ androidComposeTestRule.activity.getString(R.string.sf__login_for_admins)
+ )
+
+ menu.assertIsDisplayed()
+ menu.performClick()
+ loginForAdminsButton.assertIsDisplayed()
+ Assert.assertFalse("Login for Admins should not be called yet.", loginForAdminsCalled)
+
+ loginForAdminsButton.performClick()
+ Assert.assertTrue("Login for Admins callback should be invoked.", loginForAdminsCalled)
+
+ // Menu should dismiss after clicking the item.
+ loginForAdminsButton.assertDoesNotExist()
+ }
+
@Test
fun bottomAppBar_WithNoButton_DisplaysCorrectly() {
androidComposeTestRule.setContent {
@@ -512,10 +540,12 @@ class LoginViewActivityTest {
shouldShowBackButton: Boolean = false,
showDevSupport: (() -> Unit)? = { },
finish: () -> Unit = { },
+ onLoginForAdmins: (() -> Unit) = { },
) {
DefaultTopAppBar(
backgroundColor, titleText, titleTextColor, showServerPicker, clearCookies,
- clearWebViewCache, reloadWebView, shouldShowBackButton, showDevSupport, finish
+ clearWebViewCache, reloadWebView, shouldShowBackButton, showDevSupport, finish,
+ onLoginForAdmins,
)
}
@@ -546,6 +576,15 @@ class LoginViewActivityTest {
bottomAppBar: @Composable () -> Unit = { DefaultBottomAppBarTestWrapper() },
showServerPicker: MutableState = mutableStateOf(false),
) {
- LoginView(dynamicBackgroundColor, loginUrlData, topAppBar, webView, loading, loadingIndicator, bottomAppBar, showServerPicker)
+ LoginView(
+ dynamicBackgroundColor = dynamicBackgroundColor,
+ loginUrlData = loginUrlData,
+ topAppBar = topAppBar,
+ webView = webView,
+ loading = loading,
+ loadingIndicator = loadingIndicator,
+ bottomAppBar = bottomAppBar,
+ showServerPicker = showServerPicker,
+ )
}
}
diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginForAdminTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginForAdminTests.kt
new file mode 100644
index 0000000000..d02143d080
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginForAdminTests.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2026-present, salesforce.com, inc.
+ * All rights reserved.
+ * Redistribution and use of this software in source and binary forms, with or
+ * without modification, are permitted provided that the following conditions
+ * are met:
+ * - Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * - Neither the name of salesforce.com, inc. nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission of salesforce.com, inc.
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.salesforce.samples.authflowtester
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.salesforce.samples.authflowtester.testUtility.AuthFlowTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for the "Login for Admins" menu flow.
+ *
+ * This menu item lives in the login WebView's overflow menu and hands off to a Chrome
+ * Custom Tab using the same OAuth authorize URL (always Web Server Flow + PKCE) while
+ * keeping the in-app WebView loaded underneath. It is primarily intended for orgs that
+ * require a browser-based admin sign-in (e.g., client certificates, SSO) even when the
+ * app itself is otherwise configured to use the in-app WebView.
+ */
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class LoginForAdminTests : AuthFlowTest() {
+
+ /**
+ * Login for Admins with the default WebView flow (Web Server Flow) enabled.
+ * The custom tab URL and the WebView URL are equivalent, so this exercises the
+ * "matches what the WebView would have loaded" hand-off path.
+ */
+ @Test
+ fun testLoginForAdmin_WebServerFlowEnabled() {
+ adminLoginAndValidate()
+ }
+
+ /**
+ * Login for Admins with the WebView configured for User Agent Flow.
+ * The custom tab must still use Web Server Flow (code + PKCE) so the OAuth
+ * callback can complete via `onNewIntent` -> `completeAdvAuthFlow`, even though
+ * the WebView itself would have used the token response type.
+ */
+ @Test
+ fun testLoginForAdmin_WebServerFlowDisabled() {
+ adminLoginAndValidate(useWebServerFlow = false)
+ }
+}
diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt
index 778686eac9..9310d8c6c3 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt
@@ -115,6 +115,23 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com
Thread.sleep(TIMEOUT_MS / 4)
}
+ /**
+ * Opens the top bar overflow menu and taps the "Login for Admins" item.
+ * The SDK then launches the OAuth authorize URL in a Chrome Custom Tab while
+ * the in-app WebView remains loaded underneath.
+ */
+ fun tapLoginForAdminsMenuItem() {
+ // Tap "More Options" three-dot menu (Compose IconButton)
+ composeTestRule.onNodeWithContentDescription(getString(R.string.sf__more_options))
+ .performClick()
+ composeTestRule.waitForIdle()
+
+ // Tap "Login for Admins" dropdown menu item
+ composeTestRule.onNodeWithText(getString(R.string.sf__login_for_admins))
+ .performClick()
+ composeTestRule.waitForIdle()
+ }
+
fun changeServer(knownLoginHostConfig: KnownLoginHostConfig) {
val url = testConfig.getLoginHost(knownLoginHostConfig).url
diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt
index d3f35c1504..26f076deb4 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt
@@ -158,6 +158,45 @@ abstract class AuthFlowTest {
app.validateApiRequest()
}
+ /**
+ * Exercises the "Login for Admins" flow: starts on the REGULAR_AUTH server (in-app
+ * WebView), opens the overflow menu, taps "Login for Admins" to launch a Chrome
+ * Custom Tab, completes login in Chrome, and validates the resulting user/tokens.
+ */
+ fun adminLoginAndValidate(useWebServerFlow: Boolean = true) {
+ val loginPage = LoginPageObject(composeTestRule)
+ val chromePage = ChromeCustomTabPageObject(composeTestRule)
+
+ ensureRegularAuthServer()
+
+ loginPage.openLoginOptions()
+ if (!useWebServerFlow) {
+ loginOptions.disableWebServerFlow()
+ }
+ loginOptions.setOverrideBootConfig(KnownAppConfig.BEACON_OPAQUE, scopeSelection = EMPTY)
+
+ // Launch the admin custom tab from the WebView login view.
+ loginPage.tapLoginForAdminsMenuItem()
+
+ // Complete login in Chrome. User credentials are the REGULAR_AUTH server's users
+ // since that is the selected login host; the admin flow just swaps the surface
+ // (WebView -> Chrome Custom Tab) without changing the target server.
+ chromePage.skipGoogleSignIn()
+ val (username, password) = testConfig.getUser(REGULAR_AUTH, user)
+ chromePage.setUsername(username)
+ chromePage.tapLogin()
+ chromePage.setPassword(password)
+ chromePage.tapLogin()
+
+ // OAuth approval page is rendered inside the Chrome Custom Tab.
+ AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(ADVANCED_AUTH)
+
+ app.waitForAppLoad()
+ app.validateUser(REGULAR_AUTH, user)
+ app.validateOAuthValues(KnownAppConfig.BEACON_OPAQUE, scopeSelection = EMPTY)
+ app.validateApiRequest()
+ }
+
fun migrateAndValidate(
knownAppConfig: KnownAppConfig,
knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH,