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,