From 5fa7cf72144a5b708649ce036472c7055ef0b41e Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Thu, 5 Feb 2026 16:07:02 -0800 Subject: [PATCH 1/9] Add token migration feature and AuthFlowTester UI to exercise it. --- libs/SalesforceSDK/AndroidManifest.xml | 6 + libs/SalesforceSDK/build.gradle.kts | 1 + .../accounts/UserAccountManagerExtension.kt | 111 ++++++ .../auth/AuthenticationUtilities.kt | 38 ++- .../salesforce/androidsdk/auth/OAuth2.java | 1 + .../androidsdk/auth/idp/IDPAuthCodeHelper.kt | 3 +- .../androidsdk/config/OAuthConfig.kt | 6 +- .../salesforce/androidsdk/ui/LoginActivity.kt | 29 +- .../androidsdk/ui/LoginViewModel.kt | 34 +- .../androidsdk/ui/TokenMigrationActivity.kt | 315 ++++++++++++++++++ .../androidsdk/ui/components/LoginView.kt | 2 +- .../androidsdk/rest/RestClientTest.java | 4 +- .../authflowtester/AuthFlowTesterActivity.kt | 102 +++++- .../AuthFlowTesterApplication.kt | 3 + .../samples/authflowtester/RestUtils.kt | 38 ++- .../authflowtester/UserCredentialsView.kt | 2 +- .../src/main/res/values/strings.xml | 4 +- 17 files changed, 640 insertions(+), 59 deletions(-) create mode 100644 libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManagerExtension.kt create mode 100644 libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt diff --git a/libs/SalesforceSDK/AndroidManifest.xml b/libs/SalesforceSDK/AndroidManifest.xml index d544868de7..4daa31248f 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, "User ($user) successfully migrated to " + + "new OAuthConfig ($appConfig).") + onMigrationSuccess.invoke(user) + } + val loggedOnError: (error: String, errorDesc: String?, e: Throwable?) -> Unit = { error, errorDesc, e -> + val message = error + errorDesc?.let { "\nDescription: $it" } + SalesforceSDKLogger.e(TAG, message, e) + onMigrationError.invoke(error, errorDesc, e) + } + + val userId = userAccount?.userId ?: run { + loggedOnError("User account or userId is null.", null, null) + return + } + val orgId = userAccount.orgId ?: run { + loggedOnError("OrgId is null.", null, null) + return + } + + val callbackKey = MigrationCallbackRegistry.register( + callbacks = MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = loggedOnSuccess, + onMigrationError = loggedOnError, + ) + ) + + val context = SalesforceSDKManager.getInstance().appContext + val intent = Intent(context, TokenMigrationActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra(TokenMigrationActivity.EXTRA_ORG_ID, orgId) + intent.putExtra(TokenMigrationActivity.EXTRA_USER_ID, userId) + intent.putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, appConfig) + intent.putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey) + context.startActivity(intent) +} + +/* + 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..f7d920f8ca 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt @@ -98,6 +98,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 && @@ -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) @@ -375,14 +378,21 @@ private fun handleScreenLockPolicy( userIdentity: OAuth2.IdServiceResponse?, 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) } } @@ -393,14 +403,20 @@ private fun handleBiometricAuthPolicy( userIdentity: OAuth2.IdServiceResponse?, 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) } } @@ -438,6 +454,12 @@ private fun handleDuplicateUserAccount( clearCaches() userAccountManager.clearCachedCurrentUser() + // Remove the existing Account from AccountManager so createAccount can create a fresh one + val existingAccount = userAccountManager.buildAccount(duplicateUserAccount) + if (existingAccount != null) { + SalesforceSDKManager.getInstance().clientManager.removeAccount(existingAccount) + } + // Revoke existing refresh token if (account.refreshToken != duplicateUserAccount.refreshToken) { runCatching { 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 fa5837e684..3c486a05f7 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 629a632a29..8399098269 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) } } @@ -487,9 +493,15 @@ 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, ) = 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 +513,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..cf59d5fa9a --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt @@ -0,0 +1,315 @@ +/* + * Copyright (c) 2026-pffsent, 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.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.annotation.VisibleForTesting.Companion.PROTECTED +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.MaterialTheme +import androidx.compose.material3.Scaffold +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.graphics.luminance +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.net.toUri +import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.salesforce.androidsdk.accounts.MigrationCallbackRegistry +import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.accounts.UserAccountManager +import com.salesforce.androidsdk.analytics.model.InstrumentationEvent +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.DefaultLoadingIndicator +import com.salesforce.androidsdk.ui.components.LOADING_ALPHA +import com.salesforce.androidsdk.ui.components.SLOW_ANIMATION_MS +import com.salesforce.androidsdk.ui.components.VISIBLE_ALPHA +import com.salesforce.androidsdk.ui.components.applyImePaddingConditionally +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 + +const val HALF_ALPHA = 0.5f +internal class TokenMigrationActivity : ComponentActivity() { + + @VisibleForTesting(otherwise = PROTECTED) + private val viewModel: LoginViewModel + by viewModels { SalesforceSDKManager.getInstance().loginViewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = true + + val callbackKey = intent.getStringExtra(EXTRA_CALLBACK_ID) ?: run { + SalesforceSDKLogger.e(TAG, "Unable to parse MigrationResult callback id.") + finish() + return + } + val resultCallback = MigrationCallbackRegistry.consume(callbackKey) ?: run { + SalesforceSDKLogger.e(TAG, "Unable to retrieve MigrationResult callback.") + finish() + return + } + + // TODO: Move to non-deprecated getParcelableExtra when min API >= 33 + val oAuthConfig = intent.getParcelableExtra(EXTRA_OAUTH_CONFIG) ?: run { + resultCallback.onMigrationError("Unable to parse OAuthConfig.", null, null) + finish() + return + } + + val orgId = intent.getStringExtra(EXTRA_ORG_ID) + val userId = intent.getStringExtra(EXTRA_USER_ID) + if ( orgId == null || userId == null) { + resultCallback.onMigrationError("Unable to parse OAuthConfig.", null, null) + finish() + return + } + val user = UserAccountManager.getInstance().getUserFromOrgAndUserId(orgId, userId) ?: run { + resultCallback.onMigrationError("Unable to build user account.", null, null) + finish() + return + } + val client = runCatching { + SalesforceSDKManager.getInstance().clientManager.peekRestClient(user) + }.getOrElse { e -> + resultCallback.onMigrationError("Unable to build RestClient.", null, e as? Exception) + finish() + 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 { + resultCallback.onMigrationError( + /* error = */ "Request for single access bridge url failed.", + /* errorDesc = */ "User's existing token may be invalid.", + /* e = */ null, + ) + finish() + return@launch + } + viewModel.dynamicBackgroundColor.value = Color.Transparent.copy(alpha = HALF_ALPHA) + makeStatusBarVisible() + + setContent { + MaterialTheme( + colorScheme = SalesforceSDKManager.getInstance().colorScheme().copy( + background = viewModel.dynamicBackgroundColor.value + ) + ) { + 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 = { + buildAuthWebview(frontDoorUrl, resultCallback, user.instanceServer) + }, + ) + + if (viewModel.loading.value) { + viewModel.loadingIndicator ?: DefaultLoadingIndicator() + } + } + } + } + } + } + + @VisibleForTesting + internal fun buildAuthWebview( + frontDoorUrl: String, + resultCallback: MigrationCallbackRegistry.MigrationCallbacks, + instanceServer: String, + ): WebView = WebView(this@TokenMigrationActivity).apply { + @SuppressLint("SetJavaScriptEnabled") // Required by Salesforce + settings.javaScriptEnabled = true + settings.userAgentString = SalesforceSDKManager.getInstance().userAgent + setBackgroundColor(android.graphics.Color.TRANSPARENT) + + // This implementation is very similar to [LoginActivity.AuthWebViewClient] but the + // code cannot be shared due to the heavy reliance on the ViewModel. + webViewClient = object : 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) { + val params = UriFragmentParser.parse(request.url) + val error = params["error"] + // Did we fail? + when { + error != null -> { + resultCallback.onMigrationError( + error, + params["error_description"], + null, + ) + finish() + } + + 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( + params["code"], + onAuthFlowError = resultCallback.onMigrationError, + onAuthFlowSuccess = resultCallback.onMigrationSuccess, + loginServer = instanceServer, + tokenMigration = true, + ).join() + + else -> + viewModel.onAuthFlowComplete( + 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?) { + if (!viewModel.authFinished.value) { + viewModel.loading.value = false + } + + view?.evaluateJavascript(BACKGROUND_COLOR_JAVASCRIPT) { result -> + makeStatusBarVisible() + viewModel.dynamicBackgroundColor.value = validateAndExtractBackgroundColor(result) + ?: return@evaluateJavascript + } + + super.onPageFinished(view, url) + } + } + + loadUrl(frontDoorUrl) + } + + // 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" + } +} 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/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/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..b9a7b77abf 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/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) From 9275eb3b2219795639fcd0bc87ab651679a5945d Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Thu, 5 Feb 2026 20:24:55 -0800 Subject: [PATCH 2/9] Improve Token Migration loading indicator. Add app icon for AuthFlowTester. --- .../androidsdk/ui/TokenMigrationActivity.kt | 22 ++++++++++-------- .../src/main/AndroidManifest.xml | 2 +- .../main/res/mipmap-anydpi-v26/msdk_icon.xml | 5 ++++ .../res/mipmap-hdpi/msdk_icon_foreground.webp | Bin 0 -> 4382 bytes .../res/mipmap-mdpi/msdk_icon_foreground.webp | Bin 0 -> 2782 bytes .../mipmap-xhdpi/msdk_icon_foreground.webp | Bin 0 -> 6532 bytes .../mipmap-xxhdpi/msdk_icon_foreground.webp | Bin 0 -> 10784 bytes .../mipmap-xxxhdpi/msdk_icon_foreground.webp | Bin 0 -> 16190 bytes .../main/res/values/msdk_icon_background.xml | 4 ++++ 9 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-anydpi-v26/msdk_icon.xml create mode 100644 native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-hdpi/msdk_icon_foreground.webp create mode 100644 native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-mdpi/msdk_icon_foreground.webp create mode 100644 native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xhdpi/msdk_icon_foreground.webp create mode 100644 native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxhdpi/msdk_icon_foreground.webp create mode 100644 native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxxhdpi/msdk_icon_foreground.webp create mode 100644 native/NativeSampleApps/AuthFlowTester/src/main/res/values/msdk_icon_background.xml diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt index cf59d5fa9a..21f956a10b 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt @@ -52,16 +52,12 @@ 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.graphics.luminance import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewmodel.compose.viewModel import com.salesforce.androidsdk.accounts.MigrationCallbackRegistry -import com.salesforce.androidsdk.accounts.UserAccount import com.salesforce.androidsdk.accounts.UserAccountManager -import com.salesforce.androidsdk.analytics.model.InstrumentationEvent import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK import com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY @@ -227,6 +223,9 @@ internal class TokenMigrationActivity : ComponentActivity() { 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? @@ -281,14 +280,17 @@ internal class TokenMigrationActivity : ComponentActivity() { } override fun onPageFinished(view: WebView?, url: String?) { - if (!viewModel.authFinished.value) { - viewModel.loading.value = false - } - view?.evaluateJavascript(BACKGROUND_COLOR_JAVASCRIPT) { result -> makeStatusBarVisible() - viewModel.dynamicBackgroundColor.value = validateAndExtractBackgroundColor(result) - ?: return@evaluateJavascript + 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) 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/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 0000000000000000000000000000000000000000..0224c30f9eafc2edb2ca476411f6cdb0c5314257 GIT binary patch literal 4382 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 YAyvGJ*Q&N;@FayMzGhK#qJ)bn5uq$buK)l5 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e4b3ff48201e752e0fcc3abb5bea789509619016 GIT binary patch literal 2782 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8ec89f5de9634915a267c0341de02882c302612c GIT binary patch literal 6532 zcmV-~8GGhZNk&F|82|uRMM6+kP&iC*82|t;*T6LpRfod1Z5!$Tvy(IfJs=_`z~Azo zFzP%is0^r-EtJPrsp4b+e5a90ZpX>sIIps44yp)V6a&+8}yUe{^Jsdqe~7*HRoZVl9E37OwPw z7BN-e(11hk4pRXxnMR(vU5{8~R)M>QlM_*Zg*4!_U>UKGL{kAy2cF*)tAVu#FTmaQ zT13}F+_eHd#8hx4`{D55PVN#rl;CnYxNDsr2wn2I661CXz}?+lA~J^>sDa4IqzwQ- zCf45n|D18ox;JIJPD32RT6VK2*tV^7TPp`yH;VD>vO2UDPU9FEpsox-Lg#~y3tO!5mLZLL=Ka(A?~hI0A!nC znKz(Q0q#-QBb;c(B+bY@2H-92W|pZL^MpNN7aNs<&vmc=~F%#>jknHibOa78({ zZ5!KT+pKJ(Wo%ndl`8G`17h20(>bN>bWSSU){7|%lI*x``q`xmq;n|*0q{M2*ICet zI{Y^gN>yIJO>yN-Nyu?F+TA++!w9)KR*o|jVu`I~z|jIA&7f&*eWBEdzTxFy099$pCWO@gQhd;k!)E-Q(<0y#tc9ljD=KJe0b&qQ5~<% z=Zr$42qU$XQOa7Ct13?w5LwnPqT9mnKT?UX?xT=?2^tswxj5Fz@6gG$6P$dIr5P9e z`!5|dY$D8|vM~;XFUCONcr@*2>`}ONQ%qI~Y^y>m!n!S_-=g1jyHu0*ZfE*V&x4!A zRtAN-s(`B5=m77h=?AD>V9#R7~W~UmqU!3 za!8CZy$pn5V5A-EtyGjW-s7Vswi!#cvC;pNIv@_lg5jQnKb9)LAi~NZ<*Z`N07J3O zXr&A#E!DX3rD4m^OdG_@(Ghqa6^36+jU2d)ALNo4GsIv_@e8F)f@P^i`4)ahE%Rub zP-MFHxG-Fgg210(IWkCT)!yD9;}P0@q?Ga=)&INRT)R^LR64x^g5j8G^)M;|e?qr> zgD@kL#EeFkb%^LUFNz2rihb==8?+y@+v*fD2e+AVVVIAO;Nz8q&OuQ+DOVLUEZd=T z4I2{PsKBY%wr*Rs-_DNDx%4%SzF`F|2%n6K;Fn_yL<+vV6GIT$wl3wcco9Wtzd4sl z^cWOFPhcv|K?}1S{T3xUUs{0xL*iglM#IgA!qoC=#KQ!gUH|f)!ez%;@{zwBuF9 zyD{rX&m=IR{fZt@oyzfZM6!E4t3RjGyV*2*71I^j(Y5mZK#wag+9LD=2#7E!{vyXm z;IT}w!iY2}fUm$R(Tg}FJG3`KyG7+|h38f$`OHBvvv*W^Rp)UfPabK`8kPd|I7^x_ zo(la!p3ERCykHrZO0DW&rp?0UC$2;Tq@}c6=(6S3o<{q1RbJe2xBkghDds46ZREmFjF64e=v@gZ{)**q!rcJM<7KW4(wpqL`SE4b5U3IfW*N;S+AX_4T$(OACTaru%fJ@UEpB1}sCsl_(hp*=au z0C(kD7&ewfZTi9m4x-Q-?1s;$lLKA*GwQ~l;Ws9d zH=dd7lw(JO&+{%udCm-iZf60mog#9?WtET%9lh!P>0t0}+H_JG9?y(RCQZo8%$MLY z+~{48qg1q+I$D%Ps!hgpU0?FzHXk?FhD$4D@-d=r2P&n&ZI=2oX_h|<@(=1(`NzUE zN|4NQEVa1IjJz+k$OP(6Y|1C}w)rJ|^jw~_nVRk3p&|PAYct01rrtL}s>FfZwCq0* zh38IU8W>6FYk>!mwj*nnr{SKVnVO@%{5WdumMZ+;?&CskykN1D*ySvc!0TKlXovIn z+%QSogeWF_2Ogg^Fp$@dQ=!LMWcPj{yY+2Ml3TZgZobn&GC5xO3p8EW)1{1=OW1x~ zB@3UJJwH&vu!Y6GPmN!M3q(F() z?IbJLFB$pXM5K%#blfX6VH)jaDPl*`7@ISIZZ7!p>ZCYpatoBwVhC}rZhUe8{(c(vUEVp{yL_mZ|sfwkvoj*eC z0~a4v3Daqne{=T1i|J+3Ieez{Fe$5dA|Wn=A;t~8Z?%XRfVNAE>C=-A8-_}@*OCO!;^g2}$^?o0EExjahM^&%Q+|NV+ET?2^xbb{D|KRW=eg0{6F~gH&Id)lLPr+C!v;+e_c16DQXjKaY+i!)vS=;|nJmyERHM}$ zhHT!ilyu!+rF%%UMN+zhvHwNn??l`sY~QaMPkf00kXZFMWAS{Kz}^%}8K6$V|JNab zGci|)Z$P%7C5BAu_+Ib32=2TgRqR09^>fJ*owa*HsnyJ8=y~Cyv3k8}$C(x&PG7ua z0(0Kz=|FG1PQv-#8E1pHg<{c}vCc3ySkv_+tB$AIaF(S&#f>*uzuj>06mMeWceIp- zlfWYEZF8kDX5F1JhWt7_*8+&>CWel}_LDBT)9oySK>q(X3dNd$gb1BdXAntX?%Ue2 zUW{t@xgE-1$9W1`o_XBk2=Ta~K?+O2uU}&9dl9AY`DihB3|9#^Xs^*c!>Lk>V{XMU zTI{Di^>(@G<+sJIpw{1IDDmG{`pFFxIE5LMhYk>-u>X8$3ji*B*|MEd=?>zyHYEBy zcv`JMp` ztT&1zkDhRD7r5HU>YKB!OERY)8dV1UCSGngp9%DV`w`Sm1v>sL-)=qv!1u9|5!~W2l00(cy-{^N$X3Ccr!r{J$Kfc$MfE6mI3J z!iBq5Yl+0kd#5@c`M14Dy)r+5X0*%5_aayYZ$f!`-b4FJ>bC@5Lo5P5 zzKJ0w`0G^@kHxE~Th1H_D(OCVdR#>DK<9&E=fc&1e+SB}O20YAy#8|m`=*Nwf%i(J zVW2)~MhPFj$hqG{6j0$iw=m}$cVFuOY$jKF#6_~r7Bh$0^BrT+(3tZux8StdKA^p` zN*M99iJi?81^5apd2+pzU!pb{0r2ysj})Wt{>Tb1Mlu%CZ*uKG`)r0&fZ8Y?o`61D z8mAfZW(R|bhwixZeEHr|l-e&%=Fbld`ioHYxx1}^Q+z4#l?a33#D8DDS0LW*(Yii<&8SgU;Wy1tFKCad(q<8}AlxD5MZH5C}hvc!=IRwr)38sWb zS4K+2C)C+2;bo8#l}3xM+x&8?C%ExC+rY(y!W8nJK*h`BV@r04j@uyrh+%j189Ni@X0uQn&;sK zD20KYiT|lOk@z`cq_4osxqU-|*+4~`$H$(SD~8|TRCX#0w>=iAgZUwl8lLZ&g8F#lImFM835FGNEtogyi|Mb@9K)bxdDQh_dn1$UNl4D1_)9ZAapq@{>>>g zXI=41o%RaV?9b_QrAKbOp$3uKqafz}MKq4@WkO~@18GZ~M(eo^N_QcD-DgW0IvF8| zdH)`TiME7uQ|4-Nl&UnnIV%;B0Eg% z{8T#))%<)H0Ch9QwF*wDovL0=)&G2CRcQJ=c;$X_8x_ZTe2U8X#EF_*bBM~A;Cf35 z^d0K6FH*QxndL$c{*bu-XFPGz@nC!^=B&U#Kdu$|9I(E3{{u$_NP(0XYqFr6<=)cSTBFrD8{)b$87V7DIOvF8c9euM(- z){neZj;iZ@?&J`<7$A3CYc()i@AJEl(RF=R--jS~b&(3akUN!$68f|CS(7T@>XOxZ zI)8bk^tj=OE#ywaRRYY`(|x*5LYSmbUts+sRxVVn!ZuLVQ2*Za50;Fc5tR#y#;tOfmtyd-S`eIy)QlPmgiLeMD z5(N;t=$w*sL>hMMi%Gm5TqZ8VUZaaC)FJ?p7A06^1*j`;0^lwSTqZS1n5_pV^ZI=} z%FYYDO&Soavl{cU3e!sYX{FvW%ZwV- zoQO%?qir~%EK;*g$QheWKDiS8T?Gtow2G-zW04?@19X!FMW_n3N;DR3()!E&XOeYL9E^#;hvfZ&x}5~*BZ>|~ z%L$*<+_@qE67Zw?%T!TvFm(m0(o2wNu-A5XmD6BA%L$vc+WFuka@gz_L|=ZV%D+B5 zqxmM_LQE#7ObHrv)>y-V!9Vhm1pwf0(kgHX>g$r@PMAR(Tyx~Ij`UdU??hofuIy>7 z&y52=g;$XVw4BIMja|Uspdm)W0Yamj&S}u2ez)h@g5LL)90latQupKf#-wf1m_IE8 zSgG{wTM7>CN=~RN1a%G$Ka)ZV1qjUrh3lj-Ii=q=EE`K@ zzPlXgT2sa}N3=z7JK0=`$>fxACP!tsRC|>dXpy1-(uu1i~a5>%}iK;o06YA1Rja%*Z zj=}2AM^VyLi^2Yc^yZVymoK$hXy(9sTWkaSns0K(amHt5CXKqwOiw|EIXt$q3EK`9E`KkcJaWkj#Mkdmd*?vvsJ}qELN?u*|cUv%S|KM58sJEa?&OZrajC zB`WfNPI~(BL0F86R_{jhwI1<=UK`0A&|Ek~V{DOS5;Z{QlgP22v5R#kle}N+&V^Pd zJijgne~sqXy=o>96si=OP^Mltp1;I8WQane%hrNq8eMz@PWLmfA!W){ELnsHlS$uS zcm4wTb;%C54Gts7hy1%NFjlZwDA|dX?T6@6&8JtYe)opH$nKPCS_};5omJr3Cth-! zSN>|o(oPe`{M8(UeB+@c3i6EyVz(TwsOB3-EW3Z>{eQ&s9wguwCZKBgQ;G|aQ~vJ5J=KX9Q@U& zZ^8e1}1QICcZ9Q9~+9??x-{h&gc^@EteQ9r1VOJCE>pxzS;Sn55Q zM13ylaMb6bx1QQNIf#mWo|;5x>#2R?oxh{H{$1HgDL(&B614U2CM{cr_IibfrTDx; z2~gH6JnTK%jHSN5He`jTHepqbSJ)gepi(AEPrhZx+Rwq0la){H_{skDad zhYnT!*7gk5Z>6tuptIg?Pf)#G6{za%y7V>8Wa}fRkzz7S>G$OEL?fw>Y(Dj@tJpqt zeL9kZ--ha0*ITa;7_DdhyU0(Eyn3=p)gKRV8y1_F9>Z6E{IRz__Srg3EXp|(bMe)S zf4*G7s=mKTejbBh=O|~~j`H&utbbVi=HDKx{{I<*lkUZ>|tOM8ZhLMaOLkUhweZ#}e2qX|s#|xagDf6dx&Ji1-Sx{KnirpN6Zo qicn{>E!pL4j%~hE2L^jvh{)c$>9qN{Cdwze>nvzR9sZl>q)7pEE!3U> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..35f9f6004a44b1920b1045928b5c8bfd4a3106c5 GIT binary patch literal 10784 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b267a739d4c2aa0da890b29f4bc5ab44bf0bf516 GIT binary patch literal 16190 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 literal 0 HcmV?d00001 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 From 4662ecdf946f86db641708eebe093d6e146c41c8 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 6 Feb 2026 11:24:40 -0800 Subject: [PATCH 3/9] Cleanup Token Migration error logging. --- .../accounts/UserAccountManagerExtension.kt | 27 +++++------ .../androidsdk/ui/TokenMigrationActivity.kt | 48 +++++++++++-------- 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManagerExtension.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManagerExtension.kt index f8da0bddf7..3e2dc8becc 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManagerExtension.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManagerExtension.kt @@ -27,6 +27,7 @@ package com.salesforce.androidsdk.accounts import android.content.Intent +import com.salesforce.androidsdk.accounts.UserAccountManager.getInstance import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.ui.TokenMigrationActivity @@ -43,36 +44,32 @@ const val TAG = "UserAccountManager" * new app. If successful a new set of credentials (refresh token, access token) are obtained * and replace the existing credentials for the user. */ +@Suppress("UnusedReceiverParameter") fun UserAccountManager.migrateRefreshToken( - userAccount: UserAccount? = UserAccountManager.getInstance().currentUser, + userAccount: UserAccount? = getInstance().currentUser, appConfig: OAuthConfig, onMigrationSuccess: (userAccount: UserAccount) -> Unit, onMigrationError: (error: String, errorDesc: String?, e: Throwable?) -> Unit, ) { val loggedOnSuccess: (userAccount: UserAccount) -> Unit = { user -> - SalesforceSDKLogger.i(TAG, "User ($user) successfully migrated to " + - "new OAuthConfig ($appConfig).") + SalesforceSDKLogger.i(TAG, "Token Migration Successful \n\nUser ${user.username} " + + "(${user.instanceServer}) successfully migrated to: \n$appConfig.") onMigrationSuccess.invoke(user) } - val loggedOnError: (error: String, errorDesc: String?, e: Throwable?) -> Unit = { error, errorDesc, e -> - val message = error + errorDesc?.let { "\nDescription: $it" } - SalesforceSDKLogger.e(TAG, message, e) - onMigrationError.invoke(error, errorDesc, e) - } + val userId = userAccount?.userId + val orgId = userAccount?.orgId - val userId = userAccount?.userId ?: run { - loggedOnError("User account or userId is null.", null, null) - return - } - val orgId = userAccount.orgId ?: run { - loggedOnError("OrgId is null.", null, null) + 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 = loggedOnError, + onMigrationError = onMigrationError, ) ) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt index 21f956a10b..2284ac154a 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt @@ -107,28 +107,24 @@ internal class TokenMigrationActivity : ComponentActivity() { // TODO: Move to non-deprecated getParcelableExtra when min API >= 33 val oAuthConfig = intent.getParcelableExtra(EXTRA_OAUTH_CONFIG) ?: run { - resultCallback.onMigrationError("Unable to parse OAuthConfig.", null, null) - finish() + logMigrationError(resultCallback, "Unable to parse OAuthConfig.", null, null) return } val orgId = intent.getStringExtra(EXTRA_ORG_ID) val userId = intent.getStringExtra(EXTRA_USER_ID) if ( orgId == null || userId == null) { - resultCallback.onMigrationError("Unable to parse OAuthConfig.", null, null) - finish() + logMigrationError(resultCallback, "Unable to parse OAuthConfig.", null, null) return } val user = UserAccountManager.getInstance().getUserFromOrgAndUserId(orgId, userId) ?: run { - resultCallback.onMigrationError("Unable to build user account.", null, null) - finish() + logMigrationError(resultCallback, "Unable to build user account.", null, null) return } val client = runCatching { SalesforceSDKManager.getInstance().clientManager.peekRestClient(user) }.getOrElse { e -> - resultCallback.onMigrationError("Unable to build RestClient.", null, e as? Exception) - finish() + logMigrationError(resultCallback, "Unable to build RestClient.", null, e as? Exception) return } @@ -152,12 +148,12 @@ internal class TokenMigrationActivity : ComponentActivity() { } }.getOrNull() } ?: run { - resultCallback.onMigrationError( - /* error = */ "Request for single access bridge url failed.", - /* errorDesc = */ "User's existing token may be invalid.", - /* e = */ null, + logMigrationError( + resultCallback = resultCallback, + error = "Request for single access bridge url failed", + errorDesc = "User's existing token may be invalid.", + e = null, ) - finish() return@launch } viewModel.dynamicBackgroundColor.value = Color.Transparent.copy(alpha = HALF_ALPHA) @@ -231,12 +227,12 @@ internal class TokenMigrationActivity : ComponentActivity() { // Did we fail? when { error != null -> { - resultCallback.onMigrationError( - error, - params["error_description"], - null, + logMigrationError( + resultCallback = resultCallback, + error = error, + errorDesc = params["error_description"], + e = null, ) - finish() } else -> { @@ -248,7 +244,7 @@ internal class TokenMigrationActivity : ComponentActivity() { when { viewModel.useWebServerFlow -> viewModel.onWebServerFlowComplete( - params["code"], + code = params["code"], onAuthFlowError = resultCallback.onMigrationError, onAuthFlowSuccess = resultCallback.onMigrationSuccess, loginServer = instanceServer, @@ -257,7 +253,7 @@ internal class TokenMigrationActivity : ComponentActivity() { else -> viewModel.onAuthFlowComplete( - TokenEndpointResponse(params), + tr = TokenEndpointResponse(params), onAuthFlowError = resultCallback.onMigrationError, onAuthFlowSuccess = resultCallback.onMigrationSuccess, tokenMigration = true, @@ -300,6 +296,18 @@ internal class TokenMigrationActivity : ComponentActivity() { loadUrl(frontDoorUrl) } + 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 From 71fb91f426c0cd492c57020cf6255feb948376c9 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 6 Feb 2026 15:40:06 -0800 Subject: [PATCH 4/9] Update existing test classes. --- .github/workflows/reusable-lib-workflow.yaml | 2 +- .github/workflows/reusable-ui-workflow.yaml | 2 +- .../androidsdk/ui/LoginViewModel.kt | 3 +- .../auth/AuthenticationUtilitiesTest.kt | 173 ++++++++++- .../androidsdk/auth/LoginViewModelMockTest.kt | 279 ++++++++++-------- .../authflowtester/AuthFlowTesterActivity.kt | 4 +- 6 files changed, 324 insertions(+), 139 deletions(-) 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/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index e4c2683845..3fcf9b5dc9 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -489,7 +489,8 @@ 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, 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..59038f5d7b 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt @@ -68,8 +68,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 +95,13 @@ class AuthenticationUtilitiesTest { every { mockUserAccountManager.createAccount(any()) } returns mockk() every { mockUserAccountManager.switchToUser(any()) } returns Unit every { mockUserAccountManager.sendUserSwitchIntent(any(), any()) } returns Unit - } @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 +146,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 +172,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 +183,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 +201,167 @@ 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 - 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 +370,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..5e7b887c2b 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -40,8 +40,8 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +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 +50,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. @@ -109,6 +108,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -140,13 +140,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 +177,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -213,6 +215,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = false, context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -242,6 +245,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -289,6 +293,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -318,13 +323,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 +348,122 @@ 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, - viewModel.codeVerifier, - bootConfig.oauthRedirectURI, + mockOnError, + mockOnSuccess, + loginServer = null, + tokenMigration = false, ) } } @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 +473,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -436,59 +488,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 +524,51 @@ class LoginViewModelMockTest { handleBiometricAuthPolicy = any(), handleDuplicateUserAccount = any(), ) - } returns Unit - - // Mock exchangeCode - every { - OAuth2.exchangeCode( - any(), - any(), - any(), - any(), - any(), - any(), - ) - } returns mockTokenResponse + } + } + + @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) - // Set up the view model state - viewModel.selectedServer.value = testServer + // 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 with null code - viewModel.onWebServerFlowComplete(null, mockOnError, mockOnSuccess) - + + // 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 exchangeCode was called with null code - verify { - OAuth2.exchangeCode( - HttpAccess.DEFAULT, - URI.create(testServer), - bootConfig.remoteAccessConsumerKey, - null, - viewModel.codeVerifier, - bootConfig.oauthRedirectURI, + + // Verify doCodeExchange was called with the correct loginServer and tokenMigration + coVerify { + spyViewModel.doCodeExchange( + testCode, + mockOnError, + mockOnSuccess, + loginServer = customLoginServer, + tokenMigration = true, ) } } + + // endregion } 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 b9a7b77abf..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 @@ -562,8 +562,8 @@ class AuthFlowTesterActivity : SalesforceActivity() { onMigrationError = { error, errorDesc, e -> migrationInProgress = false migrationError = error + - errorDesc?.let { " \n\nDesc: $it" } + - e?.let { "\n\nThrowable: $it" } + (errorDesc?.let { " \n\nDesc: $it" } ?: "") + + (e?.let { "\n\nThrowable: $it" } ?: "") }, ) }, From 33d95e1c830102a1aa294ed495093d94b1f77db9 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 6 Feb 2026 17:56:37 -0800 Subject: [PATCH 5/9] Add tests for UserAccountManager.migrateRefreshToken, TokenMigrationActivity and MigrationCallbackRegistry. --- .github/test-shards/SalesforceSDK.json | 10 +- .../androidsdk/ui/TokenMigrationActivity.kt | 1 - .../accounts/MigrationCallbackRegistryTest.kt | 203 ++++++++++++ .../UserAccountManagerMigrateTokenTest.kt | 307 ++++++++++++++++++ .../ui/TokenMigrationActivityTest.kt | 239 ++++++++++++++ 5 files changed, 755 insertions(+), 5 deletions(-) create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/MigrationCallbackRegistryTest.kt create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerMigrateTokenTest.kt create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt diff --git a/.github/test-shards/SalesforceSDK.json b/.github/test-shards/SalesforceSDK.json index 140bf7cf37..a1fb228ac3 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,8 @@ "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" ] }, { @@ -68,7 +69,8 @@ "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" ] } ] diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt index 2284ac154a..a056547f48 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt @@ -92,7 +92,6 @@ internal class TokenMigrationActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = true val callbackKey = intent.getStringExtra(EXTRA_CALLBACK_ID) ?: run { SalesforceSDKLogger.e(TAG, "Unable to parse MigrationResult callback id.") 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/ui/TokenMigrationActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt new file mode 100644 index 0000000000..a9e748bd50 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt @@ -0,0 +1,239 @@ +/* + * 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.test.core.app.ActivityScenario.launch +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.salesforce.androidsdk.accounts.MigrationCallbackRegistry +import com.salesforce.androidsdk.config.OAuthConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Tests for TokenMigrationActivity using ActivityScenario. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +class TokenMigrationActivityTest { + + // region onCreate Error Handling Tests + + @Test + fun onCreate_withMissingCallbackId_finishesActivity() { + // Given - Intent without callback ID + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java) + // No EXTRA_CALLBACK_ID set + + // When + val scenario = launch(intent) + + // 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 + val scenario = launch(intent) + + // 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 + val scenario = launch(intent) + + // Then + latch.await(2, TimeUnit.SECONDS) + assertTrue("Error callback should be called", errorCalled) + assertEquals("Unable to parse OAuthConfig.", 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 mockOAuthConfig = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "testapp://oauth/callback", + scopes = listOf("api", "refresh_token"), + ) + + 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, "testUserId") + // No EXTRA_ORG_ID set + } + + // When + val scenario = launch(intent) + + // Then + latch.await(2, TimeUnit.SECONDS) + 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 mockOAuthConfig = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "testapp://oauth/callback", + scopes = listOf("api", "refresh_token") + ) + + 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, "testOrgId") + // No EXTRA_USER_ID set + } + + // When + val scenario = launch(intent) + + // Then + latch.await(2, TimeUnit.SECONDS) + 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 mockOAuthConfig = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "testapp://oauth/callback", + scopes = listOf("api", "refresh_token") + ) + + 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, "nonexistent-org") + putExtra(TokenMigrationActivity.EXTRA_USER_ID, "nonexistent-user") + } + + // When + val scenario = launch(intent) + + // Then + latch.await(2, TimeUnit.SECONDS) + assertTrue("Error callback should be called", errorCalled) + assertEquals("Unable to build user account.", errorMessage) + assertEquals(DESTROYED, scenario.state) + } + + // endregion +} From ca7876b1741d48265be5347fcde343dc22c2594b Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Wed, 11 Feb 2026 21:13:26 -0800 Subject: [PATCH 6/9] Add TokenMigrationActivity tests. --- .github/test-shards/SalesforceSDK.json | 6 +- .../accounts/UserAccountManagerExtension.kt | 19 +- .../androidsdk/ui/TokenMigrationActivity.kt | 200 ++++---- .../androidsdk/ui/LoginActivityTest.kt | 2 +- .../ui/TokenMigrationActivityTest.kt | 288 ++++++++--- .../ui/TokenMigrationWebViewTest.kt | 464 ++++++++++++++++++ 6 files changed, 813 insertions(+), 166 deletions(-) create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt diff --git a/.github/test-shards/SalesforceSDK.json b/.github/test-shards/SalesforceSDK.json index a1fb228ac3..fd4bd9f8b8 100644 --- a/.github/test-shards/SalesforceSDK.json +++ b/.github/test-shards/SalesforceSDK.json @@ -24,7 +24,8 @@ "class com.salesforce.androidsdk.ui.DevInfoActivityTest", "class com.salesforce.androidsdk.security.ScreenLockManagerTest", "class com.salesforce.androidsdk.security.BiometricAuthenticationManagerTest", - "class com.salesforce.androidsdk.ui.TokenMigrationActivityTest" + "class com.salesforce.androidsdk.ui.TokenMigrationActivityTest", + "class com.salesforce.androidsdk.ui.TokenMigrationWebViewTest" ] }, { @@ -70,7 +71,8 @@ "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.ui.TokenMigrationActivityTest" + "notClass com.salesforce.androidsdk.ui.TokenMigrationActivityTest", + "nonClass com.salesforce.androidsdk.ui.TokenMigrationWebViewTest" ] } ] diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManagerExtension.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManagerExtension.kt index 3e2dc8becc..1603ea67c0 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManagerExtension.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManagerExtension.kt @@ -73,14 +73,17 @@ fun UserAccountManager.migrateRefreshToken( ) ) - val context = SalesforceSDKManager.getInstance().appContext - val intent = Intent(context, TokenMigrationActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.putExtra(TokenMigrationActivity.EXTRA_ORG_ID, orgId) - intent.putExtra(TokenMigrationActivity.EXTRA_USER_ID, userId) - intent.putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, appConfig) - intent.putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey) - context.startActivity(intent) + 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) + } + ) + } } /* diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt index a056547f48..7fa24bbc5d 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026-pffsent, salesforce.com, inc. + * 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 @@ -27,6 +27,7 @@ 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 @@ -37,7 +38,6 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.annotation.VisibleForTesting -import androidx.annotation.VisibleForTesting.Companion.PROTECTED import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -82,10 +82,26 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -const val HALF_ALPHA = 0.5f +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() { - @VisibleForTesting(otherwise = PROTECTED) private val viewModel: LoginViewModel by viewModels { SalesforceSDKManager.getInstance().loginViewModelFactory } @@ -94,36 +110,36 @@ internal class TokenMigrationActivity : ComponentActivity() { enableEdgeToEdge() val callbackKey = intent.getStringExtra(EXTRA_CALLBACK_ID) ?: run { - SalesforceSDKLogger.e(TAG, "Unable to parse MigrationResult callback id.") + SalesforceSDKLogger.e(TAG, ERROR_PARSE_CALLBACK_ID) finish() return } val resultCallback = MigrationCallbackRegistry.consume(callbackKey) ?: run { - SalesforceSDKLogger.e(TAG, "Unable to retrieve MigrationResult callback.") + 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, "Unable to parse OAuthConfig.", null, null) + 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, "Unable to parse OAuthConfig.", null, null) + logMigrationError(resultCallback, ERROR_PARSE_OAUTH_CONFIG, null, null) return } val user = UserAccountManager.getInstance().getUserFromOrgAndUserId(orgId, userId) ?: run { - logMigrationError(resultCallback, "Unable to build user account.", null, null) + logMigrationError(resultCallback, ERROR_BUILD_USER_ACCOUNT, null, null) return } val client = runCatching { SalesforceSDKManager.getInstance().clientManager.peekRestClient(user) }.getOrElse { e -> - logMigrationError(resultCallback, "Unable to build RestClient.", null, e as? Exception) + logMigrationError(resultCallback, ERROR_BUILD_REST_CLIENT, null, e as? Exception) return } @@ -149,8 +165,8 @@ internal class TokenMigrationActivity : ComponentActivity() { } ?: run { logMigrationError( resultCallback = resultCallback, - error = "Request for single access bridge url failed", - errorDesc = "User's existing token may be invalid.", + error = ERROR_SINGLE_ACCESS_FAILED, + errorDesc = ERROR_TOKEN_INVALID_DESC, e = null, ) return@launch @@ -199,100 +215,105 @@ internal class TokenMigrationActivity : ComponentActivity() { frontDoorUrl: String, resultCallback: MigrationCallbackRegistry.MigrationCallbacks, instanceServer: String, - ): WebView = WebView(this@TokenMigrationActivity).apply { + ): WebView = webViewFactory(this@TokenMigrationActivity).apply { @SuppressLint("SetJavaScriptEnabled") // Required by Salesforce settings.javaScriptEnabled = true settings.userAgentString = SalesforceSDKManager.getInstance().userAgent setBackgroundColor(android.graphics.Color.TRANSPARENT) + webViewClient = TokenMigrationClientManager(resultCallback, instanceServer) - // This implementation is very similar to [LoginActivity.AuthWebViewClient] but the - // code cannot be shared due to the heavy reliance on the ViewModel. - webViewClient = object : 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, - ) - } + 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 - 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() + 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 - } + 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 + 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 - } + // 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) } - } - loadUrl(frontDoorUrl) + super.onPageFinished(view, url) + } } private fun logMigrationError( @@ -320,5 +341,8 @@ internal class TokenMigrationActivity : ComponentActivity() { 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/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 index a9e748bd50..5ce42e8ecb 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt @@ -28,27 +28,109 @@ 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 androidx.test.filters.SmallTest 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) -@SmallTest class TokenMigrationActivityTest { - // region onCreate Error Handling Tests + 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() { @@ -57,14 +139,14 @@ class TokenMigrationActivityTest { // No EXTRA_CALLBACK_ID set // When - val scenario = launch(intent) - - // Then - Activity should finish immediately - assertEquals( - "Activity should be destroyed when callback ID is missing", - DESTROYED, - scenario.state, - ) + launch(intent).use { scenario -> + // Then - Activity should finish immediately + assertEquals( + "Activity should be destroyed when callback ID is missing", + DESTROYED, + scenario.state, + ) + } } @Test @@ -75,14 +157,14 @@ class TokenMigrationActivityTest { } // When - val scenario = launch(intent) - - // Then - Activity should finish because callback is not in registry - assertEquals( - "Activity should be destroyed when callback ID is invalid", - DESTROYED, - scenario.state, - ) + 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 @@ -109,13 +191,13 @@ class TokenMigrationActivityTest { } // When - val scenario = launch(intent) - - // Then - latch.await(2, TimeUnit.SECONDS) - assertTrue("Error callback should be called", errorCalled) - assertEquals("Unable to parse OAuthConfig.", errorMessage) - assertEquals(DESTROYED, scenario.state) + 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 @@ -134,26 +216,20 @@ class TokenMigrationActivityTest { ) ) - val mockOAuthConfig = OAuthConfig( - consumerKey = "test_consumer_key", - redirectUri = "testapp://oauth/callback", - scopes = listOf("api", "refresh_token"), - ) - 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, "testUserId") + putExtra(TokenMigrationActivity.EXTRA_USER_ID, VALID_USER) // No EXTRA_ORG_ID set } // When - val scenario = launch(intent) - - // Then - latch.await(2, TimeUnit.SECONDS) - assertTrue("Error callback should be called", errorCalled) - assertEquals(DESTROYED, scenario.state) + launch(intent).use { scenario -> + // Then + latch.await(500, TimeUnit.MILLISECONDS) + assertTrue("Error callback should be called", errorCalled) + assertEquals(DESTROYED, scenario.state) + } } @Test @@ -172,26 +248,20 @@ class TokenMigrationActivityTest { ) ) - val mockOAuthConfig = OAuthConfig( - consumerKey = "test_consumer_key", - redirectUri = "testapp://oauth/callback", - scopes = listOf("api", "refresh_token") - ) - 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, "testOrgId") + putExtra(TokenMigrationActivity.EXTRA_ORG_ID, VALID_ORG) // No EXTRA_USER_ID set } // When - val scenario = launch(intent) - - // Then - latch.await(2, TimeUnit.SECONDS) - assertTrue("Error callback should be called", errorCalled) - assertEquals(DESTROYED, scenario.state) + launch(intent).use { scenario -> + // Then + latch.await(500, TimeUnit.MILLISECONDS) + assertTrue("Error callback should be called", errorCalled) + assertEquals(DESTROYED, scenario.state) + } } @Test @@ -208,32 +278,116 @@ class TokenMigrationActivityTest { errorCalled = true errorMessage = error latch.countDown() - } + }, ) ) - val mockOAuthConfig = OAuthConfig( - consumerKey = "test_consumer_key", - redirectUri = "testapp://oauth/callback", - scopes = listOf("api", "refresh_token") + 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, "nonexistent-org") - putExtra(TokenMigrationActivity.EXTRA_USER_ID, "nonexistent-user") + putExtra(TokenMigrationActivity.EXTRA_ORG_ID, VALID_ORG) + putExtra(TokenMigrationActivity.EXTRA_USER_ID, VALID_USER) } - // When - val scenario = launch(intent) + // Throw exception when getting client + every { + mockSdkManager.clientManager.peekRestClient(any()) + } throws RuntimeException("Account not found") - // Then - latch.await(2, TimeUnit.SECONDS) - assertTrue("Error callback should be called", errorCalled) - assertEquals("Unable to build user account.", errorMessage) - assertEquals(DESTROYED, scenario.state) + // 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) + } } - // endregion + @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/TokenMigrationWebViewTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt new file mode 100644 index 0000000000..1bb440c8af --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt @@ -0,0 +1,464 @@ +package com.salesforce.androidsdk.ui + +import android.content.Context +import android.content.Intent +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.accounts.UserAccount +import com.salesforce.androidsdk.accounts.UserAccountManager +import com.salesforce.androidsdk.analytics.AnalyticsPublishingWorker +import com.salesforce.androidsdk.analytics.SalesforceAnalyticsManager +import com.salesforce.androidsdk.analytics.logger.SalesforceLogger +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.config.OAuthConfig +import com.salesforce.androidsdk.rest.ClientManager +import com.salesforce.androidsdk.rest.RestClient +import io.mockk.coEvery +import io.mockk.coVerify +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.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.UUID + +@RunWith(AndroidJUnit4::class) +class TokenMigrationWebViewTest { + + 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 mockClientManager: ClientManager + private lateinit var mockRestClient: RestClient + private lateinit var mockUser: UserAccount + private lateinit var mockViewModel: LoginViewModel + private lateinit var mockColorState: MutableState + private lateinit var mockWebView: WebView + + + @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) + mockClientManager = 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" + every { mockSdkManager.isBrowserLoginEnabled } returns false + every { mockSdkManager.useWebServerAuthentication } returns false + + mockColorState = mockk(relaxed = true) + every { mockColorState.value } returns Color.White + + // Mock loginViewModelFactory to return a new mock LoginViewModel each time + mockViewModel = mockk(relaxed = true) { + coEvery { + getAuthorizationUrl(any(), any(), any(), any()) + } returns "https://test.salesforce.com/authorize" + every { dynamicBackgroundColor } returns mockColorState + 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 + every { mockSdkManager.loginViewModelFactory } returns object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + return mockViewModel as T + } + } + + // 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_uri": "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 UUID.randomUUID() + + SalesforceAnalyticsManager.setAnalyticsPublishingType(SalesforceAnalyticsManager.SalesforceAnalyticsPublishingType.PublishDisabled) + + TokenMigrationActivity.webViewFactory = { context -> TestWebView(context) } + + mockWebView = mockk(relaxed = true) { + every { loadUrl(any()) } just runs + every { parent } returns null + } + } + + @After + fun tearDown() { + unmockkAll() + } + + /** + * 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 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 { mockViewModel.loading.value = true } + verify(exactly = 0) { mockViewModel.authFinished.value = true } + } + + // 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 MockUserAgent", + webView.settings.userAgentString?.contains("MockUserAgent") == 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 a bare [TokenMigrationActivity] via [startActivitySync] (instrumentation thread) + * and returns the activity instance. The activity's [onCreate] will exit early because no + * intent extras are provided, but the instance is still usable for direct method calls. + */ + private fun launchActivity(): TokenMigrationActivity { + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return InstrumentationRegistry.getInstrumentation() + .startActivitySync(intent) as TokenMigrationActivity + } + + 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 From a69b73d4279089e7f64e276de2f4a1b97a8f1ef4 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Thu, 12 Feb 2026 18:56:05 -0800 Subject: [PATCH 7/9] Additional tests. --- .github/test-shards/SalesforceSDK.json | 2 +- .../androidsdk/auth/LoginViewModelMockTest.kt | 146 ++++++++++++- .../androidsdk/auth/LoginViewModelTest.kt | 57 +++++ .../ui/TokenMigrationWebViewTest.kt | 204 ++++++++++-------- 4 files changed, 314 insertions(+), 95 deletions(-) diff --git a/.github/test-shards/SalesforceSDK.json b/.github/test-shards/SalesforceSDK.json index fd4bd9f8b8..c60ffd5dd5 100644 --- a/.github/test-shards/SalesforceSDK.json +++ b/.github/test-shards/SalesforceSDK.json @@ -72,7 +72,7 @@ "notClass com.salesforce.androidsdk.rest.files.ConnectUriBuilderTest", "notClass com.salesforce.androidsdk.rest.files.FileRequestsTest", "notClass com.salesforce.androidsdk.ui.TokenMigrationActivityTest", - "nonClass com.salesforce.androidsdk.ui.TokenMigrationWebViewTest" + "notClass com.salesforce.androidsdk.ui.TokenMigrationWebViewTest" ] } ] 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 5e7b887c2b..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,8 +38,10 @@ 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 kotlinx.coroutines.runBlocking @@ -72,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 @@ -384,6 +385,50 @@ class LoginViewModelMockTest { } } + @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, + mockOnError, + mockOnSuccess, + loginServer = migrationLoginServer, + tokenMigration = true, + ) + } + } + @Test fun onWebServerFlowComplete_WithFrontDoorBridge_UsesCorrectServerAndVerifier() = runBlocking { val frontDoorServer = "https://frontdoor.salesforce.com" @@ -570,5 +615,102 @@ class LoginViewModelMockTest { } } + @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()) + } 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 + + // 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, + migrationServer, + tokenMigration = true, + ) + + // Give time for the coroutine to execute + Thread.sleep(200) + + 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/ui/TokenMigrationWebViewTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt index 1bb440c8af..c26b720983 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt @@ -1,7 +1,10 @@ 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 @@ -15,22 +18,12 @@ 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.accounts.UserAccount -import com.salesforce.androidsdk.accounts.UserAccountManager -import com.salesforce.androidsdk.analytics.AnalyticsPublishingWorker -import com.salesforce.androidsdk.analytics.SalesforceAnalyticsManager -import com.salesforce.androidsdk.analytics.logger.SalesforceLogger import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.config.OAuthConfig -import com.salesforce.androidsdk.rest.ClientManager -import com.salesforce.androidsdk.rest.RestClient -import io.mockk.coEvery import io.mockk.coVerify 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.unmockkAll import io.mockk.verify @@ -42,7 +35,8 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class TokenMigrationWebViewTest { @@ -53,65 +47,21 @@ class TokenMigrationWebViewTest { scopes = listOf("api", "refresh_token"), ) - private lateinit var mockUserAccountManager: UserAccountManager - private lateinit var mockSdkManager: SalesforceSDKManager - private lateinit var mockClientManager: ClientManager - private lateinit var mockRestClient: RestClient - private lateinit var mockUser: UserAccount + private lateinit var savedFactory: ViewModelProvider.Factory private lateinit var mockViewModel: LoginViewModel - private lateinit var mockColorState: MutableState private lateinit var mockWebView: WebView + private var testActivity: TokenMigrationActivity? = null @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) - mockClientManager = 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" - every { mockSdkManager.isBrowserLoginEnabled } returns false - every { mockSdkManager.useWebServerAuthentication } returns false - - mockColorState = mockk(relaxed = true) - every { mockColorState.value } returns Color.White - - // Mock loginViewModelFactory to return a new mock LoginViewModel each time + // 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) { - coEvery { - getAuthorizationUrl(any(), any(), any(), any()) - } returns "https://test.salesforce.com/authorize" - every { dynamicBackgroundColor } returns mockColorState + every { dynamicBackgroundColor } returns mutableStateOf(Color.White) + every { loading } returns mutableStateOf(false) every { authFinished } returns mutableStateOf(false) every { loadingIndicator } returns null every { oAuthConfig } returns mockOAuthConfig @@ -119,26 +69,14 @@ class TokenMigrationWebViewTest { } // Wire up the factory so the activity's `by viewModels` delegate returns mockViewModel - every { mockSdkManager.loginViewModelFactory } returns object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class, extras: CreationExtras): T { - return mockViewModel as T + SalesforceSDKManager.getInstance().loginViewModelFactory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras, + ): T = mockViewModel as T } - } - - // 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_uri": "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 UUID.randomUUID() - - SalesforceAnalyticsManager.setAnalyticsPublishingType(SalesforceAnalyticsManager.SalesforceAnalyticsPublishingType.PublishDisabled) TokenMigrationActivity.webViewFactory = { context -> TestWebView(context) } @@ -150,7 +88,17 @@ class TokenMigrationWebViewTest { @After fun tearDown() { - unmockkAll() + 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 } /** @@ -316,6 +264,11 @@ class TokenMigrationWebViewTest { @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") @@ -324,8 +277,32 @@ class TokenMigrationWebViewTest { clientManager.onPageStarted(mockWebView, "https://test.salesforce.com/page", null) } - verify { mockViewModel.loading.value = true } - verify(exactly = 0) { mockViewModel.authFinished.value = true } + 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 @@ -364,8 +341,10 @@ class TokenMigrationWebViewTest { instanceServer = "https://test.salesforce.com", ) assertTrue( - "User agent should contain MockUserAgent", - webView.settings.userAgentString?.contains("MockUserAgent") == true, + "User agent should contain SDK user agent", + webView.settings.userAgentString?.contains( + SalesforceSDKManager.getInstance().userAgent + ) == true, ) } catch (e: Throwable) { Assert.fail(e.stackTraceToString()) @@ -437,16 +416,57 @@ class TokenMigrationWebViewTest { // region Helpers /** - * Launches a bare [TokenMigrationActivity] via [startActivitySync] (instrumentation thread) - * and returns the activity instance. The activity's [onCreate] will exit early because no - * intent extras are provided, but the instance is still usable for direct method calls. + * 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) } - return InstrumentationRegistry.getInstrumentation() - .startActivitySync(intent) as TokenMigrationActivity + + 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 = From b63665fb30268c1e71206510d6bb38224aa92d33 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 13 Feb 2026 15:18:36 -0800 Subject: [PATCH 8/9] Add tests for AuthenticationUtilities handleScreenLockPolicy, handleBiometricAuthPolicy and handleDuplicateUserAccount. Split out TokenMigrationView and add tests. --- libs/SalesforceSDK/build.gradle.kts | 1 + .../auth/AuthenticationUtilities.kt | 15 +- .../androidsdk/ui/TokenMigrationActivity.kt | 57 +-- .../ui/components/TokenMigrationView.kt | 51 +++ .../auth/AuthenticationUtilitiesTest.kt | 422 ++++++++++++++++++ .../ui/TokenMigrationViewActivityTest.kt | 214 +++++++++ 6 files changed, 710 insertions(+), 50 deletions(-) create mode 100644 libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/TokenMigrationView.kt create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationViewActivityTest.kt diff --git a/libs/SalesforceSDK/build.gradle.kts b/libs/SalesforceSDK/build.gradle.kts index 3bb4ac3e6e..237613c68b 100644 --- a/libs/SalesforceSDK/build.gradle.kts +++ b/libs/SalesforceSDK/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { androidTestImplementation("androidx.test:runner:1.7.0") androidTestImplementation("androidx.test:rules:1.7.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") androidTestImplementation("androidx.test.ext:junit:1.3.0") androidTestImplementation("androidx.arch.core:core-testing:2.2.0") androidTestImplementation("androidx.compose.ui:ui-test-junit4:$composeVersion") diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt index f7d920f8ca..e37725fb59 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt @@ -374,9 +374,10 @@ 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? @@ -399,9 +400,10 @@ private fun handleScreenLockPolicy( /** * 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? @@ -442,10 +444,11 @@ 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?, ) { userAccountManager.authenticatedUsers?.let { existingUsers -> // Check if the user already exists diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt index 7fa24bbc5d..eb256d1663 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt @@ -38,21 +38,8 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.annotation.VisibleForTesting -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.MaterialTheme -import androidx.compose.material3.Scaffold -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.core.net.toUri import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope @@ -66,11 +53,7 @@ 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.DefaultLoadingIndicator -import com.salesforce.androidsdk.ui.components.LOADING_ALPHA -import com.salesforce.androidsdk.ui.components.SLOW_ANIMATION_MS -import com.salesforce.androidsdk.ui.components.VISIBLE_ALPHA -import com.salesforce.androidsdk.ui.components.applyImePaddingConditionally +import com.salesforce.androidsdk.ui.components.TokenMigrationView import com.salesforce.androidsdk.util.SalesforceSDKLogger import com.salesforce.androidsdk.util.UriFragmentParser import kotlinx.coroutines.CoroutineScope @@ -81,6 +64,7 @@ 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 @@ -171,6 +155,9 @@ internal class TokenMigrationActivity : ComponentActivity() { ) 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() @@ -180,31 +167,9 @@ internal class TokenMigrationActivity : ComponentActivity() { background = viewModel.dynamicBackgroundColor.value ) ) { - 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 = { - buildAuthWebview(frontDoorUrl, resultCallback, user.instanceServer) - }, - ) - - if (viewModel.loading.value) { - viewModel.loadingIndicator ?: DefaultLoadingIndicator() - } - } + TokenMigrationView( + webViewFactory = { buildAuthWebview(frontDoorUrl, resultCallback, user.instanceServer) } + ) } } } @@ -218,7 +183,11 @@ internal class TokenMigrationActivity : ComponentActivity() { ): WebView = webViewFactory(this@TokenMigrationActivity).apply { @SuppressLint("SetJavaScriptEnabled") // Required by Salesforce settings.javaScriptEnabled = true - settings.userAgentString = SalesforceSDKManager.getInstance().userAgent + settings.userAgentString = format( + "%s %s", + SalesforceSDKManager.getInstance().userAgent, + settings.userAgentString, + ) setBackgroundColor(android.graphics.Color.TRANSPARENT) webViewClient = TokenMigrationClientManager(resultCallback, instanceServer) 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/auth/AuthenticationUtilitiesTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt index 59038f5d7b..6fb4678dc8 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,15 +34,24 @@ 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 @@ -97,6 +107,11 @@ class AuthenticationUtilitiesTest { every { mockUserAccountManager.sendUserSwitchIntent(any(), any()) } returns Unit } + @After + fun tearDown() { + unmockkAll() + } + @Test fun testOnAuthFlowComplete_blockIntegrationUser_shouldCallError() = runTest { @@ -356,6 +371,413 @@ class AuthenticationUtilitiesTest { // 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 mockAccount = mockk() + val mockUam = mockk(relaxed = true) { + every { authenticatedUsers } returns mutableListOf(duplicateUser) + every { buildAccount(duplicateUser) } returns mockAccount + } + val account = buildTestUserAccount(refreshToken = "same_token") + + // When + com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null) + + // Then + verify { mockUam.clearCachedCurrentUser() } + verify { mockUam.buildAccount(duplicateUser) } + verify { mockClientManager.removeAccount(mockAccount) } + } + + @Test + fun testHandleDuplicateUserAccount_duplicateFound_differentRefreshToken_revokesToken() { + // Given + val mockClientManager = mockk(relaxed = true) + setupMockSdkManager(clientManager = mockClientManager) + + 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) + + // Then + verify { mockUam.clearCachedCurrentUser() } + verify { mockClientManager.removeAccount(mockAccount) } + } + + @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 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) + + // Then + verify { mockBioAuthManager.onUnlock() } + } + + @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, nativeLogin: Boolean = false, 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, + ) + } + } +} From d6d948e70e50c7fa73d692d12c305ce33ead0fce Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 13 Feb 2026 16:12:27 -0800 Subject: [PATCH 9/9] Remove unnecessary code and improve tests. --- .../auth/AuthenticationUtilities.kt | 25 ++++++---------- .../auth/AuthenticationUtilitiesTest.kt | 30 ++++++++++++++----- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt index e37725fb59..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 @@ -111,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 @@ -449,6 +449,7 @@ internal fun handleDuplicateUserAccount( userAccountManager: UserAccountManager, account: UserAccount, userIdentity: OAuth2.IdServiceResponse?, + revokeRefreshToken: (HttpAccess, URI, String, OAuth2.LogoutReason) -> Unit = OAuth2::revokeRefreshToken, ) { userAccountManager.authenticatedUsers?.let { existingUsers -> // Check if the user already exists @@ -457,12 +458,6 @@ internal fun handleDuplicateUserAccount( clearCaches() userAccountManager.clearCachedCurrentUser() - // Remove the existing Account from AccountManager so createAccount can create a fresh one - val existingAccount = userAccountManager.buildAccount(duplicateUserAccount) - if (existingAccount != null) { - SalesforceSDKManager.getInstance().clientManager.removeAccount(existingAccount) - } - // Revoke existing refresh token if (account.refreshToken != duplicateUserAccount.refreshToken) { runCatching { @@ -476,14 +471,12 @@ internal 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/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt index 6fb4678dc8..3dfe496ef4 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt @@ -55,6 +55,7 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import java.net.URI /** * Tests for AuthenticationUtilities. @@ -622,10 +623,8 @@ class AuthenticationUtilitiesTest { setupMockSdkManager(clientManager = mockClientManager) val duplicateUser = buildTestUserAccount(refreshToken = "same_token") - val mockAccount = mockk() val mockUam = mockk(relaxed = true) { every { authenticatedUsers } returns mutableListOf(duplicateUser) - every { buildAccount(duplicateUser) } returns mockAccount } val account = buildTestUserAccount(refreshToken = "same_token") @@ -634,8 +633,6 @@ class AuthenticationUtilitiesTest { // Then verify { mockUam.clearCachedCurrentUser() } - verify { mockUam.buildAccount(duplicateUser) } - verify { mockClientManager.removeAccount(mockAccount) } } @Test @@ -643,6 +640,7 @@ class AuthenticationUtilitiesTest { // 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", @@ -656,11 +654,19 @@ class AuthenticationUtilitiesTest { val account = buildTestUserAccount(refreshToken = "new_token") // When - com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null) + com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null, mockRevokeRefreshToken) + Thread.sleep(500) // Then verify { mockUam.clearCachedCurrentUser() } - verify { mockClientManager.removeAccount(mockAccount) } + verify { + mockRevokeRefreshToken.invoke( + any(), + any(), + eq("old_token"), + eq(OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED) + ) + } } @Test @@ -673,6 +679,7 @@ class AuthenticationUtilitiesTest { clientManager = mockClientManager ) setupBiometricEnabledPrefs(mockSdkManager) + val mockRevokeRefreshToken = mockk<(HttpAccess, URI, String, OAuth2.LogoutReason) -> Unit>(relaxed = true) val duplicateUser = buildTestUserAccount( refreshToken = "old_token", @@ -686,10 +693,19 @@ class AuthenticationUtilitiesTest { val account = buildTestUserAccount(refreshToken = "new_token") // When - com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null) + 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