diff --git a/libs/SalesforceSDK/AndroidManifest.xml b/libs/SalesforceSDK/AndroidManifest.xml
index 976c09369a..d544868de7 100644
--- a/libs/SalesforceSDK/AndroidManifest.xml
+++ b/libs/SalesforceSDK/AndroidManifest.xml
@@ -69,23 +69,6 @@
android:theme="@style/SalesforceSDK"
android:exported="true" />
-
-
-
-
-
-
-
-
-
+ val mainActivityClass: Class = mainActivity
/**
* Null or an authenticated Activity for private use when developer support
@@ -234,6 +232,16 @@ open class SalesforceSDKManager protected constructor(
*/
var loginViewModelFactory = LoginViewModel.Factory
+ /**
+ * Asynchronously retrieves the app config for the specified login host. If not set or null is
+ * returned the values found in the BootConfig file will be used for all servers.
+ */
+ var appConfigForLoginHost: suspend (server: String) -> OAuthConfig? = {
+ OAuthConfig(getBootConfig(appContext))
+ }
+
+ internal var debugOverrideAppConfig: OAuthConfig? = null
+
/** The class for the account switcher activity */
var accountSwitcherActivityClass = AccountSwitcherActivity::class.java
@@ -374,7 +382,6 @@ open class SalesforceSDKManager protected constructor(
@set:Synchronized
var useWebServerAuthentication = true
-
/**
* Whether or not the app supports welcome discovery. This should only be
* enabled if the connected app is supported.
@@ -432,6 +439,7 @@ open class SalesforceSDKManager protected constructor(
return _lightColorScheme ?: sfLightColors().also { _lightColorScheme = it }
}
+ @Suppress("unused")
fun setLightColorScheme(value: ColorScheme) {
_lightColorScheme = value
}
@@ -447,6 +455,7 @@ open class SalesforceSDKManager protected constructor(
return _darkColorScheme ?: sfDarkColors().also { _darkColorScheme = it }
}
+ @Suppress("unused")
fun setDarkColorScheme(value: ColorScheme) {
_darkColorScheme = value
}
@@ -531,7 +540,6 @@ open class SalesforceSDKManager protected constructor(
/** Initializer */
init {
- mainActivityClass = mainActivity
features = ConcurrentSkipListSet(CASE_INSENSITIVE_ORDER)
/*
@@ -615,7 +623,7 @@ open class SalesforceSDKManager protected constructor(
communityUrl: String,
reCaptchaSiteKeyId: String? = null,
googleCloudProjectId: String? = null,
- isReCaptchaEnterprise: Boolean = false
+ isReCaptchaEnterprise: Boolean = false,
): NativeLoginManagerInterface {
registerUsedAppFeature(FEATURE_NATIVE_LOGIN)
nativeLoginManager = NativeLoginManager(
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt
index 24f28bb111..b5dcc3c7cc 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt
@@ -111,7 +111,9 @@ internal suspend fun onAuthFlowComplete(
handleScreenLockPolicy: (userIdentity: OAuth2.IdServiceResponse?, account: UserAccount) -> Unit = ::handleScreenLockPolicy,
handleBiometricAuthPolicy: (userIdentity: OAuth2.IdServiceResponse?, account: UserAccount) -> Unit = ::handleBiometricAuthPolicy,
handleDuplicateUserAccount: (userAccountManager: UserAccountManager, account: UserAccount, userIdentity: OAuth2.IdServiceResponse?) -> Unit = ::handleDuplicateUserAccount,
- ) {
+) {
+ // Reset Dev Support LoginOptionsActivity override
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = null
// Note: Can't use default parameter value for suspended function parameter fetchUserIdentity
val actualFetchUserIdentity = fetchUserIdentity ?: ::fetchUserIdentity
@@ -140,14 +142,7 @@ internal suspend fun onAuthFlowComplete(
w(TAG, "Missing refresh token scope.")
}
- // Check that the tokenResponse.scope contains the identity scope before calling the identity service
- val userIdentity = if (scopeParser.hasIdentityScope()) {
- actualFetchUserIdentity(tokenResponse)
- } else {
- w(TAG, "Missing identity scope, skipping identity service call.")
- null
- }
-
+ val userIdentity = actualFetchUserIdentity(tokenResponse)
val mustBeManagedApp = userIdentity?.customPermissions?.optBoolean(MUST_BE_MANAGED_APP_PERM) ?: false
if (mustBeManagedApp && !runtimeConfig.isManagedApp) {
onAuthFlowError(
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt
new file mode 100644
index 0000000000..51ff03f7a0
--- /dev/null
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2025-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.androidsdk.config
+
+data class OAuthConfig(
+ val consumerKey: String,
+ val redirectUri: String,
+ val scopes: List? = null,
+) {
+
+ internal constructor(bootConfig: BootConfig): this(
+ bootConfig.remoteAccessConsumerKey,
+ bootConfig.oauthRedirectURI,
+ scopes = bootConfig.oauthScopes?.ifEmpty { null }?.toList(),
+ )
+
+ // Used by LoginOptionsActivity
+ internal constructor(consumerKey: String, redirectUri: String, scopes: String): this(
+ consumerKey.trim(),
+ redirectUri.trim(),
+ scopes = with(scopes) {
+ if (isNullOrBlank()) return@with null
+
+ return@with if (contains(",")) {
+ split(",")
+ } else {
+ split(" ")
+ }.map { it.trim() }
+ }
+ )
+
+ // Used by LoginOptionsActivity
+ internal val scopesString
+ get() = scopes?.joinToString(separator = " ") ?: ""
+}
\ No newline at end of file
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt
index 5580ca4bfe..47f8b56880 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt
@@ -157,9 +157,9 @@ data class DevSupportInfo(
"Consumer Key" to currentUser.clientId,
"Scopes" to currentUser.scope,
"Instance URL" to currentUser.instanceServer,
- "Token Format" to currentUser.tokenFormat,
+ "Token Format" to (currentUser.tokenFormat?.ifBlank { "Opaque" } ?: "Opaque"),
"Access Token Expiration" to accessTokenExpiration,
- "Beacon Child Consumer Key" to currentUser.beaconChildConsumerKey,
+ "Beacon Child Consumer Key" to (currentUser.beaconChildConsumerKey ?: "None"),
)
}
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
index 560e2ffaec..58481962a5 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
@@ -983,7 +983,7 @@ open class LoginActivity : FragmentActivity() {
) = salesforceWelcomeDiscoveryHostAndPathUrl.buildUpon()
.appendQueryParameter(
SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID,
- viewModel.bootConfig.remoteAccessConsumerKey
+ viewModel.oAuthConfig.redirectUri,
)
.appendQueryParameter(
SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION,
@@ -1138,7 +1138,7 @@ open class LoginActivity : FragmentActivity() {
}
val formattedUrl = request.url.toString().replace("///", "/").lowercase()
- val callbackUrl = viewModel.bootConfig.oauthRedirectURI.replace("///", "/").lowercase()
+ val callbackUrl = viewModel.oAuthConfig.redirectUri.replace("///", "/").lowercase()
val authFlowFinished = formattedUrl.startsWith(callbackUrl)
if (authFlowFinished) {
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt
index 551d8058ec..d6d118f0c0 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt
@@ -2,6 +2,7 @@ package com.salesforce.androidsdk.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
+import androidx.activity.compose.LocalActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
@@ -16,7 +17,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -47,8 +51,11 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.salesforce.androidsdk.R
+import com.salesforce.androidsdk.R.string.sf__server_url_save
import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.config.BootConfig
+import com.salesforce.androidsdk.config.OAuthConfig
+import com.salesforce.androidsdk.ui.components.CORNER_RADIUS
import com.salesforce.androidsdk.ui.components.PADDING_SIZE
import com.salesforce.androidsdk.ui.components.TEXT_SIZE
import com.salesforce.androidsdk.ui.theme.hintTextColor
@@ -104,11 +111,17 @@ class LoginOptionsActivity: ComponentActivity() {
useWebServer,
useHybridToken,
supportWelcomeDiscovery,
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig,
)
}
}
}
}
+
+ override fun finish() {
+ super.finish()
+ SalesforceSDKManager.getInstance().loginDevMenuReload = true
+ }
}
@Composable
@@ -129,10 +142,7 @@ fun OptionToggle(
)
Switch(
checked = checked,
- onCheckedChange = {
- optionData.value = it
- SalesforceSDKManager.getInstance().loginDevMenuReload = true
- },
+ onCheckedChange = { optionData.value = it },
modifier = Modifier.semantics {
this.contentDescription = contentDescription
}
@@ -141,18 +151,20 @@ fun OptionToggle(
}
@Composable
-fun BootConfigView() {
- var dynamicConsumerKey by remember { mutableStateOf("") }
- var dynamicRedirectUri by remember { mutableStateOf("") }
- var dynamicScopes by remember { mutableStateOf("") }
+fun BootConfigView(config: OAuthConfig? = null) {
+ var overrideConsumerKey by remember { mutableStateOf(config?.consumerKey ?: "") }
+ var overrideRedirectUri by remember { mutableStateOf(config?.redirectUri ?: "") }
+ var overrideScopes by remember { mutableStateOf(config?.scopesString ?: "") }
val consumerKeyFieldDesc = stringResource(R.string.sf__login_options_consumer_key_field_content_description)
val redirectFieldDesc = stringResource(R.string.sf__login_options_redirect_uri_field_content_description)
val scopesFieldDesc = stringResource(R.string.sf__login_options_scopes_field_content_description)
+ val validInput = overrideConsumerKey.isNotBlank() && overrideRedirectUri.isNotBlank()
+ val activity = LocalActivity.current
Column {
OutlinedTextField(
- value = dynamicConsumerKey,
- onValueChange = { dynamicConsumerKey = it },
+ value = overrideConsumerKey,
+ onValueChange = { overrideConsumerKey = it },
label = { Text("Consumer Key") },
singleLine = true,
modifier = Modifier
@@ -173,8 +185,8 @@ fun BootConfigView() {
)
OutlinedTextField(
- value = dynamicRedirectUri,
- onValueChange = { dynamicRedirectUri = it },
+ value = overrideRedirectUri,
+ onValueChange = { overrideRedirectUri = it },
label = { Text("Redirect URI") },
singleLine = true,
modifier = Modifier
@@ -195,8 +207,8 @@ fun BootConfigView() {
)
OutlinedTextField(
- value = dynamicScopes,
- onValueChange = { dynamicScopes = it },
+ value = overrideScopes,
+ onValueChange = { overrideScopes = it },
label = { Text("Scopes") },
singleLine = true,
modifier = Modifier
@@ -215,6 +227,33 @@ fun BootConfigView() {
cursorColor = colorScheme.tertiary,
),
)
+
+ Button(
+ modifier = Modifier.padding(PADDING_SIZE.dp).fillMaxWidth(),
+ shape = RoundedCornerShape(CORNER_RADIUS.dp),
+ contentPadding = PaddingValues(PADDING_SIZE.dp),
+ colors = ButtonColors(
+ containerColor = colorScheme.tertiary,
+ contentColor = colorScheme.tertiary,
+ disabledContainerColor = colorScheme.surfaceVariant,
+ disabledContentColor = colorScheme.surfaceVariant,
+ ),
+ enabled = validInput,
+ onClick = {
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = OAuthConfig(
+ overrideConsumerKey,
+ overrideRedirectUri,
+ overrideScopes,
+ )
+ activity?.finish()
+ },
+ ) {
+ Text(
+ text = stringResource(sf__server_url_save),
+ fontWeight = if (validInput) FontWeight.Normal else FontWeight.Medium,
+ color = if (validInput) colorScheme.onPrimary else colorScheme.onErrorContainer,
+ )
+ }
}
}
@@ -245,9 +284,10 @@ fun LoginOptionsScreen(
useWebServer: MutableLiveData,
useHybridToken: MutableLiveData,
supportWelcomeDiscovery: MutableLiveData,
+ overrideConfig: OAuthConfig?,
bootConfig: BootConfig = BootConfig.getBootConfig(LocalContext.current),
) {
- var useDynamicConfig by remember { mutableStateOf(false) }
+ var useDynamicConfig by remember { mutableStateOf(overrideConfig != null) }
Column(
modifier = Modifier
@@ -296,13 +336,19 @@ fun LoginOptionsScreen(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
- text = "Use Dynamic Boot Config",
+ text = "Override Boot Config",
modifier = Modifier.height( 50.dp).wrapContentHeight(align = Alignment.CenterVertically),
)
val dynamicConfigToggleDesc = stringResource(R.string.sf__login_options_dynamic_config_toggle_content_description)
Switch(
checked = useDynamicConfig,
- onCheckedChange = { useDynamicConfig = it },
+ onCheckedChange = {
+ useDynamicConfig = it
+ // Reset the stored value on uncheck so it is not used.
+ if (!useDynamicConfig) {
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = null
+ }
+ },
modifier = Modifier.semantics {
contentDescription = dynamicConfigToggleDesc
}
@@ -310,7 +356,7 @@ fun LoginOptionsScreen(
}
if (useDynamicConfig) {
- BootConfigView()
+ BootConfigView(overrideConfig)
}
}
}
@@ -331,6 +377,19 @@ fun BootConfigViewPreview() {
BootConfigView()
}
+@ExcludeFromJacocoGeneratedReport
+@Preview
+@Composable
+fun BootConfigViewFilledPreview() {
+ val config = OAuthConfig(
+ stringResource(R.string.remoteAccessConsumerKey),
+ stringResource(R.string.oauthRedirectURI),
+ listOf("web", "api"),
+ )
+
+ BootConfigView(config)
+}
+
@ExcludeFromJacocoGeneratedReport
@Preview(showBackground = true)
@Composable
@@ -343,6 +402,7 @@ fun LoginOptionsScreenPreview() {
useWebServer = MutableLiveData(true),
useHybridToken = MutableLiveData(false),
supportWelcomeDiscovery = MutableLiveData(false),
+ overrideConfig = null,
bootConfig = object : BootConfig() {
override fun getRemoteAccessConsumerKey() = consumerKey
override fun getOauthRedirectURI() = redirect
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt
index bc2b7be6c9..8248e54e26 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt
@@ -26,6 +26,7 @@
*/
package com.salesforce.androidsdk.ui
+import android.annotation.SuppressLint
import android.webkit.CookieManager
import android.webkit.URLUtil
import android.webkit.WebView
@@ -42,6 +43,7 @@ import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
+import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.salesforce.androidsdk.R.string.oauth_display_type
import com.salesforce.androidsdk.R.string.sf__login_with_biometric
@@ -57,6 +59,7 @@ import com.salesforce.androidsdk.auth.OAuth2.getFrontdoorUrl
import com.salesforce.androidsdk.auth.defaultBuildAccountName
import com.salesforce.androidsdk.auth.onAuthFlowComplete
import com.salesforce.androidsdk.config.BootConfig
+import com.salesforce.androidsdk.config.OAuthConfig
import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getRandom128ByteKey
import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getSHA256Hash
import com.salesforce.androidsdk.ui.LoginActivity.Companion.ABOUT_BLANK
@@ -139,6 +142,8 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
var jwt: String? = null
/** Connected App/External Client App client Id. */
+ @Deprecated("Will be removed in Mobile SDK 14.0, please use " +
+ "SalesforceSDKManager.getInstance().appConfigForLoginHost.")
protected open var clientId: String = bootConfig.remoteAccessConsumerKey
/** Authorization Display Type used for login. */
@@ -206,22 +211,37 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
internal var frontdoorBridgeCodeVerifier: String? = null
+ // Dynamic OAuth Config - initialized with bootConfig, then updated asynchronously
+ internal var oAuthConfig = OAuthConfig(bootConfig)
+ private val consumerKey: String
+ get() = if (clientId != bootConfig.remoteAccessConsumerKey) {
+ clientId
+ } else {
+ oAuthConfig.consumerKey
+ }
+
init {
// Update selectedServer when the LoginServerManager value changes
selectedServer.addSource(SalesforceSDKManager.getInstance().loginServerManager.selectedServer) { newServer ->
- val trimmedServer = newServer.url.run { trim { it <= ' ' } }
- if (selectedServer.value == trimmedServer) {
- reloadWebView()
- } else {
- selectedServer.value = trimmedServer
+ val trimmedServer = newServer?.url?.run { trim { it <= ' ' } }
+ trimmedServer?.let { nonNullServer ->
+ if (selectedServer.value == nonNullServer) {
+ reloadWebView()
+ } else {
+ selectedServer.value = nonNullServer
+ }
}
}
// Update loginUrl when selectedServer updates so webview automatically reloads
- loginUrl.addSource(selectedServer) { newServer ->
- val isNewServer = loginUrl.value?.startsWith(newServer) != true
- if (isNewServer && !isUsingFrontDoorBridge) {
- loginUrl.value = getAuthorizationUrl(newServer)
+ loginUrl.addSource(selectedServer) { newServer: String? ->
+ if (!isUsingFrontDoorBridge && newServer != null) {
+ val isNewServer = loginUrl.value?.startsWith(newServer) != true
+ if (isNewServer) {
+ viewModelScope.launch {
+ loginUrl.value = getAuthorizationUrl(newServer)
+ }
+ }
}
}
}
@@ -229,12 +249,17 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
/** Reloads the WebView with a newly generated authorization URL. */
open fun reloadWebView() {
if (!isUsingFrontDoorBridge) {
- // The Web Server Flow code challenge makes the authorization url unique each time,
- // which triggers recomposition. For User Agent Flow, change it to blank.
- if (!SalesforceSDKManager.getInstance().useWebServerAuthentication) {
- loginUrl.value = ABOUT_BLANK
+ selectedServer.value?.let { server ->
+ // The Web Server Flow code challenge makes the authorization url unique each time,
+ // which triggers recomposition. For User Agent Flow, change it to blank.
+ if (!SalesforceSDKManager.getInstance().useWebServerAuthentication) {
+ loginUrl.value = ABOUT_BLANK
+ }
+
+ viewModelScope.launch {
+ loginUrl.value = getAuthorizationUrl(server)
+ }
}
- loginUrl.value = getAuthorizationUrl(selectedServer.value ?: return)
}
}
@@ -287,6 +312,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
* Called when the webview portion of the User Agent flow or the code exchange
* portion of the Web Server is finished to create and the user.
*/
+ @SuppressLint("VisibleForTests") // onAuthFlowComplete cannot be otherwise internal.
internal suspend fun onAuthFlowComplete(
tr: TokenEndpointResponse,
onAuthFlowError: (error: String, errorDesc: String?, e: Throwable?) -> Unit,
@@ -299,7 +325,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
onAuthFlowComplete(
tokenResponse = tr,
loginServer = selectedServer.value ?: "", // This will never actually be null.
- consumerKey = clientId,
+ consumerKey = consumerKey,
onAuthFlowError = onAuthFlowError,
onAuthFlowSuccess = onAuthFlowSuccess,
buildAccountName = ::buildAccountName,
@@ -339,7 +365,19 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
}?.removeSuffix("/")
}
- private fun getAuthorizationUrl(server: String): String {
+ private suspend fun getAuthorizationUrl(server: String): String = withContext(IO) {
+ // Show loading indicator because appConfigForLoginHost could take a noticeable amount of time.
+ loading.value = true
+
+ with(SalesforceSDKManager.getInstance()) {
+ // Check if the OAuth Config has been manually set by dev support LoginOptionsActivity.
+ oAuthConfig = if (isDebugBuild && debugOverrideAppConfig != null) {
+ debugOverrideAppConfig!!
+ } else {
+ appConfigForLoginHost(server) ?: OAuthConfig(bootConfig)
+ }
+ }
+
val jwtFlow = !jwt.isNullOrBlank() && !authCodeForJwtFlow.isNullOrBlank()
val additionalParams = when {
jwtFlow -> null
@@ -353,16 +391,16 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
useWebServerFlow,
SalesforceSDKManager.getInstance().useHybridAuthentication,
URI(server),
- clientId,
- bootConfig.oauthRedirectURI,
- bootConfig.oauthScopes,
+ consumerKey,
+ oAuthConfig.redirectUri,
+ oAuthConfig.scopes?.toTypedArray(),
loginHint,
authorizationDisplayType,
codeChallenge,
additionalParams
)
- return when {
+ return@withContext when {
jwtFlow -> getFrontdoorUrl(
authorizationUrl,
authCodeForJwtFlow,
@@ -386,10 +424,10 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
val tokenResponse = exchangeCode(
HttpAccess.DEFAULT,
URI.create(server),
- clientId,
+ consumerKey,
code,
verifier,
- bootConfig.oauthRedirectURI,
+ oAuthConfig.redirectUri,
)
onAuthFlowComplete(tokenResponse, onAuthFlowError, onAuthFlowSuccess)
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 227cdb9d3d..b390d251fc 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt
@@ -229,20 +229,23 @@ internal fun LoginView(
contentWindowInsets = WindowInsets.safeDrawing,
topBar = topAppBar,
) { innerPadding ->
- if (loading) {
- loadingIndicator()
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Load the WebView as a composable
+ AndroidView(
+ modifier = Modifier
+ .background(dynamicBackgroundColor.value)
+ .padding(innerPadding)
+ .consumeWindowInsets(innerPadding)
+ .applyImePaddingConditionally()
+ .graphicsLayer(alpha = alpha),
+ factory = { webView },
+ update = { it.loadUrl(loginUrl.value ?: "") },
+ )
+
+ if (loading) {
+ loadingIndicator()
+ }
}
- // Load the WebView as a composable
- AndroidView(
- modifier = Modifier
- .background(dynamicBackgroundColor.value)
- .padding(innerPadding)
- .consumeWindowInsets(innerPadding)
- .applyImePaddingConditionally()
- .graphicsLayer(alpha = alpha),
- factory = { webView },
- update = { it.loadUrl(loginUrl.value ?: "") },
- )
if (showServerPicker.value) {
PickerBottomSheet(PickerStyle.LoginServerPicker)
diff --git a/libs/SalesforceSDK/src/debug/AndroidManifest.xml b/libs/SalesforceSDK/src/debug/AndroidManifest.xml
index b56e6a35b4..2a96ba4339 100644
--- a/libs/SalesforceSDK/src/debug/AndroidManifest.xml
+++ b/libs/SalesforceSDK/src/debug/AndroidManifest.xml
@@ -1,5 +1,24 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerAuthConfigTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerAuthConfigTest.kt
new file mode 100644
index 0000000000..f13dc940b0
--- /dev/null
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerAuthConfigTest.kt
@@ -0,0 +1,185 @@
+/*
+ * Copyright (c) 2025-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.androidsdk.app
+
+import android.app.Activity
+import android.content.Context
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.salesforce.androidsdk.MainActivity
+import com.salesforce.androidsdk.config.BootConfig
+import com.salesforce.androidsdk.config.OAuthConfig
+import com.salesforce.androidsdk.ui.LoginActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for SalesforceSDKManager's appConfigForLoginHost and debugOverrideAppConfig functionality.
+ */
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class SalesforceSDKManagerAuthConfigTest {
+
+ private lateinit var sdkManager: TestSalesforceSDKManager
+ private lateinit var targetContext: Context
+
+ @Before
+ fun setUp() {
+ targetContext = InstrumentationRegistry.getInstrumentation().targetContext
+ TestSalesforceSDKManager.init(targetContext, MainActivity::class.java)
+ sdkManager = TestSalesforceSDKManager.getInstance() as TestSalesforceSDKManager
+ sdkManager.isTestRun = true
+ }
+
+ @After
+ fun tearDown() {
+ // Reset debug override
+ sdkManager.debugOverrideAppConfig = null
+ TestSalesforceSDKManager.resetInstance()
+ }
+
+ @Test
+ fun testAppConfigForLoginHostDefaultBehavior() = runBlocking {
+ val bootConfig = BootConfig.getBootConfig(InstrumentationRegistry.getInstrumentation().targetContext)
+ // Default implementation should return config from BootConfig
+ val config = sdkManager.appConfigForLoginHost("https://login.salesforce.com")!!
+
+ assertNotNull("Config should not be null", config)
+ assertEquals(
+ "Consumer key should match BootConfig",
+ bootConfig.remoteAccessConsumerKey,
+ config.consumerKey
+ )
+ assertEquals(
+ "Redirect URI should match BootConfig",
+ bootConfig.oauthRedirectURI,
+ config.redirectUri
+ )
+ }
+
+ @Test
+ fun testAppConfigForLoginHostWithDifferentServers() = runBlocking {
+ // Default implementation ignores server parameter and returns same config
+ val loginConfig = sdkManager.appConfigForLoginHost("https://login.salesforce.com")
+ val testConfig = sdkManager.appConfigForLoginHost("https://test.salesforce.com")
+ val customConfig = sdkManager.appConfigForLoginHost("https://custom.my.salesforce.com")
+
+ assertEquals("All servers should return same config by default", loginConfig, testConfig)
+ assertEquals("All servers should return same config by default", loginConfig, customConfig)
+ }
+
+ @Test
+ fun testAppConfigForLoginHostCanBeReassigned() = runBlocking {
+ // First implementation
+ sdkManager.appConfigForLoginHost = { _ ->
+ OAuthConfig("key1", "uri1", listOf("api"))
+ }
+ val config1 = sdkManager.appConfigForLoginHost("https://test.com")!!
+ assertEquals("key1", config1.consumerKey)
+
+ // Reassign to different implementation
+ sdkManager.appConfigForLoginHost = { _ ->
+ OAuthConfig("key2", "uri2", listOf("web"))
+ }
+ val config2 = sdkManager.appConfigForLoginHost("https://test.com")!!
+ assertEquals("key2", config2.consumerKey)
+ }
+
+ @Test
+ fun testDebugOverrideAppConfigDefaultIsNull() {
+ assertNull("debugOverrideAppConfig should be null by default", sdkManager.debugOverrideAppConfig)
+ }
+
+ @Test
+ fun testDebugOverrideAppConfigCanBeSet() {
+ val overrideConfig = OAuthConfig(
+ consumerKey = "override_key",
+ redirectUri = "override://callback",
+ scopes = listOf("api", "web", "refresh_token")
+ )
+
+ sdkManager.debugOverrideAppConfig = overrideConfig
+
+ assertNotNull("debugOverrideAppConfig should not be null", sdkManager.debugOverrideAppConfig)
+ assertEquals("override_key", sdkManager.debugOverrideAppConfig?.consumerKey)
+ assertEquals("override://callback", sdkManager.debugOverrideAppConfig?.redirectUri)
+ assertEquals(listOf("api", "web", "refresh_token"), sdkManager.debugOverrideAppConfig?.scopes)
+ }
+
+ @Test
+ fun testDebugOverrideAppConfigCanBeCleared() {
+ val overrideConfig = OAuthConfig(
+ consumerKey = "override_key",
+ redirectUri = "override://callback",
+ scopes = listOf("api")
+ )
+
+ sdkManager.debugOverrideAppConfig = overrideConfig
+ assertNotNull("debugOverrideAppConfig should be set", sdkManager.debugOverrideAppConfig)
+
+ sdkManager.debugOverrideAppConfig = null
+ assertNull("debugOverrideAppConfig should be null after clearing", sdkManager.debugOverrideAppConfig)
+ }
+
+ /**
+ * Test version of SalesforceSDKManager that doesn't interfere with other tests.
+ */
+ private class TestSalesforceSDKManager(
+ context: Context,
+ mainActivity: Class,
+ loginActivity: Class,
+ ) : SalesforceSDKManager(context, mainActivity, loginActivity) {
+
+ companion object {
+ private var TEST_INSTANCE: TestSalesforceSDKManager? = null
+
+ fun init(context: Context, mainActivity: Class) {
+ if (TEST_INSTANCE == null) {
+ TEST_INSTANCE = TestSalesforceSDKManager(context, mainActivity, LoginActivity::class.java)
+ }
+ initInternal(context)
+ }
+
+ fun getInstance(): SalesforceSDKManager {
+ return TEST_INSTANCE ?: throw RuntimeException(
+ "Applications need to call TestSalesforceSDKManager.init() first."
+ )
+ }
+
+ fun resetInstance() {
+ TEST_INSTANCE = null
+ }
+ }
+ }
+}
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt
index 55bb12ae4e..61bed72135 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt
@@ -177,6 +177,9 @@ class AuthenticationUtilitiesTest {
val tokenResponseWithoutIdScope = createTokenEndpointResponse(
scope = "refresh_token" // Missing id scope
)
+
+ // Mock fetchUserIdentity to return null (simulating no id scope)
+ coEvery { fetchUserIdentity.invoke(any()) } returns null
// Create the expected UserAccount object without IdServiceResponse population
val expectedAccount = UserAccountBuilder.getInstance()
@@ -204,8 +207,8 @@ class AuthenticationUtilitiesTest {
verify { handleScreenLockPolicy.invoke(null, expectedAccount) }
verify { handleBiometricAuthPolicy.invoke(null, expectedAccount) }
- // Verify that fetchUserIdentity was never called since there's no id scope
- coVerify(exactly = 0) { fetchUserIdentity.invoke(any()) }
+ // Verify that fetchUserIdentity was called but returned null
+ coVerify(exactly = 1) { fetchUserIdentity.invoke(tokenResponseWithoutIdScope) }
}
private suspend fun callOnAuthFlowComplete(customTokenResponse: OAuth2.TokenEndpointResponse? = null) {
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt
new file mode 100644
index 0000000000..0e8d772a06
--- /dev/null
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt
@@ -0,0 +1,553 @@
+/*
+ * Copyright (c) 2025-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.androidsdk.auth
+
+import android.webkit.CookieManager
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.salesforce.androidsdk.accounts.UserAccount
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse
+import com.salesforce.androidsdk.config.BootConfig
+import com.salesforce.androidsdk.ui.LoginViewModel
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.net.URI
+
+/**
+ * Tests for LoginViewModel that require mocking.
+ * These tests are separated from LoginViewModelTest to isolate mock usage.
+ */
+@RunWith(AndroidJUnit4::class)
+class LoginViewModelMockTest {
+ @get:Rule
+ val instantExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule()
+
+ private val context = InstrumentationRegistry.getInstrumentation().context
+ private val bootConfig = BootConfig.getBootConfig(context)
+ private lateinit var viewModel: LoginViewModel
+ private lateinit var mockCookieManager: CookieManager
+
+ @Before
+ fun setup() {
+ // Mock CookieManager to avoid WebView dependencies
+ mockkStatic(CookieManager::class)
+ mockCookieManager = mockk(relaxed = true)
+ every { CookieManager.getInstance() } returns mockCookieManager
+
+ // Create view model after mocking
+ viewModel = LoginViewModel(bootConfig)
+
+ // This is required for the LiveData to actually update during the test
+ viewModel.selectedServer.observeForever { }
+ viewModel.loginUrl.observeForever { }
+
+ // Give the LiveData sources time to propagate
+ Thread.sleep(100)
+ }
+
+ @After
+ fun teardown() {
+ SalesforceSDKManager.getInstance().loginServerManager.reset()
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = null
+ unmockkAll()
+ }
+
+ @Test
+ fun onAuthFlowComplete_CallsAuthenticationUtilities_WithCorrectParameters() = runBlocking {
+ // Mock the AuthenticationUtilities.onAuthFlowComplete function
+ mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt")
+
+ // Mock the function to do nothing (just capture parameters)
+ coEvery {
+ onAuthFlowComplete(
+ tokenResponse = any(),
+ loginServer = any(),
+ consumerKey = any(),
+ onAuthFlowError = any(),
+ onAuthFlowSuccess = any(),
+ buildAccountName = any(),
+ nativeLogin = any(),
+ context = any(),
+ userAccountManager = any(),
+ blockIntegrationUser = any(),
+ runtimeConfig = any(),
+ updateLoggingPrefs = any(),
+ fetchUserIdentity = any(),
+ startMainActivity = any(),
+ setAdministratorPreferences = any(),
+ addAccount = any(),
+ handleScreenLockPolicy = any(),
+ handleBiometricAuthPolicy = any(),
+ handleDuplicateUserAccount = any(),
+ )
+ } returns Unit
+
+ // Create test data
+ val testServer = "https://test.salesforce.com"
+ val mockTokenResponse = mockk(relaxed = true)
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+
+ // Set up the view model state
+ viewModel.selectedServer.value = testServer
+ Thread.sleep(100)
+
+ // Call the method under test
+ viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess)
+
+ // Verify AuthenticationUtilities.onAuthFlowComplete was called with correct parameters
+ coVerify {
+ onAuthFlowComplete(
+ tokenResponse = eq(mockTokenResponse),
+ loginServer = eq(testServer),
+ consumerKey = eq(bootConfig.remoteAccessConsumerKey),
+ onAuthFlowError = any(),
+ onAuthFlowSuccess = any(),
+ buildAccountName = any(),
+ nativeLogin = any(),
+ context = any(),
+ userAccountManager = any(),
+ blockIntegrationUser = any(),
+ runtimeConfig = any(),
+ updateLoggingPrefs = any(),
+ fetchUserIdentity = any(),
+ startMainActivity = any(),
+ setAdministratorPreferences = any(),
+ addAccount = any(),
+ handleScreenLockPolicy = any(),
+ handleBiometricAuthPolicy = any(),
+ handleDuplicateUserAccount = any(),
+ )
+ }
+ }
+
+ @Test
+ fun onAuthFlowComplete_CallsAuthenticationUtilitiesSuccessfully() = runBlocking {
+ // Mock the AuthenticationUtilities.onAuthFlowComplete function
+ mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt")
+
+ coEvery {
+ onAuthFlowComplete(
+ tokenResponse = any(),
+ loginServer = any(),
+ consumerKey = any(),
+ onAuthFlowError = any(),
+ onAuthFlowSuccess = any(),
+ buildAccountName = any(),
+ nativeLogin = any(),
+ context = any(),
+ userAccountManager = any(),
+ blockIntegrationUser = any(),
+ runtimeConfig = any(),
+ updateLoggingPrefs = any(),
+ fetchUserIdentity = any(),
+ startMainActivity = any(),
+ setAdministratorPreferences = any(),
+ addAccount = any(),
+ handleScreenLockPolicy = any(),
+ handleBiometricAuthPolicy = any(),
+ handleDuplicateUserAccount = any(),
+ )
+ } returns Unit
+
+ val mockTokenResponse = mockk(relaxed = true)
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+
+ // Set up the view model state
+ viewModel.selectedServer.value = "https://test.salesforce.com"
+ Thread.sleep(100)
+
+ // Call the method under test
+ viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess)
+
+ // Verify onAuthFlowComplete was called, which confirms the method executed successfully
+ coVerify {
+ mockCookieManager.removeAllCookies(null)
+ onAuthFlowComplete(
+ tokenResponse = mockTokenResponse,
+ loginServer = any(),
+ consumerKey = any(),
+ onAuthFlowError = any(),
+ onAuthFlowSuccess = any(),
+ buildAccountName = any(),
+ nativeLogin = any(),
+ context = any(),
+ userAccountManager = any(),
+ blockIntegrationUser = any(),
+ runtimeConfig = any(),
+ updateLoggingPrefs = any(),
+ fetchUserIdentity = any(),
+ startMainActivity = any(),
+ setAdministratorPreferences = any(),
+ addAccount = any(),
+ handleScreenLockPolicy = any(),
+ handleBiometricAuthPolicy = any(),
+ handleDuplicateUserAccount = any(),
+ )
+ }
+ }
+
+ @Test
+ fun onAuthFlowComplete_ResetsAuthCodeForJwtFlow() = runBlocking {
+ // Mock the AuthenticationUtilities.onAuthFlowComplete function
+ mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt")
+
+ coEvery {
+ onAuthFlowComplete(
+ tokenResponse = any(),
+ loginServer = any(),
+ consumerKey = any(),
+ onAuthFlowError = any(),
+ onAuthFlowSuccess = any(),
+ buildAccountName = any(),
+ nativeLogin = any(),
+ context = any(),
+ userAccountManager = any(),
+ blockIntegrationUser = any(),
+ runtimeConfig = any(),
+ updateLoggingPrefs = any(),
+ fetchUserIdentity = any(),
+ startMainActivity = any(),
+ setAdministratorPreferences = any(),
+ addAccount = any(),
+ handleScreenLockPolicy = any(),
+ handleBiometricAuthPolicy = any(),
+ handleDuplicateUserAccount = any(),
+ )
+ } returns Unit
+
+ val mockTokenResponse = mockk(relaxed = true)
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+
+ // Set up the view model state with JWT flow
+ viewModel.selectedServer.value = "https://test.salesforce.com"
+ viewModel.authCodeForJwtFlow = "test_jwt_auth_code"
+ Thread.sleep(100)
+
+ // Verify authCodeForJwtFlow is set
+ assertNotNull("authCodeForJwtFlow should be set before call", viewModel.authCodeForJwtFlow)
+
+ // Call the method under test
+ viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess)
+
+ // Verify authCodeForJwtFlow is reset to null
+ assertNull("authCodeForJwtFlow should be null after call", viewModel.authCodeForJwtFlow)
+ }
+
+ @Test
+ fun onAuthFlowComplete_UsesEmptyString_WhenSelectedServerIsNull() = runBlocking {
+ // Mock the AuthenticationUtilities.onAuthFlowComplete function
+ mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt")
+
+ coEvery {
+ onAuthFlowComplete(
+ tokenResponse = any(),
+ loginServer = any(),
+ consumerKey = any(),
+ onAuthFlowError = any(),
+ onAuthFlowSuccess = any(),
+ buildAccountName = any(),
+ nativeLogin = any(),
+ context = any(),
+ userAccountManager = any(),
+ blockIntegrationUser = any(),
+ runtimeConfig = any(),
+ updateLoggingPrefs = any(),
+ fetchUserIdentity = any(),
+ startMainActivity = any(),
+ setAdministratorPreferences = any(),
+ addAccount = any(),
+ handleScreenLockPolicy = any(),
+ handleBiometricAuthPolicy = any(),
+ handleDuplicateUserAccount = any(),
+ )
+ } returns Unit
+
+ val mockTokenResponse = mockk(relaxed = true)
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+
+ // Set selectedServer to null
+ viewModel.selectedServer.value = null
+ Thread.sleep(100)
+
+ // Call the method under test
+ viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess)
+
+ // Verify empty string is used when selectedServer is null
+ coVerify {
+ onAuthFlowComplete(
+ tokenResponse = eq(mockTokenResponse),
+ loginServer = eq(""),
+ consumerKey = any(),
+ onAuthFlowError = any(),
+ onAuthFlowSuccess = any(),
+ buildAccountName = any(),
+ nativeLogin = any(),
+ context = any(),
+ userAccountManager = any(),
+ blockIntegrationUser = any(),
+ runtimeConfig = any(),
+ updateLoggingPrefs = any(),
+ fetchUserIdentity = any(),
+ startMainActivity = any(),
+ setAdministratorPreferences = any(),
+ addAccount = any(),
+ handleScreenLockPolicy = any(),
+ handleBiometricAuthPolicy = any(),
+ handleDuplicateUserAccount = any(),
+ )
+ }
+ }
+
+ @Test
+ fun doCodeExchange_CallsExchangeCode_WithCorrectParameters() = runBlocking {
+ // Mock OAuth2.exchangeCode
+ mockkStatic(OAuth2::class)
+
+ val testServer = "https://test.salesforce.com"
+ val testCode = "test_auth_code_123"
+ val mockTokenResponse = mockk(relaxed = true)
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+
+ // Mock AuthenticationUtilities.onAuthFlowComplete to prevent actual execution
+ mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt")
+ coEvery {
+ onAuthFlowComplete(
+ tokenResponse = any(),
+ loginServer = any(),
+ consumerKey = any(),
+ onAuthFlowError = any(),
+ onAuthFlowSuccess = any(),
+ buildAccountName = any(),
+ nativeLogin = any(),
+ context = any(),
+ userAccountManager = any(),
+ blockIntegrationUser = any(),
+ runtimeConfig = any(),
+ updateLoggingPrefs = any(),
+ fetchUserIdentity = any(),
+ startMainActivity = any(),
+ setAdministratorPreferences = any(),
+ addAccount = any(),
+ handleScreenLockPolicy = any(),
+ handleBiometricAuthPolicy = any(),
+ handleDuplicateUserAccount = any(),
+ )
+ } returns Unit
+
+ // Mock exchangeCode to return our mock token response
+ every {
+ OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
+ } returns mockTokenResponse
+
+ // Set up the view model state
+ viewModel.selectedServer.value = testServer
+ Thread.sleep(100)
+
+ // Call the method under test via onWebServerFlowComplete
+ viewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess)
+
+ // Give time for the coroutine to execute
+ Thread.sleep(200)
+
+ // Verify exchangeCode was called with correct parameters
+ verify {
+ OAuth2.exchangeCode(
+ HttpAccess.DEFAULT,
+ URI.create(testServer),
+ bootConfig.remoteAccessConsumerKey,
+ testCode,
+ viewModel.codeVerifier,
+ bootConfig.oauthRedirectURI,
+ )
+ }
+ }
+
+ @Test
+ fun doCodeExchange_WithFrontDoorBridge_UsesCorrectServerAndVerifier() = runBlocking {
+ // Mock OAuth2.exchangeCode
+ mockkStatic(OAuth2::class)
+
+ val frontDoorServer = "https://frontdoor.salesforce.com"
+ val frontDoorUrl = "$frontDoorServer/frontdoor.jsp?sid=test_session"
+ val frontDoorVerifier = "frontdoor_verifier_789"
+ val testCode = "test_auth_code_123"
+ val mockTokenResponse = mockk(relaxed = true)
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+
+ // Mock AuthenticationUtilities.onAuthFlowComplete
+ mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt")
+ coEvery {
+ onAuthFlowComplete(
+ tokenResponse = any(),
+ loginServer = any(),
+ consumerKey = any(),
+ onAuthFlowError = any(),
+ onAuthFlowSuccess = any(),
+ buildAccountName = any(),
+ nativeLogin = any(),
+ context = any(),
+ userAccountManager = any(),
+ blockIntegrationUser = any(),
+ runtimeConfig = any(),
+ updateLoggingPrefs = any(),
+ fetchUserIdentity = any(),
+ startMainActivity = any(),
+ setAdministratorPreferences = any(),
+ addAccount = any(),
+ handleScreenLockPolicy = any(),
+ handleBiometricAuthPolicy = any(),
+ handleDuplicateUserAccount = any(),
+ )
+ } returns Unit
+
+ // Mock exchangeCode
+ every {
+ OAuth2.exchangeCode(
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ )
+ } returns mockTokenResponse
+
+ // Set up front door bridge
+ viewModel.loginWithFrontDoorBridgeUrl(frontDoorUrl, frontDoorVerifier)
+ Thread.sleep(100)
+
+ // Call the method under test
+ viewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess)
+
+ // Give time for the coroutine to execute
+ Thread.sleep(200)
+
+ // Verify exchangeCode uses frontdoor server and verifier
+ verify {
+ OAuth2.exchangeCode(
+ HttpAccess.DEFAULT,
+ URI.create(frontDoorServer),
+ bootConfig.remoteAccessConsumerKey,
+ testCode,
+ frontDoorVerifier,
+ bootConfig.oauthRedirectURI,
+ )
+ }
+ }
+
+ @Test
+ fun doCodeExchange_WithNullCode_PassesNullToExchangeCode() = runBlocking {
+ // Mock OAuth2.exchangeCode
+ mockkStatic(OAuth2::class)
+
+ val testServer = "https://test.salesforce.com"
+ val mockTokenResponse = mockk(relaxed = true)
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+
+ // Mock AuthenticationUtilities.onAuthFlowComplete
+ mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt")
+ coEvery {
+ onAuthFlowComplete(
+ tokenResponse = any(),
+ loginServer = any(),
+ consumerKey = any(),
+ onAuthFlowError = any(),
+ onAuthFlowSuccess = any(),
+ buildAccountName = any(),
+ nativeLogin = any(),
+ context = any(),
+ userAccountManager = any(),
+ blockIntegrationUser = any(),
+ runtimeConfig = any(),
+ updateLoggingPrefs = any(),
+ fetchUserIdentity = any(),
+ startMainActivity = any(),
+ setAdministratorPreferences = any(),
+ addAccount = any(),
+ handleScreenLockPolicy = any(),
+ handleBiometricAuthPolicy = any(),
+ handleDuplicateUserAccount = any(),
+ )
+ } returns Unit
+
+ // Mock exchangeCode
+ every {
+ OAuth2.exchangeCode(
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ )
+ } returns mockTokenResponse
+
+ // Set up the view model state
+ viewModel.selectedServer.value = testServer
+ Thread.sleep(100)
+
+ // Call with null code
+ viewModel.onWebServerFlowComplete(null, mockOnError, mockOnSuccess)
+
+ // Give time for the coroutine to execute
+ Thread.sleep(200)
+
+ // Verify exchangeCode was called with null code
+ verify {
+ OAuth2.exchangeCode(
+ HttpAccess.DEFAULT,
+ URI.create(testServer),
+ bootConfig.remoteAccessConsumerKey,
+ null,
+ viewModel.codeVerifier,
+ bootConfig.oauthRedirectURI,
+ )
+ }
+ }
+}
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 6305a8f7f9..19c1209bff 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt
@@ -34,6 +34,7 @@ import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.auth.OAuth2.getFrontdoorUrl
import com.salesforce.androidsdk.config.BootConfig
import com.salesforce.androidsdk.config.LoginServerManager.LoginServer
+import com.salesforce.androidsdk.config.OAuthConfig
import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getSHA256Hash
import com.salesforce.androidsdk.ui.LoginActivity.Companion.ABOUT_BLANK
import com.salesforce.androidsdk.ui.LoginViewModel
@@ -69,12 +70,15 @@ class LoginViewModelTest {
// because it isn't actually being observed since there is no lifecycle.
viewModel.selectedServer.observeForever { }
viewModel.loginUrl.observeForever { }
-
+
+ // Give the LiveData sources time to propagate through the MediatorLiveData
+ Thread.sleep(100)
}
@After
fun teardown() {
SalesforceSDKManager.getInstance().loginServerManager.reset()
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = null
}
// Google's recommended naming scheme for view model test is "thingUnderTest_TriggerOfTest_ResultOfTest"
@@ -117,11 +121,19 @@ class LoginViewModelTest {
@Test
fun loginUrl_UpdatesOn_selectedServerChange() {
+ // Wait for initial values to be set
+ assertNotNull(viewModel.selectedServer.value)
+ assertNotNull(viewModel.loginUrl.value)
+
assertNotEquals(FAKE_SERVER_URL, viewModel.selectedServer.value)
assertTrue(viewModel.loginUrl.value!!.startsWith(viewModel.selectedServer.value!!))
assertFalse(viewModel.loginUrl.value!!.startsWith(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))
}
@@ -133,6 +145,8 @@ class LoginViewModelTest {
assertEquals(originalAuthUrl, viewModel.loginUrl.value)
viewModel.selectedServer.value = FAKE_SERVER_URL
+ // Wait for async update
+ Thread.sleep(200)
val newCodeChallenge = getSHA256Hash(viewModel.codeVerifier)
assertNotEquals(originalCodeChallenge, newCodeChallenge)
val newAuthUrl = generateExpectedAuthorizationUrl(FAKE_SERVER_URL, newCodeChallenge)
@@ -145,6 +159,8 @@ class LoginViewModelTest {
assertTrue(viewModel.loginUrl.value!!.contains(originalCodeChallenge))
viewModel.reloadWebView()
+ // Wait for async update
+ Thread.sleep(200)
val newCodeChallenge = getSHA256Hash(viewModel.codeVerifier)
assertNotNull(newCodeChallenge)
assertNotEquals(originalCodeChallenge, newCodeChallenge)
@@ -161,6 +177,8 @@ class LoginViewModelTest {
viewModel.jwt = FAKE_JWT
viewModel.authCodeForJwtFlow = FAKE_JWT_FLOW_AUTH
viewModel.reloadWebView()
+ // Wait for async update
+ Thread.sleep(200)
assertNotEquals(expectedUrl, viewModel.loginUrl.value)
codeChallenge = getSHA256Hash(viewModel.codeVerifier)
@@ -197,6 +215,342 @@ class LoginViewModelTest {
assertEquals(unchangedUrl, viewModel.getValidServerUrl(endingSlash))
}
+ @Test
+ fun getAuthorizationUrl_UsesDebugOverrideAppConfig_WhenSet() {
+ // Set custom OAuth config via debugOverrideAppConfig
+ val customConsumerKey = "custom_consumer_key_123"
+ val customRedirectUri = "custom://redirect"
+ val customScopes = listOf("api", "web", "custom_scope")
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = OAuthConfig(
+ consumerKey = customConsumerKey,
+ redirectUri = customRedirectUri,
+ scopes = customScopes
+ )
+
+ // Trigger URL generation
+ viewModel.reloadWebView()
+ Thread.sleep(200)
+
+ // Verify the URL contains the custom consumer key and redirect URI
+ val loginUrl = viewModel.loginUrl.value!!
+ assertTrue("URL should contain custom consumer key", loginUrl.contains(customConsumerKey))
+ assertTrue("URL should contain custom redirect URI", loginUrl.contains("redirect_uri=custom://redirect"))
+ assertTrue("URL should contain custom scope", loginUrl.contains("custom_scope"))
+ }
+
+ @Test
+ fun getAuthorizationUrl_UsesBootConfig_WhenDebugOverrideAppConfigIsNull() {
+ // Ensure debugOverrideAppConfig is null
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = null
+
+ // Trigger URL generation
+ viewModel.reloadWebView()
+ Thread.sleep(200)
+
+ // Verify the URL contains the boot config values
+ val loginUrl = viewModel.loginUrl.value!!
+ assertTrue("URL should contain boot config consumer key",
+ loginUrl.contains(bootConfig.remoteAccessConsumerKey))
+ assertTrue("URL should contain boot config redirect URI",
+ loginUrl.contains("redirect_uri=${bootConfig.oauthRedirectURI}"))
+ }
+
+ @Test
+ fun getAuthorizationUrl_UsesAppConfigForLoginHost_WhenDebugOverrideIsNull() {
+ val sdkManager = SalesforceSDKManager.getInstance()
+ val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost
+
+ try {
+ // Ensure debugOverrideAppConfig is null
+ sdkManager.debugOverrideAppConfig = null
+
+ // Set custom appConfigForLoginHost
+ val customConsumerKey = "app_config_consumer_key_456"
+ val customRedirectUri = "appconfig://redirect"
+ val customScopes = listOf("api", "refresh_token", "app_config_scope")
+ sdkManager.appConfigForLoginHost = { _ ->
+ OAuthConfig(
+ consumerKey = customConsumerKey,
+ redirectUri = customRedirectUri,
+ scopes = customScopes,
+ )
+ }
+
+ // Trigger URL generation
+ viewModel.reloadWebView()
+ Thread.sleep(200)
+
+ // Verify the URL contains the custom app config values
+ val loginUrl = viewModel.loginUrl.value!!
+ assertTrue("URL should contain app config consumer key", loginUrl.contains(customConsumerKey))
+ assertTrue("URL should contain app config redirect URI",
+ loginUrl.contains("redirect_uri=appconfig://redirect"))
+ assertTrue("URL should contain app config scope", loginUrl.contains("app_config_scope"))
+ } finally {
+ sdkManager.appConfigForLoginHost = originalAppConfigForLoginHost
+ }
+ }
+
+ @Test
+ fun getAuthorizationUrl_PrefersDebugOverrideAppConfig_OverAppConfigForLoginHost() {
+ val sdkManager = SalesforceSDKManager.getInstance()
+ val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost
+
+ try {
+ // Set both debugOverrideAppConfig and appConfigForLoginHost
+ val debugConsumerKey = "debug_override_key_789"
+ val debugRedirectUri = "debug://redirect"
+ val debugScopes = listOf("api", "debug_scope")
+ sdkManager.debugOverrideAppConfig = OAuthConfig(
+ consumerKey = debugConsumerKey,
+ redirectUri = debugRedirectUri,
+ scopes = debugScopes,
+ )
+
+ val appConfigConsumerKey = "app_config_key_should_not_be_used"
+ val appConfigRedirectUri = "appconfig://should_not_be_used"
+ sdkManager.appConfigForLoginHost = { _ ->
+ OAuthConfig(
+ consumerKey = appConfigConsumerKey,
+ redirectUri = appConfigRedirectUri,
+ scopes = listOf("api"),
+ )
+ }
+
+ // Trigger URL generation
+ viewModel.reloadWebView()
+ Thread.sleep(200)
+
+ // Verify the URL contains the debug override values, not app config values
+ val loginUrl = viewModel.loginUrl.value!!
+ assertTrue("URL should contain debug override consumer key",
+ loginUrl.contains(debugConsumerKey))
+ assertTrue("URL should contain debug override redirect URI",
+ loginUrl.contains("redirect_uri=debug://redirect"))
+ assertTrue("URL should contain debug scope", loginUrl.contains("debug_scope"))
+
+ // Verify app config values are NOT in the URL
+ assertFalse("URL should NOT contain app config consumer key",
+ loginUrl.contains(appConfigConsumerKey))
+ assertFalse("URL should NOT contain app config redirect URI",
+ loginUrl.contains("should_not_be_used"))
+ } finally {
+ sdkManager.appConfigForLoginHost = originalAppConfigForLoginHost
+ }
+ }
+
+ @Test
+ fun getAuthorizationUrl_UsesServerSpecificConfig_FromAppConfigForLoginHost() {
+ val sdkManager = SalesforceSDKManager.getInstance()
+ val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost
+
+ try {
+ // Ensure debugOverrideAppConfig is null
+ sdkManager.debugOverrideAppConfig = null
+
+ // Set appConfigForLoginHost that returns different configs based on server
+ sdkManager.appConfigForLoginHost = { server ->
+ when {
+ server.contains("test.salesforce.com") -> OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "test://redirect",
+ scopes = listOf("api", "test_scope"),
+ )
+ server.contains("login.salesforce.com") -> OAuthConfig(
+ consumerKey = "prod_consumer_key",
+ redirectUri = "prod://redirect",
+ scopes = listOf("api", "prod_scope"),
+ )
+ else -> OAuthConfig(bootConfig)
+ }
+ }
+
+ // Test with test server
+ viewModel.selectedServer.value = "https://test.salesforce.com"
+ Thread.sleep(200)
+ var loginUrl = viewModel.loginUrl.value!!
+ assertTrue("URL should contain test consumer key. URL: $loginUrl",
+ loginUrl.contains("test_consumer_key"))
+ assertTrue("URL should contain test redirect URI. URL: $loginUrl",
+ loginUrl.contains("redirect_uri=test://redirect"))
+ assertTrue("URL should contain test scope. URL: $loginUrl",
+ loginUrl.contains("test_scope"))
+
+ // Test with production server
+ viewModel.selectedServer.value = "https://login.salesforce.com"
+ Thread.sleep(200)
+ loginUrl = viewModel.loginUrl.value!!
+ assertTrue("URL should contain prod consumer key. URL: $loginUrl",
+ loginUrl.contains("prod_consumer_key"))
+ assertTrue("URL should contain prod redirect URI. URL: $loginUrl",
+ loginUrl.contains("redirect_uri=prod://redirect"))
+ assertTrue("URL should contain prod scope. URL: $loginUrl",
+ loginUrl.contains("prod_scope"))
+ } finally {
+ sdkManager.appConfigForLoginHost = originalAppConfigForLoginHost
+ }
+ }
+
+ @Test
+ fun getAuthorizationUrl_HandlesNullScopes_InOAuthConfig() {
+ // Set OAuth config with null scopes
+ val customConsumerKey = "no_scopes_consumer_key"
+ val customRedirectUri = "noscopes://redirect"
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = OAuthConfig(
+ consumerKey = customConsumerKey,
+ redirectUri = customRedirectUri,
+ scopes = null,
+ )
+
+ // Trigger URL generation
+ viewModel.reloadWebView()
+ Thread.sleep(200)
+
+ // Verify the URL is generated correctly without scopes
+ val loginUrl = viewModel.loginUrl.value!!
+ assertTrue("URL should contain custom consumer key", loginUrl.contains(customConsumerKey))
+ assertTrue("URL should contain custom redirect URI",
+ loginUrl.contains("redirect_uri=noscopes://redirect"))
+ // URL should still be valid even without explicit scopes
+ assertTrue("URL should be a valid OAuth URL",
+ loginUrl.contains("/services/oauth2/authorize"))
+ }
+
+ @Test
+ fun reloadWebView_WithFrontDoorBridge_DoesNotReloadUrl() {
+ // Set up front door bridge
+ val frontDoorUrl = "https://test.salesforce.com/frontdoor.jsp?sid=test_session"
+ viewModel.loginWithFrontDoorBridgeUrl(frontDoorUrl, null)
+
+ // Verify front door bridge is active
+ assertTrue("isUsingFrontDoorBridge should be true", viewModel.isUsingFrontDoorBridge)
+ assertEquals("loginUrl should be front door URL", frontDoorUrl, viewModel.loginUrl.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)
+ }
+
+ @Test
+ fun reloadWebView_WithUserAgentFlow_SetsAboutBlankFirst() {
+ try {
+ // Set to User Agent Flow (not Web Server Flow)
+ SalesforceSDKManager.getInstance().useWebServerAuthentication = false
+
+ // Ensure we're not using front door bridge
+ assertFalse("isUsingFrontDoorBridge should be false", viewModel.isUsingFrontDoorBridge)
+
+ // Get initial URL
+ val initialUrl = viewModel.loginUrl.value
+ assertNotNull("Initial URL should not be null", initialUrl)
+ assertNotEquals("Initial URL should not be ABOUT_BLANK", ABOUT_BLANK, initialUrl)
+
+ // Call reloadWebView
+ viewModel.reloadWebView()
+
+ // Verify URL was set to ABOUT_BLANK for User Agent Flow
+ // NOTE: If this is flaky we should use Turbine to test the actual state changes.
+ assertEquals("loginUrl should be set to ABOUT_BLANK for User Agent Flow",
+ ABOUT_BLANK, viewModel.loginUrl.value)
+
+ // Wait for the new authorization URL to be generated
+ Thread.sleep(200)
+
+ // Verify a new URL was generated
+ val newUrl = viewModel.loginUrl.value
+ assertNotNull("New URL should not be null", newUrl)
+ assertNotEquals("New URL should not be ABOUT_BLANK", ABOUT_BLANK, newUrl)
+ assertNotEquals("New URL should be different from initial", initialUrl, newUrl)
+ } finally {
+ SalesforceSDKManager.getInstance().useWebServerAuthentication = true
+ }
+ }
+
+ @Test
+ fun reloadWebView_WithWebServerFlow_DoesNotSetAboutBlank() {
+ assert(SalesforceSDKManager.getInstance().useWebServerAuthentication)
+ // Ensure we're not using front door bridge
+ assertFalse("isUsingFrontDoorBridge should be false", viewModel.isUsingFrontDoorBridge)
+
+ // Get initial URL
+ val initialUrl = viewModel.loginUrl.value
+ assertNotNull("Initial URL should not be null", initialUrl)
+
+ // Call reloadWebView
+ viewModel.reloadWebView()
+
+ // Give a brief moment to check if ABOUT_BLANK would be set
+ Thread.sleep(50)
+
+ // Verify URL was NOT set to ABOUT_BLANK for Web Server Flow
+ assertNotEquals("loginUrl should NOT be ABOUT_BLANK for Web Server Flow",
+ ABOUT_BLANK, viewModel.loginUrl.value)
+
+ // Wait for the new authorization URL to be generated
+ Thread.sleep(200)
+
+ // Verify a new URL was generated with different code challenge
+ val newUrl = viewModel.loginUrl.value
+ assertNotNull("New URL should not be null", newUrl)
+ assertNotEquals("New URL should not be ABOUT_BLANK", ABOUT_BLANK, newUrl)
+ assertNotEquals("New URL should be different from initial (different code challenge)",
+ initialUrl, newUrl)
+ }
+
+ @Test
+ fun reloadWebView_WithNullSelectedServer_DoesNothing() {
+ val initialUrl = "test"
+ viewModel.loginUrl.value = initialUrl
+
+ // Set selectedServer to null
+ viewModel.selectedServer.value = null
+ Thread.sleep(100)
+
+ // Call reloadWebView
+ viewModel.reloadWebView()
+ Thread.sleep(200)
+
+ // Verify URL did not change
+ assertEquals("loginUrl should not change when selectedServer is null",
+ initialUrl, viewModel.loginUrl.value)
+ }
+
+ @Test
+ fun getAuthorizationUrl_UsesBootConfig_WhenAppConfigForLoginHostReturnsNull() {
+ val sdkManager = SalesforceSDKManager.getInstance()
+ val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost
+
+ try {
+ // Ensure debugOverrideAppConfig is null
+ sdkManager.debugOverrideAppConfig = null
+
+ // Set appConfigForLoginHost to return null
+ sdkManager.appConfigForLoginHost = { _ -> null }
+
+ // Trigger URL generation
+ viewModel.reloadWebView()
+ Thread.sleep(200)
+
+ // Verify the URL contains the boot config values (fallback)
+ val loginUrl = viewModel.loginUrl.value!!
+ assertTrue("URL should contain boot config consumer key when appConfigForLoginHost returns null",
+ loginUrl.contains(bootConfig.remoteAccessConsumerKey))
+ assertTrue("URL should contain boot config redirect URI when appConfigForLoginHost returns null",
+ loginUrl.contains("redirect_uri=${bootConfig.oauthRedirectURI}"))
+
+ // Verify boot config scopes are present
+ bootConfig.oauthScopes.forEach { scope ->
+ assertTrue("URL should contain boot config scope '$scope' when appConfigForLoginHost returns null",
+ loginUrl.contains(scope))
+ }
+ } finally {
+ sdkManager.appConfigForLoginHost = originalAppConfigForLoginHost
+ }
+ }
+
private fun generateExpectedAuthorizationUrl(
server: String,
codeChallenge: String,
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/OAuthConfigTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/OAuthConfigTest.kt
new file mode 100644
index 0000000000..3d02beba79
--- /dev/null
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/OAuthConfigTest.kt
@@ -0,0 +1,201 @@
+/*
+ * Copyright (c) 2025-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.androidsdk.config
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for OAuthConfig.
+ */
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class OAuthConfigTest {
+
+ @Test
+ fun testPrimaryConstructorWithScopes() {
+ val config = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "test://callback",
+ scopes = listOf("api", "web", "refresh_token")
+ )
+
+ assertEquals("test_consumer_key", config.consumerKey)
+ assertEquals("test://callback", config.redirectUri)
+ assertEquals(listOf("api", "web", "refresh_token"), config.scopes)
+ assertEquals("api web refresh_token", config.scopesString)
+ }
+
+ @Test
+ fun testPrimaryConstructorWithoutScopes() {
+ val config = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "test://callback"
+ )
+
+ assertEquals("test_consumer_key", config.consumerKey)
+ assertEquals("test://callback", config.redirectUri)
+ assertNull(config.scopes)
+ assert(config.scopesString.isEmpty())
+ }
+
+ @Test
+ fun testBootConfigConstructorWithScopes() {
+ val bootConfig = mockk()
+ every { bootConfig.remoteAccessConsumerKey } returns "boot_consumer_key"
+ every { bootConfig.oauthRedirectURI } returns "boot://redirect"
+ every { bootConfig.oauthScopes } returns arrayOf("api", "web", "refresh_token")
+
+ val config = OAuthConfig(bootConfig)
+
+ assertEquals("boot_consumer_key", config.consumerKey)
+ assertEquals("boot://redirect", config.redirectUri)
+ assertEquals(listOf("api", "web", "refresh_token"), config.scopes)
+ assertEquals("api web refresh_token", config.scopesString)
+ }
+
+ @Test
+ fun testBootConfigConstructorWithEmptyScopes() {
+ val bootConfig = mockk()
+ every { bootConfig.remoteAccessConsumerKey } returns "boot_consumer_key"
+ every { bootConfig.oauthRedirectURI } returns "boot://redirect"
+ every { bootConfig.oauthScopes } returns arrayOf()
+
+ val config = OAuthConfig(bootConfig)
+
+ assertEquals("boot_consumer_key", config.consumerKey)
+ assertEquals("boot://redirect", config.redirectUri)
+ assertNull(config.scopes)
+ assert(config.scopesString.isEmpty())
+ }
+
+ @Test
+ fun testStringConstructorWithCommaSeparatedScopes() {
+ val config = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "test://callback",
+ scopes = "api,web,refresh_token"
+ )
+
+ assertEquals("test_consumer_key", config.consumerKey)
+ assertEquals("test://callback", config.redirectUri)
+ assertEquals(listOf("api", "web", "refresh_token"), config.scopes)
+ }
+
+ @Test
+ fun testStringConstructorWithSpaceSeparatedScopes() {
+ val config = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "test://callback",
+ scopes = "api web refresh_token"
+ )
+
+ assertEquals("test_consumer_key", config.consumerKey)
+ assertEquals("test://callback", config.redirectUri)
+ assertEquals(listOf("api", "web", "refresh_token"), config.scopes)
+ }
+
+ @Test
+ fun testStringConstructorWithCommaSeparatedScopesAndWhitespace() {
+ val config = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "test://callback",
+ scopes = " api , web , refresh_token "
+ )
+
+ assertEquals("test_consumer_key", config.consumerKey)
+ assertEquals("test://callback", config.redirectUri)
+ assertEquals(listOf("api", "web", "refresh_token"), config.scopes)
+ }
+
+ @Test
+ fun testStringConstructorWithEmptyString() {
+ val config = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "test://callback",
+ scopes = ""
+ )
+
+ assertEquals("test_consumer_key", config.consumerKey)
+ assertEquals("test://callback", config.redirectUri)
+ assertNull(config.scopes)
+ }
+
+ @Test
+ fun testStringConstructorWithBlankString() {
+ val config = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "test://callback",
+ scopes = " "
+ )
+
+ assertEquals("test_consumer_key", config.consumerKey)
+ assertEquals("test://callback", config.redirectUri)
+ assertNull(config.scopes)
+ }
+
+ @Test
+ fun testStringConstructorTrimsConsumerKeyAndRedirectUri() {
+ val config = OAuthConfig(
+ consumerKey = " test_consumer_key ",
+ redirectUri = " test://callback ",
+ scopes = "api"
+ )
+
+ assertEquals("test_consumer_key", config.consumerKey)
+ assertEquals("test://callback", config.redirectUri)
+ assertEquals(listOf("api"), config.scopes)
+ }
+
+ @Test
+ fun testScopesStringWithNullScopes() {
+ val config = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "test://callback",
+ scopes = null
+ )
+
+ assertEquals("", config.scopesString)
+ }
+
+ @Test
+ fun testScopesStringWithEmptyScopes() {
+ val config = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "test://callback",
+ scopes = emptyList()
+ )
+
+ assertEquals("", config.scopesString)
+ }
+}
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt
index 098a970506..9da29b2bb1 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt
@@ -654,7 +654,7 @@ class DevSupportInfoTest {
"Instance URL", "https://test.salesforce.com",
"Token Format", "oauth2",
"Access Token Expiration", "Unknown",
- "Beacon Child Consumer Key", user.beaconChildConsumerKey,
+ "Beacon Child Consumer Key", user.beaconChildConsumerKey ?: "None",
))
// Add runtime config
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java
index e474fe9d06..42816fd788 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java
@@ -730,7 +730,7 @@ public void testQueryAll() throws Exception {
* Create new account then look for it using soql.
* @throws Exception
*/
- @Test
+ @Test(timeout = 180000) // 3 minutes - test creates 201 accounts which takes time, especially in Firebase Test Lab
public void testQueryWithBatchSize() throws Exception {
cleanup();
List idNames = createAccounts(201, "-testWithBatchSize-");
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt
index d36f44d63c..6e5e2d6f72 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt
@@ -162,7 +162,9 @@ class DevInfoActivityTest {
assertTrue("Boot config should not be empty", items.isNotEmpty())
items.forEach { (key, _) ->
- composeTestRule.onNodeWithText(key).assertIsDisplayed()
+ composeTestRule.onNodeWithText(key)
+ .performScrollTo()
+ .assertIsDisplayed()
}
}
}
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt
index 41041c2ca8..c701a8fcae 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt
@@ -28,7 +28,9 @@ package com.salesforce.androidsdk.ui
import android.Manifest
import android.os.Build
+import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
@@ -36,21 +38,29 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule
import com.salesforce.androidsdk.R
import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.config.BootConfig
+import com.salesforce.androidsdk.config.OAuthConfig
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+private const val CONSUMER_KEY_LABEL = "Consumer Key"
+private const val REDIRECT_URI_LABEL = "Redirect URI"
+private const val SCOPES_LABEL = "Scopes"
+
@RunWith(AndroidJUnit4::class)
class LoginOptionsActivityTest {
@@ -68,6 +78,14 @@ class LoginOptionsActivityTest {
private var originalUseWebServer: Boolean = false
private var originalUseHybridToken: Boolean = false
private var originalSupportsWelcomeDiscovery: Boolean = false
+ private lateinit var dynamicToggle: SemanticsNodeInteraction
+ private lateinit var consumerKeyField: SemanticsNodeInteraction
+ private lateinit var redirectUriField: SemanticsNodeInteraction
+ private lateinit var scopesField: SemanticsNodeInteraction
+ private lateinit var webserverToggle: SemanticsNodeInteraction
+ private lateinit var hybridToggle: SemanticsNodeInteraction
+ private lateinit var welcomeToggle: SemanticsNodeInteraction
+ private lateinit var saveButton: SemanticsNodeInteraction
@Before
fun setup() {
@@ -76,6 +94,31 @@ class LoginOptionsActivityTest {
originalUseHybridToken = SalesforceSDKManager.getInstance().useHybridAuthentication
originalSupportsWelcomeDiscovery = SalesforceSDKManager.getInstance().supportsWelcomeDiscovery
SalesforceSDKManager.getInstance().loginDevMenuReload = false
+
+ dynamicToggle = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(R.string.sf__login_options_dynamic_config_toggle_content_description),
+ )
+ consumerKeyField = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(R.string.sf__login_options_consumer_key_field_content_description),
+ )
+ redirectUriField = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(R.string.sf__login_options_redirect_uri_field_content_description),
+ )
+ scopesField = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(R.string.sf__login_options_scopes_field_content_description),
+ )
+ webserverToggle = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(R.string.sf__login_options_webserver_toggle_content_description),
+ )
+ hybridToggle = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(R.string.sf__login_options_hybrid_toggle_content_description),
+ )
+ welcomeToggle = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(R.string.sf__login_options_welcome_toggle_content_description),
+ )
+ saveButton = composeTestRule.onNodeWithText(
+ composeTestRule.activity.getString(R.string.sf__server_url_save),
+ )
}
@After
@@ -84,6 +127,8 @@ class LoginOptionsActivityTest {
SalesforceSDKManager.getInstance().useWebServerAuthentication = originalUseWebServer
SalesforceSDKManager.getInstance().useHybridAuthentication = originalUseHybridToken
SalesforceSDKManager.getInstance().supportsWelcomeDiscovery = originalSupportsWelcomeDiscovery
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = null
+ SalesforceSDKManager.getInstance().loginDevMenuReload = false
}
@Test
@@ -115,8 +160,6 @@ class LoginOptionsActivityTest {
SalesforceSDKManager.getInstance().useWebServerAuthentication
)
- val toggleDescriptor = composeTestRule.activity.getString(R.string.sf__login_options_webserver_toggle_content_description)
- val webserverToggle = composeTestRule.onNodeWithContentDescription(toggleDescriptor)
webserverToggle.assertIsDisplayed()
webserverToggle.assertIsOff()
@@ -137,12 +180,6 @@ class LoginOptionsActivityTest {
"Use Web Server Authentication should be enabled",
SalesforceSDKManager.getInstance().useWebServerAuthentication
)
-
- // Verify the reload flag was set
- assertTrue(
- "loginDevMenuReload should be true after toggle",
- SalesforceSDKManager.getInstance().loginDevMenuReload
- )
}
@Test
@@ -161,8 +198,6 @@ class LoginOptionsActivityTest {
)
// Find and click the toggle
- val toggleDescriptor = composeTestRule.activity.getString(R.string.sf__login_options_hybrid_toggle_content_description)
- val hybridToggle = composeTestRule.onNodeWithContentDescription(toggleDescriptor)
hybridToggle.assertIsDisplayed()
hybridToggle.assertIsOff()
@@ -183,12 +218,6 @@ class LoginOptionsActivityTest {
"Use Hybrid Authentication should be enabled",
SalesforceSDKManager.getInstance().useHybridAuthentication
)
-
- // Verify the reload flag was set
- assertTrue(
- "loginDevMenuReload should be true after toggle",
- SalesforceSDKManager.getInstance().loginDevMenuReload
- )
}
@Test
@@ -207,8 +236,6 @@ class LoginOptionsActivityTest {
)
// Find and click the toggle
- val toggleDescriptor = composeTestRule.activity.getString(R.string.sf__login_options_welcome_toggle_content_description)
- val welcomeToggle = composeTestRule.onNodeWithContentDescription(toggleDescriptor)
welcomeToggle.assertIsDisplayed()
welcomeToggle.assertIsOff()
@@ -229,12 +256,6 @@ class LoginOptionsActivityTest {
"Support Welcome Discovery should be enabled",
SalesforceSDKManager.getInstance().supportsWelcomeDiscovery
)
-
- // Verify the reload flag was set
- assertTrue(
- "loginDevMenuReload should be true after toggle",
- SalesforceSDKManager.getInstance().loginDevMenuReload
- )
}
@Test
@@ -261,32 +282,19 @@ class LoginOptionsActivityTest {
@Test
fun loginOptionsActivity_DynamicBootConfigToggle_ShowsInputFields() {
- // Get content descriptions
- val dynamicToggleDesc = composeTestRule.activity.getString(R.string.sf__login_options_dynamic_config_toggle_content_description)
- val consumerKeyFieldDesc = composeTestRule.activity.getString(R.string.sf__login_options_consumer_key_field_content_description)
- val redirectUriFieldDesc = composeTestRule.activity.getString(R.string.sf__login_options_redirect_uri_field_content_description)
- val scopesFieldDesc = composeTestRule.activity.getString(R.string.sf__login_options_scopes_field_content_description)
-
- // Initially, dynamic config fields should not exist
- val dynamicToggle = composeTestRule.onNodeWithContentDescription(dynamicToggleDesc)
dynamicToggle.assertIsDisplayed()
dynamicToggle.assertIsOff()
- composeTestRule.onNodeWithContentDescription(consumerKeyFieldDesc).assertDoesNotExist()
- composeTestRule.onNodeWithContentDescription(redirectUriFieldDesc).assertDoesNotExist()
- composeTestRule.onNodeWithContentDescription(scopesFieldDesc).assertDoesNotExist()
+ consumerKeyField.assertDoesNotExist()
+ redirectUriField.assertDoesNotExist()
+ scopesField.assertDoesNotExist()
// Click to enable dynamic config
dynamicToggle.performClick()
composeTestRule.waitForIdle()
dynamicToggle.assertIsOn()
-
- // Now the input fields should be visible and accept text input
- val consumerKeyField = composeTestRule.onNodeWithContentDescription(consumerKeyFieldDesc)
- val redirectUriField = composeTestRule.onNodeWithContentDescription(redirectUriFieldDesc)
- val scopesField = composeTestRule.onNodeWithContentDescription(scopesFieldDesc)
-
+ saveButton.performScrollTo()
consumerKeyField.assertIsDisplayed()
redirectUriField.assertIsDisplayed()
scopesField.assertIsDisplayed()
@@ -299,9 +307,9 @@ class LoginOptionsActivityTest {
composeTestRule.waitForIdle()
// Verify the text was entered
- consumerKeyField.assertTextEquals("Consumer Key", "test_consumer_key")
- redirectUriField.assertTextEquals("Redirect URI", "test://redirect")
- scopesField.assertTextEquals("Scopes", "api web")
+ consumerKeyField.assertTextEquals(CONSUMER_KEY_LABEL, "test_consumer_key")
+ redirectUriField.assertTextEquals(REDIRECT_URI_LABEL, "test://redirect")
+ scopesField.assertTextEquals(SCOPES_LABEL, "api web")
}
@Test
@@ -322,35 +330,129 @@ class LoginOptionsActivityTest {
)
// Toggle web server flow
- composeTestRule.onNodeWithContentDescription(
- composeTestRule.activity.getString(R.string.sf__login_options_webserver_toggle_content_description)
- ).performClick()
+ webserverToggle.performClick()
composeTestRule.waitForIdle()
// Verify only web server flow changed
assertTrue(SalesforceSDKManager.getInstance().useWebServerAuthentication)
assertFalse(SalesforceSDKManager.getInstance().useHybridAuthentication)
- // Verify the reload flag was set
- assertTrue(
- "loginDevMenuReload should be true after first toggle",
- SalesforceSDKManager.getInstance().loginDevMenuReload
- )
-
// Toggle hybrid auth token
- composeTestRule.onNodeWithContentDescription(
- composeTestRule.activity.getString(R.string.sf__login_options_hybrid_toggle_content_description)
- ).performClick()
+ hybridToggle.performClick()
composeTestRule.waitForIdle()
// Verify both are now enabled
assertTrue(SalesforceSDKManager.getInstance().useWebServerAuthentication)
assertTrue(SalesforceSDKManager.getInstance().useHybridAuthentication)
-
- // Verify the reload flag is still true
- assertTrue(
- "loginDevMenuReload should still be true after second toggle",
- SalesforceSDKManager.getInstance().loginDevMenuReload
+ }
+
+ @Test
+ fun bootConfigView_WithEmptyFields_DisablesSaveButton() {
+ // Enable dynamic config
+ dynamicToggle.performClick()
+ composeTestRule.waitForIdle()
+
+ // Save button should be disabled when fields are empty
+ saveButton.performScrollTo()
+ saveButton.assertIsDisplayed()
+ saveButton.assertIsNotEnabled()
+ }
+
+ @Test
+ fun bootConfigView_TappingSaveButton_SetsDebugOverrideAppConfig() {
+ // Enable dynamic config
+ dynamicToggle.performClick()
+ composeTestRule.waitForIdle()
+
+ consumerKeyField.performTextInput("override_key")
+ redirectUriField.performTextInput("override://uri")
+ scopesField.performTextInput("api web")
+ composeTestRule.waitForIdle()
+
+ // Click save button
+ saveButton.performScrollTo().performClick()
+ composeTestRule.waitForIdle()
+
+ // Verify debugOverrideAppConfig was set
+ val overrideConfig = SalesforceSDKManager.getInstance().debugOverrideAppConfig
+ assertNotNull("debugOverrideAppConfig should be set", overrideConfig)
+ assertEquals("override_key", overrideConfig?.consumerKey)
+ assertEquals("override://uri", overrideConfig?.redirectUri)
+ assertEquals(listOf("api", "web"), overrideConfig?.scopes)
+ }
+
+ @Test
+ fun bootConfigView_SaveWithOnlyRequiredFields_CreatesOAuthConfig() {
+ // Enable dynamic config
+ dynamicToggle.performClick()
+ composeTestRule.waitForIdle()
+
+ consumerKeyField.performTextInput("minimal_key")
+ redirectUriField.performTextInput("minimal://uri")
+ composeTestRule.waitForIdle()
+
+ // Click save button
+ saveButton.performScrollTo().performClick()
+ composeTestRule.waitForIdle()
+
+ // Verify debugOverrideAppConfig was set with null scopes
+ val overrideConfig = SalesforceSDKManager.getInstance().debugOverrideAppConfig
+ assertNotNull("debugOverrideAppConfig should be set", overrideConfig)
+ assertEquals("minimal_key", overrideConfig?.consumerKey)
+ assertEquals("minimal://uri", overrideConfig?.redirectUri)
+ assertNull("Scopes should be null when not provided", overrideConfig?.scopes)
+ }
+
+ @Test
+ fun bootConfigView_TogglingOffDebugOverride_ClearsValues() {
+ // Set an override first
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = OAuthConfig(
+ "existing_key",
+ "existing://uri",
+ listOf("api")
)
+
+ // Recreate the activity to show the config we just set
+ with(composeTestRule.activity) {
+ runOnUiThread { recreate() }
+ }
+
+ // Enable dynamic config
+ dynamicToggle.performScrollTo().performClick()
+ composeTestRule.waitForIdle()
+
+ assertNull("Override should be cleared", SalesforceSDKManager.getInstance().debugOverrideAppConfig)
+
+ // Toggle off dynamic config
+ dynamicToggle.performScrollTo().performClick()
+ composeTestRule.waitForIdle()
+
+ // Verify override was cleared
+ assertNull("Override should be cleared when toggling off", SalesforceSDKManager.getInstance().debugOverrideAppConfig)
+ }
+
+ @Test
+ fun bootConfigView_PrePopulatesWithExistingConfig() {
+ val overrideKey = "existing_key"
+ val overrideUri = "existing://uri"
+ val overrideScopes = listOf("api", "web")
+ val existingConfig = OAuthConfig(overrideKey, overrideUri, overrideScopes)
+ SalesforceSDKManager.getInstance().debugOverrideAppConfig = existingConfig
+
+ // Recreate the activity to show the config we just set
+ with(composeTestRule.activity) {
+ runOnUiThread { recreate() }
+ }
+
+ consumerKeyField.assertTextEquals(CONSUMER_KEY_LABEL, overrideKey)
+ redirectUriField.assertTextEquals(REDIRECT_URI_LABEL, overrideUri)
+ scopesField.assertTextEquals(SCOPES_LABEL, existingConfig.scopesString)
+ }
+
+ @Test
+ fun leavingActivity_Sets_loginDevMenuReload() {
+ assertFalse(SalesforceSDKManager.getInstance().loginDevMenuReload)
+ composeTestRule.activity.finish()
+ assertTrue(SalesforceSDKManager.getInstance().loginDevMenuReload)
}
-}
+}
\ No newline at end of file