Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/test-shards/SalesforceSDK.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -23,7 +23,9 @@
"class com.salesforce.androidsdk.ui.PickerBottomSheetActivityTest",
"class com.salesforce.androidsdk.ui.DevInfoActivityTest",
"class com.salesforce.androidsdk.security.ScreenLockManagerTest",
"class com.salesforce.androidsdk.security.BiometricAuthenticationManagerTest"
"class com.salesforce.androidsdk.security.BiometricAuthenticationManagerTest",
"class com.salesforce.androidsdk.ui.TokenMigrationActivityTest",
"class com.salesforce.androidsdk.ui.TokenMigrationWebViewTest"
]
},
{
Expand Down Expand Up @@ -68,7 +70,9 @@
"notClass com.salesforce.androidsdk.rest.NotificationsActionsResponseBodyTest",
"notClass com.salesforce.androidsdk.rest.files.RenditionTypeTest",
"notClass com.salesforce.androidsdk.rest.files.ConnectUriBuilderTest",
"notClass com.salesforce.androidsdk.rest.files.FileRequestsTest"
"notClass com.salesforce.androidsdk.rest.files.FileRequestsTest",
"notClass com.salesforce.androidsdk.ui.TokenMigrationActivityTest",
"notClass com.salesforce.androidsdk.ui.TokenMigrationWebViewTest"
]
}
]
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/reusable-lib-workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/reusable-ui-workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions libs/SalesforceSDK/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
android:configChanges="orientation|screenLayout|uiMode|screenSize|smallestScreenSize"
android:exported="true" />

<!-- Token Migration Activity -->
<activity android:name="com.salesforce.androidsdk.ui.TokenMigrationActivity"
android:excludeFromRecents="true"
android:theme="@style/AccountSwitcher"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I rename this style to something more generic?

android:exported="false" />

<!-- Screen Lock Activity-->
<activity android:name="com.salesforce.androidsdk.ui.ScreenLockActivity"
android:exported="false"
Expand Down
2 changes: 2 additions & 0 deletions libs/SalesforceSDK/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ plugins {
`publish-module`
jacoco
kotlin("plugin.serialization") version "2.0.21"
kotlin("plugin.parcelize")
}

dependencies {
Expand Down Expand Up @@ -48,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")

@github-actions github-actions Bot Feb 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ A newer version of androidx.compose.ui:ui-test-junit4 than 1.8.2 is available: 1.10.3

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* 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.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
import com.salesforce.androidsdk.util.SalesforceSDKLogger
import java.util.UUID

const val TAG = "UserAccountManager"

/**
* Attempts to migrate the [userAccount] to the provided Connected App or
* External Client Application [appConfig].
*
* This might cause the approve/deny screen to be presented to the user to authorize the
* 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking the success and error callbacks as up-front parameters works so no change required. I wondered if this could benefit from returning a Result. The call would look something like this pseudo-code then:

UserAccountManager.getInstance().migrateRefreshToken(
    userAccount = user,  // defaults to current user if not set
    appConfig = config
).onSuccess { user ->
        // success lambda
}.onError { error -> // I saw there's currently three parameters, but maybe they all fold into the error here?
        // error lambda
}

@brandonpage brandonpage Feb 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same thought. Originally I had a function in the AppFlowTester's RestUtils class that called migrateRefreshToken and returned a Result but I ended up just not using it.

I don't think it makes sense to change the signature to Result since the lambdas passed in flow all the way through AuthenticationUtilities. But I will take a look in case doing so lets us get rid of MigrationCallbackRegistry.

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, "Token Migration Successful \n\nUser ${user.username} " +
"(${user.instanceServer}) successfully migrated to: \n$appConfig.")
onMigrationSuccess.invoke(user)
}
val userId = userAccount?.userId
val orgId = userAccount?.orgId

if (userId == null || orgId == null) {
val message = "User account, userId or orgId is null."
SalesforceSDKLogger.e(TAG, message)
onMigrationError(message, null, null)
return
}

val callbackKey = MigrationCallbackRegistry.register(
callbacks = MigrationCallbackRegistry.MigrationCallbacks(
onMigrationSuccess = loggedOnSuccess,
onMigrationError = onMigrationError,
)
)

with(SalesforceSDKManager.getInstance().appContext) {
startActivity(
Intent(/* packageContext = */ this, TokenMigrationActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(TokenMigrationActivity.EXTRA_ORG_ID, orgId)
putExtra(TokenMigrationActivity.EXTRA_USER_ID, userId)
putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, appConfig)
putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
}
)
}
}

