Skip to content

Commit 28eb780

Browse files
authored
Merge pull request #2937 from wmathurin/W-21167151-feature-flags-per-user
feat(W-23159744): per-user persistent feature flags (Android)
2 parents 07b107f + a7fb8d0 commit 28eb780

25 files changed

Lines changed: 827 additions & 31 deletions

File tree

libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/LayoutSyncManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ class LayoutSyncManager private constructor(
307307
store,
308308
syncManager
309309
).also { INSTANCES[uniqueId] = it }
310-
SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_LAYOUT_SYNC)
310+
SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_LAYOUT_SYNC, user)
311311
return instance
312312
}
313313

libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/MetadataSyncManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ class MetadataSyncManager private constructor(
236236
INSTANCES[uniqueId] = it
237237
}
238238
SalesforceSDKManager.getInstance()
239-
.registerUsedAppFeature(Features.FEATURE_METADATA_SYNC)
239+
.registerUsedAppFeature(Features.FEATURE_METADATA_SYNC, user)
240240
return instance
241241
}
242242

libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/manager/SyncManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,7 @@ class SyncManager private constructor(smartStore: SmartStore, restClient: RestCl
827827
instance = SyncManager(store, restClient)
828828
instance.also { INSTANCES[uniqueId] = it }
829829
}
830-
SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_MOBILE_SYNC)
830+
SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_MOBILE_SYNC, user)
831831
return instance
832832
}
833833

libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/app/SalesforceHybridSDKManager.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
import androidx.annotation.NonNull;
3333

