diff --git a/.github/test-shards/SalesforceSDK.json b/.github/test-shards/SalesforceSDK.json
index 140bf7cf37..c60ffd5dd5 100644
--- a/.github/test-shards/SalesforceSDK.json
+++ b/.github/test-shards/SalesforceSDK.json
@@ -3,7 +3,7 @@
"shards": [
{
"name": "network",
- "comment": "REST client tests that make live server calls - isolated",
+ "comment": "REST client tests that make live server calls",
"targets": [
"class com.salesforce.androidsdk.rest.RestClientTest",
"class com.salesforce.androidsdk.rest.ClientManagerTest",
@@ -14,7 +14,7 @@
},
{
"name": "ui",
- "comment": "UI tests and security tests that may require user interaction",
+ "comment": "Tests that exercise Activities/UI",
"targets": [
"class com.salesforce.androidsdk.ui.LoginViewActivityTest",
"class com.salesforce.androidsdk.ui.PickerBottomSheetTest",
@@ -23,7 +23,9 @@
"class com.salesforce.androidsdk.ui.PickerBottomSheetActivityTest",
"class com.salesforce.androidsdk.ui.DevInfoActivityTest",
"class com.salesforce.androidsdk.security.ScreenLockManagerTest",
- "class com.salesforce.androidsdk.security.BiometricAuthenticationManagerTest"
+ "class com.salesforce.androidsdk.security.BiometricAuthenticationManagerTest",
+ "class com.salesforce.androidsdk.ui.TokenMigrationActivityTest",
+ "class com.salesforce.androidsdk.ui.TokenMigrationWebViewTest"
]
},
{
@@ -68,7 +70,9 @@
"notClass com.salesforce.androidsdk.rest.NotificationsActionsResponseBodyTest",
"notClass com.salesforce.androidsdk.rest.files.RenditionTypeTest",
"notClass com.salesforce.androidsdk.rest.files.ConnectUriBuilderTest",
- "notClass com.salesforce.androidsdk.rest.files.FileRequestsTest"
+ "notClass com.salesforce.androidsdk.rest.files.FileRequestsTest",
+ "notClass com.salesforce.androidsdk.ui.TokenMigrationActivityTest",
+ "notClass com.salesforce.androidsdk.ui.TokenMigrationWebViewTest"
]
}
]
diff --git a/.github/workflows/reusable-lib-workflow.yaml b/.github/workflows/reusable-lib-workflow.yaml
index 89c905ecb1..6ca361a71f 100644
--- a/.github/workflows/reusable-lib-workflow.yaml
+++ b/.github/workflows/reusable-lib-workflow.yaml
@@ -184,7 +184,7 @@ jobs:
fi
done
- name: Test Report
- uses: mikepenz/action-junit-report@v5
+ uses: mikepenz/action-junit-report
if: success() || failure()
with:
check_name: ${{ inputs.lib }} Test Results
diff --git a/.github/workflows/reusable-ui-workflow.yaml b/.github/workflows/reusable-ui-workflow.yaml
index 3a4b8c3c9d..24de592d11 100644
--- a/.github/workflows/reusable-ui-workflow.yaml
+++ b/.github/workflows/reusable-ui-workflow.yaml
@@ -127,7 +127,7 @@ jobs:
fi
done
- name: Test Report
- uses: mikepenz/action-junit-report@v5
+ uses: mikepenz/action-junit-report
if: success() || failure()
with:
check_name: ${{ inputs.lib }} Test Results
diff --git a/libs/SalesforceSDK/AndroidManifest.xml b/libs/SalesforceSDK/AndroidManifest.xml
index 8bca33a2a7..3d9477e435 100644
--- a/libs/SalesforceSDK/AndroidManifest.xml
+++ b/libs/SalesforceSDK/AndroidManifest.xml
@@ -45,6 +45,12 @@
android:configChanges="orientation|screenLayout|uiMode|screenSize|smallestScreenSize"
android:exported="true" />
+
+
+
Unit,
+ onMigrationError: (error: String, errorDesc: String?, e: Throwable?) -> Unit,
+) {
+ val loggedOnSuccess: (userAccount: UserAccount) -> Unit = { user ->
+ SalesforceSDKLogger.i(TAG, "Token Migration Successful \n\nUser ${user.username} " +
+ "(${user.instanceServer}) successfully migrated to: \n$appConfig.")
+ onMigrationSuccess.invoke(user)
+ }
+ val userId = userAccount?.userId
+ val orgId = userAccount?.orgId
+
+ if (userId == null || orgId == null) {
+ val message = "User account, userId or orgId is null."
+ SalesforceSDKLogger.e(TAG, message)
+ onMigrationError(message, null, null)
+ return
+ }
+
+ val callbackKey = MigrationCallbackRegistry.register(
+ callbacks = MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = loggedOnSuccess,
+ onMigrationError = onMigrationError,
+ )
+ )
+
+ with(SalesforceSDKManager.getInstance().appContext) {
+ startActivity(
+ Intent(/* packageContext = */ this, TokenMigrationActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ putExtra(TokenMigrationActivity.EXTRA_ORG_ID, orgId)
+ putExtra(TokenMigrationActivity.EXTRA_USER_ID, userId)
+ putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, appConfig)
+ putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
+ }
+ )
+ }
+}
+
+/*
+ This mechanism is used to pass a _string_ id to the Activity to retrieve callback functions.
+
+ Lambda functions may appear Parcelable/Serializable but since we cannot guarantee the
+ content are they should not be passed. For instance, if the lambda function contains
+ compose state an exception will be thrown.
+ */
+internal object MigrationCallbackRegistry {
+ private val callbacks = mutableMapOf()
+
+ data class MigrationCallbacks(
+ val onMigrationSuccess: (UserAccount) -> Unit,
+ val onMigrationError: (String, String?, Throwable?) -> Unit
+ )
+
+ fun register(callbacks: MigrationCallbacks): String {
+ val key = UUID.randomUUID().toString()
+ this.callbacks[key] = callbacks
+ return key
+ }
+
+ fun consume(key: String): MigrationCallbacks? = callbacks.remove(key)
+}
\ No newline at end of file
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt
index b5dcc3c7cc..9a5281d82a 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt
@@ -47,7 +47,6 @@ import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse
import com.salesforce.androidsdk.auth.OAuth2.addAuthorizationHeader
import com.salesforce.androidsdk.auth.OAuth2.callIdentityService
-import com.salesforce.androidsdk.auth.OAuth2.revokeRefreshToken
import com.salesforce.androidsdk.config.LoginServerManager
import com.salesforce.androidsdk.config.RuntimeConfig
import com.salesforce.androidsdk.config.RuntimeConfig.getRuntimeConfig
@@ -98,6 +97,7 @@ internal suspend fun onAuthFlowComplete(
onAuthFlowSuccess: (userAccount: UserAccount) -> Unit,
buildAccountName: (username: String?, instanceServer: String?) -> String = ::defaultBuildAccountName,
nativeLogin: Boolean = false,
+ tokenMigration: Boolean = false,
context: Context = SalesforceSDKManager.getInstance().appContext,
userAccountManager: UserAccountManager = SalesforceSDKManager.getInstance().userAccountManager,
blockIntegrationUser: Boolean = (SalesforceSDKManager.getInstance().shouldBlockSalesforceIntegrationUser &&
@@ -110,7 +110,8 @@ internal suspend fun onAuthFlowComplete(
addAccount: (account: UserAccount) -> Unit = ::addAccountHelper,
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,
+ handleDuplicateUserAccount: (userAccountManager: UserAccountManager, account: UserAccount, userIdentity: OAuth2.IdServiceResponse?) -> Unit
+ = { uam, acct, identity -> com.salesforce.androidsdk.auth.handleDuplicateUserAccount(uam, acct, identity) },
) {
// Reset Dev Support LoginOptionsActivity override
SalesforceSDKManager.getInstance().debugOverrideAppConfig = null
@@ -189,9 +190,11 @@ internal suspend fun onAuthFlowComplete(
}
userAccountManager.sendUserSwitchIntent(userSwitchType, null)
- // Kickoff the end of the flow before storing mobile policy to prevent launching
- // the main activity over/after the screen lock.
- startMainActivity()
+ if (!tokenMigration) {
+ // Kickoff the end of the flow before storing mobile policy to prevent launching
+ // the main activity over/after the screen lock.
+ startMainActivity()
+ }
// Let the calling process resume
onAuthFlowSuccess(account)
@@ -371,36 +374,51 @@ private fun updateLoggingPrefsHelper(account: UserAccount) {
/**
* Helper method to handle screen lock mobile policy.
*/
-private fun handleScreenLockPolicy(
+@VisibleForTesting
+internal fun handleScreenLockPolicy(
userIdentity: OAuth2.IdServiceResponse?,
- account: UserAccount
+ account: UserAccount,
) {
+ val internalScreenLockManager =
+ SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?
+
+ // compareTo(0) is used to check if screenLockTimeout is non-null and greater than 0.
if (userIdentity?.screenLockTimeout?.compareTo(0) == 1) {
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SCREEN_LOCK)
val timeoutInMills = userIdentity.screenLockTimeout * 1000 * 60
- (SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?)?.storeMobilePolicy(
+ internalScreenLockManager?.storeMobilePolicy(
account,
- userIdentity.screenLock,
- timeoutInMills
+ enabled = userIdentity.screenLock,
+ timeoutInMills,
)
+ } else if (internalScreenLockManager?.enabled == true) {
+ SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_SCREEN_LOCK)
+ internalScreenLockManager.cleanUp(account)
}
}
/**
* Helper method to handle biometric authentication mobile policy.
*/
-private fun handleBiometricAuthPolicy(
+@VisibleForTesting
+internal fun handleBiometricAuthPolicy(
userIdentity: OAuth2.IdServiceResponse?,
- account: UserAccount
+ account: UserAccount,
) {
+ val internalBiometricAuthenticationManager =
+ SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?
+
if (userIdentity?.biometricAuth == true) {
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH)
val timeoutInMills = userIdentity.biometricAuthTimeout * 60 * 1000
- (SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?)?.storeMobilePolicy(
+ internalBiometricAuthenticationManager?.storeMobilePolicy(
account,
- userIdentity.biometricAuth,
+ enabled = userIdentity.biometricAuth,
timeoutInMills
)
+ } else if (internalBiometricAuthenticationManager?.enabled == true) {
+ SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH)
+ internalBiometricAuthenticationManager.cleanUp(account)
}
}
@@ -426,10 +444,12 @@ private fun addAccountHelper(
* - Unlocking biometric authentication for the duplicate user
* - Signing out other users with biometric auth when a new biometric user is added
*/
-private fun handleDuplicateUserAccount(
+@VisibleForTesting
+internal fun handleDuplicateUserAccount(
userAccountManager: UserAccountManager,
account: UserAccount,
- userIdentity: OAuth2.IdServiceResponse?
+ userIdentity: OAuth2.IdServiceResponse?,
+ revokeRefreshToken: (HttpAccess, URI, String, OAuth2.LogoutReason) -> Unit = OAuth2::revokeRefreshToken,
) {
userAccountManager.authenticatedUsers?.let { existingUsers ->
// Check if the user already exists
@@ -451,14 +471,12 @@ private fun handleDuplicateUserAccount(
as? BiometricAuthenticationManager)?.onUnlock()
}
CoroutineScope(IO).launch {
- CoroutineScope(IO).launch {
- revokeRefreshToken(
- HttpAccess.DEFAULT,
- uri,
- duplicateUserAccount.refreshToken,
- OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED,
- )
- }
+ revokeRefreshToken(
+ HttpAccess.DEFAULT,
+ uri,
+ duplicateUserAccount.refreshToken,
+ OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED,
+ )
}
}
}
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java
index f5d5072839..2dbaf20b96 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java
@@ -133,6 +133,7 @@ public class OAuth2 {
private static final String QUESTION = "?";
private static final String TOUCH = "touch";
private static final String FRONTDOOR = "/secur/frontdoor.jsp?";
+ public static final String FRONTDOOR_URL_KEY = "frontdoor_uri";
private static final String SID = "sid";
private static final String RETURL = "retURL";
protected static final String AUTHORIZATION = "Authorization";
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt
index 1e617875cd..42429daf39 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt
@@ -33,6 +33,7 @@ import android.webkit.WebViewClient
import com.salesforce.androidsdk.R
import com.salesforce.androidsdk.accounts.UserAccount
import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY
import com.salesforce.androidsdk.auth.OAuth2.getAuthorizationUrl
import com.salesforce.androidsdk.rest.ClientManager
import com.salesforce.androidsdk.rest.RestClient
@@ -132,7 +133,7 @@ internal class IDPAuthCodeHelper private constructor(
SalesforceSDKLogger.e(TAG, "Failed to obtain valid front door url", e)
null
}
- return if (restResponse == null || !restResponse.isSuccess) null else restResponse.asJSONObject().getString("frontdoor_uri")
+ return if (restResponse == null || !restResponse.isSuccess) null else restResponse.asJSONObject().getString(FRONTDOOR_URL_KEY)
}
private fun onError(error: String, exception: java.lang.Exception? = null) {
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt
index 51ff03f7a0..96d6fd342e 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt
@@ -26,11 +26,15 @@
*/
package com.salesforce.androidsdk.config
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
data class OAuthConfig(
val consumerKey: String,
val redirectUri: String,
val scopes: List? = null,
-) {
+): Parcelable {
internal constructor(bootConfig: BootConfig): this(
bootConfig.remoteAccessConsumerKey,
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
index 65b19ae72a..1ae35817bc 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
@@ -239,6 +239,7 @@ open class LoginActivity : FragmentActivity() {
newUserIntent = true
}
+ // TODO: Move to non-deprecated getParcelableExtra when min API >= 33
accountAuthenticatorResponse = intent.getParcelableExtra(
KEY_ACCOUNT_AUTHENTICATOR_RESPONSE
)?.apply {
@@ -1217,18 +1218,6 @@ open class LoginActivity : FragmentActivity() {
d(TAG, "Received client certificate request from server")
request.proceed(key, certChain)
}
-
- private fun validateAndExtractBackgroundColor(javaScriptResult: String): Color? {
- val rgbMatch = rgbTextPattern.find(javaScriptResult)
-
- // groupValues[0] is the entire match. [1] is red, [2] is green, [3] is green.
- rgbMatch?.groupValues?.get(3) ?: return null
- val red = rgbMatch.groupValues[1].toIntOrNull() ?: return null
- val green = rgbMatch.groupValues[2].toIntOrNull() ?: return null
- val blue = rgbMatch.groupValues[3].toIntOrNull() ?: return null
-
- return Color(red, green, blue)
- }
}
companion object {
@@ -1245,15 +1234,27 @@ open class LoginActivity : FragmentActivity() {
private const val RESPONSE_ERROR_DESCRIPTION_INTENT = "com.salesforce.auth.intent.RESPONSE_ERROR_DESCRIPTION"
// This parses the expected "rgb(x, x, x)" string.
- private val rgbTextPattern = "rgb\\((\\d{1,3}), (\\d{1,3}), (\\d{1,3})\\)".toRegex()
+ internal val rgbTextPattern = "rgb\\((\\d{1,3}), (\\d{1,3}), (\\d{1,3})\\)".toRegex()
// endregion
// region LoginWebviewClient Constants
internal const val ABOUT_BLANK = "about:blank"
- private const val BACKGROUND_COLOR_JAVASCRIPT =
+ internal const val BACKGROUND_COLOR_JAVASCRIPT =
"(function() { return window.getComputedStyle(document.body, null).getPropertyValue('background-color'); })();"
+ internal fun validateAndExtractBackgroundColor(javaScriptResult: String): Color? {
+ val rgbMatch = rgbTextPattern.find(javaScriptResult)
+
+ // groupValues[0] is the entire match. [1] is red, [2] is green, [3] is green.
+ rgbMatch?.groupValues?.get(3) ?: return null
+ val red = rgbMatch.groupValues[1].toIntOrNull() ?: return null
+ val green = rgbMatch.groupValues[2].toIntOrNull() ?: return null
+ val blue = rgbMatch.groupValues[3].toIntOrNull() ?: return null
+
+ return Color(red, green, blue)
+ }
+
// endregion
// region Log In Via Salesforce Identity API UI Bridge Front Door URL Public Implementation
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt
index 7ff1128179..3fcf9b5dc9 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt
@@ -355,8 +355,10 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
code: String?,
onAuthFlowError: (error: String, errorDesc: String?, e: Throwable?) -> Unit,
onAuthFlowSuccess: (userAccount: UserAccount) -> Unit,
+ loginServer: String? = null,
+ tokenMigration: Boolean = false,
) = CoroutineScope(IO).launch {
- doCodeExchange(code, onAuthFlowError, onAuthFlowSuccess)
+ doCodeExchange(code, onAuthFlowError, onAuthFlowSuccess, loginServer, tokenMigration)
}
/**
@@ -368,6 +370,8 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
tr: TokenEndpointResponse,
onAuthFlowError: (error: String, errorDesc: String?, e: Throwable?) -> Unit,
onAuthFlowSuccess: (userAccount: UserAccount) -> Unit,
+ tokenMigration: Boolean = false,
+ loginServer: String? = null,
) {
// Clear cookies after successful authentication to prevent automatic re-login if the user tries to add another user right away.
if (SalesforceSDKManager.getInstance().clearCookiesAfterLogin) {
@@ -376,11 +380,12 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
authCodeForJwtFlow = null
onAuthFlowComplete(
tokenResponse = tr,
- loginServer = selectedServer.value ?: "", // This will never actually be null.
+ loginServer = loginServer ?: selectedServer.value ?: "",
consumerKey = consumerKey,
onAuthFlowError = onAuthFlowError,
onAuthFlowSuccess = onAuthFlowSuccess,
buildAccountName = ::buildAccountName,
+ tokenMigration = tokenMigration,
)
}
@@ -427,22 +432,23 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
* @param scope The Coroutine scope. This parameter is intended for testing
* purposes only. Defaults to the IO scope
*/
- @VisibleForTesting
internal suspend fun getAuthorizationUrl(
server: String,
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) {
- // Check if the OAuth Config has been manually set by dev support LoginOptionsActivity.
- val debugOverrideAppConfig = debugOverrideAppConfig
- oAuthConfig = if (isDebugBuild && debugOverrideAppConfig != null) {
- debugOverrideAppConfig
- } else {
- appConfigForLoginHost(server) ?: OAuthConfig(bootConfig)
+ 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)
}
}
@@ -483,13 +489,20 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
}.toString()
}
- private suspend fun doCodeExchange(
+ @VisibleForTesting
+ internal suspend fun doCodeExchange(
code: String?,
onAuthFlowError: (error: String, errorDesc: String?, e: Throwable?) -> Unit,
onAuthFlowSuccess: (userAccount: UserAccount) -> Unit,
+ loginServer: String? = null,
+ tokenMigration: Boolean = false,
) = withContext(IO) {
runCatching {
- val server = if (isUsingFrontDoorBridge) frontdoorBridgeServer else selectedServer.value
+ val server = when {
+ loginServer != null -> loginServer
+ isUsingFrontDoorBridge -> frontdoorBridgeServer
+ else -> selectedServer.value
+ }
val verifier = if (isUsingFrontDoorBridge) frontdoorBridgeCodeVerifier else codeVerifier
val tokenResponse = exchangeCode(
@@ -501,7 +514,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() {
oAuthConfig.redirectUri,
)
- onAuthFlowComplete(tokenResponse, onAuthFlowError, onAuthFlowSuccess)
+ onAuthFlowComplete(tokenResponse, onAuthFlowError, onAuthFlowSuccess, tokenMigration, server)
}.onFailure { throwable ->
e(TAG, "Exception occurred while making token request", throwable)
onAuthFlowError("Token Request Error", throwable.message, throwable)
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt
new file mode 100644
index 0000000000..eb256d1663
--- /dev/null
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt
@@ -0,0 +1,317 @@
+/*
+ * 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.androidsdk.ui
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Bitmap
+import android.os.Bundle
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.annotation.VisibleForTesting
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.graphics.Color
+import androidx.core.net.toUri
+import androidx.core.view.WindowCompat
+import androidx.lifecycle.lifecycleScope
+import com.salesforce.androidsdk.accounts.MigrationCallbackRegistry
+import com.salesforce.androidsdk.accounts.UserAccountManager
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK
+import com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY
+import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse
+import com.salesforce.androidsdk.config.OAuthConfig
+import com.salesforce.androidsdk.rest.RestRequest
+import com.salesforce.androidsdk.ui.LoginActivity.Companion.BACKGROUND_COLOR_JAVASCRIPT
+import com.salesforce.androidsdk.ui.LoginActivity.Companion.validateAndExtractBackgroundColor
+import com.salesforce.androidsdk.ui.components.TokenMigrationView
+import com.salesforce.androidsdk.util.SalesforceSDKLogger
+import com.salesforce.androidsdk.util.UriFragmentParser
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.Default
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import java.lang.String.format
+
+private const val HALF_ALPHA = 0.5f
+
+// Error messages - internal for testing
+@VisibleForTesting
+internal const val ERROR_PARSE_CALLBACK_ID = "Unable to parse MigrationResult callback id."
+@VisibleForTesting
+internal const val ERROR_RETRIEVE_CALLBACK = "Unable to retrieve MigrationResult callback."
+@VisibleForTesting
+internal const val ERROR_PARSE_OAUTH_CONFIG = "Unable to parse OAuthConfig."
+@VisibleForTesting
+internal const val ERROR_BUILD_USER_ACCOUNT = "Unable to build user account."
+@VisibleForTesting
+internal const val ERROR_BUILD_REST_CLIENT = "Unable to build RestClient."
+@VisibleForTesting
+internal const val ERROR_SINGLE_ACCESS_FAILED = "Request for single access bridge url failed"
+@VisibleForTesting
+internal const val ERROR_TOKEN_INVALID_DESC = "User's existing token may be invalid."
+
+internal class TokenMigrationActivity : ComponentActivity() {
+
+ private val viewModel: LoginViewModel
+ by viewModels { SalesforceSDKManager.getInstance().loginViewModelFactory }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val callbackKey = intent.getStringExtra(EXTRA_CALLBACK_ID) ?: run {
+ SalesforceSDKLogger.e(TAG, ERROR_PARSE_CALLBACK_ID)
+ finish()
+ return
+ }
+ val resultCallback = MigrationCallbackRegistry.consume(callbackKey) ?: run {
+ SalesforceSDKLogger.e(TAG, ERROR_RETRIEVE_CALLBACK)
+ finish()
+ return
+ }
+
+ // TODO: Move to non-deprecated getParcelableExtra when min API >= 33
+ val oAuthConfig = intent.getParcelableExtra(EXTRA_OAUTH_CONFIG) ?: run {
+ logMigrationError(resultCallback, ERROR_PARSE_OAUTH_CONFIG, null, null)
+ return
+ }
+
+ val orgId = intent.getStringExtra(EXTRA_ORG_ID)
+ val userId = intent.getStringExtra(EXTRA_USER_ID)
+ if ( orgId == null || userId == null) {
+ logMigrationError(resultCallback, ERROR_PARSE_OAUTH_CONFIG, null, null)
+ return
+ }
+ val user = UserAccountManager.getInstance().getUserFromOrgAndUserId(orgId, userId) ?: run {
+ logMigrationError(resultCallback, ERROR_BUILD_USER_ACCOUNT, null, null)
+ return
+ }
+ val client = runCatching {
+ SalesforceSDKManager.getInstance().clientManager.peekRestClient(user)
+ }.getOrElse { e ->
+ logMigrationError(resultCallback, ERROR_BUILD_REST_CLIENT, null, e as? Exception)
+ return
+ }
+
+ lifecycleScope.launch {
+ val frontDoorUrl = withContext(IO) {
+ runCatching {
+ val authorizationUrl = viewModel.getAuthorizationUrl(
+ server = user.instanceServer,
+ migrationOAuthConfig = oAuthConfig,
+ )
+ val authorizationPath = with(authorizationUrl.toUri()) { "$path?$query" }
+ val request = RestRequest.getRequestForSingleAccess(authorizationPath)
+ val singleAccessResponse = client.sendSync(request)
+
+ singleAccessResponse
+ ?.takeIf { it.isSuccess }
+ ?.let {
+ Json.parseToJsonElement(it.asString())
+ .jsonObject[FRONTDOOR_URL_KEY]
+ ?.jsonPrimitive?.content
+ }
+ }.getOrNull()
+ } ?: run {
+ logMigrationError(
+ resultCallback = resultCallback,
+ error = ERROR_SINGLE_ACCESS_FAILED,
+ errorDesc = ERROR_TOKEN_INVALID_DESC,
+ e = null,
+ )
+ return@launch
+ }
+
+ // Initially set background to transparent, it will react to the webview
+ // background if/when the WebView loads an actual page we want to show.
+ viewModel.dynamicBackgroundColor.value = Color.Transparent.copy(alpha = HALF_ALPHA)
+ makeStatusBarVisible()
+
+ setContent {
+ MaterialTheme(
+ colorScheme = SalesforceSDKManager.getInstance().colorScheme().copy(
+ background = viewModel.dynamicBackgroundColor.value
+ )
+ ) {
+ TokenMigrationView(
+ webViewFactory = { buildAuthWebview(frontDoorUrl, resultCallback, user.instanceServer) }
+ )
+ }
+ }
+ }
+ }
+
+ @VisibleForTesting
+ internal fun buildAuthWebview(
+ frontDoorUrl: String,
+ resultCallback: MigrationCallbackRegistry.MigrationCallbacks,
+ instanceServer: String,
+ ): WebView = webViewFactory(this@TokenMigrationActivity).apply {
+ @SuppressLint("SetJavaScriptEnabled") // Required by Salesforce
+ settings.javaScriptEnabled = true
+ settings.userAgentString = format(
+ "%s %s",
+ SalesforceSDKManager.getInstance().userAgent,
+ settings.userAgentString,
+ )
+ setBackgroundColor(android.graphics.Color.TRANSPARENT)
+ webViewClient = TokenMigrationClientManager(resultCallback, instanceServer)
+
+ loadUrl(frontDoorUrl)
+ }
+
+ // This implementation is very similar to [LoginActivity.AuthWebViewClient] but the
+ // code cannot be shared due to the heavy reliance on the ViewModel.
+ @VisibleForTesting
+ internal inner class TokenMigrationClientManager(
+ val resultCallback: MigrationCallbackRegistry.MigrationCallbacks,
+ val instanceServer: String,
+ ) : WebViewClient() {
+
+ override fun shouldOverrideUrlLoading(
+ view: WebView,
+ request: WebResourceRequest,
+ ): Boolean {
+ val url = request.url.toString().replace("///", "/").lowercase()
+ val callbackUrl = viewModel.oAuthConfig.redirectUri.replace("///", "/").lowercase()
+ val migrationFinished = url.startsWith(callbackUrl)
+
+ if (migrationFinished) {
+ viewModel.authFinished.value = true
+ viewModel.loading.value = true
+
+ val params = UriFragmentParser.parse(request.url)
+ val error = params["error"]
+ // Did we fail?
+ when {
+ error != null -> {
+ logMigrationError(
+ resultCallback = resultCallback,
+ error = error,
+ errorDesc = params["error_description"],
+ e = null,
+ )
+ }
+
+ else -> {
+ // Show loading while we PKCE and/or create user account.
+ viewModel.authFinished.value = true
+ viewModel.loading.value = true
+
+ CoroutineScope(Default).launch {
+ when {
+ viewModel.useWebServerFlow ->
+ viewModel.onWebServerFlowComplete(
+ code = params["code"],
+ onAuthFlowError = resultCallback.onMigrationError,
+ onAuthFlowSuccess = resultCallback.onMigrationSuccess,
+ loginServer = instanceServer,
+ tokenMigration = true,
+ ).join()
+
+ else ->
+ viewModel.onAuthFlowComplete(
+ tr = TokenEndpointResponse(params),
+ onAuthFlowError = resultCallback.onMigrationError,
+ onAuthFlowSuccess = resultCallback.onMigrationSuccess,
+ tokenMigration = true,
+ )
+ }
+
+ // Wait until we are completely finished so progress indicator is shown.
+ finish()
+ }
+ }
+ }
+ }
+
+ return migrationFinished
+ }
+
+ override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+ super.onPageStarted(view, url, favicon)
+ viewModel.loading.value = true
+ }
+
+ override fun onPageFinished(view: WebView?, url: String?) {
+ view?.evaluateJavascript(BACKGROUND_COLOR_JAVASCRIPT) { result ->
+ makeStatusBarVisible()
+ validateAndExtractBackgroundColor(result)?.let { color ->
+ viewModel.dynamicBackgroundColor.value = color
+
+ // This check is inside validateAndExtractBackgroundColor because we only
+ // want to stop showing the spinner if WebView UI is actually displayed.
+ if (!viewModel.authFinished.value) {
+ viewModel.loading.value = false
+ }
+ }
+ }
+
+ super.onPageFinished(view, url)
+ }
+ }
+
+ private fun logMigrationError(
+ resultCallback: MigrationCallbackRegistry.MigrationCallbacks,
+ error: String,
+ errorDesc: String?,
+ e: Throwable?,
+ ) {
+ val message = error + (errorDesc?.let { ": $it" } ?: "")
+ SalesforceSDKLogger.e(TAG, message, e)
+ resultCallback.onMigrationError(error, errorDesc, e)
+ finish()
+ }
+
+ // Ensure Status Bar Icons are readable no matter which OS theme is used.
+ private fun makeStatusBarVisible() {
+ val usingDarkTheme = viewModel.dynamicBackgroundTheme.value == DARK
+ WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = usingDarkTheme
+ }
+
+ companion object {
+ const val EXTRA_OAUTH_CONFIG = "MIGRATION_OAUTH_CONFIG"
+ const val EXTRA_ORG_ID = "MIGRATION_ORG_ID"
+ const val EXTRA_USER_ID = "MIGRATION_USER_ID"
+ const val EXTRA_CALLBACK_ID = "MIGRATION_CALLBACK"
+
+ const val TAG = "TokenMigrationActivity"
+
+ // Used for mocking the webview in tests.
+ var webViewFactory = { context: Context -> WebView(context) }
+ }
+}
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 554b72d4b7..004be2f049 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt
@@ -500,7 +500,7 @@ private tailrec fun Context.getActivity(): FragmentActivity? = when (this) {
}
@Composable
-private fun Modifier.applyImePaddingConditionally() : Modifier =
+internal fun Modifier.applyImePaddingConditionally() : Modifier =
// TODO: Remove when min API is > 29
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
windowInsetsPadding(WindowInsets.ime)
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/TokenMigrationView.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/TokenMigrationView.kt
new file mode 100644
index 0000000000..867cbe6c74
--- /dev/null
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/TokenMigrationView.kt
@@ -0,0 +1,51 @@
+package com.salesforce.androidsdk.ui.components
+
+import android.webkit.WebView
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.ui.LoginViewModel
+
+@Composable
+internal fun TokenMigrationView(webViewFactory: () -> WebView) {
+ val viewModel: LoginViewModel =
+ viewModel(factory = SalesforceSDKManager.getInstance().loginViewModelFactory)
+
+ Scaffold(
+ contentWindowInsets = WindowInsets.safeDrawing,
+ modifier = Modifier.fillMaxSize(),
+ ) { innerPadding ->
+ val alpha: Float by animateFloatAsState(
+ targetValue = if (viewModel.loading.value) LOADING_ALPHA else VISIBLE_ALPHA,
+ animationSpec = tween(durationMillis = SLOW_ANIMATION_MS),
+ )
+
+ AndroidView(
+ modifier = Modifier
+ .background(Color.Transparent)
+ .padding(innerPadding)
+ .consumeWindowInsets(innerPadding)
+ .applyImePaddingConditionally()
+ .graphicsLayer(alpha = alpha),
+ factory = { webViewFactory.invoke() },
+ )
+
+ if (viewModel.loading.value) {
+ viewModel.loadingIndicator ?: DefaultLoadingIndicator()
+ }
+ }
+}
\ No newline at end of file
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/MigrationCallbackRegistryTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/MigrationCallbackRegistryTest.kt
new file mode 100644
index 0000000000..7c2bbc4635
--- /dev/null
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/MigrationCallbackRegistryTest.kt
@@ -0,0 +1,203 @@
+/*
+ * 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.androidsdk.accounts
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for MigrationCallbackRegistry.
+ */
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class MigrationCallbackRegistryTest {
+
+ @Test
+ fun register_returnsUniqueKey() {
+ // Given
+ val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val callbacks = MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = onSuccess,
+ onMigrationError = onError
+ )
+
+ // When
+ val key1 = MigrationCallbackRegistry.register(callbacks)
+ val key2 = MigrationCallbackRegistry.register(callbacks)
+
+ // Then
+ assertNotNull("Key should not be null", key1)
+ assertNotNull("Key should not be null", key2)
+ assertNotEquals("Keys should be unique", key1, key2)
+
+ // Cleanup
+ MigrationCallbackRegistry.consume(key1)
+ MigrationCallbackRegistry.consume(key2)
+ }
+
+ @Test
+ fun consume_returnsCallbacksAndRemovesThem() {
+ // Given
+ val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val callbacks = MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = onSuccess,
+ onMigrationError = onError
+ )
+ val key = MigrationCallbackRegistry.register(callbacks)
+
+ // When
+ val consumedCallbacks = MigrationCallbackRegistry.consume(key)
+
+ // Then
+ assertNotNull("Consumed callbacks should not be null", consumedCallbacks)
+ assertEquals("Consumed callbacks should match registered callbacks", callbacks, consumedCallbacks)
+ }
+
+ @Test
+ fun consume_returnsNullForNonExistentKey() {
+ // When
+ val result = MigrationCallbackRegistry.consume("non-existent-key")
+
+ // Then
+ assertNull("Should return null for non-existent key", result)
+ }
+
+ @Test
+ fun consume_returnsNullOnSecondAttempt() {
+ // Given
+ val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val callbacks = MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = onSuccess,
+ onMigrationError = onError
+ )
+ val key = MigrationCallbackRegistry.register(callbacks)
+
+ // When
+ val firstConsume = MigrationCallbackRegistry.consume(key)
+ val secondConsume = MigrationCallbackRegistry.consume(key)
+
+ // Then
+ assertNotNull("First consume should return callbacks", firstConsume)
+ assertNull("Second consume should return null", secondConsume)
+ }
+
+ @Test
+ fun migrationCallbacks_onMigrationSuccess_invokesCallback() {
+ // Given
+ val mockAccount: UserAccount = mockk()
+ val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val callbacks = MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = onSuccess,
+ onMigrationError = onError
+ )
+
+ // When
+ callbacks.onMigrationSuccess(mockAccount)
+
+ // Then
+ verify(exactly = 1) { onSuccess(mockAccount) }
+ }
+
+ @Test
+ fun migrationCallbacks_onMigrationError_invokesCallbackWithAllParams() {
+ // Given
+ val error = "Test error"
+ val errorDesc = "Test error description"
+ val throwable = RuntimeException("Test exception")
+ val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val callbacks = MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = onSuccess,
+ onMigrationError = onError
+ )
+
+ // When
+ callbacks.onMigrationError(error, errorDesc, throwable)
+
+ // Then
+ verify(exactly = 1) { onError(error, errorDesc, throwable) }
+ }
+
+ @Test
+ fun migrationCallbacks_onMigrationError_invokesCallbackWithNullParams() {
+ // Given
+ val error = "Test error"
+ val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val callbacks = MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = onSuccess,
+ onMigrationError = onError
+ )
+
+ // When
+ callbacks.onMigrationError(error, null, null)
+
+ // Then
+ verify(exactly = 1) { onError(error, null, null) }
+ }
+
+ @Test
+ fun multipleCallbacks_canBeRegisteredAndConsumedIndependently() {
+ // Given
+ val onSuccess1: (UserAccount) -> Unit = mockk(relaxed = true)
+ val onError1: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val callbacks1 = MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = onSuccess1,
+ onMigrationError = onError1
+ )
+
+ val onSuccess2: (UserAccount) -> Unit = mockk(relaxed = true)
+ val onError2: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val callbacks2 = MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = onSuccess2,
+ onMigrationError = onError2
+ )
+
+ // When
+ val key1 = MigrationCallbackRegistry.register(callbacks1)
+ val key2 = MigrationCallbackRegistry.register(callbacks2)
+
+ // Then - consume in reverse order
+ val consumed2 = MigrationCallbackRegistry.consume(key2)
+ val consumed1 = MigrationCallbackRegistry.consume(key1)
+
+ assertEquals("Callbacks 2 should be consumed correctly", callbacks2, consumed2)
+ assertEquals("Callbacks 1 should be consumed correctly", callbacks1, consumed1)
+ }
+}
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerMigrateTokenTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerMigrateTokenTest.kt
new file mode 100644
index 0000000000..e6d9f6ab0a
--- /dev/null
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerMigrateTokenTest.kt
@@ -0,0 +1,307 @@
+/*
+ * 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.androidsdk.accounts
+
+import android.content.Context
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.config.OAuthConfig
+import com.salesforce.androidsdk.ui.TokenMigrationActivity
+import com.salesforce.androidsdk.util.SalesforceSDKLogger
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.unmockkAll
+import io.mockk.verify
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for UserAccountManager.migrateRefreshToken extension function.
+ */
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class UserAccountManagerMigrateTokenTest {
+
+ private lateinit var mockUserAccountManager: UserAccountManager
+ private lateinit var mockContext: Context
+ private lateinit var mockSdkManager: SalesforceSDKManager
+ private lateinit var mockOAuthConfig: OAuthConfig
+
+ private val onMigrationSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ private val onMigrationError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+
+ @Before
+ fun setUp() {
+ mockUserAccountManager = mockk(relaxed = true)
+ mockContext = mockk(relaxed = true)
+ mockSdkManager = mockk(relaxed = true)
+ mockOAuthConfig = mockk(relaxed = true)
+
+ mockkObject(SalesforceSDKManager)
+ every { SalesforceSDKManager.getInstance() } returns mockSdkManager
+ every { mockSdkManager.appContext } returns mockContext
+ mockkStatic(SalesforceSDKLogger::class)
+ every { SalesforceSDKLogger.e(any(), any()) } just runs
+ every { SalesforceSDKLogger.e(any(), any(), any()) } just runs
+ every { SalesforceSDKLogger.i(any(), any()) } just runs
+
+ mockkObject(MigrationCallbackRegistry)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun migrateRefreshToken_withNullUserAccount_callsOnError() {
+ // Given
+ mockkStatic(UserAccountManager::class)
+ every { UserAccountManager.getInstance() } returns mockUserAccountManager
+ every { mockUserAccountManager.currentUser } returns null
+
+ // When
+ mockUserAccountManager.migrateRefreshToken(
+ userAccount = null,
+ appConfig = mockOAuthConfig,
+ onMigrationSuccess = onMigrationSuccess,
+ onMigrationError = onMigrationError
+ )
+
+ // Then
+ verify(exactly = 1) { onMigrationError.invoke("User account, userId or orgId is null.", null, null) }
+ verify(exactly = 0) { onMigrationSuccess.invoke(any()) }
+ verify(exactly = 0) { mockContext.startActivity(any()) }
+ }
+
+ @Test
+ fun migrateRefreshToken_withNullUserId_callsOnError() {
+ // Given
+ val mockUserAccount: UserAccount = mockk(relaxed = true)
+ every { mockUserAccount.userId } returns null
+ every { mockUserAccount.orgId } returns "testOrgId"
+
+ // When
+ mockUserAccountManager.migrateRefreshToken(
+ userAccount = mockUserAccount,
+ appConfig = mockOAuthConfig,
+ onMigrationSuccess = onMigrationSuccess,
+ onMigrationError = onMigrationError
+ )
+
+ // Then
+ verify(exactly = 1) { onMigrationError.invoke("User account, userId or orgId is null.", null, null) }
+ verify(exactly = 0) { onMigrationSuccess.invoke(any()) }
+ verify(exactly = 0) { mockContext.startActivity(any()) }
+ }
+
+ @Test
+ fun migrateRefreshToken_withNullOrgId_callsOnError() {
+ // Given
+ val mockUserAccount: UserAccount = mockk(relaxed = true)
+ every { mockUserAccount.userId } returns "testUserId"
+ every { mockUserAccount.orgId } returns null
+
+ // When
+ mockUserAccountManager.migrateRefreshToken(
+ userAccount = mockUserAccount,
+ appConfig = mockOAuthConfig,
+ onMigrationSuccess = onMigrationSuccess,
+ onMigrationError = onMigrationError
+ )
+
+ // Then
+ verify(exactly = 1) { onMigrationError.invoke("User account, userId or orgId is null.", null, null) }
+ verify(exactly = 1) { SalesforceSDKLogger.e(any(), "User account, userId or orgId is null.") }
+ verify(exactly = 0) { onMigrationSuccess.invoke(any()) }
+ verify(exactly = 0) { mockContext.startActivity(any()) }
+ }
+
+ @Test
+ fun migrateRefreshToken_withValidUserAccount_registersCallbackAndStartsActivity() {
+ // Given
+ val testUserId = "testUserId123"
+ val testOrgId = "testOrgId456"
+ val testCallbackKey = "test-callback-key"
+
+ val mockUserAccount: UserAccount = mockk(relaxed = true)
+ every { mockUserAccount.userId } returns testUserId
+ every { mockUserAccount.orgId } returns testOrgId
+
+ every { MigrationCallbackRegistry.register(any()) } returns testCallbackKey
+
+ val intentSlot = slot()
+ every { mockContext.startActivity(capture(intentSlot)) } just Runs
+
+ // When
+ mockUserAccountManager.migrateRefreshToken(
+ userAccount = mockUserAccount,
+ appConfig = mockOAuthConfig,
+ onMigrationSuccess = onMigrationSuccess,
+ onMigrationError = onMigrationError
+ )
+
+ // Then
+ verify(exactly = 1) { MigrationCallbackRegistry.register(any()) }
+ verify(exactly = 1) { mockContext.startActivity(any()) }
+
+ val capturedIntent = intentSlot.captured
+ assertNotNull("Intent should not be null", capturedIntent)
+ assertEquals("Intent should have NEW_TASK flag",
+ Intent.FLAG_ACTIVITY_NEW_TASK,
+ capturedIntent.flags and Intent.FLAG_ACTIVITY_NEW_TASK
+ )
+ assertEquals("Intent should have correct org id",
+ testOrgId,
+ capturedIntent.getStringExtra(TokenMigrationActivity.EXTRA_ORG_ID)
+ )
+ assertEquals("Intent should have correct user id",
+ testUserId,
+ capturedIntent.getStringExtra(TokenMigrationActivity.EXTRA_USER_ID)
+ )
+ assertEquals("Intent should have correct callback key",
+ testCallbackKey,
+ capturedIntent.getStringExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID)
+ )
+ }
+
+ @Test
+ fun migrateRefreshToken_withValidUserAccount_passesOAuthConfigInIntent() {
+ // Given
+ val mockUserAccount: UserAccount = mockk(relaxed = true)
+ every { mockUserAccount.userId } returns "testUserId"
+ every { mockUserAccount.orgId } returns "testOrgId"
+
+ every { MigrationCallbackRegistry.register(any()) } returns "callback-key"
+
+ val intentSlot = slot()
+ every { mockContext.startActivity(capture(intentSlot)) } just Runs
+
+ // When
+ mockUserAccountManager.migrateRefreshToken(
+ userAccount = mockUserAccount,
+ appConfig = mockOAuthConfig,
+ onMigrationSuccess = onMigrationSuccess,
+ onMigrationError = onMigrationError
+ )
+
+ // Then
+ val capturedIntent = intentSlot.captured
+ assertTrue("Intent should contain OAuthConfig extra",
+ capturedIntent.hasExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG)
+ )
+ }
+
+ @Test
+ fun migrateRefreshToken_registersCorrectCallbacks() {
+ // Given
+ val mockUserAccount: UserAccount = mockk(relaxed = true)
+ every { mockUserAccount.userId } returns "testUserId"
+ every { mockUserAccount.orgId } returns "testOrgId"
+ every { mockUserAccount.username } returns ""
+ every { mockUserAccount.instanceServer } returns ""
+
+ val callbacksSlot = slot()
+ every { MigrationCallbackRegistry.register(capture(callbacksSlot)) } returns "callback-key"
+ every { mockContext.startActivity(any()) } just Runs
+
+ // When
+ mockUserAccountManager.migrateRefreshToken(
+ userAccount = mockUserAccount,
+ appConfig = mockOAuthConfig,
+ onMigrationSuccess = onMigrationSuccess,
+ onMigrationError = onMigrationError
+ )
+
+ // Then
+ val capturedCallbacks = callbacksSlot.captured
+ assertNotNull("Callbacks should not be null", capturedCallbacks)
+
+ // Verify success callback is properly wrapped
+ val testAccount: UserAccount = mockk(relaxed = true)
+ capturedCallbacks.onMigrationSuccess(testAccount)
+ verify(exactly = 1) { onMigrationSuccess.invoke(testAccount) }
+
+ // Verify error callback is properly wrapped
+ val testError = "Test error"
+ val testDesc = "Test description"
+ val testThrowable = RuntimeException("Test")
+ capturedCallbacks.onMigrationError(testError, testDesc, testThrowable)
+ verify(exactly = 1) { onMigrationError.invoke(testError, testDesc, testThrowable) }
+ }
+
+ @Test
+ fun migrateRefreshToken_usesCurrentUserWhenUserAccountNotProvided() {
+ // Given
+ val testUserId = "currentUserId"
+ val testOrgId = "currentOrgId"
+
+ val mockCurrentUser: UserAccount = mockk(relaxed = true)
+ every { mockCurrentUser.userId } returns testUserId
+ every { mockCurrentUser.orgId } returns testOrgId
+
+ mockkStatic(UserAccountManager::class)
+ every { UserAccountManager.getInstance() } returns mockUserAccountManager
+ every { mockUserAccountManager.currentUser } returns mockCurrentUser
+
+ every { MigrationCallbackRegistry.register(any()) } returns "callback-key"
+
+ val intentSlot = slot()
+ every { mockContext.startActivity(capture(intentSlot)) } just Runs
+
+ // When - call with default userAccount parameter (null -> uses currentUser)
+ mockUserAccountManager.migrateRefreshToken(
+ appConfig = mockOAuthConfig,
+ onMigrationSuccess = onMigrationSuccess,
+ onMigrationError = onMigrationError
+ )
+
+ // Then
+ val capturedIntent = intentSlot.captured
+ assertEquals("Intent should have current user's org id",
+ testOrgId,
+ capturedIntent.getStringExtra(TokenMigrationActivity.EXTRA_ORG_ID)
+ )
+ assertEquals("Intent should have current user's user id",
+ testUserId,
+ capturedIntent.getStringExtra(TokenMigrationActivity.EXTRA_USER_ID)
+ )
+ }
+}
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 61bed72135..3dfe496ef4 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt
@@ -26,6 +26,7 @@
*/
package com.salesforce.androidsdk.auth
+import android.accounts.Account
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
@@ -33,18 +34,28 @@ import androidx.test.platform.app.InstrumentationRegistry
import com.salesforce.androidsdk.accounts.UserAccount
import com.salesforce.androidsdk.accounts.UserAccountBuilder
import com.salesforce.androidsdk.accounts.UserAccountManager
+import com.salesforce.androidsdk.app.Features.FEATURE_BIOMETRIC_AUTH
+import com.salesforce.androidsdk.app.Features.FEATURE_SCREEN_LOCK
+import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.config.RuntimeConfig
+import com.salesforce.androidsdk.rest.ClientManager
+import com.salesforce.androidsdk.security.BiometricAuthenticationManager
+import com.salesforce.androidsdk.security.ScreenLockManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.json.JSONObject
+import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
+import java.net.URI
/**
* Tests for AuthenticationUtilities.
@@ -68,8 +79,6 @@ class AuthenticationUtilitiesTest {
private val handleScreenLockPolicy: (OAuth2.IdServiceResponse?, UserAccount) -> Unit = mockk()
private val handleBiometricAuthPolicy: (OAuth2.IdServiceResponse?, UserAccount) -> Unit = mockk()
private val handleDuplicateUserAccount: (UserAccountManager, UserAccount, OAuth2.IdServiceResponse?) -> Unit = mockk()
- private var blockIntegrationUser: Boolean = false
- private var nativeLogin: Boolean = false
@Before
fun setUp() {
@@ -97,16 +106,18 @@ class AuthenticationUtilitiesTest {
every { mockUserAccountManager.createAccount(any()) } returns mockk()
every { mockUserAccountManager.switchToUser(any()) } returns Unit
every { mockUserAccountManager.sendUserSwitchIntent(any(), any()) } returns Unit
+ }
+ @After
+ fun tearDown() {
+ unmockkAll()
}
@Test
fun testOnAuthFlowComplete_blockIntegrationUser_shouldCallError() = runTest {
- // Given
- blockIntegrationUser = true
// When
- callOnAuthFlowComplete()
+ callOnAuthFlowComplete(blockIntegrationUser = true)
// Then
verify { onAuthFlowError.invoke("Error", "Authentication error. Please try again.", null) }
@@ -151,7 +162,7 @@ class AuthenticationUtilitiesTest {
.accountName(buildAccountName(userIdentity.username, tokenResponse.instanceUrl))
.loginServer("https://login.salesforce.com")
.clientId("test_consumer_key")
- .nativeLogin(nativeLogin)
+ .nativeLogin(false)
.build()
// When
@@ -177,7 +188,7 @@ 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
@@ -188,7 +199,7 @@ class AuthenticationUtilitiesTest {
.accountName(buildAccountName(null, tokenResponseWithoutIdScope.instanceUrl))
.loginServer("https://login.salesforce.com")
.clientId("test_consumer_key")
- .nativeLogin(nativeLogin)
+ .nativeLogin(false)
.build()
// When
@@ -206,12 +217,589 @@ class AuthenticationUtilitiesTest {
verify { startMainActivity.invoke() }
verify { handleScreenLockPolicy.invoke(null, expectedAccount) }
verify { handleBiometricAuthPolicy.invoke(null, expectedAccount) }
-
+
// Verify that fetchUserIdentity was called but returned null
coVerify(exactly = 1) { fetchUserIdentity.invoke(tokenResponseWithoutIdScope) }
}
+ @Test
+ fun testOnAuthFlowComplete_withNativeLogin_shouldCallSuccess() = runTest {
+ // Given
+ 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()
+ .populateFromTokenEndpointResponse(tokenResponseWithoutIdScope)
+ .populateFromIdServiceResponse(null) // No identity service response
+ .accountName(buildAccountName(null, tokenResponseWithoutIdScope.instanceUrl))
+ .loginServer("https://login.salesforce.com")
+ .clientId("test_consumer_key")
+ .nativeLogin(true) // Expect true
+ .build()
+
+ // When
+ callOnAuthFlowComplete(
+ customTokenResponse = tokenResponseWithoutIdScope,
+ nativeLogin = true,
+ )
+
+ // Then
+ verify(exactly = 0) { onAuthFlowError.invoke(any(), any(), any()) }
+ verify { onAuthFlowSuccess.invoke(expectedAccount) }
+ verify { mockUserAccountManager.createAccount(expectedAccount) }
+ verify { mockUserAccountManager.switchToUser(expectedAccount) }
+ verify { setAdministratorPreferences.invoke(null, expectedAccount) }
+ verify { handleDuplicateUserAccount.invoke(mockUserAccountManager, expectedAccount, null) }
+ verify { addAccount.invoke(expectedAccount) }
+ verify { updateLoggingPrefs.invoke(expectedAccount) }
+ verify { startMainActivity.invoke() }
+ verify { handleScreenLockPolicy.invoke(null, expectedAccount) }
+ verify { handleBiometricAuthPolicy.invoke(null, expectedAccount) }
+
+ // Verify that fetchUserIdentity was called but returned null
+ coVerify(exactly = 1) { fetchUserIdentity.invoke(tokenResponseWithoutIdScope) }
+ }
+
+
+ // region Token Migration Tests
+
+ @Test
+ fun testOnAuthFlowComplete_tokenMigration_shouldNotCallStartMainActivity() = runTest {
+ // Given
+ val userIdentity = createIdServiceResponse()
+ coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity
+
+ // When - tokenMigration is true
+ callOnAuthFlowComplete(tokenMigration = true)
+
+ // Then - startMainActivity should NOT be called during token migration
+ verify(exactly = 0) { startMainActivity.invoke() }
+ // But onAuthFlowSuccess should still be called
+ verify(exactly = 1) { onAuthFlowSuccess.invoke(any()) }
+ }
+
+ @Test
+ fun testOnAuthFlowComplete_tokenMigration_shouldCallSuccess() = runTest {
+ // Given
+ val tokenResponse = createTokenEndpointResponse()
+ val userIdentity = createIdServiceResponse()
+ coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity
+
+ // Create the expected UserAccount object
+ val expectedAccount = UserAccountBuilder.getInstance()
+ .populateFromTokenEndpointResponse(tokenResponse)
+ .populateFromIdServiceResponse(userIdentity)
+ .accountName(buildAccountName(userIdentity.username, tokenResponse.instanceUrl))
+ .loginServer("https://login.salesforce.com")
+ .clientId("test_consumer_key")
+ .nativeLogin(false)
+ .build()
+
+ // When - tokenMigration is true
+ callOnAuthFlowComplete(tokenMigration = true)
+
+ // Then - onAuthFlowSuccess should be called with the account
+ verify(exactly = 1) { onAuthFlowSuccess.invoke(expectedAccount) }
+ verify(exactly = 0) { onAuthFlowError.invoke(any(), any(), any()) }
+ }
+
+ @Test
+ fun testOnAuthFlowComplete_tokenMigration_shouldCreateAccount() = runTest {
+ // Given
+ val userIdentity = createIdServiceResponse()
+ coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity
+
+ // When - tokenMigration is true
+ callOnAuthFlowComplete(tokenMigration = true)
+
+ // Then - account creation and user switch should still happen
+ verify(exactly = 1) { mockUserAccountManager.createAccount(any()) }
+ verify(exactly = 1) { mockUserAccountManager.switchToUser(any()) }
+ verify(exactly = 1) { addAccount.invoke(any()) }
+ }
+
+ @Test
+ fun testOnAuthFlowComplete_tokenMigration_shouldHandleLockPolicies() = runTest {
+ // Given
+ val userIdentity = createIdServiceResponse()
+ coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity
+
+ // When - tokenMigration is true
+ callOnAuthFlowComplete(tokenMigration = true)
+
+ // Then - policies should still be handled
+ verify(exactly = 1) { handleScreenLockPolicy.invoke(any(), any()) }
+ verify(exactly = 1) { handleBiometricAuthPolicy.invoke(any(), any()) }
+ }
+
+ @Test
+ fun testOnAuthFlowComplete_tokenMigration_withBlockedIntegrationUser_shouldCallError() = runTest {
+ // When - tokenMigration is true but user is blocked
+ callOnAuthFlowComplete(
+ blockIntegrationUser = true,
+ tokenMigration = true,
+ )
+
+ // Then - error should still be called for blocked users
+ verify { onAuthFlowError.invoke("Error", "Authentication error. Please try again.", null) }
+ verify(exactly = 0) { onAuthFlowSuccess.invoke(any()) }
+ verify(exactly = 0) { startMainActivity.invoke() }
+ }
+
+ @Test
+ fun testOnAuthFlowComplete_tokenMigration_withManagedAppRequirement_shouldCallError() = runTest {
+ // Given
+ val userIdentityWithManagedAppRequirement = createIdServiceResponse(
+ customPermissions = JSONObject().apply {
+ put("must_be_managed_app", true)
+ }
+ )
+ coEvery { fetchUserIdentity.invoke(any()) } returns userIdentityWithManagedAppRequirement
+ every { mockRuntimeConfig.isManagedApp } returns false
+
+ // When - tokenMigration is true but managed app required
+ callOnAuthFlowComplete(tokenMigration = true)
+
+ // Then - error should still be called
+ verify { onAuthFlowError.invoke("Error", "Authentication only allowed from managed device.", null) }
+ verify(exactly = 0) { onAuthFlowSuccess.invoke(any()) }
+ verify(exactly = 0) { startMainActivity.invoke() }
+ }
+
+ // endregion
+
+ // region handleScreenLockPolicy Tests
+
+ @Test
+ fun testHandleScreenLockPolicy_positiveTimeout_registersFeatureAndStoresPolicy() {
+ // Given
+ val mockScreenLockManager = mockk(relaxed = true)
+ val mockSdkManager = setupMockSdkManager(screenLockManager = mockScreenLockManager)
+
+ val userIdentity = createIdServiceResponse()
+ userIdentity.screenLock = true
+ userIdentity.screenLockTimeout = 10
+
+ val account = mockk()
+
+ // When
+ com.salesforce.androidsdk.auth.handleScreenLockPolicy(userIdentity, account)
+
+ // Then
+ verify { mockSdkManager.registerUsedAppFeature(FEATURE_SCREEN_LOCK) }
+ verify { mockScreenLockManager.storeMobilePolicy(account, enabled = true, 600000) }
+ }
+
+ @Test
+ fun testHandleScreenLockPolicy_zeroTimeout_enabledManager_unregistersFeatureAndCleansUp() {
+ // Given
+ val mockScreenLockManager = mockk(relaxed = true) {
+ every { enabled } returns true
+ }
+ val mockSdkManager = setupMockSdkManager(screenLockManager = mockScreenLockManager)
+
+ val userIdentity = createIdServiceResponse()
+ userIdentity.screenLockTimeout = 0
+
+ val account = mockk()
+
+ // When
+ com.salesforce.androidsdk.auth.handleScreenLockPolicy(userIdentity, account)
+
+ // Then
+ verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_SCREEN_LOCK) }
+ verify { mockScreenLockManager.cleanUp(account) }
+ }
+
+ @Test
+ fun testHandleScreenLockPolicy_negativeTimeout_disabledManager_noOp() {
+ // Given
+ val mockScreenLockManager = mockk(relaxed = true) {
+ every { enabled } returns false
+ }
+ val mockSdkManager = setupMockSdkManager(screenLockManager = mockScreenLockManager)
+
+ val userIdentity = createIdServiceResponse()
+ val account = mockk()
+
+ // When
+ com.salesforce.androidsdk.auth.handleScreenLockPolicy(userIdentity, account)
+
+ // Then
+ verify(exactly = 0) { mockSdkManager.registerUsedAppFeature(any()) }
+ verify(exactly = 0) { mockSdkManager.unregisterUsedAppFeature(any()) }
+ verify(exactly = 0) { mockScreenLockManager.storeMobilePolicy(any(), any(), any()) }
+ verify(exactly = 0) { mockScreenLockManager.cleanUp(any()) }
+ }
+
+ @Test
+ fun testHandleScreenLockPolicy_nullIdentity_enabledManager_unregistersAndCleansUp() {
+ // Given
+ val mockScreenLockManager = mockk(relaxed = true) {
+ every { enabled } returns true
+ }
+ val mockSdkManager = setupMockSdkManager(screenLockManager = mockScreenLockManager)
+
+ val account = mockk()
+
+ // When
+ com.salesforce.androidsdk.auth.handleScreenLockPolicy(null, account)
+
+ // Then
+ verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_SCREEN_LOCK) }
+ verify { mockScreenLockManager.cleanUp(account) }
+ }
+
+ @Test
+ fun testHandleScreenLockPolicy_nullIdentity_disabledManager_noOp() {
+ // Given
+ val mockScreenLockManager = mockk(relaxed = true) {
+ every { enabled } returns false
+ }
+ val mockSdkManager = setupMockSdkManager(screenLockManager = mockScreenLockManager)
+
+ val account = mockk()
+
+ // When
+ com.salesforce.androidsdk.auth.handleScreenLockPolicy(null, account)
+
+ // Then
+ verify(exactly = 0) { mockSdkManager.registerUsedAppFeature(any()) }
+ verify(exactly = 0) { mockSdkManager.unregisterUsedAppFeature(any()) }
+ verify(exactly = 0) { mockScreenLockManager.storeMobilePolicy(any(), any(), any()) }
+ verify(exactly = 0) { mockScreenLockManager.cleanUp(any()) }
+ }
+
+ // endregion
+
+ // region handleBiometricAuthPolicy Tests
+
+ @Test
+ fun testHandleBiometricAuthPolicy_biometricEnabled_registersFeatureAndStoresPolicy() {
+ // Given
+ val mockBioAuthManager = mockk(relaxed = true)
+ val mockSdkManager = setupMockSdkManager(biometricAuthenticationManager = mockBioAuthManager)
+
+ val userIdentity = createIdServiceResponse()
+ userIdentity.biometricAuth = true
+ userIdentity.biometricAuthTimeout = 15
+
+ val account = mockk()
+
+ // When
+ com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(userIdentity, account)
+
+ // Then
+ verify { mockSdkManager.registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH) }
+ verify { mockBioAuthManager.storeMobilePolicy(account, enabled = true, 900000) }
+ }
+
+ @Test
+ fun testHandleBiometricAuthPolicy_biometricDisabled_enabledManager_unregistersFeatureAndCleansUp() {
+ // Given
+ val mockBioAuthManager = mockk(relaxed = true) {
+ every { enabled } returns true
+ }
+ val mockSdkManager = setupMockSdkManager(biometricAuthenticationManager = mockBioAuthManager)
+
+ val userIdentity = createIdServiceResponse()
+ userIdentity.biometricAuth = false
+
+ val account = mockk()
+
+ // When
+ com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(userIdentity, account)
+
+ // Then
+ verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH) }
+ verify { mockBioAuthManager.cleanUp(account) }
+ }
+
+ @Test
+ fun testHandleBiometricAuthPolicy_biometricDisabled_disabledManager_noOp() {
+ // Given
+ val mockBioAuthManager = mockk(relaxed = true) {
+ every { enabled } returns false
+ }
+ val mockSdkManager = setupMockSdkManager(biometricAuthenticationManager = mockBioAuthManager)
+
+ val userIdentity = createIdServiceResponse()
+ userIdentity.biometricAuth = false
+
+ val account = mockk()
+
+ // When
+ com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(userIdentity, account)
+
+ // Then
+ verify(exactly = 0) { mockSdkManager.registerUsedAppFeature(any()) }
+ verify(exactly = 0) { mockSdkManager.unregisterUsedAppFeature(any()) }
+ verify(exactly = 0) { mockBioAuthManager.storeMobilePolicy(any(), any(), any()) }
+ verify(exactly = 0) { mockBioAuthManager.cleanUp(any()) }
+ }
+
+ @Test
+ fun testHandleBiometricAuthPolicy_nullIdentity_enabledManager_unregistersAndCleansUp() {
+ // Given
+ val mockBioAuthManager = mockk(relaxed = true) {
+ every { enabled } returns true
+ }
+ val mockSdkManager = setupMockSdkManager(biometricAuthenticationManager = mockBioAuthManager)
+
+ val account = mockk()
+
+ // When
+ com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(null, account)
+
+ // Then
+ verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH) }
+ verify { mockBioAuthManager.cleanUp(account) }
+ }
+
+ @Test
+ fun testHandleBiometricAuthPolicy_nullIdentity_disabledManager_noOp() {
+ // Given
+ val mockBioAuthManager = mockk(relaxed = true) {
+ every { enabled } returns false
+ }
+ val mockSdkManager = setupMockSdkManager(biometricAuthenticationManager = mockBioAuthManager)
+
+ val account = mockk()
+
+ // When
+ com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(null, account)
+
+ // Then
+ verify(exactly = 0) { mockSdkManager.registerUsedAppFeature(any()) }
+ verify(exactly = 0) { mockSdkManager.unregisterUsedAppFeature(any()) }
+ verify(exactly = 0) { mockBioAuthManager.storeMobilePolicy(any(), any(), any()) }
+ verify(exactly = 0) { mockBioAuthManager.cleanUp(any()) }
+ }
+
+ // endregion
+
+ // region handleDuplicateUserAccount Tests
+
+ @Test
+ fun testHandleDuplicateUserAccount_nullAuthenticatedUsers_noOp() {
+ // Given
+ val mockUam = mockk(relaxed = true) {
+ every { authenticatedUsers } returns null
+ }
+ val account = buildTestUserAccount()
+
+ // When
+ com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null)
+
+ // Then
+ verify(exactly = 0) { mockUam.clearCachedCurrentUser() }
+ }
+
+ @Test
+ fun testHandleDuplicateUserAccount_noDuplicate_noRemoval() {
+ // Given
+ val otherUser = buildTestUserAccount(userId = "005OTHER")
+ val mockUam = mockk(relaxed = true) {
+ every { authenticatedUsers } returns mutableListOf(otherUser)
+ }
+ val account = buildTestUserAccount()
+
+ // When
+ com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null)
+
+ // Then
+ verify(exactly = 0) { mockUam.clearCachedCurrentUser() }
+ verify(exactly = 0) { mockUam.buildAccount(any()) }
+ }
+
+ @Test
+ fun testHandleDuplicateUserAccount_duplicateFound_sameRefreshToken_removesAccountOnly() {
+ // Given
+ val mockClientManager = mockk(relaxed = true)
+ setupMockSdkManager(clientManager = mockClientManager)
+
+ val duplicateUser = buildTestUserAccount(refreshToken = "same_token")
+ val mockUam = mockk(relaxed = true) {
+ every { authenticatedUsers } returns mutableListOf(duplicateUser)
+ }
+ val account = buildTestUserAccount(refreshToken = "same_token")
+
+ // When
+ com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null)
+
+ // Then
+ verify { mockUam.clearCachedCurrentUser() }
+ }
+
+ @Test
+ fun testHandleDuplicateUserAccount_duplicateFound_differentRefreshToken_revokesToken() {
+ // Given
+ val mockClientManager = mockk(relaxed = true)
+ setupMockSdkManager(clientManager = mockClientManager)
+ val mockRevokeRefreshToken = mockk<(HttpAccess, URI, String, OAuth2.LogoutReason) -> Unit>(relaxed = true)
+
+ val duplicateUser = buildTestUserAccount(
+ refreshToken = "old_token",
+ instanceServer = "https://test.salesforce.com"
+ )
+ val mockAccount = mockk()
+ val mockUam = mockk(relaxed = true) {
+ every { authenticatedUsers } returns mutableListOf(duplicateUser)
+ every { buildAccount(duplicateUser) } returns mockAccount
+ }
+ val account = buildTestUserAccount(refreshToken = "new_token")
+
+ // When
+ com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null, mockRevokeRefreshToken)
+ Thread.sleep(500)
+
+ // Then
+ verify { mockUam.clearCachedCurrentUser() }
+ verify {
+ mockRevokeRefreshToken.invoke(
+ any(),
+ any(),
+ eq("old_token"),
+ eq(OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED)
+ )
+ }
+ }
+
+ @Test
+ fun testHandleDuplicateUserAccount_duplicateFound_differentRefreshToken_biometricEnabled_unlocks() {
+ // Given
+ val mockBioAuthManager = mockk(relaxed = true)
+ val mockClientManager = mockk(relaxed = true)
+ val mockSdkManager = setupMockSdkManager(
+ biometricAuthenticationManager = mockBioAuthManager,
+ clientManager = mockClientManager
+ )
+ setupBiometricEnabledPrefs(mockSdkManager)
+ val mockRevokeRefreshToken = mockk<(HttpAccess, URI, String, OAuth2.LogoutReason) -> Unit>(relaxed = true)
+
+ val duplicateUser = buildTestUserAccount(
+ refreshToken = "old_token",
+ instanceServer = "https://test.salesforce.com"
+ )
+ val mockAccount = mockk()
+ val mockUam = mockk(relaxed = true) {
+ every { authenticatedUsers } returns mutableListOf(duplicateUser)
+ every { buildAccount(duplicateUser) } returns mockAccount
+ }
+ val account = buildTestUserAccount(refreshToken = "new_token")
+
+ // When
+ com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null, mockRevokeRefreshToken)
+ Thread.sleep(500)
+
+ // Then
+ verify { mockBioAuthManager.onUnlock() }
+ verify {
+ mockRevokeRefreshToken.invoke(
+ any(),
+ any(),
+ eq("old_token"),
+ eq(OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED)
+ )
+ }
+ }
+
+ @Test
+ fun testHandleDuplicateUserAccount_biometricIdentity_signsOutExistingBiometricUsers() {
+ // Given
+ val mockSdkManager = setupMockSdkManager()
+ setupBiometricEnabledPrefs(mockSdkManager)
+
+ val existingBioUser = buildTestUserAccount(userId = "005BIO_USER")
+
+ val mockUam = mockk(relaxed = true) {
+ every { authenticatedUsers } returns mutableListOf(existingBioUser)
+ }
+ val account = buildTestUserAccount()
+ val userIdentity = createIdServiceResponse()
+ userIdentity.biometricAuth = true
+
+ // When
+ com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, userIdentity)
+
+ // Then
+ verify { mockUam.signoutUser(existingBioUser, null, false, OAuth2.LogoutReason.UNEXPECTED) }
+ }
+
+ @Test
+ fun testHandleDuplicateUserAccount_nullIdentity_skipsBiometricSignout() {
+ // Given
+ val mockSdkManager = setupMockSdkManager()
+ setupBiometricEnabledPrefs(mockSdkManager)
+
+ val existingBioUser = buildTestUserAccount(userId = "005BIO_USER")
+
+ val mockUam = mockk(relaxed = true) {
+ every { authenticatedUsers } returns mutableListOf(existingBioUser)
+ }
+ val account = buildTestUserAccount()
+
+ // When
+ com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null)
+
+ // Then
+ verify(exactly = 0) { mockUam.signoutUser(any(), any(), any(), any()) }
+ }
+
+ // endregion
+
+ private fun buildTestUserAccount(
+ userId: String = "005000000000000AAA",
+ orgId: String = "00D000000000000EAA",
+ refreshToken: String = "test_refresh_token",
+ instanceServer: String = "https://test.salesforce.com"
+ ): UserAccount {
+ return UserAccountBuilder.getInstance()
+ .userId(userId)
+ .orgId(orgId)
+ .refreshToken(refreshToken)
+ .instanceServer(instanceServer)
+ .authToken("test_auth_token")
+ .loginServer("https://login.salesforce.com")
+ .idUrl("https://test.salesforce.com/id/$orgId/$userId")
+ .accountName("test_account")
+ .build()
+ }
+
+ private fun setupBiometricEnabledPrefs(mockSdkManager: SalesforceSDKManager) {
+ val mockPrefs = mockk(relaxed = true) {
+ every { getBoolean("bio_auth_enabled", false) } returns true
+ }
+ val mockContext = mockk(relaxed = true) {
+ every { getSharedPreferences(any(), any()) } returns mockPrefs
+ }
+ every { mockSdkManager.appContext } returns mockContext
+ }
+
+ private fun setupMockSdkManager(
+ screenLockManager: ScreenLockManager? = null,
+ biometricAuthenticationManager: BiometricAuthenticationManager? = null,
+ clientManager: ClientManager? = null
+ ): SalesforceSDKManager {
+ mockkObject(SalesforceSDKManager)
+ val mockSdkManager = mockk(relaxed = true)
+ every { SalesforceSDKManager.getInstance() } returns mockSdkManager
+ screenLockManager?.let { every { mockSdkManager.screenLockManager } returns it }
+ biometricAuthenticationManager?.let { every { mockSdkManager.biometricAuthenticationManager } returns it }
+ clientManager?.let { every { mockSdkManager.clientManager } returns it }
+ return mockSdkManager
+ }
- private suspend fun callOnAuthFlowComplete(customTokenResponse: OAuth2.TokenEndpointResponse? = null) {
+ private suspend fun callOnAuthFlowComplete(
+ customTokenResponse: OAuth2.TokenEndpointResponse? = null,
+ nativeLogin: Boolean = false,
+ tokenMigration: Boolean = false,
+ blockIntegrationUser: Boolean = false,
+ ) {
onAuthFlowComplete(
tokenResponse = customTokenResponse ?: createTokenEndpointResponse(),
loginServer = "https://login.salesforce.com",
@@ -220,6 +808,7 @@ class AuthenticationUtilitiesTest {
onAuthFlowSuccess = onAuthFlowSuccess,
buildAccountName = buildAccountName,
nativeLogin = nativeLogin,
+ tokenMigration = tokenMigration,
context = testContext,
userAccountManager = mockUserAccountManager,
blockIntegrationUser = blockIntegrationUser,
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt
index 8d3e4f78f6..2458e8b546 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt
@@ -38,10 +38,12 @@ import com.salesforce.androidsdk.ui.LoginViewModel
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
+import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.spyk
import io.mockk.unmockkAll
-import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertNotNull
@@ -50,7 +52,6 @@ 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.
@@ -73,8 +74,7 @@ class LoginViewModelMockTest {
mockCookieManager = mockk(relaxed = true)
every { CookieManager.getInstance() } returns mockCookieManager
- // Mock OAuth2 and AuthenticationUtilities
- mockkStatic(OAuth2::class)
+ // Mock AuthenticationUtilities
mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt")
// Create view model after mocking
@@ -109,6 +109,7 @@ class LoginViewModelMockTest {
onAuthFlowSuccess = any(),
buildAccountName = any(),
nativeLogin = any(),
+ tokenMigration = any(),
context = any(),
userAccountManager = any(),
blockIntegrationUser = any(),
@@ -140,13 +141,14 @@ class LoginViewModelMockTest {
// Verify AuthenticationUtilities.onAuthFlowComplete was called with correct parameters
coVerify {
onAuthFlowComplete(
- tokenResponse = eq(mockTokenResponse),
- loginServer = eq(testServer),
- consumerKey = eq(bootConfig.remoteAccessConsumerKey),
+ tokenResponse = mockTokenResponse,
+ loginServer = testServer,
+ consumerKey = bootConfig.remoteAccessConsumerKey,
onAuthFlowError = any(),
onAuthFlowSuccess = any(),
buildAccountName = any(),
nativeLogin = any(),
+ tokenMigration = false,
context = any(),
userAccountManager = any(),
blockIntegrationUser = any(),
@@ -176,6 +178,7 @@ class LoginViewModelMockTest {
onAuthFlowSuccess = any(),
buildAccountName = any(),
nativeLogin = any(),
+ tokenMigration = any(),
context = any(),
userAccountManager = any(),
blockIntegrationUser = any(),
@@ -213,6 +216,7 @@ class LoginViewModelMockTest {
onAuthFlowSuccess = any(),
buildAccountName = any(),
nativeLogin = any(),
+ tokenMigration = false,
context = any(),
userAccountManager = any(),
blockIntegrationUser = any(),
@@ -242,6 +246,7 @@ class LoginViewModelMockTest {
onAuthFlowSuccess = any(),
buildAccountName = any(),
nativeLogin = any(),
+ tokenMigration = any(),
context = any(),
userAccountManager = any(),
blockIntegrationUser = any(),
@@ -289,6 +294,7 @@ class LoginViewModelMockTest {
onAuthFlowSuccess = any(),
buildAccountName = any(),
nativeLogin = any(),
+ tokenMigration = any(),
context = any(),
userAccountManager = any(),
blockIntegrationUser = any(),
@@ -318,13 +324,14 @@ class LoginViewModelMockTest {
// Verify empty string is used when selectedServer is null
coVerify {
onAuthFlowComplete(
- tokenResponse = eq(mockTokenResponse),
- loginServer = eq(""),
+ tokenResponse = mockTokenResponse,
+ loginServer = "",
consumerKey = any(),
onAuthFlowError = any(),
onAuthFlowSuccess = any(),
buildAccountName = any(),
nativeLogin = any(),
+ tokenMigration = false,
context = any(),
userAccountManager = any(),
blockIntegrationUser = any(),
@@ -342,77 +349,166 @@ class LoginViewModelMockTest {
}
@Test
- fun doCodeExchange_CallsExchangeCode_WithCorrectParameters() = runBlocking {
+ fun onWebServerFlowComplete_CallsDoCodeExchange_WithCorrectParameters() = runBlocking {
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
- 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
+ // Create a spy of viewModel to verify and mock doCodeExchange
+ val spyViewModel = spyk(viewModel)
+ // Mock doCodeExchange to prevent actual execution
+ coEvery {
+ spyViewModel.doCodeExchange(any(), any(), any(), any(), any())
+ } returns Result.success(Unit)
+
// Set up the view model state
- viewModel.selectedServer.value = testServer
+ spyViewModel.selectedServer.value = testServer
Thread.sleep(100)
- // Call the method under test via onWebServerFlowComplete
- viewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess)
+ // Call the method under test
+ spyViewModel.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,
+ // Verify doCodeExchange was called with correct parameters
+ coVerify {
+ spyViewModel.doCodeExchange(
+ testCode,
+ mockOnError,
+ mockOnSuccess,
+ loginServer = null,
+ tokenMigration = false,
+ )
+ }
+ }
+
+ @Test
+ fun onTokenMigration_CallsDoCodeExchange_WithCorrectParameters() = runBlocking {
+ val testServer = "https://test.salesforce.com"
+ val testCode = "test_auth_code_123"
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val migrationLoginServer = "migration_login_server"
+
+ // Create a spy of viewModel to verify and mock doCodeExchange
+ val spyViewModel = spyk(viewModel)
+
+ // Mock doCodeExchange to prevent actual execution
+ coEvery {
+ spyViewModel.doCodeExchange(any(), any(), any(), any(), any())
+ } returns Result.success(Unit)
+
+ // Set up the view model state
+ spyViewModel.selectedServer.value = testServer
+ Thread.sleep(100)
+
+ // Call the method under test
+ spyViewModel.onWebServerFlowComplete(
+ testCode,
+ mockOnError,
+ mockOnSuccess,
+ migrationLoginServer,
+ tokenMigration = true,
+ )
+
+ // Give time for the coroutine to execute
+ Thread.sleep(200)
+
+ // Verify doCodeExchange was called with correct parameters
+ coVerify {
+ spyViewModel.doCodeExchange(
testCode,
- viewModel.codeVerifier,
- bootConfig.oauthRedirectURI,
+ mockOnError,
+ mockOnSuccess,
+ loginServer = migrationLoginServer,
+ tokenMigration = true,
)
}
}
@Test
- fun doCodeExchange_WithFrontDoorBridge_UsesCorrectServerAndVerifier() = runBlocking {
+ fun onWebServerFlowComplete_WithFrontDoorBridge_UsesCorrectServerAndVerifier() = runBlocking {
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
+ // Create a spy of viewModel to verify and mock doCodeExchange
+ val spyViewModel = spyk(viewModel)
+
+ // Mock doCodeExchange to prevent actual execution
+ coEvery {
+ spyViewModel.doCodeExchange(any(), any(), any(), any(), any())
+ } returns Result.success(Unit)
+
+ // Set up front door bridge
+ spyViewModel.loginWithFrontDoorBridgeUrl(frontDoorUrl, frontDoorVerifier)
+ Thread.sleep(100)
+
+ // Call the method under test
+ spyViewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess)
+
+ // Give time for the coroutine to execute
+ Thread.sleep(200)
+
+ // Verify doCodeExchange was called with null loginServer and false tokenMigration
+ coVerify {
+ spyViewModel.doCodeExchange(
+ testCode,
+ mockOnError,
+ mockOnSuccess,
+ loginServer = null,
+ tokenMigration = false,
+ )
+ }
+ }
+
+ @Test
+ fun onWebServerFlowComplete_WithNullCode_CallsDoCodeExchangeWithNull() = runBlocking {
+ val testServer = "https://test.salesforce.com"
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+
+ // Create a spy of viewModel to verify and mock doCodeExchange
+ val spyViewModel = spyk(viewModel)
+
+ // Mock doCodeExchange to prevent actual execution
+ coEvery {
+ spyViewModel.doCodeExchange(any(), any(), any(), any(), any())
+ } returns Result.success(Unit)
+
+ // Set up the view model state
+ spyViewModel.selectedServer.value = testServer
+ Thread.sleep(100)
+
+ // Call with null code
+ spyViewModel.onWebServerFlowComplete(null, mockOnError, mockOnSuccess)
+
+ // Give time for the coroutine to execute
+ Thread.sleep(200)
+
+ // Verify doCodeExchange was called with null code, null loginServer, and false tokenMigration
+ coVerify {
+ spyViewModel.doCodeExchange(
+ null,
+ mockOnError,
+ mockOnSuccess,
+ loginServer = null,
+ tokenMigration = false,
+ )
+ }
+ }
+
+ // region Token Migration Tests
+
+ @Test
+ fun onAuthFlowComplete_WithTokenMigration_PassesCorrectParameters() = runBlocking {
+ // Mock the AuthenticationUtilities.onAuthFlowComplete function
coEvery {
onAuthFlowComplete(
tokenResponse = any(),
@@ -422,6 +518,7 @@ class LoginViewModelMockTest {
onAuthFlowSuccess = any(),
buildAccountName = any(),
nativeLogin = any(),
+ tokenMigration = any(),
context = any(),
userAccountManager = any(),
blockIntegrationUser = any(),
@@ -436,59 +533,29 @@ class LoginViewModelMockTest {
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 {
- 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
- coEvery {
+
+ // Set up the view model state
+ viewModel.selectedServer.value = "https://test.salesforce.com"
+ Thread.sleep(100)
+
+ // Call the method under test with tokenMigration = true
+ viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess, tokenMigration = true)
+
+ // Verify tokenMigration parameter is passed as true
+ coVerify {
onAuthFlowComplete(
- tokenResponse = any(),
+ tokenResponse = mockTokenResponse,
loginServer = any(),
consumerKey = any(),
onAuthFlowError = any(),
onAuthFlowSuccess = any(),
buildAccountName = any(),
nativeLogin = any(),
+ tokenMigration = true,
context = any(),
userAccountManager = any(),
blockIntegrationUser = any(),
@@ -502,40 +569,148 @@ class LoginViewModelMockTest {
handleBiometricAuthPolicy = any(),
handleDuplicateUserAccount = any(),
)
- } returns Unit
+ }
+ }
+
+ @Test
+ fun onWebServerFlowComplete_WithTokenMigration_PassesCorrectParameters() = runBlocking {
+ val customLoginServer = "https://custom.salesforce.com"
+ val testCode = "test_auth_code"
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+
+ // Create a spy of viewModel to verify and mock doCodeExchange
+ val spyViewModel = spyk(viewModel)
- // Mock exchangeCode
+ // Mock doCodeExchange to prevent actual execution
+ coEvery {
+ spyViewModel.doCodeExchange(any(), any(), any(), any(), any())
+ } returns Result.success(Unit)
+
+ // Set up the view model state with different server
+ spyViewModel.selectedServer.value = "https://different.salesforce.com"
+ Thread.sleep(100)
+
+ // Call the method under test with custom loginServer and tokenMigration
+ spyViewModel.onWebServerFlowComplete(
+ testCode,
+ mockOnError,
+ mockOnSuccess,
+ loginServer = customLoginServer,
+ tokenMigration = true
+ )
+
+ // Give time for the coroutine to execute
+ Thread.sleep(200)
+
+ // Verify doCodeExchange was called with the correct loginServer and tokenMigration
+ coVerify {
+ spyViewModel.doCodeExchange(
+ testCode,
+ mockOnError,
+ mockOnSuccess,
+ loginServer = customLoginServer,
+ tokenMigration = true,
+ )
+ }
+ }
+
+ @Test
+ fun doCodeExchange_UtilizesOAuth2_AndFinishesAuth() = runBlocking {
+ val testCode = "test_auth_code"
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val mockTokenResponse: TokenEndpointResponse = mockk(relaxed = true)
+
+ // Create a spy of viewModel to verify and mock doCodeExchange
+ val spyViewModel = spyk(viewModel)
+
+ // Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError
+ OAuth2.TIMESTAMP_FORMAT
+ mockkStatic(OAuth2::class)
every {
- OAuth2.exchangeCode(
- any(),
- any(),
- any(),
- any(),
- any(),
- any(),
+ OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
+ } returns mockTokenResponse
+
+ // Mock doCodeExchange to prevent actual execution
+ coEvery {
+ spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
+ } just runs
+
+ // Set up required state
+ spyViewModel.selectedServer.value = "https://test.salesforce.com"
+ Thread.sleep(100)
+
+ // Call function under test
+ spyViewModel.doCodeExchange(
+ testCode,
+ mockOnError,
+ mockOnSuccess,
+ )
+
+ // Give time for the coroutine to execute
+ Thread.sleep(200)
+
+ coVerify {
+ spyViewModel.onAuthFlowComplete(
+ mockTokenResponse,
+ mockOnError,
+ mockOnSuccess,
+ tokenMigration = false,
+ loginServer = "https://test.salesforce.com",
)
+ }
+ }
+
+ @Test
+ fun doCodeExchange_TokenMigration_PassesCorrectValues() = runBlocking {
+ val testCode = "test_auth_code"
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val mockTokenResponse: TokenEndpointResponse = mockk(relaxed = true)
+ val migrationServer = "migration_server"
+
+ // Create a spy of viewModel to verify and mock doCodeExchange
+ val spyViewModel = spyk(viewModel)
+
+ // Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError
+ OAuth2.TIMESTAMP_FORMAT
+ mockkStatic(OAuth2::class)
+ every {
+ OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
} returns mockTokenResponse
-
- // Set up the view model state
- viewModel.selectedServer.value = testServer
+
+ // Mock doCodeExchange to prevent actual execution
+ coEvery {
+ spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
+ } just runs
+
+ // Set up required state
+ spyViewModel.selectedServer.value = "https://test.salesforce.com"
Thread.sleep(100)
-
- // Call with null code
- viewModel.onWebServerFlowComplete(null, mockOnError, mockOnSuccess)
-
+
+ // Call function under test
+ spyViewModel.doCodeExchange(
+ testCode,
+ mockOnError,
+ mockOnSuccess,
+ migrationServer,
+ tokenMigration = true,
+ )
+
// 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,
+
+ coVerify {
+ spyViewModel.onAuthFlowComplete(
+ mockTokenResponse,
+ mockOnError,
+ mockOnSuccess,
+ tokenMigration = true,
+ loginServer = migrationServer,
)
}
}
+
+ // endregion
}
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 28f848e5e6..1f3ee9540f 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt
@@ -405,6 +405,63 @@ class LoginViewModelTest {
)
}
+ @Test
+ fun getAuthorizationUrl_UsesMigrationConfig_OverAppConfigForLoginHost() {
+ val sdkManagerMock = mockk(relaxed = false)
+ val appConfigConsumerKey = "app_config_key_should_not_be_used"
+ val appConfigRedirectUri = "appconfig://should_not_be_used"
+ every { sdkManagerMock.useHybridAuthentication } returns false
+ every { sdkManagerMock.appConfigForLoginHost } returns { _ ->
+ OAuthConfig(
+ consumerKey = appConfigConsumerKey,
+ redirectUri = appConfigRedirectUri,
+ scopes = listOf("api"),
+ )
+ }
+ val debugConsumerKey = "debug_override_key_789"
+ val debugRedirectUri = "debug://redirect"
+ val debugScopes = listOf("api", "debug_scope")
+ every { sdkManagerMock.debugOverrideAppConfig } returns OAuthConfig(
+ consumerKey = debugConsumerKey,
+ redirectUri = debugRedirectUri,
+ scopes = debugScopes,
+ )
+ val migrationConsumerKey = "migration_override_key_789"
+ val migrationRedirectUri = "migration://redirect"
+ 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,
+ )
+ )
+ }
+ assertFalse(
+ "URL should not contain debug override consumer key",
+ loginUrl.contains(debugConsumerKey)
+ )
+ assertFalse(
+ "URL should not contain debug override redirect URI",
+ loginUrl.contains("redirect_uri=debug://redirect")
+ )
+ assertFalse("URL should not contain debug scope", loginUrl.contains("debug_scope"))
+
+ // Verify migration config values are in the URL
+ assertTrue(
+ "URL should contain migration consumer key",
+ loginUrl.contains(migrationConsumerKey)
+ )
+ migrationScopes.forEach { scope ->
+ assertTrue(loginUrl.contains(scope))
+ }
+ }
+
@Test
fun getAuthorizationUrl_UsesServerSpecificConfig_FromAppConfigForLoginHost() {
val sdkManager = SalesforceSDKManager.getInstance()
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 42816fd788..8da4abc8bd 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java
@@ -26,6 +26,8 @@
*/
package com.salesforce.androidsdk.rest;
+import static com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -368,7 +370,7 @@ public void testGetRawResponse() throws Exception {
public void testGetSingleAccess() throws Exception {
RestResponse response = restClient.sendSync(RestRequest.getRequestForSingleAccess("abc/def"));
checkResponse(response, HttpURLConnection.HTTP_OK, false);
- checkKeys(response.asJSONObject(), "frontdoor_uri");
+ checkKeys(response.asJSONObject(), FRONTDOOR_URL_KEY);
}
/**
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 3dd8081d39..8c53867860 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt
@@ -165,7 +165,7 @@ class LoginActivityTest {
val observer = activity.BrowserCustomTabUrlObserver(activity)
observer.onChanged(exampleUrl)
- verify(exactly = -1) {
+ verify {
activity.startBrowserCustomTabAuthorization(
match { it == exampleUrl },
match { it == activityResultLauncher }
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt
new file mode 100644
index 0000000000..5ce42e8ecb
--- /dev/null
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt
@@ -0,0 +1,393 @@
+/*
+ * 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.androidsdk.ui
+
+import android.content.Intent
+import androidx.lifecycle.Lifecycle.State.DESTROYED
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewmodel.CreationExtras
+import androidx.test.core.app.ActivityScenario.launch
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.salesforce.androidsdk.accounts.MigrationCallbackRegistry
+import com.salesforce.androidsdk.accounts.UserAccount
+import com.salesforce.androidsdk.accounts.UserAccountManager
+import com.salesforce.androidsdk.analytics.AnalyticsPublishingWorker
+import com.salesforce.androidsdk.analytics.logger.SalesforceLogger
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY
+import com.salesforce.androidsdk.config.OAuthConfig
+import com.salesforce.androidsdk.rest.RestClient
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+internal const val VALID_ORG = "valid-org"
+internal const val VALID_USER = "valid-user"
+internal const val INVALID_ORG = "invalid-org"
+internal const val INVALID_USER = "invalid-user"
+
+/**
+ * Tests for TokenMigrationActivity using ActivityScenario.
+ */
+@RunWith(AndroidJUnit4::class)
+class TokenMigrationActivityTest {
+
+ val mockOAuthConfig = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "testapp://oauth/callback",
+ scopes = listOf("api", "refresh_token"),
+ )
+
+ private lateinit var mockUserAccountManager: UserAccountManager
+ private lateinit var mockSdkManager: SalesforceSDKManager
+ private lateinit var mockRestClient: RestClient
+ private lateinit var mockUser: UserAccount
+
+ @Before
+ fun setUp() {
+ // Mock SalesforceLogger.getLogger to prevent readLoggerPrefs from being called
+ val mockLogger: SalesforceLogger = mockk(relaxed = true)
+ mockkStatic(SalesforceLogger::class)
+ every { SalesforceLogger.getLogger(any(), any()) } returns mockLogger
+ every { SalesforceLogger.getLogger(any(), any(), any()) } returns mockLogger
+
+ // Reset logger prefs as backup
+ SalesforceLogger.flushComponents()
+ SalesforceLogger.resetLoggerPrefs(getApplicationContext())
+
+ mockUserAccountManager = mockk(relaxed = true)
+ mockSdkManager = mockk(relaxed = true)
+ mockRestClient = mockk(relaxed = true)
+ mockUser = mockk(relaxed = true)
+
+ // Mock user properties needed for getAuthorizationUrl
+ every { mockUser.instanceServer } returns "https://test.salesforce.com"
+
+ mockkStatic(UserAccountManager::class)
+ every { UserAccountManager.getInstance() } returns mockUserAccountManager
+ every {
+ mockUserAccountManager.getUserFromOrgAndUserId(VALID_ORG, VALID_USER)
+ } returns mockUser
+ every {
+ mockUserAccountManager.getUserFromOrgAndUserId(INVALID_ORG, INVALID_USER)
+ } returns null
+
+ mockkObject(SalesforceSDKManager)
+ mockkObject(SalesforceSDKManager.Companion)
+ every { SalesforceSDKManager.getInstance() } returns mockSdkManager
+ every { mockSdkManager.appContext } returns getApplicationContext()
+ every { mockSdkManager.clientManager.peekRestClient(any()) } returns mockRestClient
+ every { mockSdkManager.useHybridAuthentication } returns false
+ every { mockSdkManager.userAgent } returns "MockUserAgent"
+
+ // Default mock for sendSync to prevent hanging - tests can override this
+ val mockResponse = mockk(relaxed = true)
+ every { mockResponse.isSuccess } returns true
+ every { mockResponse.asString() } returns """{"$FRONTDOOR_URL_KEY": "https://test.salesforce.com/frontdoor"}"""
+ every { mockRestClient.sendSync(any()) } returns mockResponse
+
+ // Mock AnalyticsPublishingWorker to prevent NPE during activity lifecycle
+ mockkObject(AnalyticsPublishingWorker.Companion)
+ every { AnalyticsPublishingWorker.enqueueAnalyticsPublishWorkRequest(any(), any()) } returns null
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun onCreate_withMissingCallbackId_finishesActivity() {
+ // Given - Intent without callback ID
+ val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java)
+ // No EXTRA_CALLBACK_ID set
+
+ // When
+ launch(intent).use { scenario ->
+ // Then - Activity should finish immediately
+ assertEquals(
+ "Activity should be destroyed when callback ID is missing",
+ DESTROYED,
+ scenario.state,
+ )
+ }
+ }
+
+ @Test
+ fun onCreate_withInvalidCallbackId_finishesActivity() {
+ // Given - Intent with callback ID that's not in the registry
+ val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply {
+ putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, "invalid-callback-id")
+ }
+
+ // When
+ launch(intent).use { scenario ->
+ // Then - Activity should finish because callback is not in registry
+ assertEquals(
+ "Activity should be destroyed when callback ID is invalid",
+ DESTROYED,
+ scenario.state,
+ )
+ }
+ }
+
+ @Test
+ fun onCreate_withMissingOAuthConfig_callsErrorCallbackAndFinishes() {
+ // Given
+ var errorCalled = false
+ var errorMessage: String? = null
+ val latch = CountDownLatch(1)
+
+ val callbackKey = MigrationCallbackRegistry.register(
+ MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = { },
+ onMigrationError = { error, _, _ ->
+ errorCalled = true
+ errorMessage = error
+ latch.countDown()
+ },
+ )
+ )
+
+ val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply {
+ putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
+ // No EXTRA_OAUTH_CONFIG set
+ }
+
+ // When
+ launch(intent).use { scenario ->
+ // Then
+ latch.await(500, TimeUnit.MILLISECONDS)
+ assertTrue("Error callback should be called", errorCalled)
+ assertEquals(ERROR_PARSE_OAUTH_CONFIG, errorMessage)
+ assertEquals(DESTROYED, scenario.state)
+ }
+ }
+
+ @Test
+ fun onCreate_withMissingOrgId_callsErrorCallbackAndFinishes() {
+ // Given
+ var errorCalled = false
+ val latch = CountDownLatch(1)
+
+ val callbackKey = MigrationCallbackRegistry.register(
+ MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = { },
+ onMigrationError = { _, _, _ ->
+ errorCalled = true
+ latch.countDown()
+ },
+ )
+ )
+
+ val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply {
+ putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
+ putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig)
+ putExtra(TokenMigrationActivity.EXTRA_USER_ID, VALID_USER)
+ // No EXTRA_ORG_ID set
+ }
+
+ // When
+ launch(intent).use { scenario ->
+ // Then
+ latch.await(500, TimeUnit.MILLISECONDS)
+ assertTrue("Error callback should be called", errorCalled)
+ assertEquals(DESTROYED, scenario.state)
+ }
+ }
+
+ @Test
+ fun onCreate_withMissingUserId_callsErrorCallbackAndFinishes() {
+ // Given
+ var errorCalled = false
+ val latch = CountDownLatch(1)
+
+ val callbackKey = MigrationCallbackRegistry.register(
+ MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = { },
+ onMigrationError = { _, _, _ ->
+ errorCalled = true
+ latch.countDown()
+ },
+ )
+ )
+
+ val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply {
+ putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
+ putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig)
+ putExtra(TokenMigrationActivity.EXTRA_ORG_ID, VALID_ORG)
+ // No EXTRA_USER_ID set
+ }
+
+ // When
+ launch(intent).use { scenario ->
+ // Then
+ latch.await(500, TimeUnit.MILLISECONDS)
+ assertTrue("Error callback should be called", errorCalled)
+ assertEquals(DESTROYED, scenario.state)
+ }
+ }
+
+ @Test
+ fun onCreate_withUserNotFound_callsErrorCallbackAndFinishes() {
+ // Given
+ var errorCalled = false
+ var errorMessage: String? = null
+ val latch = CountDownLatch(1)
+
+ val callbackKey = MigrationCallbackRegistry.register(
+ MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = { },
+ onMigrationError = { error, _, _ ->
+ errorCalled = true
+ errorMessage = error
+ latch.countDown()
+ },
+ )
+ )
+
+ val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply {
+ putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
+ putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig)
+ putExtra(TokenMigrationActivity.EXTRA_ORG_ID, INVALID_ORG)
+ putExtra(TokenMigrationActivity.EXTRA_USER_ID, INVALID_USER)
+ }
+
+ // When
+ launch(intent).use { scenario ->
+ // Then
+ latch.await(500, TimeUnit.MILLISECONDS)
+ assertTrue("Error callback should be called", errorCalled)
+ assertEquals(ERROR_BUILD_USER_ACCOUNT, errorMessage)
+ assertEquals(DESTROYED, scenario.state)
+ }
+ }
+
+ @Test
+ fun onCreate_withClientException_callsErrorCallbackAndFinishes() {
+ // Given
+ var errorCalled = false
+ var errorMessage: String? = null
+ val latch = CountDownLatch(1)
+
+ val callbackKey = MigrationCallbackRegistry.register(
+ MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = { },
+ onMigrationError = { error, _, _ ->
+ errorCalled = true
+ errorMessage = error
+ latch.countDown()
+ },
+ )
+ )
+
+ val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply {
+ putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
+ putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig)
+ putExtra(TokenMigrationActivity.EXTRA_ORG_ID, VALID_ORG)
+ putExtra(TokenMigrationActivity.EXTRA_USER_ID, VALID_USER)
+ }
+
+ // Throw exception when getting client
+ every {
+ mockSdkManager.clientManager.peekRestClient(any())
+ } throws RuntimeException("Account not found")
+
+ // When
+ launch(intent).use { scenario ->
+ // Then
+ latch.await(500, TimeUnit.MILLISECONDS)
+ assertTrue("Error callback should be called", errorCalled)
+ assertEquals(ERROR_BUILD_REST_CLIENT, errorMessage)
+ assertEquals(DESTROYED, scenario.state)
+ }
+ }
+
+ @Test
+ fun onCreate_withNullFrontDoorUrl_callsErrorCallbackAndFinishes() {
+ // Given
+ var errorCalled = false
+ var errorMessage: String? = null
+ var errorDesc: String? = null
+ val latch = CountDownLatch(1)
+
+ val callbackKey = MigrationCallbackRegistry.register(
+ MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = { },
+ onMigrationError = { error, desc, _ ->
+ errorCalled = true
+ errorMessage = error
+ errorDesc = desc
+ latch.countDown()
+ },
+ )
+ )
+
+ val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply {
+ putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
+ putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig)
+ putExtra(TokenMigrationActivity.EXTRA_ORG_ID, VALID_ORG)
+ putExtra(TokenMigrationActivity.EXTRA_USER_ID, VALID_USER)
+ }
+
+ // Mock response without frontdoor_uri key
+ every { mockRestClient.sendSync(any()) } returns mockk(relaxed = true) {
+ every { isSuccess } returns true
+ every { asString() } returns "{}"
+ }
+
+ // Mock loginViewModelFactory
+ every { mockSdkManager.loginViewModelFactory } returns object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class, extras: CreationExtras): T {
+ return mockk(relaxed = true) as T
+ }
+ }
+
+ // When
+ launch(intent).use { scenario ->
+ // Then
+ latch.await(500, TimeUnit.MILLISECONDS)
+ assertTrue("Error callback should be called", errorCalled)
+ assertEquals(ERROR_SINGLE_ACCESS_FAILED, errorMessage)
+ assertEquals(ERROR_TOKEN_INVALID_DESC, errorDesc)
+ assertEquals(DESTROYED, scenario.state)
+ }
+ }
+}
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationViewActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationViewActivityTest.kt
new file mode 100644
index 0000000000..1984614898
--- /dev/null
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationViewActivityTest.kt
@@ -0,0 +1,214 @@
+/*
+ * 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.androidsdk.ui
+
+import android.Manifest
+import android.os.Build
+import android.webkit.WebView
+import androidx.activity.ComponentActivity
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.graphics.Color.Companion.Red
+import androidx.compose.ui.graphics.Color.Companion.Transparent
+import androidx.compose.ui.graphics.Color.Companion.White
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewmodel.CreationExtras
+import androidx.test.rule.GrantPermissionRule
+import com.salesforce.androidsdk.R
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.ui.components.TokenMigrationView
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class TokenMigrationViewActivityTest {
+
+ @get:Rule
+ val androidComposeTestRule = createAndroidComposeRule()
+
+ // TODO: Remove if when min SDK version is 33
+ @get:Rule
+ val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ GrantPermissionRule.grant()
+ }
+
+ private lateinit var savedFactory: ViewModelProvider.Factory
+ private lateinit var mockViewModel: LoginViewModel
+
+ @Before
+ fun setUp() {
+ savedFactory = SalesforceSDKManager.getInstance().loginViewModelFactory
+
+ mockViewModel = mockk(relaxed = true) {
+ every { loading } returns mutableStateOf(false)
+ every { loadingIndicator } returns null
+ every { dynamicBackgroundColor } returns mutableStateOf(Transparent)
+ every { dynamicBackgroundTheme } returns mutableStateOf(SalesforceSDKManager.Theme.LIGHT)
+ }
+
+ SalesforceSDKManager.getInstance().loginViewModelFactory =
+ object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(
+ modelClass: Class,
+ extras: CreationExtras,
+ ): T = mockViewModel as T
+ }
+ }
+
+ @After
+ fun tearDown() {
+ SalesforceSDKManager.getInstance().loginViewModelFactory = savedFactory
+ unmockkAll()
+ }
+
+ @Test
+ fun tokenMigrationView_NotLoading_HidesLoadingIndicator() {
+ every { mockViewModel.loading } returns mutableStateOf(false)
+
+ androidComposeTestRule.setContent {
+ val context = LocalContext.current
+ TokenMigrationView(webViewFactory = { WebView(context) })
+ }
+
+ val loadingIndicator = androidComposeTestRule.onNodeWithContentDescription(
+ androidComposeTestRule.activity.getString(R.string.sf__loading_indicator)
+ )
+
+ loadingIndicator.assertIsNotDisplayed()
+ }
+
+ @Test
+ fun tokenMigrationView_Loading_ShowsLoadingIndicator() {
+ every { mockViewModel.loading } returns mutableStateOf(true)
+
+ androidComposeTestRule.setContent {
+ val context = LocalContext.current
+ TokenMigrationView(webViewFactory = { WebView(context) })
+ }
+
+ val loadingIndicator = androidComposeTestRule.onNodeWithContentDescription(
+ androidComposeTestRule.activity.getString(R.string.sf__loading_indicator)
+ )
+
+ loadingIndicator.assertIsDisplayed()
+ }
+
+ @Test
+ fun tokenMigrationView_DefaultBackgroundColor_IsTransparent() {
+ val backgroundColor = mutableStateOf(Transparent)
+ every { mockViewModel.dynamicBackgroundColor } returns backgroundColor
+
+ androidComposeTestRule.setContent {
+ val context = LocalContext.current
+ MaterialTheme(
+ colorScheme = MaterialTheme.colorScheme.copy(
+ background = backgroundColor.value
+ )
+ ) {
+ TokenMigrationView(webViewFactory = { WebView(context) })
+ }
+ }
+
+ assertEquals(
+ "Default background color should be Transparent",
+ Transparent,
+ backgroundColor.value,
+ )
+ }
+
+ @Test
+ fun tokenMigrationView_BackgroundColorUpdates_ReflectsNewColor() {
+ val backgroundColor = mutableStateOf(Transparent)
+ every { mockViewModel.dynamicBackgroundColor } returns backgroundColor
+
+ androidComposeTestRule.setContent {
+ val context = LocalContext.current
+ MaterialTheme(
+ colorScheme = MaterialTheme.colorScheme.copy(
+ background = backgroundColor.value
+ )
+ ) {
+ TokenMigrationView(webViewFactory = { WebView(context) })
+ }
+ }
+
+ androidComposeTestRule.runOnIdle {
+ backgroundColor.value = White
+ }
+
+ androidComposeTestRule.runOnIdle {
+ assertEquals(
+ "Background color should update to White",
+ White,
+ backgroundColor.value,
+ )
+ }
+ }
+
+ @Test
+ fun tokenMigrationView_BackgroundColorUpdatesToRed_ReflectsNewColor() {
+ val backgroundColor = mutableStateOf(Transparent)
+ every { mockViewModel.dynamicBackgroundColor } returns backgroundColor
+
+ androidComposeTestRule.setContent {
+ val context = LocalContext.current
+ MaterialTheme(
+ colorScheme = MaterialTheme.colorScheme.copy(
+ background = backgroundColor.value
+ )
+ ) {
+ TokenMigrationView(webViewFactory = { WebView(context) })
+ }
+ }
+
+ androidComposeTestRule.runOnIdle {
+ backgroundColor.value = Red
+ }
+
+ androidComposeTestRule.runOnIdle {
+ assertEquals(
+ "Background color should update to Red",
+ Red,
+ backgroundColor.value,
+ )
+ }
+ }
+}
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt
new file mode 100644
index 0000000000..c26b720983
--- /dev/null
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt
@@ -0,0 +1,484 @@
+package com.salesforce.androidsdk.ui
+
+import android.app.Activity
+import android.app.Application
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.graphics.Color
+import androidx.core.net.toUri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewmodel.CreationExtras
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.salesforce.androidsdk.accounts.MigrationCallbackRegistry
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.config.OAuthConfig
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.unmockkAll
+import io.mockk.verify
+import org.junit.After
+import org.junit.Assert
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+@RunWith(AndroidJUnit4::class)
+class TokenMigrationWebViewTest {
+
+ val mockOAuthConfig = OAuthConfig(
+ consumerKey = "test_consumer_key",
+ redirectUri = "testapp://oauth/callback",
+ scopes = listOf("api", "refresh_token"),
+ )
+
+ private lateinit var savedFactory: ViewModelProvider.Factory
+ private lateinit var mockViewModel: LoginViewModel
+ private lateinit var mockWebView: WebView
+ private var testActivity: TokenMigrationActivity? = null
+
+
+ @Before
+ fun setUp() {
+ // Save the real loginViewModelFactory so it can be restored in tearDown
+ savedFactory = SalesforceSDKManager.getInstance().loginViewModelFactory
+
+ // Mock loginViewModelFactory to return a mock LoginViewModel
+ mockViewModel = mockk(relaxed = true) {
+ every { dynamicBackgroundColor } returns mutableStateOf(Color.White)
+ every { loading } returns mutableStateOf(false)
+ every { authFinished } returns mutableStateOf(false)
+ every { loadingIndicator } returns null
+ every { oAuthConfig } returns mockOAuthConfig
+ every { useWebServerFlow } returns true
+ }
+
+ // Wire up the factory so the activity's `by viewModels` delegate returns mockViewModel
+ SalesforceSDKManager.getInstance().loginViewModelFactory =
+ object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(
+ modelClass: Class,
+ extras: CreationExtras,
+ ): T = mockViewModel as T
+ }
+
+ TokenMigrationActivity.webViewFactory = { context -> TestWebView(context) }
+
+ mockWebView = mockk(relaxed = true) {
+ every { loadUrl(any()) } just runs
+ every { parent } returns null
+ }
+ }
+
+ @After
+ fun tearDown() {
+ testActivity?.let { activity ->
+ if (!activity.isFinishing && !activity.isDestroyed) {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ activity.finish()
+ }
+ }
+ }
+ testActivity = null
+ // Restore the real loginViewModelFactory
+ SalesforceSDKManager.getInstance().loginViewModelFactory = savedFactory
+ unmockkAll() // cleans up any mockk() instances created in tests
+ }
+
+ /**
+ * A WebView subclass that disables network loading and allows state verification.
+ */
+ open class TestWebView(context: Context) : WebView(context) {
+ var onLoadUrlCallback: (() -> Unit)? = null
+
+ override fun loadUrl(url: String) {
+ // No-op to prevent network loading
+ onLoadUrlCallback?.invoke()
+ }
+
+ override fun loadUrl(url: String, additionalHttpHeaders: MutableMap) {
+ // No-op
+ onLoadUrlCallback?.invoke()
+ }
+ }
+
+ // region shouldOverrideUrlLoading Tests
+
+ @Test
+ fun shouldOverrideUrlLoading_returnsFalseForNonCallbackUrl() {
+ val activity = launchActivity()
+ val mockResultCallback = createMockResultCallback()
+ val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "https://test.salesforce.com")
+ val mockRequest = createMockWebResourceRequest("https://login.salesforce.com/somepage")
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ assertFalse(
+ "Non-callback URL should not be overridden",
+ clientManager.shouldOverrideUrlLoading(mockWebView, mockRequest)
+ )
+
+ verify(exactly = 0) {
+ mockViewModel.onWebServerFlowComplete(any(), any(), any(), any(), any())
+ }
+ coVerify(exactly = 0) {
+ mockViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
+ }
+ }
+ }
+
+ @Test
+ fun shouldOverrideUrlLoading_returnsTrueForCallbackUrl_webServerFlow() {
+ val activity = launchActivity()
+ val mockResultCallback = createMockResultCallback()
+ val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "instanceServer")
+
+ every { mockViewModel.useWebServerFlow } returns true
+
+ val mockRequest = createMockWebResourceRequest("testapp://oauth/callback?code=test_code")
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ assertTrue(
+ "Callback URL should be overridden because migration is finished",
+ clientManager.shouldOverrideUrlLoading(mockWebView, mockRequest)
+ )
+
+ verify {
+ mockViewModel.onWebServerFlowComplete(
+ code = "test_code",
+ onAuthFlowSuccess = mockResultCallback.onMigrationSuccess,
+ onAuthFlowError = mockResultCallback.onMigrationError,
+ loginServer = "instanceServer",
+ tokenMigration = true,
+ )
+ }
+
+ coVerify(exactly = 0) {
+ mockViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
+ }
+ }
+ }
+
+ @Test
+ fun shouldOverrideUrlLoading_returnsTrueForCallbackUrl_userAgentFlow() {
+ val activity = launchActivity()
+ val mockResultCallback = createMockResultCallback()
+ val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "instanceServer")
+
+ every { mockViewModel.useWebServerFlow } returns false
+
+ val mockRequest = createMockWebResourceRequest("testapp://oauth/callback?code=test_code")
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ assertTrue(
+ "Callback URL should be overridden because migration is finished",
+ clientManager.shouldOverrideUrlLoading(mockWebView, mockRequest)
+ )
+ coVerify {
+ mockViewModel.onAuthFlowComplete(
+ tr = any(),
+ onAuthFlowSuccess = mockResultCallback.onMigrationSuccess,
+ onAuthFlowError = mockResultCallback.onMigrationError,
+ tokenMigration = true,
+ )
+ }
+
+ verify(exactly = 0) {
+ mockViewModel.onWebServerFlowComplete(any(), any(), any(), any(), any())
+ }
+ }
+ }
+
+ @Test
+ fun shouldOverrideUrlLoading_callsErrorCallbackOnError() {
+ val activity = launchActivity()
+ val mockResultCallback = createMockResultCallback()
+ val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "https://test.salesforce.com")
+
+ val mockRequest = createMockWebResourceRequest(
+ "testapp://oauth/callback?error=access_denied&error_description=User%20denied%20access"
+ )
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ clientManager.shouldOverrideUrlLoading(mockWebView, mockRequest)
+ }
+
+ verify {
+ mockResultCallback.onMigrationError("access_denied", "User denied access", null)
+ }
+
+
+ }
+
+ @Test
+ fun shouldOverrideUrlLoading_normalizesTripleSlashes() {
+ every { mockViewModel.oAuthConfig } returns OAuthConfig(
+ consumerKey = "test",
+ redirectUri = "testapp:///oauth/callback",
+ scopes = listOf("api"),
+ )
+
+ val activity = launchActivity()
+ val mockResultCallback = createMockResultCallback()
+ val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "instanceServer")
+
+ // URL with triple slash should still match after normalization
+ val mockRequest = createMockWebResourceRequest("testapp:///oauth/callback?code=test_code")
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ assertTrue(
+ "Triple-slash URL should match after normalization",
+ clientManager.shouldOverrideUrlLoading(mockWebView, mockRequest)
+ )
+ verify {
+ mockViewModel.onWebServerFlowComplete(
+ code = "test_code",
+ onAuthFlowSuccess = mockResultCallback.onMigrationSuccess,
+ onAuthFlowError = mockResultCallback.onMigrationError,
+ loginServer = "instanceServer",
+ tokenMigration = true,
+ )
+ }
+
+ coVerify(exactly = 0) {
+ mockViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
+ }
+ }
+ }
+
+ // endregion
+
+ // region onPageStarted / onPageFinished Tests
+
+ @Test
+ fun onPageStarted_setsLoadingTrue() {
+ val loadingState: MutableState = mockk(relaxed = true)
+ val authFinishedState: MutableState = mockk(relaxed = true)
+ every { mockViewModel.loading } returns loadingState
+ every { mockViewModel.authFinished } returns authFinishedState
+
+ val activity = launchActivity()
+ val mockResultCallback = createMockResultCallback()
+ val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "https://test.salesforce.com")
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ clientManager.onPageStarted(mockWebView, "https://test.salesforce.com/page", null)
+ }
+
+ verify { loadingState.value = true }
+ verify(exactly = 0) { authFinishedState.value = true }
+ }
+
+ @Test
+ fun onPageFinished_setsBackgroundColor() {
+ val purpleColor = "rgb(128, 0, 128)"
+ val loadingState: MutableState = mockk(relaxed = true)
+ val backgroundColor: MutableState = mockk(relaxed = true)
+ every { mockViewModel.loading } returns loadingState
+ every { mockViewModel.authFinished } returns mutableStateOf(false)
+ every { mockViewModel.dynamicBackgroundColor } returns backgroundColor
+ every { mockWebView.evaluateJavascript(any(), any()) } answers {
+ secondArg>().onReceiveValue(purpleColor)
+ }
+
+ val activity = launchActivity()
+ val mockResultCallback = createMockResultCallback()
+ val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "https://test.salesforce.com")
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ clientManager.onPageFinished(mockWebView, "https://test.salesforce.com/page")
+ }
+
+ verify { backgroundColor.value = Color(128, 0, 128) }
+ verify { loadingState.value = false }
+ }
+
+ // endregion
+
+ // region buildAuthWebview Tests
+
+ @Test
+ fun buildAuthWebview_enablesJavaScript() {
+ val activity = launchActivity()
+ val resultCallback = createMockResultCallback()
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ try {
+ val webView = activity.buildAuthWebview(
+ frontDoorUrl = "https://test.salesforce.com/frontdoor",
+ resultCallback = resultCallback,
+ instanceServer = "https://test.salesforce.com",
+ )
+ assertTrue("JavaScript should be enabled", webView.settings.javaScriptEnabled)
+ } catch (e: Throwable) {
+ Assert.fail(e.stackTraceToString())
+ }
+ }
+ }
+
+ @Test
+ fun buildAuthWebview_setsUserAgent() {
+ val activity = launchActivity()
+ val resultCallback = createMockResultCallback()
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ try {
+ val webView = activity.buildAuthWebview(
+ frontDoorUrl = "https://test.salesforce.com/frontdoor",
+ resultCallback = resultCallback,
+ instanceServer = "https://test.salesforce.com",
+ )
+ assertTrue(
+ "User agent should contain SDK user agent",
+ webView.settings.userAgentString?.contains(
+ SalesforceSDKManager.getInstance().userAgent
+ ) == true,
+ )
+ } catch (e: Throwable) {
+ Assert.fail(e.stackTraceToString())
+ }
+ }
+ }
+
+ @Test
+ fun buildAuthWebview_loadsProvidedUrl() {
+ val activity = launchActivity()
+ val resultCallback = createMockResultCallback()
+ var loadedUrl: String? = null
+
+ TokenMigrationActivity.webViewFactory = { context ->
+ object : TestWebView(context) {
+ override fun loadUrl(url: String) {
+ loadedUrl = url
+ }
+ }
+ }
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ try {
+ activity.buildAuthWebview(
+ frontDoorUrl = "https://test.salesforce.com/frontdoor?sid=abc",
+ resultCallback = resultCallback,
+ instanceServer = "https://test.salesforce.com",
+ )
+ } catch (e: Throwable) {
+ Assert.fail(e.stackTraceToString())
+ }
+ }
+
+ assertEquals(
+ "buildAuthWebview should load the provided frontDoorUrl",
+ "https://test.salesforce.com/frontdoor?sid=abc",
+ loadedUrl,
+ )
+ }
+
+ @Test
+ fun buildAuthWebview_assignsTokenMigrationClientManager() {
+ val activity = launchActivity()
+ val resultCallback = createMockResultCallback()
+
+ TokenMigrationActivity.webViewFactory = { context ->
+ TestWebView(context).also { it.onLoadUrlCallback = { } }
+ }
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ try {
+ val webView = activity.buildAuthWebview(
+ frontDoorUrl = "https://test.salesforce.com/frontdoor",
+ resultCallback = resultCallback,
+ instanceServer = "https://test.salesforce.com",
+ )
+ assertTrue(
+ "WebViewClient should be TokenMigrationClientManager",
+ webView.webViewClient is TokenMigrationActivity.TokenMigrationClientManager,
+ )
+ } catch (e: Throwable) {
+ Assert.fail(e.stackTraceToString())
+ }
+ }
+ }
+
+ // endregion
+
+ // region Helpers
+
+ /**
+ * Launches [TokenMigrationActivity] with intent extras. [UserAccountManager]
+ * returns null for any user lookup, so the activity finishes in [onCreate]
+ * before reaching [clientManager.peekRestClient] or the lifecycleScope
+ * coroutine. This avoids Compose rendering and coroutine-related hangs entirely.
+ *
+ * Uses [Application.ActivityLifecycleCallbacks] to capture the activity
+ * instance because [ActivityScenario.onActivity] cannot be used on
+ * activities that have already reached the DESTROYED state.
+ */
+ private fun launchActivity(): TokenMigrationActivity {
+ val callbackKey = MigrationCallbackRegistry.register(
+ MigrationCallbackRegistry.MigrationCallbacks(
+ onMigrationSuccess = { },
+ onMigrationError = { _, _, _ -> },
+ )
+ )
+ val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
+ putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig)
+ putExtra(TokenMigrationActivity.EXTRA_ORG_ID, VALID_ORG)
+ putExtra(TokenMigrationActivity.EXTRA_USER_ID, VALID_USER)
+ }
+
+ var capturedActivity: TokenMigrationActivity? = null
+ val latch = CountDownLatch(1)
+ val app = getApplicationContext()
+
+ val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
+ if (activity is TokenMigrationActivity) {
+ capturedActivity = activity
+ latch.countDown()
+ }
+ }
+ override fun onActivityStarted(activity: Activity) {}
+ override fun onActivityResumed(activity: Activity) {}
+ override fun onActivityPaused(activity: Activity) {}
+ override fun onActivityStopped(activity: Activity) {}
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
+ override fun onActivityDestroyed(activity: Activity) {}
+ }
+
+ app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
+ getApplicationContext().startActivity(intent)
+ latch.await(10, TimeUnit.SECONDS)
+ app.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
+
+ testActivity = capturedActivity
+ ?: throw RuntimeException("TokenMigrationActivity was not created within timeout")
+ return testActivity!!
+ }
+
+ private fun createMockResultCallback(): MigrationCallbackRegistry.MigrationCallbacks =
+ mockk {
+ every { onMigrationSuccess(any()) } just runs
+ every { onMigrationError(any(), any(), any()) } just runs
+ }
+
+ private fun createMockWebResourceRequest(url: String): WebResourceRequest =
+ mockk {
+ every { this@mockk.url } returns url.toUri()
+ }
+
+ // endregion
+}
\ No newline at end of file
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml b/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml
index 98dd7345e1..8b310df37c 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml
@@ -2,7 +2,7 @@
-
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt
index fb83a17e90..19accbcdca 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt
@@ -36,6 +36,7 @@ import android.os.Build
import android.os.Bundle
import android.widget.ScrollView
import android.widget.TextView
+import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
@@ -84,6 +85,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.remember
@@ -94,6 +96,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
@@ -105,8 +108,10 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.salesforce.androidsdk.accounts.UserAccountManager
+import com.salesforce.androidsdk.accounts.migrateRefreshToken
import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.auth.JwtAccessToken
+import com.salesforce.androidsdk.config.OAuthConfig
import com.salesforce.androidsdk.rest.ApiVersionStrings
import com.salesforce.androidsdk.rest.ClientManager
import com.salesforce.androidsdk.rest.RestClient
@@ -226,11 +231,14 @@ class AuthFlowTesterActivity : SalesforceActivity() {
RevokeButtonCard()
RequestButtonCard()
- UserCredentialsView(currentUser.value)
+ // Use key() to force recomposition when tokens change (UserAccount.equals only compares userId/orgId)
+ key(currentUser.value?.authToken, currentUser.value?.refreshToken) {
+ UserCredentialsView(currentUser.value)
- if (jwtTokenInUse) {
- currentUser.value?.authToken?.let { token ->
- JwtTokenView(jwtToken = JwtAccessToken(token))
+ if (jwtTokenInUse) {
+ currentUser.value?.authToken?.let { token ->
+ JwtTokenView(jwtToken = JwtAccessToken(token))
+ }
}
}
@@ -287,7 +295,6 @@ class AuthFlowTesterActivity : SalesforceActivity() {
) {
Button(
onClick = {
- @Suppress("AssignedValueIsNeverRead")
coroutineScope.launch {
revokeInProgress = true
response = revokeAccessTokenAction(client)
@@ -361,7 +368,6 @@ class AuthFlowTesterActivity : SalesforceActivity() {
Card(modifier = Modifier.padding((INNER_CARD_PADDING/2).dp)) {
Button(
onClick = {
- @Suppress("AssignedValueIsNeverRead")
coroutineScope.launch {
response = null
requestInProgress = true
@@ -457,6 +463,10 @@ class AuthFlowTesterActivity : SalesforceActivity() {
var scopes by remember { mutableStateOf("") }
val validInput = consumerKey.isNotBlank() && callbackUrl.isNotBlank()
var showJsonImportDialog by remember { mutableStateOf(false) }
+ var migrationInProgress by remember { mutableStateOf(false) }
+ var migrationError: String? by remember { mutableStateOf(null) }
+ val clipboard = LocalClipboard.current
+ val context = LocalContext.current
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -530,21 +540,66 @@ class AuthFlowTesterActivity : SalesforceActivity() {
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = (INNER_CARD_PADDING/2).dp),
shape = RoundedCornerShape(CORNER_SHAPE.dp),
- enabled = validInput,
- onClick = { },
+ enabled = validInput && !migrationInProgress,
+ onClick = {
+ migrationInProgress = true
+
+ UserAccountManager.getInstance().migrateRefreshToken(
+ appConfig = OAuthConfig(
+ consumerKey = consumerKey,
+ redirectUri = callbackUrl,
+ ),
+ onMigrationSuccess = {
+ runOnUiThread {
+ Toast.makeText(
+ this@AuthFlowTesterActivity,
+ resources.getString(R.string.migration_success),
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ onDismiss.invoke()
+ },
+ onMigrationError = { error, errorDesc, e ->
+ migrationInProgress = false
+ migrationError = error +
+ (errorDesc?.let { " \n\nDesc: $it" } ?: "") +
+ (e?.let { "\n\nThrowable: $it" } ?: "")
+ },
+ )
+ },
) {
- Text(
- text = stringResource(R.string.migrate_button),
- fontWeight = if (validInput) FontWeight.Normal else FontWeight.Medium,
- color = if (validInput) colorScheme.onPrimary else colorScheme.onErrorContainer,
- )
+ if (migrationInProgress) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(INNER_CARD_PADDING.dp),
+ strokeWidth = SPINNER_STROKE_WIDTH.dp,
+ )
+ Spacer(Modifier.width(INNER_CARD_PADDING.dp))
+ Text(
+ text = stringResource(R.string.migration_in_progress),
+ fontWeight = FontWeight.Medium,
+ color = colorScheme.onErrorContainer,
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.migrate_button),
+ fontWeight = if (validInput) FontWeight.Normal else FontWeight.Medium,
+ color = if (validInput) colorScheme.onPrimary else colorScheme.onErrorContainer,
+ )
+ }
}
}
}
if (showJsonImportDialog) {
- var jsonInput by remember { mutableStateOf("") }
- val alertBody = LocalContext.current.getString(
+ var jsonInput by remember { mutableStateOf(
+ // Default to clipboard text, if available.
+ value = clipboard.nativeClipboard.primaryClip
+ ?.takeIf { it.itemCount > 0 }
+ ?.getItemAt(/* index = */ 0)
+ ?.coerceToText(context)
+ ?.toString() ?: ""
+ ) }
+ val alertBody = context.getString(
/* resId = */ R.string.json_import,
/* ...formatArgs = */ CONSUMER_JSON_KEY, REDIRECT_JSON_KEY, SCOPE_JSON_KEY,
)
@@ -564,7 +619,7 @@ class AuthFlowTesterActivity : SalesforceActivity() {
modifier = Modifier.fillMaxWidth(),
)
}
- },
+ },
confirmButton = {
TextButton(
onClick = {
@@ -594,6 +649,21 @@ class AuthFlowTesterActivity : SalesforceActivity() {
shape = RoundedCornerShape(CORNER_SHAPE.dp),
)
}
+
+ migrationError?.let { errorMessage ->
+ @Suppress("AssignedValueIsNeverRead")
+ AlertDialog(
+ onDismissRequest = { migrationError = null },
+ title = { Text(stringResource(R.string.migration_failure)) },
+ text = { Text(text = errorMessage) },
+ confirmButton = {
+ TextButton(onClick = { migrationError = null }) {
+ Text("Ok")
+ }
+ },
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ )
+ }
}
@OptIn(ExperimentalMaterial3Api::class)
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt
index f8b39b1168..d9a7d38c48 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt
@@ -52,6 +52,9 @@ class AuthFlowTesterApplication : Application() {
with(SalesforceSDKManager.getInstance()) {
registerUsedAppFeature(FEATURE_APP_USES_KOTLIN)
+ // TODO: remove when W-20524841 is fixed
+ useHybridAuthentication = false
+
appConfigForLoginHost = { server: String ->
var oauthConfig: OAuthConfig? = null
val jsonConfig = ResourceReaderHelper.readAssetFile(
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt
index 2e72e8616f..49e70942da 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt
@@ -1,9 +1,38 @@
+/*
+ * 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.samples.authflowtester
import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.rest.RestClient
import com.salesforce.androidsdk.rest.RestRequest
import com.salesforce.androidsdk.rest.RestResponse
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
@@ -11,7 +40,6 @@ import okhttp3.RequestBody.Companion.toRequestBody
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
const val FAILED_OPERATION = "The operation could not be completed."
const val UNKNOWN_ERROR = "An unexpected error has occurred."
@@ -27,8 +55,10 @@ suspend fun revokeAccessTokenAction(client: RestClient?): RequestResult {
// This should never happen.
if (client == null) return RequestResult(success = false, AUTH_REQUIRED)
- val token = SalesforceSDKManager.getInstance().userAccountManager.currentUser.authToken
- val encodedToken = URLEncoder.encode(token, StandardCharsets.UTF_8.toString())
+ val token = SalesforceSDKManager.getInstance().userAccountManager.currentUser?.authToken
+ val encodedToken = withContext(Dispatchers.IO) {
+ URLEncoder.encode(token, StandardCharsets.UTF_8.toString())
+ }
val body = "token=$encodedToken".toRequestBody(
contentType = "application/x-www-form-urlencoded".toMediaType(),
)
@@ -84,7 +114,7 @@ suspend fun makeRestRequest(client: RestClient?, apiVersion: String): RequestRes
}
suspend fun RestClient.sendAsync(request: RestRequest): Result {
- return suspendCoroutine { continuation ->
+ return suspendCancellableCoroutine { continuation ->
sendAsync(request, object : RestClient.AsyncRequestCallback {
override fun onSuccess(request: RestRequest?, response: RestResponse?) {
val result: Result = if (response == null) {
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt
index 49f516f43c..099a9a2e1d 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt
@@ -127,7 +127,7 @@ fun UserCredentialsView(currentUser: UserAccount?) {
InfoSection(title = TOKENS) {
InfoRowView(label = ACCESS_TOKEN, value = currentUser?.authToken, isSensitive = true)
InfoRowView(label = REFRESH_TOKEN, value = currentUser?.refreshToken, isSensitive = true)
- InfoRowView(label = TOKEN_FORMAT, value = currentUser?.tokenFormat)
+ InfoRowView(label = TOKEN_FORMAT, value = currentUser?.tokenFormat?.ifBlank { "Opaque" })
InfoRowView(label = SCOPES, value = formatScopes(currentUser))
}
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-anydpi-v26/msdk_icon.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-anydpi-v26/msdk_icon.xml
new file mode 100644
index 0000000000..85a3f7ccc3
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-anydpi-v26/msdk_icon.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-hdpi/msdk_icon_foreground.webp b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-hdpi/msdk_icon_foreground.webp
new file mode 100644
index 0000000000..0224c30f9e
Binary files /dev/null and b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-hdpi/msdk_icon_foreground.webp differ
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-mdpi/msdk_icon_foreground.webp b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-mdpi/msdk_icon_foreground.webp
new file mode 100644
index 0000000000..e4b3ff4820
Binary files /dev/null and b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-mdpi/msdk_icon_foreground.webp differ
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xhdpi/msdk_icon_foreground.webp b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xhdpi/msdk_icon_foreground.webp
new file mode 100644
index 0000000000..8ec89f5de9
Binary files /dev/null and b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xhdpi/msdk_icon_foreground.webp differ
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxhdpi/msdk_icon_foreground.webp b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxhdpi/msdk_icon_foreground.webp
new file mode 100644
index 0000000000..35f9f6004a
Binary files /dev/null and b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxhdpi/msdk_icon_foreground.webp differ
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxxhdpi/msdk_icon_foreground.webp b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxxhdpi/msdk_icon_foreground.webp
new file mode 100644
index 0000000000..b267a739d4
Binary files /dev/null and b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxxhdpi/msdk_icon_foreground.webp differ
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/values/msdk_icon_background.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/msdk_icon_background.xml
new file mode 100644
index 0000000000..d8b3750382
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/msdk_icon_background.xml
@@ -0,0 +1,4 @@
+
+
+ #414450
+
\ No newline at end of file
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml
index 0e97750c6d..a16ee252e7 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml
@@ -25,10 +25,12 @@
close
import json
Migrate Refresh Token
+ Migrating…
Import Configuration
Import
Paste json with %1$s, %2$s and %3$s:
-
+ Migration Success
+ Migration Failure
(empty)