/*
This mechanism is used to pass a _string_ id to the Activity to retrieve callback functions.

Lambda functions may appear Parcelable/Serializable but since we cannot guarantee the
content are they should not be passed. For instance, if the lambda function contains
compose state an exception will be thrown.
*/
internal object MigrationCallbackRegistry {
private val callbacks = mutableMapOf<String, MigrationCallbacks>()

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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -98,6 +97,7 @@ internal suspend fun onAuthFlowComplete(
onAuthFlowSuccess: (userAccount: UserAccount) -> Unit,
buildAccountName: (username: String?, instanceServer: String?) -> String = ::defaultBuildAccountName,
nativeLogin: Boolean = false,
tokenMigration: Boolean = false,
context: Context = SalesforceSDKManager.getInstance().appContext,
userAccountManager: UserAccountManager = SalesforceSDKManager.getInstance().userAccountManager,
blockIntegrationUser: Boolean = (SalesforceSDKManager.getInstance().shouldBlockSalesforceIntegrationUser &&
Expand All @@ -110,7 +110,8 @@ internal suspend fun onAuthFlowComplete(
addAccount: (account: UserAccount) -> Unit = ::addAccountHelper,
handleScreenLockPolicy: (userIdentity: OAuth2.IdServiceResponse?, account: UserAccount) -> Unit = ::handleScreenLockPolicy,
handleBiometricAuthPolicy: (userIdentity: OAuth2.IdServiceResponse?, account: UserAccount) -> Unit = ::handleBiometricAuthPolicy,
handleDuplicateUserAccount: (userAccountManager: UserAccountManager, account: UserAccount, userIdentity: OAuth2.IdServiceResponse?) -> Unit = ::handleDuplicateUserAccount,
handleDuplicateUserAccount: (userAccountManager: UserAccountManager, account: UserAccount, userIdentity: OAuth2.IdServiceResponse?) -> Unit
= { uam, acct, identity -> com.salesforce.androidsdk.auth.handleDuplicateUserAccount(uam, acct, identity) },
) {
// Reset Dev Support LoginOptionsActivity override
SalesforceSDKManager.getInstance().debugOverrideAppConfig = null
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -371,36 +374,51 @@ private fun updateLoggingPrefsHelper(account: UserAccount) {
/**
* Helper method to handle screen lock mobile policy.
*/
private fun handleScreenLockPolicy(
@VisibleForTesting
internal fun handleScreenLockPolicy(
userIdentity: OAuth2.IdServiceResponse?,
account: UserAccount
account: UserAccount,
) {
val internalScreenLockManager =
SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?

// compareTo(0) is used to check if screenLockTimeout is non-null and greater than 0.
if (userIdentity?.screenLockTimeout?.compareTo(0) == 1) {
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SCREEN_LOCK)
val timeoutInMills = userIdentity.screenLockTimeout * 1000 * 60
(SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?)?.storeMobilePolicy(
internalScreenLockManager?.storeMobilePolicy(
account,
userIdentity.screenLock,
timeoutInMills
enabled = userIdentity.screenLock,
timeoutInMills,
)
} else if (internalScreenLockManager?.enabled == true) {
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_SCREEN_LOCK)
internalScreenLockManager.cleanUp(account)
}
}

/**
* Helper method to handle biometric authentication mobile policy.
*/
private fun handleBiometricAuthPolicy(
@VisibleForTesting
internal fun handleBiometricAuthPolicy(
userIdentity: OAuth2.IdServiceResponse?,
account: UserAccount
account: UserAccount,
) {
val internalBiometricAuthenticationManager =
SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?

if (userIdentity?.biometricAuth == true) {
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH)
val timeoutInMills = userIdentity.biometricAuthTimeout * 60 * 1000
(SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?)?.storeMobilePolicy(
internalBiometricAuthenticationManager?.storeMobilePolicy(
account,
userIdentity.biometricAuth,
enabled = userIdentity.biometricAuth,
timeoutInMills
)
} else if (internalBiometricAuthenticationManager?.enabled == true) {
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH)
internalBiometricAuthenticationManager.cleanUp(account)
}
}

Expand All @@ -426,10 +444,12 @@ private fun addAccountHelper(
* - Unlocking biometric authentication for the duplicate user
* - Signing out other users with biometric auth when a new biometric user is added
*/
private fun handleDuplicateUserAccount(
@VisibleForTesting
internal fun handleDuplicateUserAccount(
userAccountManager: UserAccountManager,
account: UserAccount,
userIdentity: OAuth2.IdServiceResponse?
userIdentity: OAuth2.IdServiceResponse?,
revokeRefreshToken: (HttpAccess, URI, String, OAuth2.LogoutReason) -> Unit = OAuth2::revokeRefreshToken,
) {
userAccountManager.authenticatedUsers?.let { existingUsers ->
// Check if the user already exists
Expand All @@ -451,14 +471,12 @@ private fun handleDuplicateUserAccount(
as? BiometricAuthenticationManager)?.onUnlock()
}
CoroutineScope(IO).launch {
CoroutineScope(IO).launch {
revokeRefreshToken(
HttpAccess.DEFAULT,
uri,
duplicateUserAccount.refreshToken,
OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED,
)
}
revokeRefreshToken(
HttpAccess.DEFAULT,
uri,
duplicateUserAccount.refreshToken,
OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED,
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@
*/
package com.salesforce.androidsdk.config

import android.os.Parcelable
import kotlinx.parcelize.Parcelize

@Parcelize

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is Kotlin magic ✨. The compiler is able to implement the required functions to satisfy the Parcelable interface by itself since this is a data class.

We should do this for UserAccount in the future.

data class OAuthConfig(
val consumerKey: String,
val redirectUri: String,
val scopes: List<String>? = null,
) {
): Parcelable {

internal constructor(bootConfig: BootConfig): this(
bootConfig.remoteAccessConsumerKey,
Expand Down
Loading