34+
import com.salesforce.androidsdk.accounts.UserAccount;
3435
import com.salesforce.androidsdk.config.BootConfig;
3536
import com.salesforce.androidsdk.mobilesync.app.MobileSyncSDKManager;
3637
import com.salesforce.androidsdk.mobilesync.config.SyncsConfig;
@@ -148,13 +149,19 @@ public static SalesforceHybridSDKManager getInstance() {
148149
@NonNull
149150
@Override
150151
public final String getUserAgent(@NonNull String qualifier) {
152+
return getUserAgent(qualifier, null);
153+
}
154+
155+
@NonNull
156+
@Override
157+
public final String getUserAgent(@NonNull String qualifier, @androidx.annotation.Nullable UserAccount user) {
151158
final BootConfig config = BootConfig.getBootConfig(context);
152159
if (config.isLocal()) {
153160
qualifier = qualifier + "Local";
154161
} else {
155162
qualifier = qualifier + "Remote";
156163
}
157-
return super.getUserAgent(qualifier);
164+
return super.getUserAgent(qualifier, user);
158165
}
159166

160167
@NonNull

libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccount.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,12 @@
4949
import org.json.JSONObject;
5050

5151
import java.io.File;
52+
import java.util.Collections;
53+
import java.util.ArrayList;
54+
import java.util.HashSet;
5255
import java.util.List;
5356
import java.util.Map;
57+
import java.util.Set;
5458

5559
/**
5660
* This class represents a single user account that is currently
@@ -100,6 +104,7 @@ public class UserAccount {
100104
public static final String BEACON_CHILD_CONSUMER_KEY = "auto_installed_app_org_consumer_key";
101105
public static final String BEACON_CHILD_CONSUMER_SECRET = "auto_installed_app_org_consumer_secret";
102106
public static final String SCOPE = "scope";
107+
public static final String FEATURE_FLAGS = "feature_flags";
103108

104109
private static final String TAG = "UserAccount";
105110
private static final String FORWARD_SLASH = "/";
@@ -147,6 +152,7 @@ public class UserAccount {
147152
private String beaconChildConsumerKey;
148153
private String beaconChildConsumerSecret;
149154
private String scope;
155+
private Set<String> featureFlags = new java.util.HashSet<>();
150156

151157
/**
152158
* Parameterized constructor.
@@ -760,6 +766,24 @@ public Map<String, String> getAdditionalOauthValues() {
760766
return additionalOauthValues;
761767
}
762768

769+
/**
770+
* Returns the persisted per-user feature flags (e.g. BW, SU, MS).
771+
*
772+
* @return Unmodifiable set of feature flag codes.
773+
*/
774+
public Set<String> getFeatureFlags() {
775+
return Collections.unmodifiableSet(featureFlags);
776+
}
777+
778+
/**
779+
* Replaces the in-memory set of persisted per-user feature flags.
780+
*
781+
* @param flags The new set of feature flags.
782+
*/
783+
public void setFeatureFlags(Set<String> flags) {
784+
featureFlags = flags != null ? new HashSet<>(flags) : new HashSet<>();
785+
}
786+
763787
/**
764788
* Fetches this user's profile photo from the cache.
765789
*
@@ -1024,6 +1048,11 @@ JSONObject toJson(List<String> additionalOauthKeys) {
10241048
object.put(BEACON_CHILD_CONSUMER_KEY, beaconChildConsumerKey);
10251049
object.put(BEACON_CHILD_CONSUMER_SECRET, beaconChildConsumerSecret);
10261050
object.put(SCOPE, scope);
1051+
if (!featureFlags.isEmpty()) {
1052+
org.json.JSONArray flagsArray = new org.json.JSONArray();
1053+
for (String f : featureFlags) flagsArray.put(f);
1054+
object.put(FEATURE_FLAGS, flagsArray);
1055+
}
10271056
object = MapUtil.addMapToJSONObject(additionalOauthValues, additionalOauthKeys, object);
10281057
} catch (JSONException e) {
10291058
SalesforceSDKLogger.e(TAG, "Unable to convert to JSON", e);

libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,12 @@
5050
import com.salesforce.androidsdk.util.SalesforceSDKLogger;
5151

5252
import java.util.ArrayList;
53+
import java.util.Arrays;
5354
import java.util.HashMap;
55+
import java.util.HashSet;
5456
import java.util.List;
5557
import java.util.Map;
58+
import java.util.Set;
5659

5760
/**
5861
* This class acts as a manager that provides methods to access
@@ -553,6 +556,7 @@ public Bundle updateAccount(Account account, UserAccount userAccount) {
553556
final String beaconChildConsumerKey = decryptUserData(account, AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_KEY, encryptionKey);
554557
final String beaconChildConsumerSecret = decryptUserData(account, AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_SECRET, encryptionKey);
555558
final String scope = decryptUserData(account, AuthenticatorService.KEY_SCOPE, encryptionKey);
559+
final String featureFlagsRaw = decryptUserData(account, AuthenticatorService.KEY_FEATURE_FLAGS, encryptionKey);
556560

557561
Map<String, String> additionalOauthValues = null;
558562
List<String> additionalOauthKeys = SalesforceSDKManager.getInstance().getAdditionalOauthKeys();
@@ -571,7 +575,7 @@ public Bundle updateAccount(Account account, UserAccount userAccount) {
571575
if (authToken == null || instanceServer == null || userId == null || orgId == null) {
572576
return null;
573577
} else {
574-
return UserAccountBuilder.getInstance()
578+
final UserAccount userAccount = UserAccountBuilder.getInstance()
575579
.authToken(authToken)
576580
.refreshToken(refreshToken)
577581
.loginServer(loginServer)
@@ -610,6 +614,10 @@ public Bundle updateAccount(Account account, UserAccount userAccount) {
610614
.scope(scope)
611615
.additionalOauthValues(additionalOauthValues)
612616
.build();
617+
if (!TextUtils.isEmpty(featureFlagsRaw)) {
618+
userAccount.setFeatureFlags(new HashSet<>(Arrays.asList(featureFlagsRaw.split(","))));
619+
}
620+
return userAccount;
613621
}
614622
}
615623

@@ -758,6 +766,11 @@ private Bundle buildAuthBundle(UserAccount userAccount) {
758766
extras.putString(AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_KEY, SalesforceSDKManager.encrypt(userAccount.getBeaconChildConsumerKey(), encryptionKey));
759767
extras.putString(AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_SECRET, SalesforceSDKManager.encrypt(userAccount.getBeaconChildConsumerSecret(), encryptionKey));
760768
extras.putString(AuthenticatorService.KEY_SCOPE, SalesforceSDKManager.encrypt(userAccount.getScope(), encryptionKey));
769+
final Set<String> featureFlags = userAccount.getFeatureFlags();
770+
if (!featureFlags.isEmpty()) {
771+
extras.putString(AuthenticatorService.KEY_FEATURE_FLAGS,
772+
SalesforceSDKManager.encrypt(android.text.TextUtils.join(",", featureFlags), encryptionKey));
773+
}
761774

762775
final List<String> additionalOauthKeys = SalesforceSDKManager.getInstance().getAdditionalOauthKeys();
763776
if (additionalOauthKeys != null && !additionalOauthKeys.isEmpty()) {

libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ import java.net.URI
154154
import java.util.Locale.US
155155
import java.util.SortedSet
156156
import java.util.UUID.randomUUID
157+
import java.util.concurrent.ConcurrentHashMap
157158
import java.util.concurrent.ConcurrentSkipListSet
158159
import java.util.regex.Pattern
159160
import com.salesforce.androidsdk.auth.idp.IDPManager as DefaultIDPManager
@@ -408,6 +409,9 @@ open class SalesforceSDKManager protected constructor(
408409
/** App feature codes for reporting in the user agent header */
409410
private val features: SortedSet<String?>
410411

412+
/** Per-user feature codes keyed by "orgId/userId" */
413+
private val perUserFeatures: ConcurrentHashMap<String, ConcurrentSkipListSet<String>> = ConcurrentHashMap()
414+
411415
/**
412416
* An additional list of OAuth keys to fetch and store from the token
413417
* endpoint
@@ -1272,8 +1276,24 @@ open class SalesforceSDKManager protected constructor(
12721276
* @param qualifier The user agent qualifier
12731277
* @return The user agent string to use for all requests
12741278
*/
1275-
open fun getUserAgent(qualifier: String) =
1276-
String.format(
1279+
open fun getUserAgent(qualifier: String) = getUserAgent(qualifier, null)
1280+
1281+
/**
1282+
* Returns a per-user agent string. Feature flags include both global and user-specific codes.
1283+
*
1284+
* @param qualifier The user agent qualifier
1285+
* @param user The user account, or null to use the current user
1286+
* @return The user agent string to use for all requests
1287+
*/
1288+
open fun getUserAgent(qualifier: String, user: UserAccount?) : String {
1289+
val resolvedUser = user ?: userAccountManager.currentUser
1290+
val userKey = resolvedUser?.let { "${it.orgId}/${it.userId}" }
1291+
val userFeatures = userKey?.let { perUserFeatures[it] } ?: emptySet<String>()
1292+
val allFeatures = ConcurrentSkipListSet<String>(CASE_INSENSITIVE_ORDER).apply {
1293+
addAll(features.filterNotNull())
1294+
addAll(userFeatures)
1295+
}
1296+
return String.format(
12771297
"SalesforceMobileSDK/%s android mobile/%s (%s) %s/%s %s uid_%s ftr_%s SecurityPatch/%s",
12781298
SDK_VERSION,
12791299
RELEASE,
@@ -1282,9 +1302,10 @@ open class SalesforceSDKManager protected constructor(
12821302
appVersion,
12831303
"$appType$qualifier",
12841304
deviceId,
1285-
join(".", features),
1305+
join(".", allFeatures),
12861306
SECURITY_PATCH
12871307
)
1308+
}
12881309

12891310
/** The app version */
12901311
val appVersion: String
@@ -1321,6 +1342,59 @@ open class SalesforceSDKManager protected constructor(
13211342
fun unregisterUsedAppFeature(appFeatureCode: String?) =
13221343
features.remove(appFeatureCode)
13231344

1345+
/**
1346+
* Returns true if the feature code is in the global (non-user-specific) set.
1347+
* @param appFeatureCode The app feature code
1348+
*/
1349+
fun isGlobalFeatureRegistered(appFeatureCode: String) = features.contains(appFeatureCode)
1350+
1351+
/**
1352+
* Adds a per-user app feature code for reporting in the user agent header.
1353+
* Falls back to the global set when user is null.
1354+
* @param appFeatureCode The app feature code
1355+
* @param user The user account to associate the feature with
1356+
*/
1357+
fun registerUsedAppFeature(appFeatureCode: String, user: UserAccount?) {
1358+
if (user == null) { registerUsedAppFeature(appFeatureCode); return }
1359+
val key = "${user.orgId}/${user.userId}"
1360+
val set = perUserFeatures.getOrPut(key) { ConcurrentSkipListSet(CASE_INSENSITIVE_ORDER) }
1361+
set.add(appFeatureCode)
1362+
persistUserFeatureFlags(user, set)
1363+
}
1364+
1365+
/**
1366+
* Removes a per-user app feature code from reporting in the user agent header.
1367+
* Falls back to the global set when user is null.
1368+
* @param appFeatureCode The app feature code
1369+
* @param user The user account to remove the feature from
1370+
*/
1371+
fun unregisterUsedAppFeature(appFeatureCode: String, user: UserAccount?) {
1372+
if (user == null) { unregisterUsedAppFeature(appFeatureCode); return }
1373+
val key = "${user.orgId}/${user.userId}"
1374+
perUserFeatures[key]?.remove(appFeatureCode)
1375+
persistUserFeatureFlags(user, perUserFeatures[key] ?: emptySet())
1376+
}
1377+
1378+
private fun persistUserFeatureFlags(user: UserAccount, flags: Set<String>) {
1379+
user.featureFlags = HashSet(flags)
1380+
val account = userAccountManager.buildAccount(user) ?: return
1381+
userAccountManager.updateAccount(account, user)
1382+
}
1383+
1384+
/** Hydrates per-user features from persisted accounts at startup */
1385+
private fun hydratePerUserFeatures() {
1386+
val users = userAccountManager.authenticatedUsers ?: return
1387+
for (u in users) {
1388+
val flags = u.featureFlags
1389+
if (flags.isNotEmpty()) {
1390+
val key = "${u.orgId}/${u.userId}"
1391+
val set = ConcurrentSkipListSet<String>(CASE_INSENSITIVE_ORDER)
1392+
set.addAll(flags)
1393+
perUserFeatures[key] = set
1394+
}
1395+
}
1396+
}
1397+
13241398
/** The app type */
13251399
open val appType = "Native"
13261400

@@ -1808,6 +1882,9 @@ open class SalesforceSDKManager protected constructor(
18081882
nativeLoginActivity,
18091883
googleCloudProjectId,
18101884
)
1885+
// Hydrate after INSTANCE is set — UserAccountManager.getInstance() checks
1886+
// SalesforceSDKManager.getInstance() internally, which requires INSTANCE != null.
1887+
INSTANCE?.hydratePerUserFeatures()
18111888
}
18121889
initInternal(context)
18131890
EventsObservable.get().notifyEvent(

libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -391,15 +391,15 @@ internal fun handleScreenLockPolicy(
391391

392392
// compareTo(0) is used to check if screenLockTimeout is non-null and greater than 0.
393393
if (userIdentity?.screenLockTimeout?.compareTo(0) == 1) {
394-
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SCREEN_LOCK)
394+
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SCREEN_LOCK, account)
395395
val timeoutInMills = userIdentity.screenLockTimeout * 1000 * 60
396396
internalScreenLockManager?.storeMobilePolicy(
397397
account,
398398
enabled = userIdentity.screenLock,
399399
timeoutInMills,
400400
)
401401
} else if (internalScreenLockManager?.enabled == true) {
402-
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_SCREEN_LOCK)
402+
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_SCREEN_LOCK, account)
403403
internalScreenLockManager.cleanUp(account)
404404
}
405405
}
@@ -416,15 +416,15 @@ internal fun handleBiometricAuthPolicy(
416416
SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?
417417

418418
if (userIdentity?.biometricAuth == true) {
419-
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH)
419+
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH, account)
420420
val timeoutInMills = userIdentity.biometricAuthTimeout * 60 * 1000
421421
internalBiometricAuthenticationManager?.storeMobilePolicy(
422422
account,
423423
enabled = userIdentity.biometricAuth,
424424
timeoutInMills
425425
)
426426
} else if (internalBiometricAuthenticationManager?.enabled == true) {
427-
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH)
427+
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH, account)
428428
internalBiometricAuthenticationManager.cleanUp(account)
429429
}
430430
}

libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ public class AuthenticatorService extends Service {
9292
public static final String KEY_BEACON_CHILD_CONSUMER_KEY = "auto_installed_app_org_consumer_key";
9393
public static final String KEY_BEACON_CHILD_CONSUMER_SECRET = "auto_installed_app_org_consumer_secret";
9494
public static final String KEY_SCOPE = "scope";
95+
public static final String KEY_FEATURE_FLAGS = "feature_flags";
9596

9697
private static final String TAG = "AuthenticatorService";
9798

libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/RestClient.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,8 @@ public JSONObject getJSONCredentials() {
259259
data.put(LOGIN_URL, clientInfo.loginUrl.toString());
260260
data.put(IDENTITY_URL, clientInfo.identityUrl.toString());
261261
data.put(INSTANCE_URL, clientInfo.instanceUrl.toString());
262-
data.put(USER_AGENT, SalesforceSDKManager.getInstance().getUserAgent());
262+
UserAccount currentUser = SalesforceSDKManager.getInstance().getUserAccountManager().getCurrentUser();
263+
data.put(USER_AGENT, SalesforceSDKManager.getInstance().getUserAgent("", currentUser));
263264
data.put(COMMUNITY_ID, clientInfo.communityId);
264265
data.put(COMMUNITY_URL, clientInfo.communityUrl);
265266
return new JSONObject(data);

0 commit comments

Comments
 (0)