diff --git a/SalesforceSDKCore.podspec b/SalesforceSDKCore.podspec index 2e368d539e..7cc1ac0dcd 100644 --- a/SalesforceSDKCore.podspec +++ b/SalesforceSDKCore.podspec @@ -25,7 +25,7 @@ Pod::Spec.new do |s| sdkcore.resources = ['shared/resources/SalesforceSDKAssets.xcassets'] sdkcore.source_files = 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/**/*.{h,m,swift}', 'libs/SalesforceSDKCore/SalesforceSDKCore/SalesforceSDKCore.h' # public_header_files is automatically populated by update_podspec_headers.sh which is run when building SalesforceSDKCore - sdkcore.public_header_files = 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Analytics/SFSDKAILTNPublisher.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Analytics/SFSDKAnalyticsPublisher.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Analytics/SFSDKEventBuilderHelper.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Analytics/SFSDKSalesforceAnalyticsManager.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSData+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSData+SFSDKUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSDictionary+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSString+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSURL+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSURLResponse+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFFormatUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppConfig.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKConstants.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/UIDevice+SFHardware.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/UIScreen+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/IDP/SFSDKLoginFlowSelectionView.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/IDP/SFSDKUITableViewCell.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/IDP/SFSDKUserSelectionNavViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/IDP/SFSDKUserSelectionTableViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/IDP/SFSDKUserSelectionView.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityCoordinator.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityData.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/LoginHost/SFSDKLoginHost.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/LoginHost/SFSDKLoginHostDelegate.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/LoginHost/SFSDKLoginHostListViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/LoginHost/SFSDKLoginHostStorage.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFSDKLoginViewControllerConfig.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthInfo.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthOrgAuthConfiguration.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthSessionRefresher.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/PushNotification/SFSDKPushNotificationDecryption.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/PushNotification/SFSDKPushNotificationError.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/PushNotification/SFSDKPushNotificationFieldsConstants.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFNetwork.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI+Blocks.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI+Files.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI+Notifications.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI+QueryBuilder.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestRequest.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKBatchRequest.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKBatchResponse.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKCollectionResponse.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKCompositeRequest.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKCompositeResponse.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKPrimingRecordsResponse.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSObjectTree.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Security/SFSDKCryptoUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Test/SFSDKTestCredentialsData.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Test/SFSDKTestRequestListener.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Test/TestSetupUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFAuthErrorHandlerList.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccount.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountConstants.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountIdentity.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/ViewControllers/SFDefaultUserManagementDetailViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/ViewControllers/SFDefaultUserManagementListViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/ViewControllers/SFDefaultUserManagementViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/NSURL+SFStringUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFApplicationHelper.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFDirectoryManager.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFManagedPreferences.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFPreferences.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKAuthConfigUtil.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKAuthHelper.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKCoreLogger.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuthConstants.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKResourceUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKSoqlBuilder.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKSoslBuilder.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKSoslReturningBuilder.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKViewControllerConfig.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKWebUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SalesforceSDKCoreDefines.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/UIColor+SFColors.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKAlertMessage.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKAlertMessageBuilder.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKNavigationController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKWindowContainer.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKWindowManager.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/SalesforceSDKCore.h' + sdkcore.public_header_files = 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Analytics/SFSDKAILTNPublisher.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Analytics/SFSDKAnalyticsPublisher.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Analytics/SFSDKEventBuilderHelper.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Analytics/SFSDKSalesforceAnalyticsManager.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSData+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSData+SFSDKUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSDictionary+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSString+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSURL+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/NSURLResponse+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKConstants.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFFormatUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppConfig.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/UIDevice+SFHardware.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/UIScreen+SFAdditions.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityCoordinator.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityData.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/IDP/SFSDKLoginFlowSelectionView.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/IDP/SFSDKUITableViewCell.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/IDP/SFSDKUserSelectionNavViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/IDP/SFSDKUserSelectionTableViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/IDP/SFSDKUserSelectionView.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/LoginHost/SFSDKLoginHost.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/LoginHost/SFSDKLoginHostDelegate.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/LoginHost/SFSDKLoginHostListViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/LoginHost/SFSDKLoginHostStorage.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFSDKLoginViewControllerConfig.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthInfo.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthOrgAuthConfiguration.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthSessionRefresher.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/PushNotification/SFSDKPushNotificationDecryption.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/PushNotification/SFSDKPushNotificationError.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/PushNotification/SFSDKPushNotificationFieldsConstants.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFNetwork.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI+Blocks.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI+Files.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI+Notifications.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI+QueryBuilder.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestRequest.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKBatchRequest.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKBatchResponse.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKCollectionResponse.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKCompositeRequest.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKCompositeResponse.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSDKPrimingRecordsResponse.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFSObjectTree.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Security/SFSDKCryptoUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Test/SFSDKTestCredentialsData.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Test/SFSDKTestRequestListener.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Test/TestSetupUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFAuthErrorHandlerList.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccount.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountConstants.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountIdentity.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/ViewControllers/SFDefaultUserManagementDetailViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/ViewControllers/SFDefaultUserManagementListViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/ViewControllers/SFDefaultUserManagementViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/NSURL+SFStringUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SalesforceSDKCoreDefines.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFApplicationHelper.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFDirectoryManager.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFManagedPreferences.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFPreferences.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKAuthConfigUtil.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKAuthHelper.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKCoreLogger.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuthConstants.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKResourceUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKSoqlBuilder.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKSoslBuilder.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKSoslReturningBuilder.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKViewControllerConfig.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKWebUtils.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/UIColor+SFColors.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKAlertMessage.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKAlertMessageBuilder.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKNavigationController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKViewController.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKWindowContainer.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Views/SFSDKWindowManager.h', 'libs/SalesforceSDKCore/SalesforceSDKCore/SalesforceSDKCore.h' sdkcore.requires_arc = true sdkcore.prefix_header_contents = '#import "SFSDKCoreLogger.h"', '#import "SalesforceSDKConstants.h"' diff --git a/libs/MobileSync/MobileSync/Classes/Manager/SFMobileSyncSyncManager.m b/libs/MobileSync/MobileSync/Classes/Manager/SFMobileSyncSyncManager.m index 25795086b9..78d2fb1d5a 100644 --- a/libs/MobileSync/MobileSync/Classes/Manager/SFMobileSyncSyncManager.m +++ b/libs/MobileSync/MobileSync/Classes/Manager/SFMobileSyncSyncManager.m @@ -116,7 +116,7 @@ + (instancetype)sharedInstanceForStore:(SFSmartStore *)store { syncMgr = [[self alloc] initWithStore:store]; syncMgrList[key] = syncMgr; } - [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureMobileSync]; + [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureMobileSync forUser:store.user]; return syncMgr; } } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.h index bacd66ca1c..c300c757a2 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.h @@ -24,6 +24,8 @@ #import +@class SFUserAccount; + NS_ASSUME_NONNULL_BEGIN // App Feature Marker Constants @@ -49,22 +51,51 @@ extern NSString * const kSFAppFeatureQrCodeLogin; @interface SFSDKAppFeatureMarkers : NSObject /** - Register a particular app feature. + Register a particular app feature (global — all users). @param appFeature The string representation of the feature to register. */ + (void)registerAppFeature:(nonnull NSString *)appFeature; /** - Unregister a particular app feature. + Unregister a particular app feature (global — all users). @param appFeature The string representation of the feature to unregister. */ + (void)unregisterAppFeature:(nonnull NSString *)appFeature; /** - @return The current set of registered features. + @return The current set of globally registered features. */ + (nonnull NSSet *)appFeatures; +/** + Register a feature for a specific user. If user is nil, registers globally. + @param appFeature The string representation of the feature to register. + @param user The user account to register the feature for, or nil for global registration. + */ ++ (void)registerAppFeature:(nonnull NSString *)appFeature forUser:(nullable SFUserAccount *)user; + +/** + Unregister a feature for a specific user. If user is nil, unregisters globally. + @param appFeature The string representation of the feature to unregister. + @param user The user account to unregister the feature for, or nil for global unregistration. + */ ++ (void)unregisterAppFeature:(nonnull NSString *)appFeature forUser:(nullable SFUserAccount *)user; + +/** + Returns the union of global features and per-user features for the given user. + @param user The user account, or nil to return global features only. + @return The combined set of registered features. + */ ++ (nonnull NSSet *)appFeaturesForUser:(nullable SFUserAccount *)user; + +/** + Populates the in-memory per-user map from persisted flags without triggering a save. + Called during SDK startup after accounts are loaded. + @param features The set of persisted feature flags. + @param user The user account to load flags for. + */ ++ (void)loadPersistedFeatures:(nonnull NSSet *)features forUser:(nonnull SFUserAccount *)user; + @end NS_ASSUME_NONNULL_END diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.m index cf036e0704..c2a8977236 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.m @@ -23,6 +23,8 @@ */ #import "SFSDKAppFeatureMarkers.h" +#import "SFUserAccount.h" +#import "SFUserAccountManager.h" // App Feature Marker Constants NSString * const kSFAppFeatureSwiftApp = @"SW"; @@ -42,6 +44,7 @@ static NSMutableSet *SFSDKAppFeatureMarkersSet = nil; static dispatch_queue_t SFSDKAppFeatureDispatchQueue = nil; +static NSMutableDictionary *> *SFSDKPerUserFeatureMarkersMap = nil; @implementation SFSDKAppFeatureMarkers @@ -53,6 +56,9 @@ + (void)initialize { if (SFSDKAppFeatureDispatchQueue == nil) { SFSDKAppFeatureDispatchQueue = dispatch_queue_create("com.salesforce.mobilesdk.appFeaturesQueue", DISPATCH_QUEUE_SERIAL); } + if (SFSDKPerUserFeatureMarkersMap == nil) { + SFSDKPerUserFeatureMarkersMap = [NSMutableDictionary dictionary]; + } } } @@ -76,4 +82,60 @@ + (NSSet *)appFeatures { return markersSet; } ++ (void)registerAppFeature:(NSString *)appFeature forUser:(SFUserAccount *)user { + if (!user) { + [self registerAppFeature:appFeature]; + return; + } + NSString *key = SFKeyForUserAndScope(user, SFUserAccountScopeUser); + __block NSSet *snapshot = nil; + dispatch_sync(SFSDKAppFeatureDispatchQueue, ^{ + NSMutableSet *set = SFSDKPerUserFeatureMarkersMap[key]; + if (!set) { + set = [NSMutableSet set]; + SFSDKPerUserFeatureMarkersMap[key] = set; + } + [set addObject:appFeature]; + snapshot = [set copy]; + }); + user.persistedFeatureFlags = snapshot; + [[SFUserAccountManager sharedInstance] saveAccountForUser:user error:nil]; +} + ++ (void)unregisterAppFeature:(NSString *)appFeature forUser:(SFUserAccount *)user { + if (!user) { + [self unregisterAppFeature:appFeature]; + return; + } + NSString *key = SFKeyForUserAndScope(user, SFUserAccountScopeUser); + __block NSSet *snapshot = nil; + dispatch_sync(SFSDKAppFeatureDispatchQueue, ^{ + [SFSDKPerUserFeatureMarkersMap[key] removeObject:appFeature]; + snapshot = [SFSDKPerUserFeatureMarkersMap[key] copy]; + }); + user.persistedFeatureFlags = snapshot; + [[SFUserAccountManager sharedInstance] saveAccountForUser:user error:nil]; +} + ++ (NSSet *)appFeaturesForUser:(SFUserAccount *)user { + if (!user) return [self appFeatures]; + __block NSSet *combined; + NSString *key = SFKeyForUserAndScope(user, SFUserAccountScopeUser); + dispatch_sync(SFSDKAppFeatureDispatchQueue, ^{ + NSMutableSet *all = [NSMutableSet setWithSet:SFSDKAppFeatureMarkersSet]; + NSSet *userSet = SFSDKPerUserFeatureMarkersMap[key]; + if (userSet) [all unionSet:userSet]; + combined = [all copy]; + }); + return combined; +} + ++ (void)loadPersistedFeatures:(NSSet *)features forUser:(SFUserAccount *)user { + if (!user || features.count == 0) return; + NSString *key = SFKeyForUserAndScope(user, SFUserAccountScopeUser); + dispatch_sync(SFSDKAppFeatureDispatchQueue, ^{ + SFSDKPerUserFeatureMarkersMap[key] = [features mutableCopy]; + }); +} + @end diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager+Internal.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager+Internal.h index 33c4e6963a..e95f58bbe7 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager+Internal.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager+Internal.h @@ -35,5 +35,6 @@ API_UNAVAILABLE(visionos) - (void)dismissSnapshot:(nonnull UIScene *)scene completion:(void (^ __nullable)(void))completion API_UNAVAILABLE(visionos); - (nonnull NSArray *)getDevActions:(nonnull UIViewController *)presentedViewController; +- (void)hydratePerUserFeatureFlags; @end diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.h index 53475e9afb..9850eeb4c1 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.h @@ -203,6 +203,14 @@ NS_SWIFT_NAME(SalesforceManager) */ @property (nonatomic, copy) SFSDKUserAgentCreationBlock userAgentString NS_SWIFT_NAME(userAgentGenerator); +/** + Returns a user agent string that includes both global and per-user feature flags. + @param qualifier Optional string appended to the app type (e.g., "Local" for hybrid). + @param user The user account whose per-user flags to include, or nil to use the current user. + @return The user agent string for the given user. + */ +- (nonnull NSString *)userAgentString:(nonnull NSString *)qualifier forUser:(nullable SFUserAccount *)user NS_SWIFT_NAME(userAgent(qualifier:for:)); + /** Block to dynamically select the app config at runtime based on login host. diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.m index d25c001b1e..0edc31c367 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.m @@ -237,6 +237,7 @@ + (instancetype)sharedManager { else{ [SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureMultiUser]; } + [sdkManager hydratePerUserFeatureFlags]; }); return sdkManager; } @@ -877,34 +878,48 @@ - (void)clearClipboard } } +- (NSString *)userAgentString:(NSString *)qualifier forUser:(SFUserAccount *)user { + SFUserAccount *resolvedUser = user ?: [SFUserAccountManager sharedInstance].currentUser; + UIDevice *curDevice = [UIDevice currentDevice]; + NSString *appName = [SalesforceSDKManager appName]; + NSString *prodAppVersion = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]; + NSString *buildNumber = [[NSBundle mainBundle] infoDictionary][(NSString*)kCFBundleVersionKey]; + NSString *appVersion = [NSString stringWithFormat:@"%@(%@)", prodAppVersion, buildNumber]; + NSString *appTypeStr = [self getAppTypeAsString]; + NSString *ftr = [[[SFSDKAppFeatureMarkers appFeaturesForUser:resolvedUser].allObjects + sortedArrayUsingSelector:@selector(caseInsensitiveCompare:)] + componentsJoinedByString:@"."]; + return [NSString stringWithFormat: + @"SalesforceMobileSDK/%@ %@/%@ (%@) %@/%@ %@%@ uid_%@ ftr_%@ %@", + SALESFORCE_SDK_VERSION, + [curDevice systemName], + [curDevice systemVersion], + [curDevice model], + appName, + appVersion, + appTypeStr, + (qualifier != nil ? qualifier : @""), + uid, + ftr, + self.webViewUserAgent == nil ? @"" : self.webViewUserAgent]; +} + - (SFSDKUserAgentCreationBlock)defaultUserAgentString { + __weak typeof(self) weakSelf = self; return ^NSString *(NSString *qualifier) { - UIDevice *curDevice = [UIDevice currentDevice]; - NSString *appName = [SalesforceSDKManager appName]; - NSString *prodAppVersion = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]; - NSString *buildNumber = [[NSBundle mainBundle] infoDictionary][(NSString*)kCFBundleVersionKey]; - NSString *appVersion = [NSString stringWithFormat:@"%@(%@)", prodAppVersion, buildNumber]; - - // App type. - NSString *appTypeStr = [self getAppTypeAsString]; - NSString *myUserAgent = [NSString stringWithFormat: - @"SalesforceMobileSDK/%@ %@/%@ (%@) %@/%@ %@%@ uid_%@ ftr_%@ %@", - SALESFORCE_SDK_VERSION, - [curDevice systemName], - [curDevice systemVersion], - [curDevice model], - appName, - appVersion, - appTypeStr, - (qualifier != nil ? qualifier : @""), - uid, - [[[SFSDKAppFeatureMarkers appFeatures].allObjects sortedArrayUsingSelector:@selector(caseInsensitiveCompare:)] componentsJoinedByString:@"."], - self.webViewUserAgent == nil ? @"" : self.webViewUserAgent - ]; - return myUserAgent; + return [weakSelf userAgentString:qualifier forUser:nil]; }; } +- (void)hydratePerUserFeatureFlags { + NSArray *allUsers = [[SFUserAccountManager sharedInstance] allUserAccounts]; + for (SFUserAccount *account in allUsers) { + if (account.persistedFeatureFlags.count > 0) { + [SFSDKAppFeatureMarkers loadPersistedFeatures:account.persistedFeatureFlags forUser:account]; + } + } +} + - (void)computeWebViewUserAgent { static dispatch_once_t onceToken; __weak typeof(self) weakSelf = self; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccount.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccount.h index 125e8e7588..c8d89cd31e 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccount.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccount.h @@ -104,6 +104,10 @@ To set this property use `setPhoto:completion:` */ @property (nonatomic, readonly, assign) SFUserAccountLoginState loginState; +/** Feature flags persisted for this user (e.g. BW, QR). Populated from SFSDKAppFeatureMarkers. + */ +@property (nonatomic, copy, nullable) NSSet *persistedFeatureFlags; + /** Initialize with SFOAuthCredentials credentials @param credentials The credentials to link with the SFUserAccount. @return the account instance diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccount.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccount.m index c8c334252d..17d3e15908 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccount.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccount.m @@ -39,6 +39,7 @@ static NSString * const kCredentialsUserIdPropName = @"userId"; static NSString * const kCredentialsOrgIdPropName = @"organizationId"; static NSString * const kUser_NOTIFICATION_TYPES = @"notificationTypes"; +static NSString * const kUser_FEATURE_FLAGS = @"featureFlags"; static const char * kSyncQueue = "com.salesforce.mobilesdk.sfuseraccount.syncqueue"; /** Key that identifies the global scope @@ -102,6 +103,7 @@ - (void)encodeWithCoder:(NSCoder*)encoder { [encoder encodeObject:self->_customData forKey:kUser_CUSTOM_DATA]; [encoder encodeInteger:self->_accessRestrictions forKey:kUser_ACCESS_RESTRICTIONS]; [encoder encodeObject:self->_notificationTypes forKey:kUser_NOTIFICATION_TYPES]; + [encoder encodeObject:self->_persistedFeatureFlags forKey:kUser_FEATURE_FLAGS]; }); } @@ -121,6 +123,7 @@ - (id)initWithCoder:(NSCoder*)decoder { [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureOAuth]; } _notificationTypes = [decoder decodeObjectOfClasses:[NSSet setWithObjects:[NSArray class], [SFSDKNotificationType class], nil] forKey:kUser_NOTIFICATION_TYPES]; + _persistedFeatureFlags = [decoder decodeObjectOfClasses:[NSSet setWithObjects:[NSSet class], [NSString class], nil] forKey:kUser_FEATURE_FLAGS]; } return self; } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m index 822adf8c26..b71803c863 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m @@ -1972,8 +1972,8 @@ - (void)retrievedIdentityData:(SFSDKAuthSession *)authSession { [bioAuthManager unlockPostProcessing]; } - [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureBioAuth]; - [bioAuthManager storePolicyWithUserAccount:self.currentUser hasMobilePolicy:hasBioAuthPolicy sessionTimeout:sessionTimeout]; + [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureBioAuth forUser:strongSelf.currentUser]; + [bioAuthManager storePolicyWithUserAccount:strongSelf.currentUser hasMobilePolicy:hasBioAuthPolicy sessionTimeout:sessionTimeout]; if (![bioAuthManager hasBiometricOptedIn] && bioAuthManager.automaticPresentation) { [bioAuthManager presentOptInDialogWithViewController:[[SFSDKWindowManager sharedManager] mainWindow:authSession.oauthRequest.scene].topViewController]; } @@ -1984,8 +1984,8 @@ - (void)retrievedIdentityData:(SFSDKAuthSession *)authSession { [authClient revokeRefreshToken:preLoginCredentials reason:SFLogoutReasonRefreshTokenRotated]; } } else if (hasMobilePolicy) { - [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureScreenLock]; - [[SFScreenLockManagerInternal shared] storeMobilePolicyWithUserAccount:self.currentUser hasMobilePolicy:hasMobilePolicy lockTimeout:lockTimeout]; + [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureScreenLock forUser:strongSelf.currentUser]; + [[SFScreenLockManagerInternal shared] storeMobilePolicyWithUserAccount:strongSelf.currentUser hasMobilePolicy:hasMobilePolicy lockTimeout:lockTimeout]; } } }]; @@ -2075,7 +2075,39 @@ - (void)finalizeAuthCompletion:(SFSDKAuthSession *)authSession { // Notify the session is ready. [self initAnalyticsManager]; [self handleAnalyticsAddUserEvent:authSession account:userAccount]; - + + // Promote auth-method feature flags to the now-known user account. + // Write the per-user flag and clear the transient global flag so it does not + // bleed into other users' User-Agent strings. + SFOAuthType completedAuthType = authSession.oauthCoordinator.authInfo.authType; + if (completedAuthType == SFOAuthTypeAdvancedBrowser) { + [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureSafariBrowserForLogin forUser:userAccount]; + } else { + [SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureSafariBrowserForLogin forUser:userAccount]; + } + [SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureSafariBrowserForLogin]; + if (completedAuthType != SFOAuthTypeRefresh) { + // Check the transient global flag rather than re-deriving from credentials.domain, which by + // this point has been replaced with the resolved org domain (no longer contains "/discovery"). + BOOL usedWelcomeDiscovery = [[SFSDKAppFeatureMarkers appFeatures] containsObject:kSFAppFeatureWelcomeDiscovery]; + if (usedWelcomeDiscovery) { + [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureWelcomeDiscovery forUser:userAccount]; + } else { + [SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureWelcomeDiscovery forUser:userAccount]; + } + // WD: clear the transient global flag after promoting to per-user + [SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureWelcomeDiscovery]; + + // QR: write per-user and clear the transient global flag + BOOL usedQrLogin = [[SFSDKAppFeatureMarkers appFeatures] containsObject:kSFAppFeatureQrCodeLogin]; + if (usedQrLogin) { + [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureQrCodeLogin forUser:userAccount]; + [SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureQrCodeLogin]; + } else { + [SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureQrCodeLogin forUser:userAccount]; + } + } + // Async call, ignore if theres a failure. If success save the user photo locally. [self retrieveUserPhotoIfNeeded:userAccount]; BOOL shouldNotify = YES; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKAppFeatureMarkersTests.m b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKAppFeatureMarkersTests.m index d37d7dabc8..dc2aa4d387 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKAppFeatureMarkersTests.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKAppFeatureMarkersTests.m @@ -24,10 +24,15 @@ #import #import "SFSDKAppFeatureMarkers.h" +#import "SFUserAccount.h" +#import "SFOAuthCredentials.h" +#import "SFOAuthCredentials+Internal.h" @interface SFSDKAppFeatureMarkersTests : XCTestCase @property (nonatomic, strong) NSMutableSet *existingMarkers; +@property (nonatomic, strong) SFUserAccount *userA; +@property (nonatomic, strong) SFUserAccount *userB; @end @@ -38,9 +43,21 @@ - (void)setUp { self.existingMarkers = [NSMutableSet set]; [self persistExistingMarkers]; [self clearExistingMarkers]; + self.userA = [self fakeUserWithOrgId:@"org1" userId:@"user1" credentialsIdentifier:@"test-creds-A"]; + self.userB = [self fakeUserWithOrgId:@"org2" userId:@"user2" credentialsIdentifier:@"test-creds-B"]; } - (void)tearDown { + [SFSDKAppFeatureMarkers unregisterAppFeature:@"XY" forUser:self.userA]; + [SFSDKAppFeatureMarkers unregisterAppFeature:@"XY" forUser:self.userB]; + [SFSDKAppFeatureMarkers unregisterAppFeature:@"GL" forUser:nil]; + [SFSDKAppFeatureMarkers unregisterAppFeature:@"PU" forUser:self.userA]; + [SFSDKAppFeatureMarkers unregisterAppFeature:@"NL" forUser:nil]; + [SFSDKAppFeatureMarkers unregisterAppFeature:@"RM" forUser:self.userA]; + [SFSDKAppFeatureMarkers unregisterAppFeature:@"HY" forUser:self.userA]; + [SFSDKAppFeatureMarkers unregisterAppFeature:@"PS" forUser:self.userA]; + self.userA = nil; + self.userB = nil; [self clearExistingMarkers]; [self resetPreviousMarkers]; self.existingMarkers = [NSMutableSet set]; @@ -68,8 +85,109 @@ - (void)testUnregisterNonExistingNoError { [SFSDKAppFeatureMarkers unregisterAppFeature:someFeature]; } +#pragma mark - Per-user feature flag tests + +- (void)test_givenTwoUsers_whenRegisterFeatureForUserA_thenOnlyUserAHasFlag { + [SFSDKAppFeatureMarkers registerAppFeature:@"XY" forUser:self.userA]; + + XCTAssertTrue([[SFSDKAppFeatureMarkers appFeaturesForUser:self.userA] containsObject:@"XY"], + @"userA should have feature XY"); + XCTAssertFalse([[SFSDKAppFeatureMarkers appFeaturesForUser:self.userB] containsObject:@"XY"], + @"userB should NOT have feature XY"); + XCTAssertFalse([[SFSDKAppFeatureMarkers appFeatures] containsObject:@"XY"], + @"Global set should NOT contain per-user feature XY"); +} + +- (void)test_givenGlobalAndPerUserFlags_whenAppFeaturesForUser_thenUnionReturned { + [SFSDKAppFeatureMarkers registerAppFeature:@"GL"]; + [SFSDKAppFeatureMarkers registerAppFeature:@"PU" forUser:self.userA]; + + NSSet *featuresForA = [SFSDKAppFeatureMarkers appFeaturesForUser:self.userA]; + XCTAssertTrue([featuresForA containsObject:@"GL"], + @"appFeaturesForUser:userA should include global feature GL"); + XCTAssertTrue([featuresForA containsObject:@"PU"], + @"appFeaturesForUser:userA should include per-user feature PU"); + + NSSet *featuresForB = [SFSDKAppFeatureMarkers appFeaturesForUser:self.userB]; + XCTAssertTrue([featuresForB containsObject:@"GL"], + @"appFeaturesForUser:userB should include global feature GL"); + XCTAssertFalse([featuresForB containsObject:@"PU"], + @"appFeaturesForUser:userB should NOT include userA's per-user feature PU"); +} + +- (void)test_givenNilUser_whenRegisterForUser_thenFlagGoesToGlobalSet { + [SFSDKAppFeatureMarkers registerAppFeature:@"NL" forUser:nil]; + + XCTAssertTrue([[SFSDKAppFeatureMarkers appFeatures] containsObject:@"NL"], + @"Registering with nil user should fall back to global set"); +} + +- (void)test_givenUserWithFlag_whenUnregisterForUser_thenFlagRemovedFromUser { + // Register RM only per-user for userA; do not add to global set + [SFSDKAppFeatureMarkers registerAppFeature:@"RM" forUser:self.userA]; + XCTAssertTrue([[SFSDKAppFeatureMarkers appFeaturesForUser:self.userA] containsObject:@"RM"], + @"Feature RM should be present for userA before unregister"); + + [SFSDKAppFeatureMarkers unregisterAppFeature:@"RM" forUser:self.userA]; + + XCTAssertFalse([[SFSDKAppFeatureMarkers appFeaturesForUser:self.userA] containsObject:@"RM"], + @"Feature RM should be removed from userA after per-user unregister"); + XCTAssertFalse([[SFSDKAppFeatureMarkers appFeatures] containsObject:@"RM"], + @"Global set should not contain RM (it was never registered globally)"); +} + +- (void)test_givenLoadPersistedFeatures_whenAppFeaturesForUser_thenFlagsPresent { + [SFSDKAppFeatureMarkers loadPersistedFeatures:[NSSet setWithObject:@"HY"] forUser:self.userA]; + + XCTAssertTrue([[SFSDKAppFeatureMarkers appFeaturesForUser:self.userA] containsObject:@"HY"], + @"Hydrated feature HY should be visible via appFeaturesForUser:"); + XCTAssertFalse([self.userA.persistedFeatureFlags containsObject:@"HY"], + @"loadPersistedFeatures: should NOT write back to persistedFeatureFlags"); +} + +- (void)test_givenPersistedFlagsOnUser_whenRegisterForUser_thenPersistedFlagsUpdated { + [SFSDKAppFeatureMarkers registerAppFeature:@"PS" forUser:self.userA]; + + XCTAssertTrue([self.userA.persistedFeatureFlags containsObject:@"PS"], + @"registerAppFeature:forUser: should save PS to user.persistedFeatureFlags"); +} + +- (void)test_givenNilUser_whenAppFeaturesForUser_thenReturnsGlobalSet { + [SFSDKAppFeatureMarkers registerAppFeature:@"GL"]; + [SFSDKAppFeatureMarkers registerAppFeature:@"PU" forUser:self.userA]; + + NSSet *forNil = [SFSDKAppFeatureMarkers appFeaturesForUser:nil]; + NSSet *global = [SFSDKAppFeatureMarkers appFeatures]; + + XCTAssertEqualObjects(forNil, global, + @"appFeaturesForUser:nil should be identical to appFeatures"); + XCTAssertFalse([forNil containsObject:@"PU"], + @"appFeaturesForUser:nil should not include per-user features"); +} + +- (void)test_givenPersistedFlagsOnUser_whenUnregisterForUser_thenPersistedFlagsUpdated { + [SFSDKAppFeatureMarkers registerAppFeature:@"RM" forUser:self.userA]; + XCTAssertTrue([self.userA.persistedFeatureFlags containsObject:@"RM"], + @"Precondition: RM should be in persistedFeatureFlags after register"); + + [SFSDKAppFeatureMarkers unregisterAppFeature:@"RM" forUser:self.userA]; + + XCTAssertFalse([self.userA.persistedFeatureFlags containsObject:@"RM"], + @"unregisterAppFeature:forUser: should remove RM from user.persistedFeatureFlags"); +} + #pragma mark - Private helpers +- (SFUserAccount *)fakeUserWithOrgId:(NSString *)orgId userId:(NSString *)userId credentialsIdentifier:(NSString *)identifier { + SFOAuthCredentials *credentials = [[SFOAuthCredentials alloc] initWithIdentifier:identifier + clientId:@"fakeClientIdForTesting" + encrypted:NO]; + credentials.organizationId = orgId; + credentials.userId = userId; + SFUserAccount *user = [[SFUserAccount alloc] initWithCredentials:credentials]; + return user; +} + - (void)persistExistingMarkers { for (NSString *marker in [SFSDKAppFeatureMarkers appFeatures]) { [self.existingMarkers addObject:marker]; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFUserAccountManagerTests.m b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFUserAccountManagerTests.m index 7f4f764f78..ff2b1b3f04 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFUserAccountManagerTests.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFUserAccountManagerTests.m @@ -673,6 +673,29 @@ - (void)testUserAccountEncoding { XCTAssertEqual(userIn.accessRestrictions, userOut.accessRestrictions, @"accessRestrictions mismatch"); } +- (void)test_givenPersistedFeatureFlags_whenEncodeAndDecode_thenFlagsRoundtrip { + SFOAuthCredentials *credentials = [[SFOAuthCredentials alloc] initWithIdentifier:@"identifier-ff-roundtrip" + clientId:@"fakeClientIdForTesting" + encrypted:NO]; + [credentials setIdentityUrl:[NSURL URLWithString:@"https://test.salesforce.com/id/00DS0000000IDdtWAH/005S0000004y9JkCAF"]]; + SFUserAccount *userIn = [[SFUserAccount alloc] initWithCredentials:credentials]; + userIn.persistedFeatureFlags = [NSSet setWithObjects:@"BW", @"QR", nil]; + + NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initRequiringSecureCoding:YES]; + [archiver encodeObject:userIn forKey:@"account"]; + [archiver finishEncoding]; + NSData *data = archiver.encodedData; + + NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingFromData:data error:nil]; + unarchiver.requiresSecureCoding = YES; + SFUserAccount *userOut = [unarchiver decodeObjectOfClass:[SFUserAccount class] forKey:@"account"]; + + XCTAssertNotNil(userOut, @"Should unarchive successfully"); + XCTAssertEqual(userOut.persistedFeatureFlags.count, 2u, @"Should decode both feature flags"); + XCTAssertTrue([userOut.persistedFeatureFlags containsObject:@"BW"], @"BW flag should roundtrip"); + XCTAssertTrue([userOut.persistedFeatureFlags containsObject:@"QR"], @"QR flag should roundtrip"); +} + - (void)testMigrateRefreshAuthRequest { // Setup SFSDKAppConfig with test data NSString *testConsumerKey = @"TestConsumerKey123"; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKManagerTests.m b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKManagerTests.m index 920d8128cf..2b15c0778f 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKManagerTests.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKManagerTests.m @@ -31,6 +31,7 @@ #import "SFSDKAppConfig.h" #import "SFUserAccount+Internal.h" #import "SFOAuthCredentials+Internal.h" +#import "SFSDKAppFeatureMarkers.h" #import "SFOAuthTestFlowCoordinatorDelegate.h" #import "SFLoginViewController.h" #import "SFSDKAuthRootController.h" @@ -392,6 +393,70 @@ - (void)testAuthenticationFlags { XCTAssertEqual(SFOAuthTypeUserAgent, coordinator.authInfo.authType); } +#pragma mark - Per-user user-agent tests + +- (void)test_givenUserWithPerUserFeature_whenUserAgentStringForUser_thenFtrContainsUserFlag { + [self createTestAppIdentity]; + SFUserAccount *user = [self createUserAccount]; + [[SFUserAccountManager sharedInstance] setCurrentUserInternal:user]; + + // Register a per-user flag for our user and a different per-user flag for another user. + [SFSDKAppFeatureMarkers registerAppFeature:@"XY" forUser:user]; + + NSString *ua = [[SalesforceSDKManager sharedManager] userAgentString:@"" forUser:user]; + + XCTAssertTrue([ua containsString:@"ftr_"], @"User agent should contain the ftr_ segment"); + XCTAssertTrue([ua containsString:@"XY"], @"User agent for user should include their per-user flag XY"); + + // Cleanup + [SFSDKAppFeatureMarkers unregisterAppFeature:@"XY" forUser:user]; + NSError *error = nil; + [[SFUserAccountManager sharedInstance] deleteAccountForUser:user error:&error]; + [[SFUserAccountManager sharedInstance] setCurrentUserInternal:nil]; +} + +- (void)test_givenNilUser_whenUserAgentStringForUser_thenFallsBackToCurrentUser { + [self createTestAppIdentity]; + SFUserAccount *user = [self createUserAccount]; + [[SFUserAccountManager sharedInstance] setCurrentUserInternal:user]; + + [SFSDKAppFeatureMarkers registerAppFeature:@"CU" forUser:user]; + + NSString *uaForNil = [[SalesforceSDKManager sharedManager] userAgentString:@"" forUser:nil]; + NSString *uaForUser = [[SalesforceSDKManager sharedManager] userAgentString:@"" forUser:user]; + + XCTAssertEqualObjects(uaForNil, uaForUser, + @"userAgentString:forUser:nil should produce same result as forUser:currentUser"); + + // Cleanup + [SFSDKAppFeatureMarkers unregisterAppFeature:@"CU" forUser:user]; + NSError *error = nil; + [[SFUserAccountManager sharedInstance] deleteAccountForUser:user error:&error]; + [[SFUserAccountManager sharedInstance] setCurrentUserInternal:nil]; +} + +- (void)test_givenAccountWithPersistedFlags_whenHydratePerUserFeatureFlags_thenFlagsLoadedIntoMarkers { + [self createTestAppIdentity]; + SFUserAccount *user = [self createUserAccount]; + user.persistedFeatureFlags = [NSSet setWithObject:@"BW"]; + + // Simulate what happens on SDK startup: save the account then hydrate. + NSError *saveError = nil; + [[SFUserAccountManager sharedInstance] saveAccountForUser:user error:&saveError]; + XCTAssertNil(saveError, @"Should save account without error"); + + [[SalesforceSDKManager sharedManager] hydratePerUserFeatureFlags]; + + NSSet *features = [SFSDKAppFeatureMarkers appFeaturesForUser:user]; + XCTAssertTrue([features containsObject:@"BW"], + @"BW should be in appFeaturesForUser: after hydratePerUserFeatureFlags"); + + // Cleanup + [SFSDKAppFeatureMarkers unregisterAppFeature:@"BW" forUser:user]; + NSError *error = nil; + [[SFUserAccountManager sharedInstance] deleteAccountForUser:user error:&error]; +} + #pragma mark - Dispaly Name Tests - (void)testDefaultDisplayName { diff --git a/libs/SmartStore/SmartStore/Classes/SFSmartStore.m b/libs/SmartStore/SmartStore/Classes/SFSmartStore.m index 4a5be75d43..8037b52fed 100755 --- a/libs/SmartStore/SmartStore/Classes/SFSmartStore.m +++ b/libs/SmartStore/SmartStore/Classes/SFSmartStore.m @@ -167,7 +167,7 @@ - (id) initWithName:(NSString*)name user:(SFUserAccount *)user isGlobal:(BOOL)is [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureSmartStoreGlobal]; } else { _dbMgr = [SFSmartStoreDatabaseManager sharedManagerForUser:_user]; - [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureSmartStoreUser]; + [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureSmartStoreUser forUser:_user]; } // Setup listening for data protection available / unavailable diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/UserCredentialsView.swift b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/UserCredentialsView.swift index 1443cc7819..ad0573f247 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/UserCredentialsView.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/UserCredentialsView.swift @@ -94,6 +94,10 @@ public struct CredentialsLabels { // Other fields public static let additionalOAuthFields = "Additional OAuth Fields" + + // SDK section + public static let sdk = "SDK" + public static let userAgent = "User Agent" } struct UserCredentialsView: View { @@ -110,6 +114,7 @@ struct UserCredentialsView: View { @State private var cookiesAndSecurityExpanded = true @State private var beaconExpanded = true @State private var otherExpanded = true + @State private var sdkSectionExpanded = false // Export alert state @State private var showExportAlert = false @@ -211,6 +216,11 @@ struct UserCredentialsView: View { InfoSectionView(title: CredentialsLabels.other, isExpanded: $otherExpanded) { InfoRowView(label: "\(CredentialsLabels.additionalOAuthFields):", value: additionalOAuthFields) } + + InfoSectionView(title: CredentialsLabels.sdk, isExpanded: $sdkSectionExpanded) { + InfoRowView(label: "User Agent", value: userAgentString) + .accessibilityIdentifier("userAgent") + } } .id(refreshTrigger) } @@ -240,6 +250,7 @@ struct UserCredentialsView: View { cookiesAndSecurityExpanded = expand beaconExpanded = expand otherExpanded = expand + sdkSectionExpanded = expand } private func generateCredentialsJSON() -> String { @@ -316,7 +327,10 @@ struct UserCredentialsView: View { result[CredentialsLabels.other] = [ CredentialsLabels.additionalOAuthFields: additionalOAuthFields ] - + + // SDK section + result[CredentialsLabels.sdk] = [CredentialsLabels.userAgent: userAgentString] + guard let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted]), let jsonString = String(data: jsonData, encoding: .utf8) else { return "{}" @@ -326,11 +340,16 @@ struct UserCredentialsView: View { } // MARK: - Computed Properties - + + private var userAgentString: String { + guard let user = UserAccountManager.shared.currentUserAccount else { return "" } + return SalesforceManager.shared.userAgent(qualifier: "", for: user) + } + private var credentials: OAuthCredentials? { return UserAccountManager.shared.currentUserAccount?.credentials } - + // User Identity private var username: String { return UserAccountManager.shared.currentUserAccount?.idData.username ?? "" diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift index 86b8bd61cd..b06cbf196d 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift @@ -96,6 +96,10 @@ struct CredentialsLabels { // Other fields static let additionalOAuthFields = "Additional OAuth Fields" + + // SDK section + static let sdk = "SDK" + static let userAgent = "User Agent" } struct OAuthConfigLabels { @@ -547,6 +551,12 @@ class AuthFlowTesterMainPageObject { ) } + func getUserAgent() -> String { + let json = tapExportAndGetJSON(exportCredentialsButton(), alertTitle: "Credentials JSON") + let sdk = json[CredentialsLabels.sdk] as? [String: String] ?? [:] + return sdk[CredentialsLabels.userAgent] ?? "" + } + func getOAuthConfiguration() -> OAuthConfigurationData { // Tap export button and get JSON let json = tapExportAndGetJSON(exportOAuthConfigButton(), alertTitle: "OAuth Configuration JSON") diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LoginWithRestartTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LoginWithRestartTests.swift index 9f8ddbb67b..dabe40662c 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LoginWithRestartTests.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LoginWithRestartTests.swift @@ -178,7 +178,8 @@ class LoginWithRestartTests: BaseAuthFlowTester { loginHost: .regularAuth, user: .fourth, userAppConfigName: .ecaOpaque, - userScopeSelection: .empty + userScopeSelection: .empty, + isMultiUser: true ) // Test API call for User A @@ -189,7 +190,8 @@ class LoginWithRestartTests: BaseAuthFlowTester { loginHost: .regularAuth, user: .fifth, userAppConfigName: .ecaJwt, - userScopeSelection: .empty + userScopeSelection: .empty, + isMultiUser: true ) // Test API call for User B diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift index 7b1d0d873b..67886006ba 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift @@ -493,6 +493,41 @@ class MultiUserLoginTests: BaseAuthFlowTester { XCTAssertTrue(makeRestRequest(), "User A's API call should succeed") } + // MARK: - Feature Flag User Agent Tests + + /// Verifies that the BW (browser-web) feature flag is set in the user agent for advanced auth users + /// and absent for regular auth users. Also verifies the MU (multi-user) flag when multiple users + /// are logged in simultaneously. + /// + /// NB: Uses .fourth user from regular_auth and .third user from advanced_auth (beaconOpaque app) + /// to avoid parallel conflicts with AdvancedAuthBeaconLoginTests which uses .second. + /// loginOtherUser (no validate) is used for the advanced auth user because identity data + /// may not be immediately available in a cross-host multi-user login. + func testAdvancedAuthUser_HasBWFlag_RegularAuthUser_DoesNot() throws { + // User A: regular auth — no BW + // Use launchLoginAndValidate to ensure credentials (including identity data) are fully loaded + // before calling validateUserAgent. launchLoginAndValidate calls validateUserAgent internally. + launchLoginAndValidate(loginHost: .regularAuth, user: .fourth, staticAppConfigName: .ecaOpaque) + + // User B: advanced auth — has BW, both users now logged in → MU + // Use loginOtherUser (without full credential validation) since identity data + // may not be immediately available in cross-host multi-user scenarios. + loginOtherUser(loginHost: .advancedAuth, user: .third, staticAppConfigName: .beaconOpaque) + validateUserAgent(loginHost: .advancedAuth, isMultiUser: true) + + // Switch to User A — no BW, MU still set + switchToUser(loginHost: .regularAuth, user: .fourth) + validateUserAgent(loginHost: .regularAuth, isMultiUser: true) + + // Switch back to User B — BW back, MU still set + switchToUser(loginHost: .advancedAuth, user: .third) + validateUserAgent(loginHost: .advancedAuth, isMultiUser: true) + + // Logout User B — app auto-switches to User A; MU must be gone + logout() + validateUserAgent(loginHost: .regularAuth, isMultiUser: false) + } + /// Logout CA user and verify ECA user is unaffected. /// Tests that logging out one user automatically switches to the other user with different app types. func testDifferentAppTypes_LogoutCaUser_EcaUserUnaffected() throws { diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/RefreshTokenMigrationWithRestartTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/RefreshTokenMigrationWithRestartTests.swift index 7d77500b81..6a40905ca0 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/RefreshTokenMigrationWithRestartTests.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/RefreshTokenMigrationWithRestartTests.swift @@ -173,7 +173,8 @@ class RefreshTokenMigrationWithRestartTests: BaseAuthFlowTester { loginHost: .regularAuth, user: .third, userAppConfigName: .ecaOpaque, - userScopeSelection: .empty + userScopeSelection: .empty, + isMultiUser: true ) // Switch to User B and validate @@ -181,7 +182,8 @@ class RefreshTokenMigrationWithRestartTests: BaseAuthFlowTester { loginHost: .regularAuth, user: .fourth, userAppConfigName: .beaconOpaque, - userScopeSelection: .empty + userScopeSelection: .empty, + isMultiUser: true ) // Logout second user diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift index f7989fb525..3c5d3a060c 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift @@ -206,6 +206,7 @@ class BaseAuthFlowTester: XCTestCase { /// - userScopeSelection: The scope selection the user was logged in with. Defaults to `.empty`. /// - useWebServerFlow: Whether web server OAuth flow was used. Defaults to `true`. /// - useHybridFlow: Whether hybrid authentication flow was used. Defaults to `true`. + /// - isMultiUser: Whether multiple users are still logged in after the switch. Defaults to `false`. func switchToUserAndValidate( loginHost: KnownLoginHostConfig, user: KnownUserConfig, @@ -214,11 +215,12 @@ class BaseAuthFlowTester: XCTestCase { userAppConfigName: KnownAppConfig, userScopeSelection: ScopeSelection = .empty, useWebServerFlow: Bool = true, - useHybridFlow: Bool = true + useHybridFlow: Bool = true, + isMultiUser: Bool = false ) { // Switch user mainPage.switchToUser(username: getUser(loginHost: loginHost, user: user).username) - + // Validate validate( loginHost: loginHost, @@ -228,10 +230,11 @@ class BaseAuthFlowTester: XCTestCase { userAppConfigName: userAppConfigName, userScopeSelection: userScopeSelection, useWebServerFlow: useWebServerFlow, - useHybridFlow: useHybridFlow + useHybridFlow: useHybridFlow, + isMultiUser: isMultiUser ) } - + /// Switches to an already logged-in user and validates the user credentials. /// /// Use this method when multiple users are logged in and you want to switch between them. @@ -250,11 +253,12 @@ class BaseAuthFlowTester: XCTestCase { userAppConfigName: KnownAppConfig, userScopeSelection: ScopeSelection = .empty, useWebServerFlow: Bool = true, - useHybridFlow: Bool = true + useHybridFlow: Bool = true, + isMultiUser: Bool = false ) { // Switch user mainPage.switchToUser(username: getUser(loginHost: loginHost, user: user).username) - + // Validate validateUser( loginHost: loginHost, @@ -264,6 +268,9 @@ class BaseAuthFlowTester: XCTestCase { useWebServerFlow: useWebServerFlow, useHybridFlow: useHybridFlow ) + + // Validate persisted feature flags survived the restart and user switch + validateUserAgent(loginHost: loginHost, isMultiUser: isMultiUser) } /// Launches the app and performs login. @@ -334,6 +341,7 @@ class BaseAuthFlowTester: XCTestCase { useHybridFlow: Bool = true, useWelcomeDiscovery: Bool = false, loginForAdmin: Bool = false, + isMultiUser: Bool = false, ) { let useStaticConfiguration = dynamicAppConfigName == nil let userAppConfigName = useStaticConfiguration ? staticAppConfigName : dynamicAppConfigName! @@ -367,10 +375,46 @@ class BaseAuthFlowTester: XCTestCase { userAppConfigName: userAppConfigName, userScopeSelection: userScopeSelection, useWebServerFlow: effectiveUseWebServerFlow, - useHybridFlow: useHybridFlow + useHybridFlow: useHybridFlow, + isMultiUser: isMultiUser, + usesWelcomeDiscovery: useWelcomeDiscovery ) } + /// Logs in an additional user (multi-user scenario) WITHOUT performing credential validation. + /// + /// Use this method when you need to add a second user account but don't need full credential + /// validation (e.g., when using advanced auth where identity data may not be immediately available). + /// + /// - Parameters: + /// - loginHost: The login host configuration to use. + /// - user: The user to log in with. + /// - staticAppConfigName: The static app configuration name. + /// - useWebServerFlow: Whether to use web server OAuth flow. Defaults to `true`. + /// - useHybridFlow: Whether to use hybrid authentication flow. Defaults to `true`. + func loginOtherUser( + loginHost: KnownLoginHostConfig, + user: KnownUserConfig, + staticAppConfigName: KnownAppConfig, + useWebServerFlow: Bool = true, + useHybridFlow: Bool = true, + ) { + // Switch to add new user + mainPage.performAddUser() + + // Login + login( + loginHost: loginHost, + user: user, + staticAppConfigName: staticAppConfigName, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow, + ) + + // Wait for main page to show (user is logged in) + assertMainPageLoaded() + } + /// Logs in an additional user (multi-user scenario) and validates the credentials. /// /// Use this method after an initial user is already logged in to add another user account. @@ -385,6 +429,7 @@ class BaseAuthFlowTester: XCTestCase { /// - dynamicScopeSelection: The scope selection for dynamic configuration. Defaults to `.empty`. /// - useWebServerFlow: Whether to use web server OAuth flow. Defaults to `true`. /// - useHybridFlow: Whether to use hybrid authentication flow. Defaults to `true`. + /// - isMultiUser: Whether multiple users are logged in after this login. Defaults to `true`. func loginOtherUserAndValidate( loginHost: KnownLoginHostConfig, user: KnownUserConfig, @@ -394,14 +439,15 @@ class BaseAuthFlowTester: XCTestCase { dynamicScopeSelection: ScopeSelection = .empty, useWebServerFlow: Bool = true, useHybridFlow: Bool = true, + isMultiUser: Bool = true, ) { let useStaticConfiguration = dynamicAppConfigName == nil let userAppConfigName = useStaticConfiguration ? staticAppConfigName : dynamicAppConfigName! let userScopeSelection = useStaticConfiguration ? staticScopeSelection : dynamicScopeSelection - + // Switch to add new user mainPage.performAddUser() - + // Login login( loginHost: loginHost, @@ -413,7 +459,7 @@ class BaseAuthFlowTester: XCTestCase { useWebServerFlow: useWebServerFlow, useHybridFlow: useHybridFlow, ) - + // Validate validate( loginHost: loginHost, @@ -423,10 +469,11 @@ class BaseAuthFlowTester: XCTestCase { userAppConfigName: userAppConfigName, userScopeSelection: userScopeSelection, useWebServerFlow: useWebServerFlow, - useHybridFlow: useHybridFlow + useHybridFlow: useHybridFlow, + isMultiUser: isMultiUser ) } - + /// Restarts the app and validates that the user session persists. /// /// Terminates and relaunches the app, then validates that the user is still logged in @@ -445,7 +492,8 @@ class BaseAuthFlowTester: XCTestCase { userAppConfigName: KnownAppConfig, userScopeSelection: ScopeSelection = .empty, useWebServerFlow: Bool = true, - useHybridFlow: Bool = true + useHybridFlow: Bool = true, + isMultiUser: Bool = false ) { // Restart app.terminate() @@ -464,6 +512,9 @@ class BaseAuthFlowTester: XCTestCase { useWebServerFlow: useWebServerFlow, useHybridFlow: useHybridFlow ) + + // Validate persisted feature flags are rehydrated after restart + validateUserAgent(loginHost: loginHost, isMultiUser: isMultiUser) } /// Migrates the refresh token to a new app configuration and validates the result. @@ -548,6 +599,58 @@ class BaseAuthFlowTester: XCTestCase { return mainPage.getUserCredentials() } + /// Gets the current user's User Agent string from the SDK section. + func getUserAgent() -> String { + return mainPage.getUserAgent() + } + + /// Validates the user agent string for the current user. + /// Fetches the UA via the export button then delegates to the private overload. + /// + /// - Parameters: + /// - loginHost: The login host used for the current user. + /// - usesWelcomeDiscovery: Whether welcome domain discovery was used. Defaults to `false`. + /// - isMultiUser: Whether multiple users are currently logged in. Defaults to `false`. + func validateUserAgent(loginHost: KnownLoginHostConfig, usesWelcomeDiscovery: Bool = false, isMultiUser: Bool = false) { + validateUserAgent(ua: getUserAgent(), loginHost: loginHost, usesWelcomeDiscovery: usesWelcomeDiscovery, isMultiUser: isMultiUser) + } + + /// Validates a pre-fetched user agent string. Called from validate() which already has the UA. + /// + /// - Parameters: + /// - ua: A pre-fetched user agent string. + /// - loginHost: The login host used for the current user. + /// - usesWelcomeDiscovery: Whether welcome domain discovery was used. Defaults to `false`. + /// - isMultiUser: Whether multiple users are currently logged in. Defaults to `false`. + private func validateUserAgent(ua: String, loginHost: KnownLoginHostConfig, usesWelcomeDiscovery: Bool = false, isMultiUser: Bool = false) { + XCTAssertTrue(ua.contains("SalesforceMobileSDK/"), "User agent should contain 'SalesforceMobileSDK/' prefix; got: \(ua)") + XCTAssertTrue(ua.contains("ftr_"), "User agent should contain 'ftr_' feature flag segment; got: \(ua)") + + // Extract the flag string after "ftr_" up to the next space + let flagSet: Set + if let ftrRange = ua.range(of: "ftr_") { + let afterFtr = String(ua[ftrRange.upperBound...]) + let flagString = afterFtr.components(separatedBy: " ").first ?? "" + flagSet = Set(flagString.components(separatedBy: ".").filter { !$0.isEmpty }) + } else { + flagSet = [] + } + + if loginHost == .advancedAuth { + XCTAssertTrue(flagSet.contains("BW"), "User agent should contain 'BW' flag for advancedAuth; flags: \(flagSet), ua: \(ua)") + } else { + XCTAssertFalse(flagSet.contains("BW"), "User agent should NOT contain 'BW' flag for non-advancedAuth; flags: \(flagSet), ua: \(ua)") + } + + if usesWelcomeDiscovery { + XCTAssertTrue(flagSet.contains("WD"), "User agent should contain 'WD' flag when welcome discovery is used; flags: \(flagSet), ua: \(ua)") + } + + if isMultiUser { + XCTAssertTrue(flagSet.contains("MU"), "User agent should contain 'MU' flag when multiple users are logged in; flags: \(flagSet), ua: \(ua)") + } + } + /// Revokes the current user's access token. @discardableResult func revokeAccessToken() -> Bool { @@ -625,10 +728,12 @@ class BaseAuthFlowTester: XCTestCase { userScopeSelection: ScopeSelection, useWebServerFlow: Bool, useHybridFlow: Bool, + isMultiUser: Bool = false, + usesWelcomeDiscovery: Bool = false, ) -> UserCredentialsData { - + let staticAppConfig = getAppConfig(named: staticAppConfigName) - + // Check that app loads and shows the expected user credentials etc assertMainPageLoaded() @@ -640,7 +745,7 @@ class BaseAuthFlowTester: XCTestCase { useWebServerFlow: useWebServerFlow, useHybridFlow: useHybridFlow ) - + // Revoke and refresh cycle let userAppConfig = getAppConfig(named: userAppConfigName) assertRevokeAndRefreshWorks(previousCredentials: userCredentials, isRtr: userAppConfig.isRtr) @@ -651,7 +756,10 @@ class BaseAuthFlowTester: XCTestCase { staticCallbackUrl: staticAppConfig.redirectUri, staticScopes: testConfig.getScopesToRequest(for: staticAppConfig, staticScopeSelection) ) - + + // Validate feature flags using pre-fetched UA + validateUserAgent(ua: getUserAgent(), loginHost: loginHost, usesWelcomeDiscovery: usesWelcomeDiscovery, isMultiUser: isMultiUser) + return userCredentials }