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..f9a8c70ba4 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 @@ -1272,8 +1276,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 +1302,10 @@ open class SalesforceSDKManager protected constructor( appVersion, "$appType$qualifier", deviceId, - join(".", features), + join(".", allFeatures), SECURITY_PATCH ) + } /** The app version */ val appVersion: String @@ -1321,6 +1342,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" @@ -1788,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/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 dfb9cc2077..32b6ebfb26 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..20b1ec3802 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,32 @@ 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: 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 { + 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 +812,8 @@ 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 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 + } + } + } } 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) } } 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..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 @@ -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, ) } @@ -369,7 +371,7 @@ class MultiUserLoginTests: AuthFlowTest() { ) { app.switchToUser(knownUserConfig) composeTestRule.waitForIdle() - app.validateUser(knownLoginHostConfig, knownUserConfig) + app.validateUser(knownLoginHostConfig, knownUserConfig, isMultiUser = true) } /** @@ -395,6 +397,44 @@ 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) + + // Switch back to User B — BW back, MU still present + switchToUserAndValidate(otherUser, ADVANCED_AUTH) + + // 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 { 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..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 @@ -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 @@ -264,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 { @@ -297,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) { @@ -479,4 +489,52 @@ 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) + 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" + } + 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..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 @@ -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) @@ -214,7 +215,7 @@ abstract class AuthFlowTest { } app.waitForAppLoad() - app.validateUser(knownLoginHostConfig, knownUserConfig) + app.validateUser(knownLoginHostConfig, knownUserConfig, useWelcomeDiscovery, isMultiUser) app.validateOAuthValues(knownAppConfig, scopeSelection) app.validateApiRequest() } 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)) } }