From 364ca9e4910097ec68c5a1345dc37666984d088a Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Wed, 24 Jun 2026 18:46:26 -0600 Subject: [PATCH 01/15] feat(W-23159744): per-user persistent feature flags (Android) Implements per-user feature flag storage and hydration so each user account's active features are accurately reflected in the User-Agent string across app restarts. - UserAccount: featureFlags field (Set), serialized to JSON, persisted via AccountManager (KEY_FEATURE_FLAGS, encrypted comma-separated) - SalesforceSDKManager: perUserFeatures map (ConcurrentHashMap), getUserAgent(qualifier, user) unions global + per-user flags, registerUsedAppFeature/unregisterUsedAppFeature per-user overloads, hydratePerUserFeatures() called from init block, isGlobalFeatureRegistered() helper - LoginActivity: completedViaBrowserTab flag captures Custom Tab completion (covers both regular and Login for Admin); onAuthFlowSuccess promotes BW/WD/QR transient global flags to per-user - AuthenticationUtilities: BA/SL flags registered per-user - SmartStoreSDKManager: SU flag registered per-user - SyncManager/LayoutSyncManager/MetadataSyncManager: MS/Layout/Metadata flags per-user - SalesforceHybridSDKManager: getUserAgent(qualifier, user) override - UserAccountTest, SalesforceSDKManagerTests: new unit tests --- .../mobilesync/manager/LayoutSyncManager.kt | 2 +- .../mobilesync/manager/MetadataSyncManager.kt | 2 +- .../mobilesync/manager/SyncManager.kt | 2 +- .../app/SalesforceHybridSDKManager.java | 9 +- .../androidsdk/accounts/UserAccount.java | 29 ++++ .../accounts/UserAccountManager.java | 15 ++- .../androidsdk/app/SalesforceSDKManager.kt | 81 ++++++++++- .../auth/AuthenticationUtilities.kt | 8 +- .../androidsdk/auth/AuthenticatorService.java | 1 + .../salesforce/androidsdk/ui/LoginActivity.kt | 28 ++++ .../smartstore/app/SmartStoreSDKManager.java | 2 +- .../androidsdk/accounts/UserAccountTest.java | 66 +++++++++ .../app/SalesforceSDKManagerTests.kt | 126 ++++++++++++++++++ 13 files changed, 358 insertions(+), 13 deletions(-) diff --git a/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/LayoutSyncManager.kt b/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/LayoutSyncManager.kt index c080b16826..a9a6744251 100644 --- a/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/LayoutSyncManager.kt +++ b/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/LayoutSyncManager.kt @@ -307,7 +307,7 @@ class LayoutSyncManager private constructor( store, syncManager ).also { INSTANCES[uniqueId] = it } - SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_LAYOUT_SYNC) + SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_LAYOUT_SYNC, user) return instance } diff --git a/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/MetadataSyncManager.kt b/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/MetadataSyncManager.kt index adc822d9d4..0e53cbf4cd 100644 --- a/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/MetadataSyncManager.kt +++ b/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/MetadataSyncManager.kt @@ -236,7 +236,7 @@ class MetadataSyncManager private constructor( INSTANCES[uniqueId] = it } SalesforceSDKManager.getInstance() - .registerUsedAppFeature(Features.FEATURE_METADATA_SYNC) + .registerUsedAppFeature(Features.FEATURE_METADATA_SYNC, user) return instance } diff --git a/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/SyncManager.kt b/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/SyncManager.kt index 5c91bd492f..22dfaa9f22 100644 --- a/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/SyncManager.kt +++ b/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/SyncManager.kt @@ -827,7 +827,7 @@ class SyncManager private constructor(smartStore: SmartStore, restClient: RestCl instance = SyncManager(store, restClient) instance.also { INSTANCES[uniqueId] = it } } - SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_MOBILE_SYNC) + SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_MOBILE_SYNC, user) return instance } diff --git a/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/app/SalesforceHybridSDKManager.java b/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/app/SalesforceHybridSDKManager.java index e7c6d8f760..6035285fae 100644 --- a/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/app/SalesforceHybridSDKManager.java +++ b/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/app/SalesforceHybridSDKManager.java @@ -31,6 +31,7 @@ import androidx.annotation.NonNull; +import com.salesforce.androidsdk.accounts.UserAccount; import com.salesforce.androidsdk.config.BootConfig; import com.salesforce.androidsdk.mobilesync.app.MobileSyncSDKManager; import com.salesforce.androidsdk.mobilesync.config.SyncsConfig; @@ -148,13 +149,19 @@ public static SalesforceHybridSDKManager getInstance() { @NonNull @Override public final String getUserAgent(@NonNull String qualifier) { + return getUserAgent(qualifier, null); + } + + @NonNull + @Override + public final String getUserAgent(@NonNull String qualifier, @androidx.annotation.Nullable UserAccount user) { final BootConfig config = BootConfig.getBootConfig(context); if (config.isLocal()) { qualifier = qualifier + "Local"; } else { qualifier = qualifier + "Remote"; } - return super.getUserAgent(qualifier); + return super.getUserAgent(qualifier, user); } @NonNull diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccount.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccount.java index 84e1240a92..474d87e80e 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccount.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccount.java @@ -49,8 +49,12 @@ import org.json.JSONObject; import java.io.File; +import java.util.Collections; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * This class represents a single user account that is currently @@ -100,6 +104,7 @@ public class UserAccount { public static final String BEACON_CHILD_CONSUMER_KEY = "auto_installed_app_org_consumer_key"; public static final String BEACON_CHILD_CONSUMER_SECRET = "auto_installed_app_org_consumer_secret"; public static final String SCOPE = "scope"; + public static final String FEATURE_FLAGS = "feature_flags"; private static final String TAG = "UserAccount"; private static final String FORWARD_SLASH = "/"; @@ -147,6 +152,7 @@ public class UserAccount { private String beaconChildConsumerKey; private String beaconChildConsumerSecret; private String scope; + private Set featureFlags = new java.util.HashSet<>(); /** * Parameterized constructor. @@ -760,6 +766,24 @@ public Map getAdditionalOauthValues() { return additionalOauthValues; } + /** + * Returns the persisted per-user feature flags (e.g. BW, SU, MS). + * + * @return Unmodifiable set of feature flag codes. + */ + public Set getFeatureFlags() { + return Collections.unmodifiableSet(featureFlags); + } + + /** + * Replaces the in-memory set of persisted per-user feature flags. + * + * @param flags The new set of feature flags. + */ + public void setFeatureFlags(Set flags) { + featureFlags = flags != null ? new HashSet<>(flags) : new HashSet<>(); + } + /** * Fetches this user's profile photo from the cache. * @@ -1024,6 +1048,11 @@ JSONObject toJson(List additionalOauthKeys) { object.put(BEACON_CHILD_CONSUMER_KEY, beaconChildConsumerKey); object.put(BEACON_CHILD_CONSUMER_SECRET, beaconChildConsumerSecret); object.put(SCOPE, scope); + if (!featureFlags.isEmpty()) { + org.json.JSONArray flagsArray = new org.json.JSONArray(); + for (String f : featureFlags) flagsArray.put(f); + object.put(FEATURE_FLAGS, flagsArray); + } object = MapUtil.addMapToJSONObject(additionalOauthValues, additionalOauthKeys, object); } catch (JSONException e) { SalesforceSDKLogger.e(TAG, "Unable to convert to JSON", e); diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java index 1f45e2c9a1..4109566dcd 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java @@ -50,9 +50,12 @@ import com.salesforce.androidsdk.util.SalesforceSDKLogger; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * This class acts as a manager that provides methods to access @@ -553,6 +556,7 @@ public Bundle updateAccount(Account account, UserAccount userAccount) { final String beaconChildConsumerKey = decryptUserData(account, AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_KEY, encryptionKey); final String beaconChildConsumerSecret = decryptUserData(account, AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_SECRET, encryptionKey); final String scope = decryptUserData(account, AuthenticatorService.KEY_SCOPE, encryptionKey); + final String featureFlagsRaw = decryptUserData(account, AuthenticatorService.KEY_FEATURE_FLAGS, encryptionKey); Map additionalOauthValues = null; List additionalOauthKeys = SalesforceSDKManager.getInstance().getAdditionalOauthKeys(); @@ -571,7 +575,7 @@ public Bundle updateAccount(Account account, UserAccount userAccount) { if (authToken == null || instanceServer == null || userId == null || orgId == null) { return null; } else { - return UserAccountBuilder.getInstance() + final UserAccount userAccount = UserAccountBuilder.getInstance() .authToken(authToken) .refreshToken(refreshToken) .loginServer(loginServer) @@ -610,6 +614,10 @@ public Bundle updateAccount(Account account, UserAccount userAccount) { .scope(scope) .additionalOauthValues(additionalOauthValues) .build(); + if (!TextUtils.isEmpty(featureFlagsRaw)) { + userAccount.setFeatureFlags(new HashSet<>(Arrays.asList(featureFlagsRaw.split(",")))); + } + return userAccount; } } @@ -758,6 +766,11 @@ private Bundle buildAuthBundle(UserAccount userAccount) { extras.putString(AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_KEY, SalesforceSDKManager.encrypt(userAccount.getBeaconChildConsumerKey(), encryptionKey)); extras.putString(AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_SECRET, SalesforceSDKManager.encrypt(userAccount.getBeaconChildConsumerSecret(), encryptionKey)); extras.putString(AuthenticatorService.KEY_SCOPE, SalesforceSDKManager.encrypt(userAccount.getScope(), encryptionKey)); + final Set featureFlags = userAccount.getFeatureFlags(); + if (!featureFlags.isEmpty()) { + extras.putString(AuthenticatorService.KEY_FEATURE_FLAGS, + SalesforceSDKManager.encrypt(android.text.TextUtils.join(",", featureFlags), encryptionKey)); + } final List additionalOauthKeys = SalesforceSDKManager.getInstance().getAdditionalOauthKeys(); if (additionalOauthKeys != null && !additionalOauthKeys.isEmpty()) { diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index 485b015821..68b10d334a 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -154,6 +154,7 @@ import java.net.URI import java.util.Locale.US import java.util.SortedSet import java.util.UUID.randomUUID +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentSkipListSet import java.util.regex.Pattern import com.salesforce.androidsdk.auth.idp.IDPManager as DefaultIDPManager @@ -408,6 +409,9 @@ open class SalesforceSDKManager protected constructor( /** App feature codes for reporting in the user agent header */ private val features: SortedSet + /** Per-user feature codes keyed by "orgId/userId" */ + private val perUserFeatures: ConcurrentHashMap> = ConcurrentHashMap() + /** * An additional list of OAuth keys to fetch and store from the token * endpoint @@ -683,6 +687,7 @@ open class SalesforceSDKManager protected constructor( Handler(getMainLooper()).post { ProcessLifecycleOwner.get().lifecycle.addObserver(this) } + hydratePerUserFeatures() } /** @@ -1272,8 +1277,24 @@ open class SalesforceSDKManager protected constructor( * @param qualifier The user agent qualifier * @return The user agent string to use for all requests */ - open fun getUserAgent(qualifier: String) = - String.format( + open fun getUserAgent(qualifier: String) = getUserAgent(qualifier, null) + + /** + * Returns a per-user agent string. Feature flags include both global and user-specific codes. + * + * @param qualifier The user agent qualifier + * @param user The user account, or null to use the current user + * @return The user agent string to use for all requests + */ + open fun getUserAgent(qualifier: String, user: UserAccount?) : String { + val resolvedUser = user ?: userAccountManager.currentUser + val userKey = resolvedUser?.let { "${it.orgId}/${it.userId}" } + val userFeatures = userKey?.let { perUserFeatures[it] } ?: emptySet() + val allFeatures = ConcurrentSkipListSet(CASE_INSENSITIVE_ORDER).apply { + addAll(features.filterNotNull()) + addAll(userFeatures) + } + return String.format( "SalesforceMobileSDK/%s android mobile/%s (%s) %s/%s %s uid_%s ftr_%s SecurityPatch/%s", SDK_VERSION, RELEASE, @@ -1282,9 +1303,10 @@ open class SalesforceSDKManager protected constructor( appVersion, "$appType$qualifier", deviceId, - join(".", features), + join(".", allFeatures), SECURITY_PATCH ) + } /** The app version */ val appVersion: String @@ -1321,6 +1343,59 @@ open class SalesforceSDKManager protected constructor( fun unregisterUsedAppFeature(appFeatureCode: String?) = features.remove(appFeatureCode) + /** + * Returns true if the feature code is in the global (non-user-specific) set. + * @param appFeatureCode The app feature code + */ + fun isGlobalFeatureRegistered(appFeatureCode: String) = features.contains(appFeatureCode) + + /** + * Adds a per-user app feature code for reporting in the user agent header. + * Falls back to the global set when user is null. + * @param appFeatureCode The app feature code + * @param user The user account to associate the feature with + */ + fun registerUsedAppFeature(appFeatureCode: String, user: UserAccount?) { + if (user == null) { registerUsedAppFeature(appFeatureCode); return } + val key = "${user.orgId}/${user.userId}" + val set = perUserFeatures.getOrPut(key) { ConcurrentSkipListSet(CASE_INSENSITIVE_ORDER) } + set.add(appFeatureCode) + persistUserFeatureFlags(user, set) + } + + /** + * Removes a per-user app feature code from reporting in the user agent header. + * Falls back to the global set when user is null. + * @param appFeatureCode The app feature code + * @param user The user account to remove the feature from + */ + fun unregisterUsedAppFeature(appFeatureCode: String, user: UserAccount?) { + if (user == null) { unregisterUsedAppFeature(appFeatureCode); return } + val key = "${user.orgId}/${user.userId}" + perUserFeatures[key]?.remove(appFeatureCode) + persistUserFeatureFlags(user, perUserFeatures[key] ?: emptySet()) + } + + private fun persistUserFeatureFlags(user: UserAccount, flags: Set) { + user.featureFlags = HashSet(flags) + val account = userAccountManager.buildAccount(user) ?: return + userAccountManager.updateAccount(account, user) + } + + /** Hydrates per-user features from persisted accounts at startup */ + private fun hydratePerUserFeatures() { + val users = userAccountManager.authenticatedUsers ?: return + for (u in users) { + val flags = u.featureFlags + if (flags.isNotEmpty()) { + val key = "${u.orgId}/${u.userId}" + val set = ConcurrentSkipListSet(CASE_INSENSITIVE_ORDER) + set.addAll(flags) + perUserFeatures[key] = set + } + } + } + /** The app type */ open val appType = "Native" diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt index 6a37a66585..16a4f1c93d 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt @@ -391,7 +391,7 @@ internal fun handleScreenLockPolicy( // 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) + SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SCREEN_LOCK, account) val timeoutInMills = userIdentity.screenLockTimeout * 1000 * 60 internalScreenLockManager?.storeMobilePolicy( account, @@ -399,7 +399,7 @@ internal fun handleScreenLockPolicy( timeoutInMills, ) } else if (internalScreenLockManager?.enabled == true) { - SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_SCREEN_LOCK) + SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_SCREEN_LOCK, account) internalScreenLockManager.cleanUp(account) } } @@ -416,7 +416,7 @@ internal fun handleBiometricAuthPolicy( SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager? if (userIdentity?.biometricAuth == true) { - SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH) + SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH, account) val timeoutInMills = userIdentity.biometricAuthTimeout * 60 * 1000 internalBiometricAuthenticationManager?.storeMobilePolicy( account, @@ -424,7 +424,7 @@ internal fun handleBiometricAuthPolicy( timeoutInMills ) } else if (internalBiometricAuthenticationManager?.enabled == true) { - SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH) + SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH, account) internalBiometricAuthenticationManager.cleanUp(account) } } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java index 08b112f93c..37a0db950a 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java @@ -92,6 +92,7 @@ public class AuthenticatorService extends Service { public static final String KEY_BEACON_CHILD_CONSUMER_KEY = "auto_installed_app_org_consumer_key"; public static final String KEY_BEACON_CHILD_CONSUMER_SECRET = "auto_installed_app_org_consumer_secret"; public static final String KEY_SCOPE = "scope"; + public static final String KEY_FEATURE_FLAGS = "feature_flags"; private static final String TAG = "AuthenticatorService"; diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 481582ab82..e3e968c708 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -121,6 +121,7 @@ import com.salesforce.androidsdk.R.string.sf__ssl_not_yet_valid import com.salesforce.androidsdk.R.string.sf__ssl_unknown_error import com.salesforce.androidsdk.R.string.sf__ssl_untrusted import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.app.Features.FEATURE_BROWSER_LOGIN import com.salesforce.androidsdk.app.Features.FEATURE_QR_CODE_LOGIN import com.salesforce.androidsdk.app.Features.FEATURE_WELCOME_DISCOVERY_LOGIN import com.salesforce.androidsdk.app.SalesforceSDKManager @@ -233,6 +234,7 @@ open class LoginActivity : FragmentActivity() { // Private variables private var baseUserAgentString = "" private var wasBackgrounded = false + private var completedViaBrowserTab = false private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null private var accountAuthenticatorResult: Bundle? = null private var newUserIntent = false @@ -537,6 +539,31 @@ open class LoginActivity : FragmentActivity() { * @param userAccount The newly created user account. */ protected open fun onAuthFlowSuccess(userAccount: UserAccount) { + val sdkManager = SalesforceSDKManager.getInstance() + + // WD: write per-user and clear transient global + val usedWelcomeDiscovery = sdkManager.isGlobalFeatureRegistered(FEATURE_WELCOME_DISCOVERY_LOGIN) + sdkManager.unregisterUsedAppFeature(FEATURE_WELCOME_DISCOVERY_LOGIN) + if (usedWelcomeDiscovery) { + sdkManager.registerUsedAppFeature(FEATURE_WELCOME_DISCOVERY_LOGIN, userAccount) + } + + // BW: set per-user based on whether this session actually used a Custom Tab. + // isBrowserLoginEnabled is unreliable here — Login for Admin uses a Custom Tab + // without setting that flag. completedViaBrowserTab is true for both paths. + if (completedViaBrowserTab) { + sdkManager.registerUsedAppFeature(FEATURE_BROWSER_LOGIN, userAccount) + } else { + sdkManager.unregisterUsedAppFeature(FEATURE_BROWSER_LOGIN, userAccount) + } + + // QR: write per-user and clear transient global + val usedQrLogin = sdkManager.isGlobalFeatureRegistered(FEATURE_QR_CODE_LOGIN) + sdkManager.unregisterUsedAppFeature(FEATURE_QR_CODE_LOGIN) + if (usedQrLogin) { + sdkManager.registerUsedAppFeature(FEATURE_QR_CODE_LOGIN, userAccount) + } + // Create account and save result before switching to new user accountAuthenticatorResult = SalesforceSDKManager.getInstance().userAccountManager.createAccount(userAccount) @@ -784,6 +811,7 @@ open class LoginActivity : FragmentActivity() { @VisibleForTesting internal fun loadLoginPageInCustomTab(loginUrl: String, customTabLauncher: ActivityResultLauncher) { + completedViaBrowserTab = true val customTabsIntent = CustomTabsIntent.Builder().apply { /* * Set a custom animation to slide in and out for Chrome custom tab diff --git a/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java b/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java index 5184e0b449..946da8dc3a 100644 --- a/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java +++ b/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java @@ -254,7 +254,7 @@ public SmartStore getSmartStore(String dbNamePrefix, UserAccount account, String if (TextUtils.isEmpty(dbNamePrefix)) { dbNamePrefix = DBOpenHelper.DEFAULT_DB_NAME; } - SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_SMART_STORE_USER); + SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_SMART_STORE_USER, account); final SQLiteOpenHelper dbOpenHelper = DBOpenHelper.getOpenHelper(getEncryptionKey(), context, dbNamePrefix, account, communityId); diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java index e868804cd8..82269c39f7 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java @@ -44,8 +44,10 @@ import org.junit.runner.RunWith; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; @@ -723,6 +725,70 @@ private OAuth2.IdServiceResponse createIdServiceResponse() throws JSONException return new OAuth2.IdServiceResponse(response); } + /** + * Tests that feature flags survive a toJson round-trip. + * Verifies that the FEATURE_FLAGS key is present in the serialized JSON and that the + * flags can be read back by manually parsing the JSON array (since the JSON constructor + * does not populate featureFlags — that path goes through AccountManager). + */ + @Test + public void test_givenUserAccountWithFlags_whenToJson_thenFeatureFlagsKeyPresent() throws JSONException { + final UserAccount account = createTestAccount(); + account.setFeatureFlags(new HashSet<>(Arrays.asList("BW", "WD"))); + + final JSONObject json = account.toJson(createAdditionalOauthKeys()); + + Assert.assertTrue("JSON should contain FEATURE_FLAGS key", json.has(UserAccount.FEATURE_FLAGS)); + + // Verify both flags are serialized in the JSON array + org.json.JSONArray flagsArray = json.getJSONArray(UserAccount.FEATURE_FLAGS); + HashSet serialized = new HashSet<>(); + for (int i = 0; i < flagsArray.length(); i++) { + serialized.add(flagsArray.getString(i)); + } + Assert.assertTrue("Serialized flags should contain BW", serialized.contains("BW")); + Assert.assertTrue("Serialized flags should contain WD", serialized.contains("WD")); + } + + /** + * Tests that a UserAccount built from JSON without a FEATURE_FLAGS key returns an empty set. + */ + @Test + public void test_givenUserAccountJsonMissingFeatureFlags_whenFromJson_thenEmptySet() throws JSONException { + final JSONObject testJSON = createTestAccountJSON(); + // Confirm the helper JSON does not include FEATURE_FLAGS + Assert.assertFalse("Test JSON should not have FEATURE_FLAGS", testJSON.has(UserAccount.FEATURE_FLAGS)); + + final UserAccount account = new UserAccount(testJSON, "SalesforceSDKTest", createAdditionalOauthKeys()); + + Assert.assertNotNull("featureFlags should never be null", account.getFeatureFlags()); + Assert.assertTrue("featureFlags should be empty when key is absent", account.getFeatureFlags().isEmpty()); + } + + /** + * Tests that setFeatureFlags/getFeatureFlags are symmetric. + */ + @Test + public void test_givenUserAccount_whenSetFeatureFlags_thenGetFeatureFlagsReturnsSameValues() { + final UserAccount account = createTestAccount(); + final HashSet flags = new HashSet<>(Arrays.asList("AA", "BB", "CC")); + account.setFeatureFlags(flags); + + Assert.assertEquals("getFeatureFlags should return the set values", flags, account.getFeatureFlags()); + } + + /** + * Tests that setFeatureFlags(null) results in an empty set, not a NullPointerException. + */ + @Test + public void test_givenUserAccount_whenSetFeatureFlagsNull_thenEmptySet() { + final UserAccount account = createTestAccount(); + account.setFeatureFlags(null); + + Assert.assertNotNull("featureFlags should never be null after setFeatureFlags(null)", account.getFeatureFlags()); + Assert.assertTrue("featureFlags should be empty after setFeatureFlags(null)", account.getFeatureFlags().isEmpty()); + } + /** * Check the user accounts are the same * @param expected Expected UserAccount diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt index 47a687ea73..719927c299 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt @@ -4,6 +4,9 @@ import android.app.Activity import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.accounts.UserAccountBuilder +import com.salesforce.androidsdk.accounts.UserAccountManager import com.salesforce.androidsdk.auth.HttpAccess import com.salesforce.androidsdk.config.LoginServerManager import com.salesforce.androidsdk.config.LoginServerManager.LoginServer @@ -519,6 +522,106 @@ class SalesforceSDKManagerTests { assertNull(sdkManager.appAttestationClient) } + // ------------------------------------------------------------------------- + // Per-user feature flag tests + // ------------------------------------------------------------------------- + + @Test + fun test_givenTwoUsers_whenRegisterFeatureForUserA_thenOnlyUserAUAContainsFlag() { + val sdkManager = createSdkManagerWithMockedAccountManager() + + val userA = buildMinimalUserAccount(orgId = "org1", userId = "user1") + val userB = buildMinimalUserAccount(orgId = "org2", userId = "user2") + + sdkManager.registerUsedAppFeature("XY", userA) + + try { + assertTrue( + "getUserAgent for userA should contain XY", + sdkManager.getUserAgent("", userA).contains("XY") + ) + assertFalse( + "getUserAgent for userB should NOT contain XY", + sdkManager.getUserAgent("", userB).contains("XY") + ) + } finally { + sdkManager.unregisterUsedAppFeature("XY", userA) + } + } + + @Test + fun test_givenGlobalAndPerUserFlags_whenGetUserAgentForUser_thenUnionPresent() { + val sdkManager = createSdkManagerWithMockedAccountManager() + + val userA = buildMinimalUserAccount(orgId = "org1", userId = "user1") + val userB = buildMinimalUserAccount(orgId = "org2", userId = "user2") + + sdkManager.registerUsedAppFeature("GL") + sdkManager.registerUsedAppFeature("PU", userA) + + try { + val agentA = sdkManager.getUserAgent("", userA) + assertTrue("getUserAgent for userA should contain global flag GL", agentA.contains("GL")) + assertTrue("getUserAgent for userA should contain per-user flag PU", agentA.contains("PU")) + + val agentB = sdkManager.getUserAgent("", userB) + assertTrue("getUserAgent for userB should contain global flag GL", agentB.contains("GL")) + assertFalse("getUserAgent for userB should NOT contain per-user flag PU", agentB.contains("PU")) + } finally { + sdkManager.unregisterUsedAppFeature("GL") + sdkManager.unregisterUsedAppFeature("PU", userA) + } + } + + @Test + fun test_givenNullUser_whenRegisterUsedAppFeature_thenGlobalFlagRegistered() { + val sdkManager = SalesforceSDKManager.getInstance() + + sdkManager.registerUsedAppFeature("GF", null) + + try { + assertTrue( + "isGlobalFeatureRegistered should return true for GF", + sdkManager.isGlobalFeatureRegistered("GF") + ) + } finally { + sdkManager.unregisterUsedAppFeature("GF") + } + } + + // ------------------------------------------------------------------------- + // Helpers for per-user feature flag tests + // ------------------------------------------------------------------------- + + /** + * Builds a minimal [UserAccount] for testing using known test constants. + * orgId and userId are parameterized so tests can create distinct users. + */ + private fun buildMinimalUserAccount(orgId: String, userId: String): UserAccount = + UserAccountBuilder.getInstance() + .authToken("test_auth_token") + .refreshToken("test_refresh_token") + .loginServer("https://test.salesforce.com") + .idUrl("https://test.salesforce.com/$orgId/$userId") + .instanceServer("https://cs1.salesforce.com") + .orgId(orgId) + .userId(userId) + .username("user_${userId}@example.com") + .accountName("user_$userId (https://cs1.salesforce.com) (SalesforceSDKTest)") + .build() + + /** + * Creates a [SalesforceSDKManager] subclass whose [UserAccountManager] is fully + * mocked so that [persistUserFeatureFlags] cannot reach [AccountManager]. + * This keeps per-user feature flag tests in-memory only. + */ + private fun createSdkManagerWithMockedAccountManager(): SalesforceSDKManager = + TestSalesforceSDKManagerWithMockedAccounts( + context = getInstrumentation().targetContext, + mainActivity = LoginActivity::class.java, + loginActivity = LoginActivity::class.java, + ) + /** * Helper to create a test [SalesforceSDKManager] instance with optional * [googleCloudProjectId] for app attestation tests. @@ -573,4 +676,27 @@ class SalesforceSDKManagerTests { } } } + + /** + * A [SalesforceSDKManager] subclass that replaces [userAccountManager] with a + * relaxed mock so that [persistUserFeatureFlags] cannot reach [AccountManager]. + * Per-user feature flag tests use this to stay fully in-memory. + */ + private class TestSalesforceSDKManagerWithMockedAccounts( + context: android.content.Context, + mainActivity: Class, + loginActivity: Class? = null, + ) : SalesforceSDKManager(context, mainActivity, loginActivity) { + + override val userAccountManager: UserAccountManager by lazy { + mockk(relaxed = true).apply { + // buildAccount returns null → persistUserFeatureFlags exits early + every { buildAccount(any()) } returns null + // currentUser returns null → getUserAgent falls back to no per-user key + every { currentUser } returns null + // authenticatedUsers returns null → hydratePerUserFeatures is a no-op + every { authenticatedUsers } returns null + } + } + } } From e2b6575f514a80fcda4ab8bbb5cd92b423426374 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Wed, 24 Jun 2026 19:10:22 -0600 Subject: [PATCH 02/15] fix(W-23159744): register BW flag globally when Custom Tab launches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FEATURE_BROWSER_LOGIN must appear in the UA for requests made during the login session. Register it globally in loadLoginPageInCustomTab() (alongside completedViaBrowserTab = true), then clear global and promote to per-user at onAuthFlowSuccess — matching the WD/QR pattern. --- .../src/com/salesforce/androidsdk/ui/LoginActivity.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index e3e968c708..20b1ec3802 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -548,9 +548,10 @@ open class LoginActivity : FragmentActivity() { sdkManager.registerUsedAppFeature(FEATURE_WELCOME_DISCOVERY_LOGIN, userAccount) } - // BW: set per-user based on whether this session actually used a Custom Tab. - // isBrowserLoginEnabled is unreliable here — Login for Admin uses a Custom Tab - // without setting that flag. completedViaBrowserTab is true for both paths. + // BW: promote transient global to per-user, then clear global. + // completedViaBrowserTab is true for both regular and Login for Admin Custom Tab paths. + // The global was set in loadLoginPageInCustomTab() so it appears in UA during login. + sdkManager.unregisterUsedAppFeature(FEATURE_BROWSER_LOGIN) if (completedViaBrowserTab) { sdkManager.registerUsedAppFeature(FEATURE_BROWSER_LOGIN, userAccount) } else { @@ -812,6 +813,7 @@ open class LoginActivity : FragmentActivity() { @VisibleForTesting internal fun loadLoginPageInCustomTab(loginUrl: String, customTabLauncher: ActivityResultLauncher) { completedViaBrowserTab = true + SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BROWSER_LOGIN) val customTabsIntent = CustomTabsIntent.Builder().apply { /* * Set a custom animation to slide in and out for Chrome custom tab From 419e5d73fecdb645d4081db66d799830d8210ea4 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 25 Jun 2026 10:56:24 -0600 Subject: [PATCH 03/15] fix(W-23159744): avoid NPE in hydratePerUserFeatures during construction hydratePerUserFeatures() is called from the init block, before the userAccountManager lazy delegate is set up. Call UserAccountManager.getInstance() directly instead of going through the lazy property. --- .../src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index 68b10d334a..328342835a 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -1384,7 +1384,9 @@ open class SalesforceSDKManager protected constructor( /** Hydrates per-user features from persisted accounts at startup */ private fun hydratePerUserFeatures() { - val users = userAccountManager.authenticatedUsers ?: return + // Use getInstance() directly — this is called from the constructor init block before the + // userAccountManager lazy delegate is initialized, so we can't use the property here. + val users = UserAccountManager.getInstance().authenticatedUsers ?: return for (u in users) { val flags = u.featureFlags if (flags.isNotEmpty()) { From d48668dd1b98fbd7f5a7e043ad5dc3811e5371ed Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 25 Jun 2026 11:44:30 -0600 Subject: [PATCH 04/15] feat(W-23159744): display user agent in AuthFlowTester; add UA validation to UI tests - Add USER_AGENT_CONTENT_DESC constant to AuthFlowTesterActivity - Add getUserAgentString() helper and User Agent InfoRowView to UserCredentialsView - Include User Agent in JSON export of credentials card - Add contentDescription param to InfoRowView (defaults to label) to allow distinct semantic IDs - Add validateUserAgent() to AuthFlowTesterPageObject: asserts SalesforceMobileSDK/ prefix, ftr_ segment, and BW/WD/MU flag presence/absence based on login config - Thread isMultiUser param through loginAndValidate() and call validateUserAgent() after login - Update RefreshTokenMigrationTests override to include new isMultiUser param - Add loginOtherUserAndValidate/switchToUserAndValidate UA validation in MultiUserLoginTests - Add testAdvancedAuthUser_HasBWFlag_RegularAuthUser_DoesNot test Note: pre-existing WebView 5s timeout failures in BootConfigLoginTests, NegativeLoginTests, RefreshTokenMigrationTests are infrastructure flakiness unrelated to these changes. --- .../authflowtester/MultiUserLoginTests.kt | 28 +++++++++++++ .../RefreshTokenMigrationTests.kt | 2 + .../pageObjects/AuthFlowTesterPageObject.kt | 42 +++++++++++++++++++ .../testUtility/AuthFlowTest.kt | 2 + .../authflowtester/AuthFlowTesterActivity.kt | 1 + .../components/BaseComponents.kt | 3 +- .../components/UserCredentialsView.kt | 13 ++++++ 7 files changed, 90 insertions(+), 1 deletion(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt index 18cb869784..26f33a1cc3 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt @@ -39,6 +39,7 @@ import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.CA_OPAQU import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig import com.salesforce.samples.authflowtester.testUtility.ScopeSelection @@ -360,6 +361,7 @@ class MultiUserLoginTests: AuthFlowTest() { useHybridAuthToken, knownLoginHostConfig, knownUserConfig = otherUser, + isMultiUser = true, ) } @@ -370,6 +372,7 @@ class MultiUserLoginTests: AuthFlowTest() { app.switchToUser(knownUserConfig) composeTestRule.waitForIdle() app.validateUser(knownLoginHostConfig, knownUserConfig) + app.validateUserAgent(knownLoginHostConfig, isMultiUser = true) } /** @@ -395,6 +398,31 @@ class MultiUserLoginTests: AuthFlowTest() { ) } + @Test + fun testAdvancedAuthUser_HasBWFlag_RegularAuthUser_DoesNot() { + // User A: regular auth — no BW + loginAndValidate( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + knownUserConfig = user, + isMultiUser = false, + ) + + // User B: advanced auth — has BW; now 2 users → MU + loginOtherUserAndValidate( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = ADVANCED_AUTH, + ) + + // Switch to User A — no BW, MU still present + switchToUserAndValidate(user) + app.validateUserAgent(REGULAR_AUTH, isMultiUser = true) + + // Switch back to User B — BW back, MU still present + switchToUserAndValidate(otherUser, ADVANCED_AUTH) + app.validateUserAgent(ADVANCED_AUTH, isMultiUser = true) + } + companion object { private const val USER_COUNT_TIMEOUT_MS = 15_000L private const val POLL_INTERVAL_MS = 250L diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt index 40ed85c914..aff93968aa 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt @@ -205,6 +205,7 @@ class RefreshTokenMigrationTests: AuthFlowTest() { knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig, useWelcomeDiscovery: Boolean, + isMultiUser: Boolean, ) { super.loginAndValidate( knownAppConfig = knownAppConfig, @@ -214,6 +215,7 @@ class RefreshTokenMigrationTests: AuthFlowTest() { knownLoginHostConfig = knownLoginHostConfig, knownUserConfig = user, useWelcomeDiscovery = useWelcomeDiscovery, + isMultiUser = isMultiUser, ) } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt index c8bc2c49b0..f29e21faff 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt @@ -54,6 +54,7 @@ import com.salesforce.samples.authflowtester.R import com.salesforce.samples.authflowtester.REQUEST_BUTTON_CONTENT_DESC import com.salesforce.samples.authflowtester.REVOKE_BUTTON_CONTENT_DESC import com.salesforce.samples.authflowtester.SCROLL_CONTAINER_CONTENT_DESC +import com.salesforce.samples.authflowtester.USER_AGENT_CONTENT_DESC import com.salesforce.samples.authflowtester.components.ACCESS_TOKEN import com.salesforce.samples.authflowtester.components.CLIENT_ID import com.salesforce.samples.authflowtester.components.REFRESH_TOKEN @@ -479,4 +480,45 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject .config[SemanticsProperties.Text] .last().text // Value is last; first is the label } + + fun validateUserAgent( + knownLoginHostConfig: KnownLoginHostConfig, + usesWelcomeDiscovery: Boolean = false, + isMultiUser: Boolean = false, + ) { + expandUserCredentialsSection(targetNode = USER_AGENT_CONTENT_DESC) + val ua = getText(USER_AGENT_CONTENT_DESC) + + assert(ua.contains("SalesforceMobileSDK/")) { + "User agent missing 'SalesforceMobileSDK/' prefix: $ua" + } + assert(ua.contains("ftr_")) { + "User agent missing 'ftr_' segment: $ua" + } + + // Parse flag codes from the ftr_XXXX segment + val ftrSegment = ua.substringAfter("ftr_").substringBefore(" ") + val flags = ftrSegment.split(".").toSet() + + when (knownLoginHostConfig) { + KnownLoginHostConfig.ADVANCED_AUTH -> assert("BW" in flags) { + "Expected 'BW' flag for ADVANCED_AUTH in: $ua" + } + KnownLoginHostConfig.REGULAR_AUTH -> assert("BW" !in flags) { + "Expected no 'BW' flag for REGULAR_AUTH in: $ua" + } + } + + if (usesWelcomeDiscovery) { + assert("WD" in flags) { + "Expected 'WD' flag for Welcome Discovery in: $ua" + } + } + + if (isMultiUser) { + assert("MU" in flags) { + "Expected 'MU' flag for multi-user in: $ua" + } + } + } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index 36c6ef5909..6c976e8d6a 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -138,6 +138,7 @@ abstract class AuthFlowTest { knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, knownUserConfig: KnownUserConfig = user, useWelcomeDiscovery: Boolean = false, + isMultiUser: Boolean = false, ) { val loginPage = when(knownLoginHostConfig) { REGULAR_AUTH -> LoginPageObject(composeTestRule) @@ -217,6 +218,7 @@ abstract class AuthFlowTest { app.validateUser(knownLoginHostConfig, knownUserConfig) app.validateOAuthValues(knownAppConfig, scopeSelection) app.validateApiRequest() + app.validateUserAgent(knownLoginHostConfig, useWelcomeDiscovery, isMultiUser) } companion object { 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 cba9f62f2a..f13ba44507 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 @@ -162,6 +162,7 @@ const val MIGRATE_USER_RADIO_CONTENT_DESC = "migrate_user_radio" const val ALERT_TITLE_CONTENT_DESC = "alert_title" const val ALERT_POSITIVE_BUTTON_CONTENT_DESC = "alert_positive" const val SCROLL_CONTAINER_CONTENT_DESC = "scroll_container" +const val USER_AGENT_CONTENT_DESC = "user_agent" class AuthFlowTesterActivity : SalesforceActivity() { private var client: RestClient? = null diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/BaseComponents.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/BaseComponents.kt index 50902f0feb..7e39e5cef4 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/BaseComponents.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/BaseComponents.kt @@ -271,6 +271,7 @@ fun InfoRowView( label: String, value: String?, isSensitive: Boolean = false, + contentDescription: String = label, ) { var isValueVisible by remember { mutableStateOf(!isSensitive) } val emptyText = stringResource(R.string.empty_placeholder) @@ -321,7 +322,7 @@ fun InfoRowView( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(VALUE_WEIGHT) .padding(end = (INNER_CARD_PADDING /2).dp) - .semantics { contentDescription = label }, + .semantics { this.contentDescription = contentDescription }, ) if (isSensitive && !value.isNullOrEmpty()) { diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/UserCredentialsView.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/UserCredentialsView.kt index c571e09c49..d6ebf8b241 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/UserCredentialsView.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/UserCredentialsView.kt @@ -36,11 +36,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.auth.ScopeParser.Companion.toScopeParser import com.salesforce.androidsdk.ui.theme.sfDarkColors import com.salesforce.androidsdk.ui.theme.sfLightColors import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport import com.salesforce.samples.authflowtester.CREDS_SECTION_CONTENT_DESC +import com.salesforce.samples.authflowtester.USER_AGENT_CONTENT_DESC import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject @@ -105,6 +107,7 @@ private const val BEACON_CHILD_CONSUMER_SECRET = "Beacon Child Consumer Secret" // Other fields private const val ADDITIONAL_OAUTH_FIELDS = "Additional OAuth Fields" +private const val USER_AGENT_LABEL = "User Agent" @Composable fun UserCredentialsView(currentUser: UserAccount?) { @@ -166,10 +169,19 @@ fun UserCredentialsView(currentUser: UserAccount?) { InfoSection(title = OTHER) { InfoRowView(label = ADDITIONAL_OAUTH_FIELDS, value = formatAdditionalOAuthFields(currentUser)) + InfoRowView( + label = USER_AGENT_LABEL, + value = getUserAgentString(currentUser), + contentDescription = USER_AGENT_CONTENT_DESC, + ) } } } +private fun getUserAgentString(user: UserAccount?): String { + return if (user != null) SalesforceSDKManager.getInstance().getUserAgent("", user) else "" +} + private fun formatScopes(user: UserAccount?): String? { return user?.scope?.toScopeParser()?.scopesAsString } @@ -246,6 +258,7 @@ private fun generateCredentialsJSON(user: UserAccount?): String { putJsonObject(OTHER) { put(ADDITIONAL_OAUTH_FIELDS, formatAdditionalOAuthFields(user)) + put(USER_AGENT_LABEL, getUserAgentString(user)) } } From b3bb88930f7f4c1b852e85545cd069e7ee520e54 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 25 Jun 2026 14:05:44 -0600 Subject: [PATCH 05/15] test(W-23159744): assert MU gone after second user logs out --- .../samples/authflowtester/MultiUserLoginTests.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt index 26f33a1cc3..fe4dfa6f7f 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt @@ -421,6 +421,21 @@ class MultiUserLoginTests: AuthFlowTest() { // Switch back to User B — BW back, MU still present switchToUserAndValidate(otherUser, ADVANCED_AUTH) app.validateUserAgent(ADVANCED_AUTH, isMultiUser = true) + + // Log out User B via SDK — auto-switches to User A; MU must be gone + val sdkManager = SalesforceSDKManager.getInstance() + val otherUserAccount = sdkManager.userAccountManager.authenticatedUsers + ?.find { it.username == testConfig.getUser(ADVANCED_AUTH, otherUser).username } + ?: throw AssertionError("Other user account not found") + sdkManager.logout( + account = sdkManager.userAccountManager.buildAccount(otherUserAccount), + frontActivity = null, + showLoginPage = false, + ) + waitForUserCount(sdkManager.userAccountManager, expectedCount = 1) + + // Back on User A — MU gone, no BW + app.validateUserAgent(REGULAR_AUTH, isMultiUser = false) } companion object { From c181d1c6cee958429264b8bfbc61ad277596043d Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 25 Jun 2026 16:28:34 -0600 Subject: [PATCH 06/15] refactor(W-23159744): avoid double UA fetch by splitting validateUserAgent into public/private overloads validateUserAgent() fetches the UA once and delegates to a private overload taking ua: String. validateUser() calls the private overload using credentials already loaded, eliminating a second UI traversal. --- .../androidsdk/app/SalesforceSDKManager.kt | 8 +++---- .../authflowtester/MultiUserLoginTests.kt | 5 +---- .../pageObjects/AuthFlowTesterPageObject.kt | 22 ++++++++++++++++--- .../testUtility/AuthFlowTest.kt | 3 +-- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index 328342835a..f9a8c70ba4 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -687,7 +687,6 @@ open class SalesforceSDKManager protected constructor( Handler(getMainLooper()).post { ProcessLifecycleOwner.get().lifecycle.addObserver(this) } - hydratePerUserFeatures() } /** @@ -1384,9 +1383,7 @@ open class SalesforceSDKManager protected constructor( /** Hydrates per-user features from persisted accounts at startup */ private fun hydratePerUserFeatures() { - // Use getInstance() directly — this is called from the constructor init block before the - // userAccountManager lazy delegate is initialized, so we can't use the property here. - val users = UserAccountManager.getInstance().authenticatedUsers ?: return + val users = userAccountManager.authenticatedUsers ?: return for (u in users) { val flags = u.featureFlags if (flags.isNotEmpty()) { @@ -1865,6 +1862,9 @@ open class SalesforceSDKManager protected constructor( nativeLoginActivity, googleCloudProjectId, ) + // Hydrate after INSTANCE is set — UserAccountManager.getInstance() checks + // SalesforceSDKManager.getInstance() internally, which requires INSTANCE != null. + INSTANCE?.hydratePerUserFeatures() } initInternal(context) EventsObservable.get().notifyEvent( diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt index fe4dfa6f7f..39dee8ab30 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt @@ -371,8 +371,7 @@ class MultiUserLoginTests: AuthFlowTest() { ) { app.switchToUser(knownUserConfig) composeTestRule.waitForIdle() - app.validateUser(knownLoginHostConfig, knownUserConfig) - app.validateUserAgent(knownLoginHostConfig, isMultiUser = true) + app.validateUser(knownLoginHostConfig, knownUserConfig, isMultiUser = true) } /** @@ -416,11 +415,9 @@ class MultiUserLoginTests: AuthFlowTest() { // Switch to User A — no BW, MU still present switchToUserAndValidate(user) - app.validateUserAgent(REGULAR_AUTH, isMultiUser = true) // Switch back to User B — BW back, MU still present switchToUserAndValidate(otherUser, ADVANCED_AUTH) - app.validateUserAgent(ADVANCED_AUTH, isMultiUser = true) // Log out User B via SDK — auto-switches to User A; MU must be gone val sdkManager = SalesforceSDKManager.getInstance() diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt index f29e21faff..63fbc689f1 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt @@ -265,11 +265,16 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject ) } - fun validateUser(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) { + fun validateUser( + knownLoginHostConfig: KnownLoginHostConfig, + knownUserConfig: KnownUserConfig, + usesWelcomeDiscovery: Boolean = false, + isMultiUser: Boolean = false, + ) { val expected = testConfig.getUser(knownLoginHostConfig, knownUserConfig) waitForNode(CREDS_SECTION_CONTENT_DESC) - + // Wait for the UI to update asynchronously after login or user switch. // The view may be recreated and collapsed when the current user state updates. try { @@ -298,6 +303,10 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject throw AssertionError("Timed out after ${TIMEOUT_MS}ms waiting for username to show \"${expected.username}\"", e) } assertEquals(expected.username, getText(USERNAME)) + + // Validate feature flags — UI is already settled, reuse the existing layout traversal + expandUserCredentialsSection(targetNode = USER_AGENT_CONTENT_DESC) + validateUserAgent(getText(USER_AGENT_CONTENT_DESC), knownLoginHostConfig, usesWelcomeDiscovery, isMultiUser) } fun validateOAuthValues(knownAppConfig: KnownAppConfig, scopeSelection: ScopeSelection) { @@ -487,8 +496,15 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject isMultiUser: Boolean = false, ) { expandUserCredentialsSection(targetNode = USER_AGENT_CONTENT_DESC) - val ua = getText(USER_AGENT_CONTENT_DESC) + validateUserAgent(getText(USER_AGENT_CONTENT_DESC), knownLoginHostConfig, usesWelcomeDiscovery, isMultiUser) + } + private fun validateUserAgent( + ua: String, + knownLoginHostConfig: KnownLoginHostConfig, + usesWelcomeDiscovery: Boolean = false, + isMultiUser: Boolean = false, + ) { assert(ua.contains("SalesforceMobileSDK/")) { "User agent missing 'SalesforceMobileSDK/' prefix: $ua" } diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index 6c976e8d6a..f476940530 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -215,10 +215,9 @@ abstract class AuthFlowTest { } app.waitForAppLoad() - app.validateUser(knownLoginHostConfig, knownUserConfig) + app.validateUser(knownLoginHostConfig, knownUserConfig, useWelcomeDiscovery, isMultiUser) app.validateOAuthValues(knownAppConfig, scopeSelection) app.validateApiRequest() - app.validateUserAgent(knownLoginHostConfig, useWelcomeDiscovery, isMultiUser) } companion object { From 3f882866913ee53770648661045730bd7d119407 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 25 Jun 2026 17:04:59 -0600 Subject: [PATCH 07/15] fix(W-23159744): update AuthenticationUtilitiesTest to use two-arg registerUsedAppFeature handleScreenLockPolicy and handleBiometricAuthPolicy now call registerUsedAppFeature(feature, account) / unregisterUsedAppFeature(feature, account). Update the 6 mock verifications accordingly. --- .../androidsdk/auth/AuthenticationUtilitiesTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 40bfac037a..c4058c14fe 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt @@ -451,7 +451,7 @@ class AuthenticationUtilitiesTest { com.salesforce.androidsdk.auth.handleScreenLockPolicy(userIdentity, account) // Then - verify { mockSdkManager.registerUsedAppFeature(FEATURE_SCREEN_LOCK) } + verify { mockSdkManager.registerUsedAppFeature(FEATURE_SCREEN_LOCK, account) } verify { mockScreenLockManager.storeMobilePolicy(account, enabled = true, 600000) } } @@ -472,7 +472,7 @@ class AuthenticationUtilitiesTest { com.salesforce.androidsdk.auth.handleScreenLockPolicy(userIdentity, account) // Then - verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_SCREEN_LOCK) } + verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_SCREEN_LOCK, account) } verify { mockScreenLockManager.cleanUp(account) } } @@ -511,7 +511,7 @@ class AuthenticationUtilitiesTest { com.salesforce.androidsdk.auth.handleScreenLockPolicy(null, account) // Then - verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_SCREEN_LOCK) } + verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_SCREEN_LOCK, account) } verify { mockScreenLockManager.cleanUp(account) } } @@ -555,7 +555,7 @@ class AuthenticationUtilitiesTest { com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(userIdentity, account) // Then - verify { mockSdkManager.registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH) } + verify { mockSdkManager.registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH, account) } verify { mockBioAuthManager.storeMobilePolicy(account, enabled = true, 900000) } } @@ -576,7 +576,7 @@ class AuthenticationUtilitiesTest { com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(userIdentity, account) // Then - verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH) } + verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH, account) } verify { mockBioAuthManager.cleanUp(account) } } @@ -617,7 +617,7 @@ class AuthenticationUtilitiesTest { com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(null, account) // Then - verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH) } + verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH, account) } verify { mockBioAuthManager.cleanUp(account) } } From 8fc58b988827cf68385bfa9a234515324cc413f0 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 26 Jun 2026 06:48:14 -0600 Subject: [PATCH 08/15] =?UTF-8?q?ci:=20retrigger=20=E2=80=94=20MobileSync/?= =?UTF-8?q?Hybrid=20org=20flakes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From ede4794cace03030a2fc4bfc23bce226593ac855 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 26 Jun 2026 12:01:33 -0600 Subject: [PATCH 09/15] =?UTF-8?q?ci:=20retrigger=20=E2=80=94=20RestClientT?= =?UTF-8?q?est/MobileSync=20org=20rate-limit=20flakes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 3f14a834bd014775b71c7a265ee3434eb6a7f88b Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 26 Jun 2026 16:15:12 -0600 Subject: [PATCH 10/15] fix(W-23159744): pass expectAdvancedAuth to validateUserAgent for loginForAdmin LoginForAdminTests use a regular login host but browser-based auth, which sets the BW flag. validateUserAgent was deciding BW solely from loginHostConfig, causing LoginForAdminTests to fail. Now callers pass expectAdvancedAuth (true when loginForAdmin || loginHostConfig == ADVANCED_AUTH) and the UA check uses that instead of re-deriving from the host. --- .../pageObjects/AuthFlowTesterPageObject.kt | 19 ++++++++++++------- .../testUtility/AuthFlowTest.kt | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt index 63fbc689f1..062de79387 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt @@ -270,6 +270,7 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject knownUserConfig: KnownUserConfig, usesWelcomeDiscovery: Boolean = false, isMultiUser: Boolean = false, + expectAdvancedAuth: Boolean = false, ) { val expected = testConfig.getUser(knownLoginHostConfig, knownUserConfig) @@ -306,7 +307,7 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject // Validate feature flags — UI is already settled, reuse the existing layout traversal expandUserCredentialsSection(targetNode = USER_AGENT_CONTENT_DESC) - validateUserAgent(getText(USER_AGENT_CONTENT_DESC), knownLoginHostConfig, usesWelcomeDiscovery, isMultiUser) + validateUserAgent(getText(USER_AGENT_CONTENT_DESC), knownLoginHostConfig, usesWelcomeDiscovery, isMultiUser, expectAdvancedAuth) } fun validateOAuthValues(knownAppConfig: KnownAppConfig, scopeSelection: ScopeSelection) { @@ -494,9 +495,10 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject knownLoginHostConfig: KnownLoginHostConfig, usesWelcomeDiscovery: Boolean = false, isMultiUser: Boolean = false, + expectAdvancedAuth: Boolean = false, ) { expandUserCredentialsSection(targetNode = USER_AGENT_CONTENT_DESC) - validateUserAgent(getText(USER_AGENT_CONTENT_DESC), knownLoginHostConfig, usesWelcomeDiscovery, isMultiUser) + validateUserAgent(getText(USER_AGENT_CONTENT_DESC), knownLoginHostConfig, usesWelcomeDiscovery, isMultiUser, expectAdvancedAuth) } private fun validateUserAgent( @@ -504,6 +506,7 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject knownLoginHostConfig: KnownLoginHostConfig, usesWelcomeDiscovery: Boolean = false, isMultiUser: Boolean = false, + expectAdvancedAuth: Boolean = false, ) { assert(ua.contains("SalesforceMobileSDK/")) { "User agent missing 'SalesforceMobileSDK/' prefix: $ua" @@ -516,12 +519,14 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject val ftrSegment = ua.substringAfter("ftr_").substringBefore(" ") val flags = ftrSegment.split(".").toSet() - when (knownLoginHostConfig) { - KnownLoginHostConfig.ADVANCED_AUTH -> assert("BW" in flags) { - "Expected 'BW' flag for ADVANCED_AUTH in: $ua" + val shouldHaveBW = expectAdvancedAuth || knownLoginHostConfig == KnownLoginHostConfig.ADVANCED_AUTH + if (shouldHaveBW) { + assert("BW" in flags) { + "Expected 'BW' flag for browser-based auth in: $ua" } - KnownLoginHostConfig.REGULAR_AUTH -> assert("BW" !in flags) { - "Expected no 'BW' flag for REGULAR_AUTH in: $ua" + } else { + assert("BW" !in flags) { + "Expected no 'BW' flag for in-app WebView auth in: $ua" } } diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index f476940530..c4703e83fd 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -259,7 +259,7 @@ abstract class AuthFlowTest { AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(ADVANCED_AUTH) app.waitForAppLoad() - app.validateUser(REGULAR_AUTH, user) + app.validateUser(REGULAR_AUTH, user, expectAdvancedAuth = true) app.validateOAuthValues(KnownAppConfig.BEACON_OPAQUE, scopeSelection = EMPTY) app.validateApiRequest() } From 6ece8ae1fcffecd5c3ae5069a4f6bde77995b63e Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 26 Jun 2026 17:24:42 -0600 Subject: [PATCH 11/15] =?UTF-8?q?ci:=20retrigger=20=E2=80=94=20pick=20up?= =?UTF-8?q?=20full=20UI=20suite=20for=20AuthFlowTester=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c84ded02efd2d0088795b2b055db529987e1efae Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 26 Jun 2026 18:08:06 -0600 Subject: [PATCH 12/15] test: update @Ignore message for RTRLoginTests to reference W-22512846 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the TODO actionable — re-enable once server enables Named JWTs for Hybrid Flows. --- .../com/salesforce/samples/authflowtester/RTRLoginTests.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RTRLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RTRLoginTests.kt index eddaf07d3a..440e768515 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RTRLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RTRLoginTests.kt @@ -46,10 +46,9 @@ class RTRLoginTests : AuthFlowTest() { // region ECA JWT RTR Tests - // Login with ECA JWT RTR using hybrid auth token flow. - // Expected to fail until W-22512846 (Enable Named JWTs for Hybrid Flows) is resolved. - // The server currently returns invalid_grant when RTR is used with JWT tokens in hybrid flow. - @Ignore("Won't pass until server completes W-22512846") + // TODO: W-22512846 — Re-enable when server enables Named JWTs for Hybrid Flows. + // Server currently returns invalid_grant for RTR + JWT tokens in hybrid flow. + @Ignore("TODO: W-22512846 — Re-enable when server enables Named JWTs for Hybrid Flows") @Test fun testECAJwtRtr_Hybrid() { loginAndValidate(knownAppConfig = ECA_JWT_RTR) From 516e18a4048a3f22215cf8e79ae2b21808e57ada Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 26 Jun 2026 18:36:15 -0600 Subject: [PATCH 13/15] =?UTF-8?q?ci:=20retrigger=20=E2=80=94=20pick=20up?= =?UTF-8?q?=20run=5Fall=5Fui=5Ftests=20workflow=20fix=20from=20#2942?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c2a1137e5b2761436975bdc0620699564b903e33 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Sat, 27 Jun 2026 12:12:18 -0600 Subject: [PATCH 14/15] test(W-21167151): verify BW and WD feature flags persist across app restart Adds LoginWithRestartTests with four tests mirroring iOS: - testCAOpaque_WithRestart / testECAOpaque_WithRestart: session persistence baselines - testAdvancedAuth_WithRestart: login via advanced auth (BW flag), force-stop, relaunch, assert BW persists - testWelcomeDiscovery_WithRestart: login via welcome discovery (WD flag), force-stop, relaunch, assert WD persists Also adds restartAndValidateUser helper to AuthFlowTest that uses `am force-stop` via UiAutomator to fully kill the process before relaunching, exercising the same persistence code path as a real device restart. --- .../authflowtester/LoginWithRestartTests.kt | 109 ++++++++++++++++++ .../testUtility/AuthFlowTest.kt | 35 ++++++ 2 files changed, 144 insertions(+) create mode 100644 native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginWithRestartTests.kt diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginWithRestartTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginWithRestartTests.kt new file mode 100644 index 0000000000..1b529df03c --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginWithRestartTests.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.samples.authflowtester + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import com.salesforce.samples.authflowtester.testUtility.AuthFlowTest +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.CA_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for verifying that user sessions and per-user feature flags persist across app restarts. + * + * Mirrors iOS `LoginWithRestartTests.swift`. Each test logs in, force-stops the process, + * relaunches, and asserts that both the session credentials and the feature flags encoded + * in the user agent string are reloaded correctly from disk. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class LoginWithRestartTests : AuthFlowTest() { + + // MARK: - Session Persistence + + /** Login with CA Opaque, restart app, verify session persists. */ + @Test + fun testCAOpaque_WithRestart() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + ) + restartAndValidateUser( + knownAppConfig = CA_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + ) + } + + /** Login with ECA Opaque, restart app, verify session persists. */ + @Test + fun testECAOpaque_WithRestart() { + loginAndValidate( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + ) + restartAndValidateUser( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + ) + } + + // MARK: - Feature Flag Persistence After Restart + + /** Login via advanced auth (BW flag set), restart app, verify BW flag persists in user agent. */ + @Test + fun testAdvancedAuth_WithRestart() { + loginAndValidate( + knownAppConfig = BEACON_OPAQUE, + knownLoginHostConfig = ADVANCED_AUTH, + ) + restartAndValidateUser( + knownAppConfig = BEACON_OPAQUE, + knownLoginHostConfig = ADVANCED_AUTH, + expectAdvancedAuth = true, + ) + } + + /** Login via welcome discovery (WD flag set), restart app, verify WD flag persists in user agent. */ + @Test + fun testWelcomeDiscovery_WithRestart() { + loginAndValidate( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + useWelcomeDiscovery = true, + ) + restartAndValidateUser( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + usesWelcomeDiscovery = true, + ) + } +} diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index c4703e83fd..fa54e306b9 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -35,6 +35,7 @@ import androidx.test.espresso.Espresso import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.UiDevice import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.samples.authflowtester.AuthFlowTesterActivity import com.salesforce.samples.authflowtester.pageObjects.AuthFlowTesterPageObject @@ -220,6 +221,40 @@ abstract class AuthFlowTest { app.validateApiRequest() } + /** + * Force-stops and relaunches the app, then validates the persisted user session. + * + * Mirrors iOS `restartAndValidateUser`. Uses `am force-stop` via UiAutomator to fully + * kill the process (not just recreate the activity), then relaunches via an explicit + * intent so the SDK reads all state from disk, exercising the same persistence code + * path as a real device restart. + */ + fun restartAndValidateUser( + knownAppConfig: KnownAppConfig, + knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + knownUserConfig: KnownUserConfig = user, + usesWelcomeDiscovery: Boolean = false, + expectAdvancedAuth: Boolean = false, + ) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val packageName = context.packageName + + // Kill the app process entirely so the SDK must reload state from disk. + UiDevice.getInstance(instrumentation).executeShellCommand("am force-stop $packageName") + Thread.sleep(1_000) + + // Relaunch via explicit intent (mirrors how the ActivityScenarioRule would launch it). + val launchIntent = Intent(context, AuthFlowTesterActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + putExtra(AuthFlowTesterActivity.EXTRA_IS_UI_TESTING, true) + } + context.startActivity(launchIntent) + + app.waitForAppLoad() + app.validateUser(knownLoginHostConfig, knownUserConfig, usesWelcomeDiscovery, expectAdvancedAuth = expectAdvancedAuth) + } + companion object { @VisibleForTesting const val WELCOME_DISCOVERY_URL = "https://welcome.salesforce.com/discovery" From f629125eeefcb3db372e713a29f21f317e14055b Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Sat, 27 Jun 2026 12:23:42 -0600 Subject: [PATCH 15/15] test(W-21167151): full iOS parity for LoginWithRestartTests + extract restart helpers AuthFlowTest: - Add restartApp() helper (am force-stop + relaunch via explicit intent) - restartAndValidateUser() now delegates to restartApp() - Add addOtherUserAndValidate() and switchToUserAndValidateUser() helpers for multi-user restart tests LoginWithRestartTests: - Full parity with iOS: CA, ECA, Beacon (static + dynamic + subset scopes), advanced auth (BW), welcome discovery (WD), multi-user restart --- .../authflowtester/LoginWithRestartTests.kt | 121 +++++++++++++++++- .../testUtility/AuthFlowTest.kt | 70 +++++++--- 2 files changed, 170 insertions(+), 21 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginWithRestartTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginWithRestartTests.kt index 1b529df03c..584b1838b0 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginWithRestartTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginWithRestartTests.kt @@ -29,30 +29,35 @@ package com.salesforce.samples.authflowtester import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import com.salesforce.samples.authflowtester.testUtility.AuthFlowTest +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_JWT import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_OPAQUE import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.CA_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.SUBSET import org.junit.Test import org.junit.runner.RunWith /** * Tests for verifying that user sessions and per-user feature flags persist across app restarts. * - * Mirrors iOS `LoginWithRestartTests.swift`. Each test logs in, force-stops the process, - * relaunches, and asserts that both the session credentials and the feature flags encoded - * in the user agent string are reloaded correctly from disk. + * Mirrors iOS `LoginWithRestartTests.swift`. Each test logs in, force-stops the process via + * `am force-stop`, relaunches, and asserts that both the session credentials and the feature + * flags encoded in the user agent string are reloaded correctly from disk. + * + * NB: Tests use the `user` and `otherUser` derived from the device SDK level (see AuthFlowTest). */ @RunWith(AndroidJUnit4::class) @LargeTest class LoginWithRestartTests : AuthFlowTest() { - // MARK: - Session Persistence + // MARK: - Legacy Login Persistence /** Login with CA Opaque, restart app, verify session persists. */ @Test - fun testCAOpaque_WithRestart() { + fun testCAOpaque_DefaultScopes_WithRestart() { loginAndValidate( knownAppConfig = CA_OPAQUE, knownLoginHostConfig = REGULAR_AUTH, @@ -63,9 +68,11 @@ class LoginWithRestartTests : AuthFlowTest() { ) } + // MARK: - ECA Login Persistence + /** Login with ECA Opaque, restart app, verify session persists. */ @Test - fun testECAOpaque_WithRestart() { + fun testECAOpaque_DefaultScopes_WithRestart() { loginAndValidate( knownAppConfig = ECA_OPAQUE, knownLoginHostConfig = REGULAR_AUTH, @@ -76,6 +83,79 @@ class LoginWithRestartTests : AuthFlowTest() { ) } + // MARK: - Beacon Login Persistence + + /** Login with Beacon Opaque, restart app, verify session and child key persist. */ + @Test + fun testBeaconOpaque_DefaultScopes_WithRestart() { + loginAndValidate( + knownAppConfig = BEACON_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + ) + restartAndValidateUser( + knownAppConfig = BEACON_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + ) + } + + // MARK: - ECA Dynamic Configuration + + /** Login with ECA JWT via dynamic config, restart app, verify session persists. */ + @Test + fun testECAJwt_DefaultScopes_DynamicConfiguration_WithRestart() { + loginAndValidate( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = REGULAR_AUTH, + ) + restartAndValidateUser( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = REGULAR_AUTH, + ) + } + + /** Login with ECA JWT using subset scopes via dynamic config, restart app, verify session persists. */ + @Test + fun testECAJwt_SubsetScopes_DynamicConfiguration_WithRestart() { + loginAndValidate( + knownAppConfig = ECA_JWT, + scopeSelection = SUBSET, + knownLoginHostConfig = REGULAR_AUTH, + ) + restartAndValidateUser( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = REGULAR_AUTH, + ) + } + + // MARK: - Beacon Dynamic Configuration + + /** Login with Beacon JWT via dynamic config, restart app, verify session persists. */ + @Test + fun testBeaconJwt_DefaultScopes_DynamicConfiguration_WithRestart() { + loginAndValidate( + knownAppConfig = BEACON_JWT, + knownLoginHostConfig = REGULAR_AUTH, + ) + restartAndValidateUser( + knownAppConfig = BEACON_JWT, + knownLoginHostConfig = REGULAR_AUTH, + ) + } + + /** Login with Beacon JWT using subset scopes via dynamic config, restart app, verify session persists. */ + @Test + fun testBeaconJwt_SubsetScopes_DynamicConfiguration_WithRestart() { + loginAndValidate( + knownAppConfig = BEACON_JWT, + scopeSelection = SUBSET, + knownLoginHostConfig = REGULAR_AUTH, + ) + restartAndValidateUser( + knownAppConfig = BEACON_JWT, + knownLoginHostConfig = REGULAR_AUTH, + ) + } + // MARK: - Feature Flag Persistence After Restart /** Login via advanced auth (BW flag set), restart app, verify BW flag persists in user agent. */ @@ -106,4 +186,33 @@ class LoginWithRestartTests : AuthFlowTest() { usesWelcomeDiscovery = true, ) } + + // MARK: - Multi-User Restart + + /** Login two users, restart app, verify both sessions and user agent flags persist. */ + @Test + fun testMultiUserRestart() { + // Login user A + loginAndValidate( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + ) + + // Login user B + addOtherUserAndValidate( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = REGULAR_AUTH, + ) + + // Force-stop and relaunch — credentials must reload from disk + restartApp() + + // Verify and switch to user A + switchToUserAndValidateUser(user) + app.validateApiRequest() + + // Verify and switch to user B + switchToUserAndValidateUser(otherUser) + app.validateApiRequest() + } } diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index fa54e306b9..9d59732d5f 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -222,39 +222,79 @@ abstract class AuthFlowTest { } /** - * Force-stops and relaunches the app, then validates the persisted user session. + * Force-stops and relaunches the app process. * - * Mirrors iOS `restartAndValidateUser`. Uses `am force-stop` via UiAutomator to fully - * kill the process (not just recreate the activity), then relaunches via an explicit - * intent so the SDK reads all state from disk, exercising the same persistence code - * path as a real device restart. + * Uses `am force-stop` via UiAutomator shell so the SDK must reload all state from disk, + * exercising the same persistence code path as a real device restart. The instrumentation + * process stays alive; the Compose/UiAutomator bridge reattaches by package name. */ - fun restartAndValidateUser( - knownAppConfig: KnownAppConfig, - knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, - knownUserConfig: KnownUserConfig = user, - usesWelcomeDiscovery: Boolean = false, - expectAdvancedAuth: Boolean = false, - ) { + fun restartApp() { val instrumentation = InstrumentationRegistry.getInstrumentation() val context = instrumentation.targetContext val packageName = context.packageName - // Kill the app process entirely so the SDK must reload state from disk. UiDevice.getInstance(instrumentation).executeShellCommand("am force-stop $packageName") Thread.sleep(1_000) - // Relaunch via explicit intent (mirrors how the ActivityScenarioRule would launch it). val launchIntent = Intent(context, AuthFlowTesterActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) putExtra(AuthFlowTesterActivity.EXTRA_IS_UI_TESTING, true) } context.startActivity(launchIntent) - app.waitForAppLoad() + } + + /** + * Force-stops and relaunches the app, then validates the persisted user session. + * + * Mirrors iOS `restartAndValidateUser`. + */ + fun restartAndValidateUser( + knownAppConfig: KnownAppConfig, + knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + knownUserConfig: KnownUserConfig = user, + usesWelcomeDiscovery: Boolean = false, + expectAdvancedAuth: Boolean = false, + ) { + restartApp() app.validateUser(knownLoginHostConfig, knownUserConfig, usesWelcomeDiscovery, expectAdvancedAuth = expectAdvancedAuth) } + /** + * Adds a second account by tapping "Add New Account", logs in, and validates. + * Mirrors iOS `loginOtherUserAndValidate`. + */ + fun addOtherUserAndValidate( + knownAppConfig: KnownAppConfig, + scopeSelection: ScopeSelection = EMPTY, + useWebServerFlow: Boolean = true, + useHybridAuthToken: Boolean = true, + knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + ) { + app.addNewAccount() + loginAndValidate( + knownAppConfig = knownAppConfig, + scopeSelection = scopeSelection, + useWebServerFlow = useWebServerFlow, + useHybridAuthToken = useHybridAuthToken, + knownLoginHostConfig = knownLoginHostConfig, + knownUserConfig = otherUser, + isMultiUser = true, + ) + } + + /** + * Switches to a user already logged in and validates. Mirrors iOS `switchToUserAndValidateUser`. + */ + fun switchToUserAndValidateUser( + knownUserConfig: KnownUserConfig, + knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + ) { + app.switchToUser(knownUserConfig) + composeTestRule.waitForIdle() + app.validateUser(knownLoginHostConfig, knownUserConfig, isMultiUser = true) + } + companion object { @VisibleForTesting const val WELCOME_DISCOVERY_URL = "https://welcome.salesforce.com/discovery"