diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.h b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.h index b94a956d6..e1900bdac 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.h +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.h @@ -31,7 +31,7 @@ #import #import -@interface AppDelegate : UIResponder +@interface AppDelegate : UIResponder @property (strong, nonatomic) UIWindow *window; diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.m b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.m index d3061a94a..b14f5a82e 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.m +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.m @@ -46,6 +46,10 @@ @implementation AppDelegate OneSignalNotificationCenterDelegate *_notificationDelegate; +// ECM Should we ship these typedefs in OneSignalFramework.h to make them available to Objective C customers? +typedef void (^JwtCompletionBlock)(NSString*); +typedef void (^JwtExpiredBlock)(NSString *, JwtCompletionBlock); + - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // [FIRApp configure]; @@ -72,6 +76,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( [OneSignal.User addObserver:self]; [OneSignal.Notifications addPermissionObserver:self]; [OneSignal.InAppMessages addClickListener:self]; + [OneSignal addUserJwtInvalidatedListener:self]; NSLog(@"UNUserNotificationCenter.delegate: %@", UNUserNotificationCenter.currentNotificationCenter.delegate); @@ -87,7 +92,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( } #define ONESIGNAL_APP_ID_DEFAULT @"77e32082-ea27-42e3-a898-c72e141824ef" -#define ONESIGNAL_APP_ID_KEY_FOR_TESTING @"YOUR_APP_ID_HERE" +#define ONESIGNAL_APP_ID_KEY_FOR_TESTING @"77e32082-ea27-42e3-a898-c72e141824ef" + (NSString*)getOneSignalAppId { NSString* userDefinedAppId = [[NSUserDefaults standardUserDefaults] objectForKey:ONESIGNAL_APP_ID_KEY_FOR_TESTING]; @@ -121,6 +126,10 @@ - (void)onUserStateDidChangeWithState:(OSUserChangedState * _Nonnull)state { NSLog(@"Dev App onUserStateDidChangeWithState: %@", [state jsonRepresentation]); } +- (void)onUserJwtInvalidatedWithEvent:(OSUserJwtInvalidatedEvent * _Nonnull)event { + NSLog(@"Dev App onUserJwtInvalidatedWithEvent: %@", [event jsonRepresentation]); +} + #pragma mark OSInAppMessageDelegate - (void)onClickInAppMessage:(OSInAppMessageClickEvent * _Nonnull)event { diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/Base.lproj/Main.storyboard b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/Base.lproj/Main.storyboard index 9b87b6079..7e3e8014a 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/Base.lproj/Main.storyboard +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -257,17 +257,25 @@ - - + + + + + + + + + @@ -528,13 +536,24 @@ + - @@ -566,22 +585,25 @@ + - + + - + + @@ -598,9 +620,11 @@ + + @@ -616,6 +640,7 @@ + @@ -630,6 +655,7 @@ + @@ -645,10 +671,12 @@ + + @@ -658,7 +686,6 @@ - @@ -667,7 +694,6 @@ - @@ -675,7 +701,6 @@ - @@ -747,7 +772,9 @@ + + @@ -760,7 +787,7 @@ - + diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/OneSignalDevApp.entitlements b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/OneSignalDevApp.entitlements index 28007cfd9..5388fb2fe 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/OneSignalDevApp.entitlements +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/OneSignalDevApp.entitlements @@ -11,7 +11,7 @@ com.apple.security.application-groups - group.com.onesignal.example.onesignal + group.com.onesignal.example.staging.onesignal diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/SwiftTest.swift b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/SwiftTest.swift index ddae6a8ec..57a662b73 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/SwiftTest.swift +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/SwiftTest.swift @@ -28,10 +28,19 @@ import Foundation import OneSignalFramework -class SwiftTest: NSObject { +class SwiftTest: NSObject, OSUserJwtInvalidatedListener { + func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { + print("event: \(event.jsonRepresentation())") + print("externalId: \(event.externalId)") + } + func testSwiftUserModel() { let token1 = OneSignal.User.pushSubscription.token let token = OneSignal.User.pushSubscription.token OneSignal.Debug._dump() + OneSignal.login(externalId: "euid", token: "token") + OneSignal.updateUserJwt(externalId: "euid", token: "token") + OneSignal.addUserJwtInvalidatedListener(self) + OneSignal.removeUserJwtInvalidatedListener(self) } } diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.h b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.h index 8e4b15988..6e778926d 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.h +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.h @@ -52,7 +52,9 @@ @property (weak, nonatomic) IBOutlet UIButton *removeSmsButton; @property (weak, nonatomic) IBOutlet UITextField *externalUserIdTextField; +@property (weak, nonatomic) IBOutlet UITextField *tokenTextField; @property (weak, nonatomic) IBOutlet UIButton *loginExternalUserIdButton; +@property (weak, nonatomic) IBOutlet UIButton *updateJwtButton; @property (weak, nonatomic) IBOutlet UIButton *logoutButton; @property (weak, nonatomic) IBOutlet UITextField *addAliasLabelTextField; diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m index 8a5f72eaf..b63fdd4a4 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m @@ -196,8 +196,16 @@ - (IBAction)inAppMessagingSegmentedControlValueChanged:(UISegmentedControl *)sen - (IBAction)loginExternalUserId:(UIButton *)sender { NSString* externalUserId = self.externalUserIdTextField.text; - NSLog(@"Dev App: Logging in to external user ID %@", externalUserId); - [OneSignal login:externalUserId]; + NSString* token = self.tokenTextField.text; + NSLog(@"Dev App: Logging in to external user ID %@ and token %@", externalUserId, token); + [OneSignal login:externalUserId withToken:token]; +} + +- (IBAction)updateJwt:(id)sender { + NSString* externalUserId = self.externalUserIdTextField.text; + NSString* token = self.tokenTextField.text; + NSLog(@"Dev App: updating JWT for external user ID %@ and token %@", externalUserId, token); + [OneSignal updateUserJwt:externalUserId withToken:token]; } - (IBAction)logout:(UIButton *)sender { diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevAppClip/ViewController.h b/iOS_SDK/OneSignalDevApp/OneSignalDevAppClip/ViewController.h index b11c2c05b..f1a45b976 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevAppClip/ViewController.h +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevAppClip/ViewController.h @@ -54,6 +54,7 @@ @property (weak, nonatomic) IBOutlet UITextField *addTriggerValue; @property (weak, nonatomic) IBOutlet UIButton *addTriggerButton; @property (weak, nonatomic) IBOutlet UITextField *removeTriggerKey; +- (IBAction)updateJwt:(id)sender; @property (weak, nonatomic) IBOutlet UITextField *getTriggerKey; @property (weak, nonatomic) IBOutlet UILabel *infoLabel; @property (weak, nonatomic) IBOutlet UITextField *outcomeName; diff --git a/iOS_SDK/OneSignalDevApp/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements b/iOS_SDK/OneSignalDevApp/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements index c70461e82..fa1031a37 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements +++ b/iOS_SDK/OneSignalDevApp/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.com.onesignal.example.onesignal + group.com.onesignal.example.staging.onesignal diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 210704586..e4dda7d43 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -74,6 +74,9 @@ 3C277D7E2BD76E0000857606 /* OSIdentityModelRepo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C277D7D2BD76E0000857606 /* OSIdentityModelRepo.swift */; }; 3C2C7DC8288F3C020020F9AE /* OSSubscriptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2C7DC7288F3C020020F9AE /* OSSubscriptionModel.swift */; }; 3C2D8A5928B4C4E300BE41F6 /* OSDelta.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2D8A5828B4C4E300BE41F6 /* OSDelta.swift */; }; + 3C2FF9D02C5FCD760081293B /* OSUserJwtConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2FF9CF2C5FCD760081293B /* OSUserJwtConfig.swift */; }; + 3C3130E02CA383F800906665 /* OSUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3130DF2CA383F800906665 /* OSUser.swift */; }; + 3C3130E32CA3858500906665 /* OSPushSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3130E22CA3858500906665 /* OSPushSubscription.swift */; }; 3C44673E296D099D0039A49E /* OneSignalMobileProvision.m in Sources */ = {isa = PBXBuildFile; fileRef = 912411FD1E73342200E41FD7 /* OneSignalMobileProvision.m */; }; 3C44673F296D09CC0039A49E /* OneSignalMobileProvision.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411FC1E73342200E41FD7 /* OneSignalMobileProvision.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3C448B9D2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.h in Headers */ = {isa = PBXBuildFile; fileRef = 3C448B9B2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.h */; }; @@ -85,6 +88,8 @@ 3C47A975292642B100312125 /* OneSignalConfigManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C47A973292642B100312125 /* OneSignalConfigManager.m */; }; 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C4F9E4328A4466C009F453A /* OSOperationRepo.swift */; }; 3C5117172B15C31E00563465 /* OSUserState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5117162B15C31E00563465 /* OSUserState.swift */; }; + 3C5929E32CAD9EC50020D6FF /* OneSignalUserManagerImpl+OSLoggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5929E22CAD9EC50020D6FF /* OneSignalUserManagerImpl+OSLoggable.swift */; }; + 3C5929E52CAE523E0020D6FF /* MockUserJwtInvalidatedListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5929E42CAE523E0020D6FF /* MockUserJwtInvalidatedListener.swift */; }; 3C62999F2BEEA34800649187 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C62999E2BEEA34800649187 /* PrivacyInfo.xcprivacy */; }; 3C6299A12BEEA38100649187 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C6299A02BEEA38100649187 /* PrivacyInfo.xcprivacy */; }; 3C6299A32BEEA3CC00649187 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C6299A22BEEA3CC00649187 /* PrivacyInfo.xcprivacy */; }; @@ -181,6 +186,8 @@ 3CF11E3D2C6D6155002856F5 /* UserExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */; }; 3CF11E402C6E6DE2002856F5 /* MockNewRecordsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF11E3F2C6E6DE2002856F5 /* MockNewRecordsState.swift */; }; 3CF1A5632C669EA40056B3AA /* OSNewRecordsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */; }; + 3CF807352C80E3A6003E5FE1 /* OSAliasPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF807342C80E3A6003E5FE1 /* OSAliasPair.swift */; }; + 3CF807372C80F3B5003E5FE1 /* OSUserUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF807362C80F3B5003E5FE1 /* OSUserUtils.swift */; }; 3CF8629E28A183F900776CA4 /* OSIdentityModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF8629D28A183F900776CA4 /* OSIdentityModel.swift */; }; 3CF862A028A1964F00776CA4 /* OSPropertiesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF8629F28A1964F00776CA4 /* OSPropertiesModel.swift */; }; 3CF862A228A197D200776CA4 /* OSPropertiesModelStoreListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF862A128A197D200776CA4 /* OSPropertiesModelStoreListener.swift */; }; @@ -345,11 +352,16 @@ DE16C14524D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14724D3727200670EFA /* OneSignalLifecycleObserver.h in Headers */ = {isa = PBXBuildFile; fileRef = DE16C14624D3727200670EFA /* OneSignalLifecycleObserver.h */; }; DE16C17024D3989A00670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; + DE1DD0602C87D87B00787071 /* OSUserJwtInvalidatedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1DD05F2C87D87B00787071 /* OSUserJwtInvalidatedEvent.swift */; }; DE20425E24E21C2C00350E4F /* UIApplication+OneSignal.m in Sources */ = {isa = PBXBuildFile; fileRef = DE20425D24E21C2C00350E4F /* UIApplication+OneSignal.m */; }; DE20425F24E21C2C00350E4F /* UIApplication+OneSignal.m in Sources */ = {isa = PBXBuildFile; fileRef = DE20425D24E21C2C00350E4F /* UIApplication+OneSignal.m */; }; DE20426024E21C2C00350E4F /* UIApplication+OneSignal.m in Sources */ = {isa = PBXBuildFile; fileRef = DE20425D24E21C2C00350E4F /* UIApplication+OneSignal.m */; }; DE2D8F452947D85800844084 /* OneSignalExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17F927026BA3002D3A5D /* OneSignalExtension.framework */; }; DE2D8F4A2947D86200844084 /* OneSignalOutcomes.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D188027037F43002D3A5D /* OneSignalOutcomes.framework */; }; + DE3568EA2C88F56600AF447C /* PropertyExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE3568E92C88F56600AF447C /* PropertyExecutorTests.swift */; }; + DE3568EC2C88F5BD00AF447C /* OneSignalExecutorMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE3568EB2C88F5BD00AF447C /* OneSignalExecutorMocks.swift */; }; + DE3568F02C89067400AF447C /* SubscriptionsExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE3568EF2C89067400AF447C /* SubscriptionsExecutorTests.swift */; }; + DE3568F22C8911EA00AF447C /* IdentityExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE3568F12C8911EA00AF447C /* IdentityExecutorTests.swift */; }; DE3784842888CFF900453A8E /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; }; DE3784852888D00300453A8E /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; }; DE3784862888D00B00453A8E /* OneSignalUser.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -1229,6 +1241,9 @@ 3C2C7DC2288E007E0020F9AE /* UnitTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UnitTests-Bridging-Header.h"; sourceTree = ""; }; 3C2C7DC7288F3C020020F9AE /* OSSubscriptionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSubscriptionModel.swift; sourceTree = ""; }; 3C2D8A5828B4C4E300BE41F6 /* OSDelta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDelta.swift; sourceTree = ""; }; + 3C2FF9CF2C5FCD760081293B /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; + 3C3130DF2CA383F800906665 /* OSUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUser.swift; sourceTree = ""; }; + 3C3130E22CA3858500906665 /* OSPushSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSPushSubscription.swift; sourceTree = ""; }; 3C448B9B2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSBackgroundTaskHandlerImpl.h; sourceTree = ""; }; 3C448B9C2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSBackgroundTaskHandlerImpl.m; sourceTree = ""; }; 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSBackgroundTaskManager.swift; sourceTree = ""; }; @@ -1236,6 +1251,8 @@ 3C47A973292642B100312125 /* OneSignalConfigManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalConfigManager.m; sourceTree = ""; }; 3C4F9E4328A4466C009F453A /* OSOperationRepo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRepo.swift; sourceTree = ""; }; 3C5117162B15C31E00563465 /* OSUserState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserState.swift; sourceTree = ""; }; + 3C5929E22CAD9EC50020D6FF /* OneSignalUserManagerImpl+OSLoggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+OSLoggable.swift"; sourceTree = ""; }; + 3C5929E42CAE523E0020D6FF /* MockUserJwtInvalidatedListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserJwtInvalidatedListener.swift; sourceTree = ""; }; 3C62999E2BEEA34800649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3C6299A02BEEA38100649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3C6299A22BEEA3CC00649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -1302,6 +1319,8 @@ 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserExecutorTests.swift; sourceTree = ""; }; 3CF11E3F2C6E6DE2002856F5 /* MockNewRecordsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNewRecordsState.swift; sourceTree = ""; }; 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSNewRecordsState.swift; sourceTree = ""; }; + 3CF807342C80E3A6003E5FE1 /* OSAliasPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSAliasPair.swift; sourceTree = ""; }; + 3CF807362C80F3B5003E5FE1 /* OSUserUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserUtils.swift; sourceTree = ""; }; 3CF8629D28A183F900776CA4 /* OSIdentityModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModel.swift; sourceTree = ""; }; 3CF8629F28A1964F00776CA4 /* OSPropertiesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSPropertiesModel.swift; sourceTree = ""; }; 3CF862A128A197D200776CA4 /* OSPropertiesModelStoreListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSPropertiesModelStoreListener.swift; sourceTree = ""; }; @@ -1504,8 +1523,13 @@ CACBAAAB218A662B000ACAA5 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalLifecycleObserver.m; sourceTree = ""; }; DE16C14624D3727200670EFA /* OneSignalLifecycleObserver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLifecycleObserver.h; sourceTree = ""; }; + DE1DD05F2C87D87B00787071 /* OSUserJwtInvalidatedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserJwtInvalidatedEvent.swift; sourceTree = ""; }; DE20425C24E21C1500350E4F /* UIApplication+OneSignal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIApplication+OneSignal.h"; sourceTree = ""; }; DE20425D24E21C2C00350E4F /* UIApplication+OneSignal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIApplication+OneSignal.m"; sourceTree = ""; }; + DE3568E92C88F56600AF447C /* PropertyExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyExecutorTests.swift; sourceTree = ""; }; + DE3568EB2C88F5BD00AF447C /* OneSignalExecutorMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalExecutorMocks.swift; sourceTree = ""; }; + DE3568EF2C89067400AF447C /* SubscriptionsExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsExecutorTests.swift; sourceTree = ""; }; + DE3568F12C8911EA00AF447C /* IdentityExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityExecutorTests.swift; sourceTree = ""; }; DE3CD2FE270FA9F200A5BECD /* OSOutcomes.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSOutcomes.m; sourceTree = ""; }; DE51DDE3294262AB0073D5C4 /* OSRemoteParamController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSRemoteParamController.m; sourceTree = ""; }; DE51DDE4294262AB0073D5C4 /* OSRemoteParamController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSRemoteParamController.h; sourceTree = ""; }; @@ -2056,6 +2080,7 @@ isa = PBXGroup; children = ( 3C115163289A259500565C41 /* OneSignalOSCore.h */, + 3C2FF9CE2C5FCD590081293B /* Jwt */, 3C115188289ADEA300565C41 /* OSModelStore.swift */, 3C115186289ADE7700565C41 /* OSModelStoreListener.swift */, 3C115184289ADE4F00565C41 /* OSModel.swift */, @@ -2075,6 +2100,39 @@ path = Source; sourceTree = ""; }; + 3C2FF9CE2C5FCD590081293B /* Jwt */ = { + isa = PBXGroup; + children = ( + 3C2FF9CF2C5FCD760081293B /* OSUserJwtConfig.swift */, + 3CF807342C80E3A6003E5FE1 /* OSAliasPair.swift */, + ); + path = Jwt; + sourceTree = ""; + }; + 3C3130E12CA384BD00906665 /* Modeling */ = { + isa = PBXGroup; + children = ( + 3CF8629D28A183F900776CA4 /* OSIdentityModel.swift */, + 3CF8629F28A1964F00776CA4 /* OSPropertiesModel.swift */, + 3C2C7DC7288F3C020020F9AE /* OSSubscriptionModel.swift */, + 3CE92279289FA88B001B1062 /* OSIdentityModelStoreListener.swift */, + 3CF862A128A197D200776CA4 /* OSPropertiesModelStoreListener.swift */, + 3CE795F828DB99B500736BD4 /* OSSubscriptionModelStoreListener.swift */, + ); + path = Modeling; + sourceTree = ""; + }; + 3C3130E52CA385B700906665 /* Public */ = { + isa = PBXGroup; + children = ( + 3C3130DF2CA383F800906665 /* OSUser.swift */, + 3C3130E22CA3858500906665 /* OSPushSubscription.swift */, + 3C5117162B15C31E00563465 /* OSUserState.swift */, + DE1DD05F2C87D87B00787071 /* OSUserJwtInvalidatedEvent.swift */, + ); + path = Public; + sourceTree = ""; + }; 3C8544B72C5AEFF700F542A9 /* OneSignalOSCoreMocks */ = { isa = PBXGroup; children = ( @@ -2153,6 +2211,8 @@ 3C87066F2BDE0957000D8CD2 /* MockUserRequests.swift */, 3C8706712BDEE076000D8CD2 /* MockUserDefines.swift */, 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */, + DE3568EB2C88F5BD00AF447C /* OneSignalExecutorMocks.swift */, + 3C5929E42CAE523E0020D6FF /* MockUserJwtInvalidatedListener.swift */, ); path = OneSignalUserMocks; sourceTree = ""; @@ -2174,6 +2234,7 @@ isa = PBXGroup; children = ( 3CEE90A62BFE6ABD00B0FB5B /* OSPropertiesSupportedProperty.swift */, + 3CF807362C80F3B5003E5FE1 /* OSUserUtils.swift */, ); path = Support; sourceTree = ""; @@ -2182,6 +2243,9 @@ isa = PBXGroup; children = ( 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, + DE3568E92C88F56600AF447C /* PropertyExecutorTests.swift */, + DE3568EF2C89067400AF447C /* SubscriptionsExecutorTests.swift */, + DE3568F12C8911EA00AF447C /* IdentityExecutorTests.swift */, ); path = Executors; sourceTree = ""; @@ -2462,19 +2526,15 @@ isa = PBXGroup; children = ( 3CEE90A52BFE6A7700B0FB5B /* Support */, + 3C3130E12CA384BD00906665 /* Modeling */, 3C9AD6BA2B2284AB00BC1540 /* Executors */, 3C9AD6BD2B22877600BC1540 /* Requests */, + 3C3130E52CA385B700906665 /* Public */, DE69E1A9282ED8790090BB3D /* UnitTestApp-Bridging-Header.h */, - 3C0EF49D28A1DBCB00E5434B /* OSUserInternalImpl.swift */, DE69E1AA282ED8790090BB3D /* OneSignalUserManagerImpl.swift */, + 3C5929E22CAD9EC50020D6FF /* OneSignalUserManagerImpl+OSLoggable.swift */, + 3C0EF49D28A1DBCB00E5434B /* OSUserInternalImpl.swift */, 3C277D7D2BD76E0000857606 /* OSIdentityModelRepo.swift */, - 3C2C7DC7288F3C020020F9AE /* OSSubscriptionModel.swift */, - 3CE92279289FA88B001B1062 /* OSIdentityModelStoreListener.swift */, - 3CF8629D28A183F900776CA4 /* OSIdentityModel.swift */, - 3CF862A128A197D200776CA4 /* OSPropertiesModelStoreListener.swift */, - 3CF8629F28A1964F00776CA4 /* OSPropertiesModel.swift */, - 3CE795F828DB99B500736BD4 /* OSSubscriptionModelStoreListener.swift */, - 3C5117162B15C31E00563465 /* OSUserState.swift */, ); path = Source; sourceTree = ""; @@ -4052,6 +4112,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3CF807352C80E3A6003E5FE1 /* OSAliasPair.swift in Sources */, DEFB3E652BB7346D00E65DAD /* OSLiveActivities.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C11518B289ADEEB00565C41 /* OSEventProducer.swift in Sources */, @@ -4065,6 +4126,7 @@ 3CE5F9E3289D88DC004A156E /* OSModelStoreChangedHandler.swift in Sources */, 3C2D8A5928B4C4E300BE41F6 /* OSDelta.swift in Sources */, 4710EA532B8FCFB200435356 /* OSDispatchQueue.swift in Sources */, + 3C2FF9D02C5FCD760081293B /* OSUserJwtConfig.swift in Sources */, DEFB3E672BB735B500E65DAD /* OSStubLiveActivities.swift in Sources */, 3C11518D289AF5E800565C41 /* OSModelChangedHandler.swift in Sources */, 3C8E6DF928A6D89E0031E48A /* OSOperationExecutor.swift in Sources */, @@ -4108,6 +4170,8 @@ 3C8706702BDE0957000D8CD2 /* MockUserRequests.swift in Sources */, 3C8706722BDEE076000D8CD2 /* MockUserDefines.swift in Sources */, 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */, + 3C5929E52CAE523E0020D6FF /* MockUserJwtInvalidatedListener.swift in Sources */, + DE3568EC2C88F5BD00AF447C /* OneSignalExecutorMocks.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4116,9 +4180,12 @@ buildActionMask = 2147483647; files = ( 3CF11E3D2C6D6155002856F5 /* UserExecutorTests.swift in Sources */, + DE3568EA2C88F56600AF447C /* PropertyExecutorTests.swift in Sources */, + DE3568F22C8911EA00AF447C /* IdentityExecutorTests.swift in Sources */, 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, + DE3568F02C89067400AF447C /* SubscriptionsExecutorTests.swift in Sources */, 3CDE664C2BFC2A56006DA114 /* OneSignalUserObjcTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4267,6 +4334,8 @@ files = ( 3CE795F928DB99B500736BD4 /* OSSubscriptionModelStoreListener.swift in Sources */, DE69E1AC282ED87A0090BB3D /* OneSignalUserManagerImpl.swift in Sources */, + DE1DD0602C87D87B00787071 /* OSUserJwtInvalidatedEvent.swift in Sources */, + 3C5929E32CAD9EC50020D6FF /* OneSignalUserManagerImpl+OSLoggable.swift in Sources */, 3C9AD6CF2B228B7800BC1540 /* OSRequestAddAliases.swift in Sources */, 3C9AD6D32B228BB000BC1540 /* OSRequestUpdateProperties.swift in Sources */, 3C9AD6CD2B228B6300BC1540 /* OSRequestFetchUser.swift in Sources */, @@ -4276,9 +4345,11 @@ 3C277D7E2BD76E0000857606 /* OSIdentityModelRepo.swift in Sources */, 3CEE90A72BFE6ABD00B0FB5B /* OSPropertiesSupportedProperty.swift in Sources */, 3C9AD6C12B22886600BC1540 /* OSRequestUpdateSubscription.swift in Sources */, + 3C3130E02CA383F800906665 /* OSUser.swift in Sources */, 3C0EF49E28A1DBCB00E5434B /* OSUserInternalImpl.swift in Sources */, 3C8E6DFF28AB09AE0031E48A /* OSPropertyOperationExecutor.swift in Sources */, 3C9AD6CB2B228B5200BC1540 /* OSRequestIdentifyUser.swift in Sources */, + 3CF807372C80F3B5003E5FE1 /* OSUserUtils.swift in Sources */, 3C9AD6BC2B2285FB00BC1540 /* OSUserExecutor.swift in Sources */, 3C9AD6C32B22887700BC1540 /* OSRequestCreateUser.swift in Sources */, 3C9AD6D12B228B9200BC1540 /* OSRequestRemoveAlias.swift in Sources */, @@ -4288,6 +4359,7 @@ 3C2C7DC8288F3C020020F9AE /* OSSubscriptionModel.swift in Sources */, 3CF8629E28A183F900776CA4 /* OSIdentityModel.swift in Sources */, 3CE795FB28DBDCE700736BD4 /* OSSubscriptionOperationExecutor.swift in Sources */, + 3C3130E32CA3858500906665 /* OSPushSubscription.swift in Sources */, 3C5117172B15C31E00563465 /* OSUserState.swift in Sources */, 3C9AD6BF2B22881D00BC1540 /* OSRequestFetchIdentityBySubscription.swift in Sources */, 3CE9227A289FA88B001B1062 /* OSIdentityModelStoreListener.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m index d9066e347..43ef0038e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m @@ -176,8 +176,10 @@ - (double)calculateReattemptDelay:(int)reattemptCount { } - (void)prettyPrintDebugStatementWithRequest:(OneSignalRequest *)request { - if (![NSJSONSerialization isValidJSONObject:request.parameters]) + if (![NSJSONSerialization isValidJSONObject:request.parameters]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"HTTP Request (%@) with URL: %@, with headers: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, request.additionalHeaders]]; return; + } NSError *error; @@ -190,7 +192,7 @@ - (void)prettyPrintDebugStatementWithRequest:(OneSignalRequest *)request { NSString *jsonString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"HTTP Request (%@) with URL: %@, with parameters: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, jsonString]]; + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"HTTP Request (%@) with URL: %@, with headers: %@, with params: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, request.additionalHeaders, jsonString]]; } - (void)handleJSONNSURLResponse:(NSURLResponse*)response data:(NSData*)data error:(NSError*)error isAsync:(BOOL)async withRequest:(OneSignalRequest *)request onSuccess:(OSResultSuccessBlock)successBlock onFailure:(OSFailureBlock)failureBlock { diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 42d48d10d..f953e6c10 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -77,6 +77,12 @@ // Remote Params #define OSUD_LOCATION_ENABLED @"OSUD_LOCATION_ENABLED" #define OSUD_REQUIRES_USER_PRIVACY_CONSENT @"OSUD_REQUIRES_USER_PRIVACY_CONSENT" + +/* Identity Verification */ +#define OSUD_USE_IDENTITY_VERIFICATION @"OSUD_USE_IDENTITY_VERIFICATION" +#define OS_JWT_BEARER_TOKEN @"OS_JWT_BEARER_TOKEN" +#define OS_JWT_TOKEN_INVALID @"OS_JWT_TOKEN_INVALID" + // Remote Params - Receive Receipts #define OSUD_RECEIVE_RECEIPTS_ENABLED @"OS_ENABLE_RECEIVE_RECEIPTS" // * OSUD_RECEIVE_RECEIPTS_ENABLED // Outcomes @@ -128,7 +134,7 @@ #define IOS_USES_PROVISIONAL_AUTHORIZATION @"uses_provisional_auth" #define IOS_REQUIRES_EMAIL_AUTHENTICATION @"require_email_auth" #define IOS_REQUIRES_SMS_AUTHENTICATION @"require_sms_auth" -#define IOS_REQUIRES_USER_ID_AUTHENTICATION @"require_user_id_auth" +#define IOS_JWT_REQUIRED @"jwt_required" // Returned by remote params #define IOS_RECEIVE_RECEIPTS_ENABLE @"receive_receipts_enable" #define IOS_OUTCOMES_V2_SERVICE_ENABLE @"v2_enabled" #define IOS_LOCATION_SHARED @"location_shared" @@ -319,6 +325,8 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_PUSH_SUBSCRIPTION_MODEL_KEY @"OS_PUSH_SUBSCRIPTION_MODEL_KEY" #define OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY @"OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY" #define OS_SUBSCRIPTION_MODEL_STORE_KEY @"OS_SUBSCRIPTION_MODEL_STORE_KEY" +#define OS_MODEL_STORE_LISTENER_POSTFIX @"_LISTENER" +#define OS_IDENTITY_MODEL_REPO @"OS_IDENTITY_MODEL_REPO" // Deltas #define OS_ADD_ALIAS_DELTA @"OS_ADD_ALIAS_DELTA" @@ -331,26 +339,35 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_UPDATE_SUBSCRIPTION_DELTA @"OS_UPDATE_SUBSCRIPTION_DELTA" // Operation Repo +#define OS_OPERATION_REPO @"OS_OPERATION_REPO" #define OS_OPERATION_REPO_DELTA_QUEUE_KEY @"OS_OPERATION_REPO_DELTA_QUEUE_KEY" // User Executor +#define OS_USER_EXECUTOR @"OS_USER_EXECUTOR" #define OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY @"OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY" #define OS_USER_EXECUTOR_TRANSFER_SUBSCRIPTION_REQUEST_QUEUE_KEY @"OS_USER_EXECUTOR_TRANSFER_SUBSCRIPTION_REQUEST_QUEUE_KEY" +#define OS_USER_EXECUTOR_PENDING_QUEUE_KEY @"OS_USER_EXECUTOR_PENDING_QUEUE_KEY" // Identity Executor +#define OS_IDENTITY_EXECUTOR @"OS_IDENTITY_EXECUTOR" #define OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY @"OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY" #define OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY @"OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY" #define OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY @"OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY" +#define OS_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY @"OS_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY" // Property Executor +#define OS_PROPERTIES_EXECUTOR @"OS_PROPERTIES_EXECUTOR" #define OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY @"OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY" #define OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY @"OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY" +#define OS_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY @"OS_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY" // Subscription Executor +#define OS_SUBSCRIPTION_EXECUTOR @"OS_SUBSCRIPTION_EXECUTOR" #define OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY @"OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY" #define OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY @"OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY" #define OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY @"OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY" #define OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY @"OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY" +#define OS_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY @"OS_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY" // Live Activies Executor #define OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.h index d37e3577e..089a7be59 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.h @@ -29,6 +29,7 @@ #import "OSInAppMessageInternal.h" #import "OSInAppMessageViewController.h" #import "OSTriggerController.h" +#import #import NS_ASSUME_NONNULL_BEGIN @@ -39,7 +40,7 @@ NS_ASSUME_NONNULL_BEGIN @end -@interface OSMessagingController : NSObject +@interface OSMessagingController : NSObject @property (class, readonly) BOOL isInAppMessagingPaused; @@ -52,7 +53,7 @@ NS_ASSUME_NONNULL_BEGIN + (void)removeInstance; - (void)presentInAppMessage:(OSInAppMessageInternal *)message; - (void)updateInAppMessagesFromCache; -- (void)getInAppMessagesFromServer:(NSString * _Nullable)subscriptionId; +- (void)getInAppMessagesFromServer; - (void)messageViewImpressionRequest:(OSInAppMessageInternal *)message; - (void)messageViewPageImpressionRequest:(OSInAppMessageInternal *)message withPageId:(NSString *)pageId; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 47bc312ff..cbc638354 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -145,6 +145,21 @@ @implementation OSMessagingController @dynamic isInAppMessagingPaused; // Maximum time decided to save IAM with redisplay on cache - current value: six months in seconds static long OS_IAM_MAX_CACHE_TIME = 6 * 30 * 24 * 60 * 60; + +/** + If an attempt to get IAMs from the server returns an Unauthorized response, + the controller should re-attempt once the JWT token is updated. + */ +static BOOL shouldRetryGetInAppMessagesOnJwtUpdated = false; + +/** + If an attempt to get IAMs from the server is blocked by incomplete alias information, + the controller should re-attempt once the user state changes. + An example of when this can happen occurs when users are switching with Identity Verification turned off - + the SDK has a push subscription ID but no onesignal ID for the current user. + */ +static BOOL shouldRetryGetInAppMessagesOnUserChange = false; + static OSMessagingController *sharedInstance = nil; static dispatch_once_t once; + (OSMessagingController *)sharedInstance { @@ -167,6 +182,8 @@ + (void)removeInstance { + (void)start { OSMessagingController *shared = OSMessagingController.sharedInstance; [OneSignalUserManagerImpl.sharedInstance.pushSubscriptionImpl addObserver:shared]; + [OneSignalUserManagerImpl.sharedInstance addObserver:shared]; + [OneSignalUserManagerImpl.sharedInstance subscribeToJwtConfig:shared key:OS_MESSAGING_CONTROLLER]; } static BOOL _isInAppMessagingPaused = false; @@ -233,18 +250,54 @@ - (void)initializeTriggerController { - (void)updateInAppMessagesFromCache { self.messages = [OneSignalUserDefaults.initStandard getSavedCodeableDataForKey:OS_IAM_MESSAGES_ARRAY defaultValue:[NSArray new]]; - [self evaluateMessages]; + // ECM THIS NEEDS TO RUN ON THE MAIN THREAD + dispatch_async(dispatch_get_main_queue(), ^{ + [self evaluateMessages]; + }); } -- (void)getInAppMessagesFromServer:(NSString *)subscriptionId { - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer"]; - +/** + To get IAMs from the server, the following requirements are necessary: + - A subscription ID + - An appropriate alias (depending on Identity Verification enabled) for the subscription + - A valid JWT token for the user if Identity Verification is enabled + + This current implementation is not completely correct, as it will always use the current subscription ID and the current user. + The SDK would need to consider if the current user owns the subscription on the server. + */ +- (void)getInAppMessagesFromServer { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer attempted"]; + + NSString *subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId; if (!subscriptionId) { + // When the subscription observer fires, it will drive a re-fetch + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer blocked by null subscriptionId"]; + [self updateInAppMessagesFromCache]; + return; + } + + OSAliasPair *alias = [OneSignalUserManagerImpl.sharedInstance getAliasForCurrentUser]; + if (!alias) { + // When the user observer fires, it will drive a re-fetch + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer blocked by null alias"]; + shouldRetryGetInAppMessagesOnUserChange = true; + [self updateInAppMessagesFromCache]; + return; + } + + NSDictionary *header = [OneSignalUserManagerImpl.sharedInstance getCurrentUserFullHeader]; + if (!header) { + // When the JWT updated listener fires, it will drive a re-fetch + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer blocked by missing header"]; + shouldRetryGetInAppMessagesOnJwtUpdated = true; [self updateInAppMessagesFromCache]; return; } - OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId]; + OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId + withAliasLabel:alias.label + withAliasId:alias.id + withHeader:header]; [OneSignalCoreImpl.sharedClient executeRequest:request onSuccess:^(NSDictionary *result) { dispatch_async(dispatch_get_main_queue(), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer success"]; @@ -270,10 +323,19 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { }); } onFailure:^(NSError *error) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"getInAppMessagesFromServer failure: %@", error.localizedDescription]]; + OSResponseStatusType responseType = [OSNetworkingUtils getResponseStatusType:error.code]; + if (responseType == OSResponseStatusUnauthorized) { + shouldRetryGetInAppMessagesOnJwtUpdated = true; + [self handleUnauthroizedError:error externalId:alias.id]; + } [self updateInAppMessagesFromCache]; }]; } +- (void)handleUnauthroizedError:(NSError*)error externalId:(NSString *)externalId { + [OneSignalUserManagerImpl.sharedInstance invalidateJwtForExternalIdWithExternalId:externalId error:error]; +} + - (void)updateInAppMessagesFromServer:(NSArray *)newMessages { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"updateInAppMessagesFromServer"]; self.messages = newMessages; @@ -1087,7 +1149,27 @@ - (void)onPushSubscriptionDidChangeWithState:(OSPushSubscriptionChangedState * _ // Pull new IAMs when the subscription id changes to a new valid subscription id [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OSMessagingController onPushSubscriptionDidChange: changed to new valid subscription id"]; - [self getInAppMessagesFromServer:state.current.id]; + [self getInAppMessagesFromServer]; +} + +#pragma mark OSUserStateObserver Methods +- (void)onUserStateDidChangeWithState:(OSUserChangedState * _Nonnull)state { + if (state.current.onesignalId && shouldRetryGetInAppMessagesOnUserChange) { + shouldRetryGetInAppMessagesOnUserChange = false; + [self getInAppMessagesFromServer]; + } +} + +#pragma mark OSUserJwtConfigListener Methods +- (void)onRequiresUserAuthChangedFrom:(enum OSRequiresUserAuth)from to:(enum OSRequiresUserAuth)to { + // This callback is unused, the controller will fetch when subscription ID changes +} + +- (void)onJwtUpdatedWithExternalId:(NSString *)externalId token:(NSString *)token { + if (![token isEqual: OS_JWT_TOKEN_INVALID] && shouldRetryGetInAppMessagesOnJwtUpdated) { + shouldRetryGetInAppMessagesOnJwtUpdated = false; + [self getInAppMessagesFromServer]; + } } - (void)dealloc { diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OSInAppMessagingDefines.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OSInAppMessagingDefines.h index 9bda4a68b..8ee3db3b9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OSInAppMessagingDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OSInAppMessagingDefines.h @@ -28,6 +28,8 @@ #ifndef OSInAppMessagingDefines_h #define OSInAppMessagingDefines_h +// OSMessagingController name +#define OS_MESSAGING_CONTROLLER @"OSMessagingController" // IAM display position enums typedef NS_ENUM(NSUInteger, OSInAppMessageDisplayPosition) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.h index 11d0faa67..fda5f4def 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.h @@ -32,7 +32,7 @@ + (Class_Nonnull)InAppMessages; + (void)start; -+ (void)getInAppMessagesFromServer:(NSString * _Nullable)subscriptionId; ++ (void)getInAppMessagesFromServer; + (void)onApplicationDidBecomeActive; + (void)migrate; @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m index f9540813c..27fef03df 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m @@ -40,8 +40,8 @@ + (void)start { [OSMessagingController start]; } -+ (void)getInAppMessagesFromServer:(NSString * _Nullable)subscriptionId { - [OSMessagingController.sharedInstance getInAppMessagesFromServer:subscriptionId]; ++ (void)getInAppMessagesFromServer { + [OSMessagingController.sharedInstance getInAppMessagesFromServer]; } + (void)onApplicationDidBecomeActive { diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h index c37b4bbf1..2b1f2a381 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h @@ -29,7 +29,7 @@ #import "OSInAppMessageClickResult.h" @interface OSRequestGetInAppMessages : OneSignalRequest -+ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId; ++ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId withAliasLabel:(NSString * _Nonnull)aliasLabel withAliasId:(NSString * _Nonnull)aliasId withHeader:(NSDictionary * _Nonnull)header; @end @interface OSRequestInAppMessageViewed : OneSignalRequest diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m index 6f837a243..f0b2defe6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m @@ -28,11 +28,15 @@ of this software and associated documentation files (the "Software"), to deal #import "OSInAppMessagingRequests.h" @implementation OSRequestGetInAppMessages -+ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId { ++ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId + withAliasLabel:(NSString * _Nonnull)aliasLabel + withAliasId:(NSString * _Nonnull)aliasId + withHeader:(NSDictionary * _Nonnull)header { let request = [OSRequestGetInAppMessages new]; request.method = GET; NSString *appId = [OneSignalConfigManager getAppId]; - request.path = [NSString stringWithFormat:@"apps/%@/subscriptions/%@/iams", appId, subscriptionId]; + request.additionalHeaders = header; + request.path = [NSString stringWithFormat:@"apps/%@/users/by/%@/%@/subscriptions/%@/iams", appId, aliasLabel, aliasId, subscriptionId]; return request; } @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetStartToken.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetStartToken.swift index 81469d3f4..23b833538 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetStartToken.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetStartToken.swift @@ -28,6 +28,7 @@ import OneSignalCore import OneSignalUser +// TODO: JWT 🔐 This request needs the alias class OSRequestSetStartToken: OneSignalRequest, OSLiveActivityRequest, OSLiveActivityStartTokenRequest { override var description: String { return "(OSRequestSetStartToken) key:\(key) requestSuccessful:\(requestSuccessful) token:\(token)" } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift new file mode 100644 index 000000000..c0f9b161b --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift @@ -0,0 +1,39 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/** + An alias label and alias ID pair to represent a user. + */ +@objc public class OSAliasPair: NSObject { + @objc public let label: String + @objc public let id: String + + public init(_ label: String, _ id: String) { + self.label = label + self.id = id + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift new file mode 100644 index 000000000..5ebe2d7c5 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift @@ -0,0 +1,108 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore + +@objc +public enum OSRequiresUserAuth: Int { + case on = 1 + case off = -1 + case unknown = 0 + // TODO: JWT 🔐 consider additional reasons such as detecting this by dev calling loginWithJWT / onViaRemoteParams + + func isRequired() -> Bool? { + return switch self { + case .on: + true + case .off: + false + default: + nil + } + } +} + +/** + Internal listener. + */ +@objc public protocol OSUserJwtConfigListener { + func onRequiresUserAuthChanged(from: OSRequiresUserAuth, to: OSRequiresUserAuth) + func onJwtUpdated(externalId: String, token: String?) +} + +public class OSUserJwtConfig { + private let changeNotifier = OSEventProducer() + + private var requiresUserAuth: OSRequiresUserAuth { + didSet { + guard oldValue != requiresUserAuth else { + return + } + + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig.requiresUserAuth: changing from \(oldValue) to \(requiresUserAuth), firing listeners") + // Persist new value + OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: requiresUserAuth.rawValue) + + self.changeNotifier.fire { listener in + listener.onRequiresUserAuthChanged(from: oldValue, to: requiresUserAuth) + } + } + } + + public var isRequired: Bool? { + get { + return requiresUserAuth.isRequired() + } + set { + requiresUserAuth = switch newValue { + case true: + OSRequiresUserAuth.on + case false: + OSRequiresUserAuth.off + default: + OSRequiresUserAuth.unknown + } + } + } + + public init() { + let rawValue = OneSignalUserDefaults.initShared().getSavedInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, defaultValue: OSRequiresUserAuth.unknown.rawValue) + requiresUserAuth = OSRequiresUserAuth(rawValue: rawValue) ?? OSRequiresUserAuth.unknown + } + + public func subscribe(_ listener: OSUserJwtConfigListener, key: String) { + self.changeNotifier.subscribe(listener, key: key) + } + + public func onJwtTokenChanged(externalId: String, token: String?) { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig.onJwtTokenChanged for \(externalId) with token \(token ?? "nil"), firing listeners") + changeNotifier.fire { listener in + listener.onJwtUpdated(externalId: externalId, token: token) + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSEventProducer.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSEventProducer.swift index fd84d34c2..9f206d159 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSEventProducer.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSEventProducer.swift @@ -29,24 +29,27 @@ import Foundation import OneSignalCore public class OSEventProducer: NSObject { - // Not an array as there is at most 1 subsriber per OSEventProducer anyway - var subscriber: THandler? + private var subscribers: [String: THandler] = [:] + private let lock = NSLock() - public func subscribe(_ handler: THandler) { - // TODO: UM do we want to synchronize on subscribers - subscriber = handler // TODO: UM style, implicit or explicit self? + public func subscribe(_ handler: THandler, key: String) { + lock.withLock { + subscribers[key] = handler + } } - public func unsubscribe(_ handler: THandler) { + public func unsubscribe(_ handler: THandler, key: String) { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSEventProducer.unsubscribe() called with handler: \(handler)") - // TODO: UM do we want to synchronize on subscribers - subscriber = nil + lock.withLock { + subscribers.removeValue(forKey: key) + } } public func fire(callback: (THandler) -> Void) { - // dump(subscribers) -> uncomment for more verbose log during testing - if let subscriber = subscriber { - callback(subscriber) + lock.withLock { + for subscriber in subscribers.values { + callback(subscriber) + } } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift index 64a2f832c..c25691ffa 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift @@ -47,7 +47,7 @@ open class OSModelStore: NSObject { // listen for changes to the models for model in self.models.values { - model.changeNotifier.subscribe(self) + model.changeNotifier.subscribe(self, key: storeKey) } } @@ -96,7 +96,7 @@ open class OSModelStore: NSObject { OneSignalUserDefaults.initShared().saveCodeableData(forKey: self.storeKey, withValue: self.models) // listen for changes to this model - model.changeNotifier.subscribe(self) + model.changeNotifier.subscribe(self, key: storeKey) guard !hydrating else { return @@ -121,7 +121,7 @@ open class OSModelStore: NSObject { OneSignalUserDefaults.initShared().saveCodeableData(forKey: self.storeKey, withValue: self.models) // no longer listen for changes to this model - model.changeNotifier.unsubscribe(self) + model.changeNotifier.unsubscribe(self, key: storeKey) self.changeSubscription.fire { modelStoreListener in modelStoreListener.onRemoved(model) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift index 540dcca40..5602d1dc1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift @@ -31,9 +31,11 @@ import OneSignalCore public protocol OSModelStoreListener: OSModelStoreChangedHandler { associatedtype TModel: OSModel + var operationRepo: OSOperationRepo { get } + var store: OSModelStore { get } - init(store: OSModelStore) + init(store: OSModelStore, operationRepo: OSOperationRepo) func getAddModelDelta(_ model: TModel) -> OSDelta? @@ -44,11 +46,13 @@ public protocol OSModelStoreListener: OSModelStoreChangedHandler { extension OSModelStoreListener { public func start() { - store.changeSubscription.subscribe(self) + let key = store.storeKey + OS_MODEL_STORE_LISTENER_POSTFIX + store.changeSubscription.subscribe(self, key: key) } public func close() { - store.changeSubscription.unsubscribe(self) + let key = store.storeKey + OS_MODEL_STORE_LISTENER_POSTFIX + store.changeSubscription.unsubscribe(self, key: key) } public func onAdded(_ model: OSModel) { @@ -57,13 +61,13 @@ extension OSModelStoreListener { return } if let delta = getAddModelDelta(addedModel) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } public func onUpdated(_ args: OSModelChangedArgs) { if let delta = getUpdateModelDelta(args) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } @@ -74,7 +78,7 @@ extension OSModelStoreListener { return } if let delta = getRemoveModelDelta(removedModel) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index a59a92ac7..c95561288 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -32,8 +32,7 @@ import OneSignalCore The OSOperationRepo is a static singleton. OSDeltas are enqueued when model store observers observe changes to their models, and sorted to their appropriate executors. */ -public class OSOperationRepo: NSObject { - public static let sharedInstance = OSOperationRepo() +public class OSOperationRepo { private var hasCalledStart = false // The Operation Repo dispatch queue, serial. This synchronizes access to `deltaQueue` and flushing behavior. @@ -47,16 +46,35 @@ public class OSOperationRepo: NSObject { // TODO: This could come from a config, plist, method, remote params var pollIntervalMilliseconds = Int(POLL_INTERVAL_MS) public var paused = false + let jwtConfig: OSUserJwtConfig /** - Initilize this Operation Repo. Read from the cache. Executors may not be available by this time. - If everything starts up on initialize(), order can matter, ideally not but it can. - Likely call init on this from oneSignal but exeuctors can come from diff modules. + Sets the jwt config and uncaches + */ + public init(jwtConfig: OSUserJwtConfig) { + self.jwtConfig = jwtConfig + self.jwtConfig.subscribe(self, key: OS_OPERATION_REPO) + // Read the Deltas from cache, if any... + guard let deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo is unable to uncache the OSDelta queue.") + return + } + self.deltaQueue = deltaQueue + } + + /** + Start this Operation Repo. */ public func start() { guard !OneSignalConfigManager.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } + + guard jwtConfig.isRequired != nil else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo.start() returning early due to unknown Identity Verification status") + return + } + guard !hasCalledStart else { return } @@ -68,13 +86,6 @@ public class OSOperationRepo: NSObject { selector: #selector(self.addFlushDeltaQueueToDispatchQueue), name: Notification.Name(OS_ON_USER_WILL_CHANGE), object: nil) - // Read the Deltas from cache, if any... - if let deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { - self.deltaQueue = deltaQueue - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo.start() with deltaQueue: \(deltaQueue)") - } else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo.start() is unable to uncache the OSDelta queue.") - } pollFlushQueue() } @@ -87,13 +98,12 @@ public class OSOperationRepo: NSObject { } /** - Add and start an executor. + Add an executor. */ public func addExecutor(_ executor: OSOperationExecutor) { guard !OneSignalConfigManager.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } - start() executors.append(executor) for delta in executor.supportedDeltas { deltasToExecutorMap[delta] = executor @@ -111,7 +121,6 @@ public class OSOperationRepo: NSObject { guard !OneSignalConfigManager.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } - start() self.dispatchQueue.async { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo enqueueDelta: \(delta)") self.deltaQueue.append(delta) @@ -140,8 +149,6 @@ public class OSOperationRepo: NSObject { OSBackgroundTaskManager.beginBackgroundTask(OPERATION_REPO_BACKGROUND_TASK) } - self.start() - if !self.deltaQueue.isEmpty { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo flushDeltaQueue in background: \(inBackground) with queue: \(self.deltaQueue)") } @@ -174,8 +181,40 @@ public class OSOperationRepo: NSObject { } } +extension OSOperationRepo: OSUserJwtConfigListener { + public func onRequiresUserAuthChanged(from: OSRequiresUserAuth, to: OSRequiresUserAuth) { + // If auth changed from false or unknown to true, process deltas + if to == .on { + removeInvalidDeltas() + } + start() + } + + public func onJwtUpdated(externalId: String, token: String?) { + // Not used for now + } + + /** + TODO: The operation repo cannot easily remove invalid Deltas that do not have an External ID. + Deltas have an Identity Model ID only and would need to access the User module to find the corresponding Identity Model. + Executors will handle this. + */ + func removeInvalidDeltas() { + // Not used for now + } +} + extension OSOperationRepo: OSLoggable { public func logSelf() { - // TODO: You fill in + OneSignalLog.onesignalLog(.LL_VERBOSE, message: + """ + Operation Repo: deltaQueue: \(self.deltaQueue) + + Operation Repo: executors that are subscribed: + """ + ) + for executor in self.executors { + executor.logSelf() + } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift index 6a144eef7..c460c0e24 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift @@ -31,9 +31,7 @@ import OneSignalCore @objc public class OSCoreMocks: NSObject { - public static func resetOperationRepo() { - OSOperationRepo.sharedInstance.reset() - } + // TODO: Add mocks here } extension OSOperationRepo { @@ -41,7 +39,7 @@ extension OSOperationRepo { The Operation Repo needs to reset between tests until we dependency inject the Operation Repo, to prevent state from carrying over between tests. */ - func reset() { + public func reset() { deltaQueue.removeAll() executors.removeAll() deltasToExecutorMap.removeAll() diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index c6f7c468f..f57ea8a1a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -34,83 +34,134 @@ class OSIdentityOperationExecutor: OSOperationExecutor { // To simplify uncaching, we maintain separate request queues for each type var addRequestQueue: [OSRequestAddAliases] = [] var removeRequestQueue: [OSRequestRemoveAlias] = [] + var pendingAuthRequests: [String: [OSUserRequest]] = [String: [OSUserRequest]]() let newRecordsState: OSNewRecordsState + let jwtConfig: OSUserJwtConfig // The Identity executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSIdentityOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, jwtConfig: OSUserJwtConfig) { self.newRecordsState = newRecordsState + self.jwtConfig = jwtConfig + self.jwtConfig.subscribe(self, key: OS_IDENTITY_EXECUTOR) // Read unfinished deltas and requests from cache, if any... uncacheDeltas() - uncacheAddAliasRequests() - uncacheRemoveAliasRequests() + uncacheRequests() } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { - // Hook each uncached Delta to the model in the store - for (index, delta) in deltaQueue.enumerated().reversed() { - if let modelInStore = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.model.modelId) { - // The model exists in the repo, set it to be the Delta's model - delta.model = modelInStore - } else { - // The model does not exist, drop this Delta - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor.init dropped \(delta)") - deltaQueue.remove(at: index) - } - } - self.deltaQueue = deltaQueue - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) - } else { + guard var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor error encountered reading from cache for \(OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY)") + return } + + // Hook each uncached Delta to the model in the store + for (index, delta) in deltaQueue.enumerated().reversed() { + if jwtConfig.isRequired == true, + (delta.model as? OSIdentityModel)?.externalId == nil + { + // remove if jwt is on but the model does not have external ID + deltaQueue.remove(at: index) + continue + } + + if let modelInStore = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.model.modelId) { + // The model exists in the repo, set it to be the Delta's model + delta.model = modelInStore + } else { + // The model does not exist, drop this Delta + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor.init dropped \(delta)") + deltaQueue.remove(at: index) + } + } + + self.deltaQueue = deltaQueue + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) } - private func uncacheAddAliasRequests() { - if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestAddAliases] { - // Hook each uncached Request to the model in the store - for (index, request) in addRequestQueue.enumerated().reversed() { - if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { - // 1. The model exists in the repo, so set it to be the Request's models - request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo - OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) - } else { - // 3. The model do not exist AND this request cannot be sent, drop this Request - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor.init dropped \(request)") - addRequestQueue.remove(at: index) + private func uncacheRequests() { + var addRequestQueue: [OSRequestAddAliases] = [] + var removeRequestQueue: [OSRequestRemoveAlias] = [] + + if let cachedAddRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestAddAliases] { + addRequestQueue = cachedAddRequestQueue + } + + if let cachedRemoveRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestRemoveAlias] { + removeRequestQueue = cachedRemoveRequestQueue + } + + if let pendingRequests = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, defaultValue: [:]) as? [String: [OSUserRequest]] { + for requests in pendingRequests.values { + for request in requests { + if request.isKind(of: OSRequestAddAliases.self), let req = request as? OSRequestAddAliases { + addRequestQueue.append(req) + } else if request.isKind(of: OSRequestRemoveAlias.self), let req = request as? OSRequestRemoveAlias { + removeRequestQueue.append(req) + } } } - self.addRequestQueue = addRequestQueue - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) - } else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor error encountered reading from cache for \(OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY)") } + + linkAddAliasRequests(requests: &addRequestQueue) + linkRemoveAliasRequests(requests: &removeRequestQueue) } - private func uncacheRemoveAliasRequests() { - if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestRemoveAlias] { - // Hook each uncached Request to the model in the store - for (index, request) in removeRequestQueue.enumerated().reversed() { - if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { - // 1. The model exists in the repo, so set it to be the Request's model - request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo - OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) - } else { - // 3. The model does not exist AND this request cannot be sent, drop this Request - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor.init dropped \(request)") - removeRequestQueue.remove(at: index) - } + private func linkAddAliasRequests(requests: inout [OSRequestAddAliases]) { + // Hook each uncached Request to the model in the store + for (index, request) in requests.enumerated().reversed() { + if jwtConfig.isRequired == true, + request.identityModel.externalId == nil + { + // remove if jwt is on but the model does not have external ID + requests.remove(at: index) + continue + } + + if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { + // 1. The model exists in the repo, so set it to be the Request's models + request.identityModel = identityModel + } else if request.prepareForExecution(newRecordsState: newRecordsState) { + // 2. The request can be sent, add the model to the repo + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + } else { + // 3. The model do not exist AND this request cannot be sent, drop this Request + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor.init dropped \(request)") + requests.remove(at: index) } - self.removeRequestQueue = removeRequestQueue - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) - } else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor error encountered reading from cache for \(OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY)") } + + self.addRequestQueue = requests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } + + private func linkRemoveAliasRequests(requests: inout [OSRequestRemoveAlias]) { + // Hook each uncached Request to the model in the store + for (index, request) in requests.enumerated().reversed() { + if jwtConfig.isRequired == true, + request.identityModel.externalId == nil + { + // remove if jwt is on but the model does not have external ID + requests.remove(at: index) + continue + } + + if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { + // 1. The model exists in the repo, so set it to be the Request's models + request.identityModel = identityModel + } else if request.prepareForExecution(newRecordsState: newRecordsState) { + // 2. The request can be sent, add the model to the repo + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + } else { + // 3. The model do not exist AND this request cannot be sent, drop this Request + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor.init dropped \(request)") + requests.remove(at: index) + } + } + + self.removeRequestQueue = requests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) } func enqueueDelta(_ delta: OSDelta) { @@ -126,6 +177,19 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } } + /** + This method does not handle concurrency; it should be called with thread-safe usage. + */ + private func removeFromRequestQueueAndPersist(_ request: OSUserRequest) { + if request.isKind(of: OSRequestAddAliases.self) { + self.addRequestQueue.removeAll(where: { $0 == request}) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } else if request.isKind(of: OSRequestRemoveAlias.self) { + self.removeRequestQueue.removeAll(where: { $0 == request}) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } + } + func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { if !self.deltaQueue.isEmpty { @@ -139,6 +203,11 @@ class OSIdentityOperationExecutor: OSOperationExecutor { continue } + // If JWT is on but the external ID does not exist, drop this Delta + if self.jwtConfig.isRequired == true, model.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSIdentityOperationExecutor.processDeltaQueue dropped \(delta)") + } + switch delta.name { case OS_ADD_ALIAS_DELTA: let request = OSRequestAddAliases(aliases: aliases, identityModel: model) @@ -189,10 +258,38 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } } + func handleUnauthorizedError(externalId: String, error: NSError, request: OSUserRequest) { + if jwtConfig.isRequired ?? false { + self.pendRequestUntilAuthUpdated(request, externalId: externalId) + OneSignalUserManagerImpl.sharedInstance.invalidateJwtForExternalId(externalId: externalId, error: error) + } + } + + func pendRequestUntilAuthUpdated(_ request: OSUserRequest, externalId: String?) { + self.dispatchQueue.async { + self.removeFromRequestQueueAndPersist(request) + guard let externalId = externalId else { + return + } + var requests = self.pendingAuthRequests[externalId] ?? [] + let inQueue = requests.contains(where: {$0 == request}) + guard !inQueue else { + return + } + requests.append(request) + self.pendingAuthRequests[externalId] = requests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + } + } + func executeAddAliasesRequest(_ request: OSRequestAddAliases, inBackground: Bool) { guard !request.sentToClient else { return } + guard request.addJWTHeaderIsValid(identityModel: request.identityModel) else { + pendRequestUntilAuthUpdated(request, externalId: request.identityModel.externalId) + return + } guard request.prepareForExecution(newRecordsState: newRecordsState) else { return } @@ -209,21 +306,17 @@ class OSIdentityOperationExecutor: OSOperationExecutor { // No hydration from response // On success, remove request from cache self.dispatchQueue.async { - self.addRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + self.removeFromRequestQueueAndPersist(request) if inBackground { OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) } } } onFailure: { error in - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor add aliases request failed with error: \(error.debugDescription)") self.dispatchQueue.async { if let nsError = error as? NSError { let responseType = OSNetworkingUtils.getResponseStatusType(nsError.code) if responseType == .missing { - // Remove from cache and queue - self.addRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + self.removeFromRequestQueueAndPersist(request) // Logout if the user in the SDK is the same guard OneSignalUserManagerImpl.sharedInstance.isCurrentUser(request.identityModel) else { @@ -235,10 +328,14 @@ class OSIdentityOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized && (self.jwtConfig.isRequired ?? false) { + if let externalId = request.identityModel.externalId { + self.handleUnauthorizedError(externalId: externalId, error: nsError, request: request) + } + request.sentToClient = false } else if responseType != .retryable { // Fail, no retry, remove from cache and queue - self.addRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + self.removeFromRequestQueueAndPersist(request) } } if inBackground { @@ -252,6 +349,10 @@ class OSIdentityOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } + guard request.addJWTHeaderIsValid(identityModel: request.identityModel) else { + pendRequestUntilAuthUpdated(request, externalId: request.identityModel.externalId) + return + } guard request.prepareForExecution(newRecordsState: newRecordsState) else { return } @@ -268,8 +369,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { // There is nothing to hydrate // On success, remove request from cache self.dispatchQueue.async { - self.removeRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + self.removeFromRequestQueueAndPersist(request) if inBackground { OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) } @@ -279,11 +379,15 @@ class OSIdentityOperationExecutor: OSOperationExecutor { self.dispatchQueue.async { if let nsError = error as? NSError { let responseType = OSNetworkingUtils.getResponseStatusType(nsError.code) - if responseType != .retryable { + if responseType == .unauthorized && (self.jwtConfig.isRequired ?? false) { + if let externalId = request.identityModel.externalId { + self.handleUnauthorizedError(externalId: externalId, error: nsError, request: request) + } + request.sentToClient = false + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue // A response of .missing could mean the alias doesn't exist on this user OR this user has been deleted - self.removeRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + self.removeFromRequestQueueAndPersist(request) } } if inBackground { @@ -294,8 +398,78 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } } +extension OSIdentityOperationExecutor: OSUserJwtConfigListener { + func onRequiresUserAuthChanged(from: OSRequiresUserAuth, to: OSRequiresUserAuth) { + // If auth changed from false or unknown to true, process requests + if to == .on { + removeInvalidDeltasAndRequests() + } + } + + func onJwtUpdated(externalId: String, token: String?) { + reQueuePendingRequestsForExternalId(externalId: externalId) + } + + private func reQueuePendingRequestsForExternalId(externalId: String) { + self.dispatchQueue.async { + guard let requests = self.pendingAuthRequests[externalId] else { + return + } + for request in requests { + if let addRequest = request as? OSRequestAddAliases { + self.addRequestQueue.append(addRequest) + } else if let removeRequest = request as? OSRequestRemoveAlias { + self.removeRequestQueue.append(removeRequest) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + self.pendingAuthRequests[externalId] = nil + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + self.processRequestQueue(inBackground: false) + } + } + + private func removeInvalidDeltasAndRequests() { + self.dispatchQueue.async { + for (index, delta) in self.deltaQueue.enumerated().reversed() { + if (delta.model as? OSIdentityModel)?.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSIdentityOperationExecutor.removeInvalidDeltasAndRequests dropped \(delta)") + self.deltaQueue.remove(at: index) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + + for (index, request) in self.addRequestQueue.enumerated().reversed() { + if request.identityModel.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSIdentityOperationExecutor.removeInvalidDeltasAndRequests dropped \(request)") + self.addRequestQueue.remove(at: index) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + + for (index, request) in self.removeRequestQueue.enumerated().reversed() { + if request.identityModel.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSIdentityOperationExecutor.removeInvalidDeltasAndRequests dropped \(request)") + self.removeRequestQueue.remove(at: index) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } + } +} + extension OSIdentityOperationExecutor: OSLoggable { func logSelf() { - // TODO: You fill in + OneSignalLog.onesignalLog(.LL_VERBOSE, message: + """ + OSIdentityOperationExecutor has the following queues: + addRequestQueue: \(self.addRequestQueue) + removeRequestQueue: \(self.removeRequestQueue) + deltaQueue: \(self.deltaQueue) + pendingAuthRequests: \(self.pendingAuthRequests) + + """ + ) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 377fa2bdc..b2e2bf25b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -63,14 +63,18 @@ private struct OSCombinedProperties { class OSPropertyOperationExecutor: OSOperationExecutor { var supportedDeltas: [String] = [OS_UPDATE_PROPERTIES_DELTA] var deltaQueue: [OSDelta] = [] + var pendingAuthRequests: [String: [OSRequestUpdateProperties]] = [String: [OSRequestUpdateProperties]]() var updateRequestQueue: [OSRequestUpdateProperties] = [] let newRecordsState: OSNewRecordsState + let jwtConfig: OSUserJwtConfig // The property executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `updateRequestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSPropertyOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, jwtConfig: OSUserJwtConfig) { self.newRecordsState = newRecordsState + self.jwtConfig = jwtConfig + self.jwtConfig.subscribe(self, key: OS_PROPERTIES_EXECUTOR) // Read unfinished deltas and requests from cache, if any... // Note that we should only have deltas for the current user as old ones are flushed.. uncacheDeltas() @@ -80,10 +84,17 @@ class OSPropertyOperationExecutor: OSOperationExecutor { private func uncacheDeltas() { if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { for (index, delta) in deltaQueue.enumerated().reversed() { - if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { + guard let model = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) else { // The identity model does not exist, drop this Delta OneSignalLog.onesignalLog(.LL_WARN, message: "OSPropertyOperationExecutor.init dropped: \(delta)") deltaQueue.remove(at: index) + continue + } + + // If JWT is on but the external ID does not exist, drop this Delta + if jwtConfig.isRequired == true, model.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSPropertyOperationExecutor.uncacheDeltas dropped \(delta)") + deltaQueue.remove(at: index) } } self.deltaQueue = deltaQueue @@ -94,26 +105,44 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } private func uncacheUpdateRequests() { - if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestUpdateProperties] { - // Hook each uncached Request to the model in the store - for (index, request) in updateRequestQueue.enumerated().reversed() { - if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { - // 1. The identity model exist in the repo, set it to be the Request's model - request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo - OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) - } else { - // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request - OneSignalLog.onesignalLog(.LL_WARN, message: "OSPropertyOperationExecutor.init dropped: \(request)") - updateRequestQueue.remove(at: index) + var updateRequestQueue: [OSRequestUpdateProperties] = [] + + if let cachedQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestUpdateProperties] { + updateRequestQueue = cachedQueue + } + + if let pendingRequests = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, defaultValue: [:]) as? [String: [OSRequestUpdateProperties]] { + for requests in pendingRequests.values { + for request in requests { + updateRequestQueue.append(request) } } - self.updateRequestQueue = updateRequestQueue - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) - } else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertyOperationExecutor error encountered reading from cache for \(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY)") } + + // Hook each uncached Request to the model in the store + for (index, request) in updateRequestQueue.enumerated().reversed() { + if jwtConfig.isRequired == true, + request.identityModel.externalId == nil + { + // remove if jwt is on but the model does not have external ID + updateRequestQueue.remove(at: index) + continue + } + + if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { + // 1. The identity model exist in the repo, set it to be the Request's model + request.identityModel = identityModel + } else if request.prepareForExecution(newRecordsState: newRecordsState) { + // 2. The request can be sent, add the model to the repo + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + } else { + // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request + OneSignalLog.onesignalLog(.LL_WARN, message: "OSPropertyOperationExecutor.init dropped: \(request)") + updateRequestQueue.remove(at: index) + } + } + self.updateRequestQueue = updateRequestQueue + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) } func enqueueDelta(_ delta: OSDelta) { @@ -129,9 +158,17 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } } - /// The `deltaQueue` should only contain updates for one user. - /// Even when login -> addTag -> login -> addTag are called in immediate succession. + /** + The `deltaQueue` should typically only contain updates for one user + Even when login -> addTag -> login -> addTag are called in immediate succession. + However, when Identity Verification requirements are unknown, we keep deltas in the queue even when users switch. + */ func processDeltaQueue(inBackground: Bool) { + guard jwtConfig.isRequired != nil else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSPropertyOperationExecutor processDeltaQueue returning early due to requiresAuth: \(String(describing: jwtConfig.isRequired))") + return + } + self.dispatchQueue.async { if self.deltaQueue.isEmpty { // Delta queue is empty but there may be pending requests @@ -140,7 +177,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSPropertyOperationExecutor processDeltaQueue with queue: \(self.deltaQueue)") - // Holds mapping of identity model ID to the updates for it; there should only be one user + // Holds mapping of identity model ID to the updates for it var combinedProperties: [String: OSCombinedProperties] = [:] // 1. Combined deltas into a single OSCombinedProperties for every user @@ -148,16 +185,19 @@ class OSPropertyOperationExecutor: OSOperationExecutor { guard let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertyOperationExecutor.processDeltaQueue dropped: \(delta)") + // ECM Remove the delta here. Need an iterator to do it in place continue } + + // If JWT is on but the external ID does not exist, drop this Delta + if self.jwtConfig.isRequired == true, identityModel.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSPropertyOperationExecutor.processDeltaQueue dropped \(delta)") + } + let combinedSoFar: OSCombinedProperties? = combinedProperties[identityModel.modelId] combinedProperties[identityModel.modelId] = self.combineProperties(existing: combinedSoFar, delta: delta) } - if combinedProperties.count > 1 { - OneSignalLog.onesignalLog(.LL_WARN, message: "OSPropertyOperationExecutor.combinedProperties contains \(combinedProperties.count) users") - } - // 2. Turn each OSCombinedProperties' data into a Request for (modelId, properties) in combinedProperties { guard let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId) @@ -231,13 +271,45 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } } + func handleUnauthorizedError(externalId: String, error: NSError, request: OSRequestUpdateProperties) { + if jwtConfig.isRequired ?? false { + self.pendRequestUntilAuthUpdated(request, externalId: externalId) + OneSignalUserManagerImpl.sharedInstance.invalidateJwtForExternalId(externalId: externalId, error: error) + } + } + + func pendRequestUntilAuthUpdated(_ request: OSRequestUpdateProperties, externalId: String?) { + self.dispatchQueue.async { + self.updateRequestQueue.removeAll(where: { $0 == request}) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + guard let externalId = externalId else { + return + } + var requests = self.pendingAuthRequests[externalId] ?? [] + let inQueue = requests.contains(where: {$0 == request}) + guard !inQueue else { + return + } + requests.append(request) + self.pendingAuthRequests[externalId] = requests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + } + } + func executeUpdatePropertiesRequest(_ request: OSRequestUpdateProperties, inBackground: Bool) { guard !request.sentToClient else { return } + + guard request.addJWTHeaderIsValid(identityModel: request.identityModel) else { + pendRequestUntilAuthUpdated(request, externalId: request.identityModel.externalId) + return + } + guard request.prepareForExecution(newRecordsState: newRecordsState) else { return } + request.sentToClient = true let backgroundTaskIdentifier = PROPERTIES_EXECUTOR_BACKGROUND_TASK + UUID().uuidString @@ -256,7 +328,6 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } } } onFailure: { error in - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertyOperationExecutor update properties request failed with error: \(error.debugDescription)") self.dispatchQueue.async { if let nsError = error as? NSError { let responseType = OSNetworkingUtils.getResponseStatusType(nsError.code) @@ -275,6 +346,11 @@ class OSPropertyOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized && (self.jwtConfig.isRequired ?? false) { + if let externalId = request.identityModel.externalId { + self.handleUnauthorizedError(externalId: externalId, error: nsError, request: request) + } + request.sentToClient = false } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.updateRequestQueue.removeAll(where: { $0 == request}) @@ -289,8 +365,66 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } } +extension OSPropertyOperationExecutor: OSUserJwtConfigListener { + func onRequiresUserAuthChanged(from: OSRequiresUserAuth, to: OSRequiresUserAuth) { + // If auth changed from false or unknown to true, process requests + if to == .on { + removeInvalidDeltasAndRequests() + } + } + + func onJwtUpdated(externalId: String, token: String?) { + reQueuePendingRequestsForExternalId(externalId: externalId) + } + + private func reQueuePendingRequestsForExternalId(externalId: String) { + self.dispatchQueue.async { + guard let requests = self.pendingAuthRequests[externalId] else { + return + } + for request in requests { + self.updateRequestQueue.append(request) + } + self.pendingAuthRequests[externalId] = nil + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + self.processRequestQueue(inBackground: false) + } + } + + private func removeInvalidDeltasAndRequests() { + self.dispatchQueue.async { + for (index, delta) in self.deltaQueue.enumerated().reversed() { + if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId), + identityModel.externalId == nil + { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSPropertyOperationExecutor.removeInvalidDeltasAndRequests dropped \(delta)") + self.deltaQueue.remove(at: index) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + + for (index, request) in self.updateRequestQueue.enumerated().reversed() { + if request.identityModel.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSPropertyOperationExecutor.removeInvalidDeltasAndRequests dropped \(request)") + self.updateRequestQueue.remove(at: index) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + } + } +} + extension OSPropertyOperationExecutor: OSLoggable { func logSelf() { - // TODO: You fill in + OneSignalLog.onesignalLog(.LL_VERBOSE, message: + """ + OSPropertyOperationExecutor has the following queues: + updateRequestQueue: \(self.updateRequestQueue) + deltaQueue: \(self.deltaQueue) + pendingAuthRequests: \(self.pendingAuthRequests) + + """ + ) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index 345fb1b04..e98ab7e4f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -35,19 +35,21 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { var addRequestQueue: [OSRequestCreateSubscription] = [] var removeRequestQueue: [OSRequestDeleteSubscription] = [] var updateRequestQueue: [OSRequestUpdateSubscription] = [] + var pendingAuthRequests: [String: [OSUserRequest]] = [String: [OSUserRequest]]() var subscriptionModels: [String: OSSubscriptionModel] = [:] let newRecordsState: OSNewRecordsState + let jwtConfig: OSUserJwtConfig // The Subscription executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSubscriptionOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + // TODO: JWT 🔐 Subscription Executor updates are still WIP + init(newRecordsState: OSNewRecordsState, jwtConfig: OSUserJwtConfig) { self.newRecordsState = newRecordsState - // Read unfinished deltas and requests from cache, if any... + self.jwtConfig = jwtConfig + self.jwtConfig.subscribe(self, key: OS_SUBSCRIPTION_EXECUTOR) uncacheDeltas() - uncacheCreateSubscriptionRequests() - uncacheDeleteSubscriptionRequests() - uncacheUpdateSubscriptionRequests() + uncacheRequests() } private func uncacheDeltas() { @@ -70,65 +72,102 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } - private func uncacheCreateSubscriptionRequests() { - var requestQueue: [OSRequestCreateSubscription] = [] + private func uncacheRequests() { + // Uncache the Create and Delete requests from the queues and pending queue + + var addRequestQueue: [OSRequestCreateSubscription] = [] + var removeRequestQueue: [OSRequestDeleteSubscription] = [] if let cachedAddRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestCreateSubscription] { - // Hook each uncached Request to the model in the store - for request in cachedAddRequestQueue { - // 1. Hook up the subscription model - if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { - // a. The model exist in the store, set it to be the Request's models - request.subscriptionModel = subscriptionModel - } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { - // b. The model exists in the dictionary of seen models - request.subscriptionModel = subscriptionModel - } else { - // c. The model has not been seen yet, add to dict - subscriptionModels[request.subscriptionModel.modelId] = request.subscriptionModel - } - // 2. Hook up the identity model - if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { - // a. The model exist in the repo - request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // b. The request can be sent, add the model to the repo - OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) - } else { - // c. The model do not exist AND this request cannot be sent, drop this Request - OneSignalLog.onesignalLog(.LL_WARN, message: "OSSubscriptionOperationExecutor.init dropped: \(request)") - continue + addRequestQueue = cachedAddRequestQueue + } + if let cachedRemoveRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestDeleteSubscription] { + removeRequestQueue = cachedRemoveRequestQueue + } + + if let pendingRequests = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, defaultValue: [:]) as? [String: [OSUserRequest]] { + for requests in pendingRequests.values { + for request in requests { + if request.isKind(of: OSRequestCreateSubscription.self), let req = request as? OSRequestCreateSubscription { + addRequestQueue.append(req) + } else if request.isKind(of: OSRequestDeleteSubscription.self), let req = request as? OSRequestDeleteSubscription { + removeRequestQueue.append(req) + } } - requestQueue.append(request) } - self.addRequestQueue = requestQueue - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) - } else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor error encountered reading from cache for \(OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY)") } + + linkCreateSubscriptionRequests(requests: addRequestQueue) + linkDeleteSubscriptionRequests(requests: &removeRequestQueue) + // Update Requests are not added to the pending queues + uncacheUpdateSubscriptionRequests() } - private func uncacheDeleteSubscriptionRequests() { - if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestDeleteSubscription] { - // Hook each uncached Request to the model in the store - for (index, request) in removeRequestQueue.enumerated().reversed() { - if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { - // 1. The model exists in the store, set it to be the Request's model - request.subscriptionModel = subscriptionModel - } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { - // 2. The model exists in the dict of seen subscription models - request.subscriptionModel = subscriptionModel - } else if !request.prepareForExecution(newRecordsState: newRecordsState) { - // 3. The model does not exist AND this request cannot be sent, drop this Request - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.init dropped \(request)") - removeRequestQueue.remove(at: index) - } + private func linkCreateSubscriptionRequests(requests: [OSRequestCreateSubscription]) { + var requestQueue: [OSRequestCreateSubscription] = [] + + // Hook each uncached Request to the model in the store + for request in requests { + // 1. Hook up the subscription model + if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { + // a. The model exist in the store, set it to be the Request's models + request.subscriptionModel = subscriptionModel + } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { + // b. The model exists in the dictionary of seen models + request.subscriptionModel = subscriptionModel + } else { + // c. The model has not been seen yet, add to dict + subscriptionModels[request.subscriptionModel.modelId] = request.subscriptionModel + } + // 2. Hook up the identity model + if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { + // a. The model exist in the repo + request.identityModel = identityModel + } else if request.prepareForExecution(newRecordsState: newRecordsState) { + // b. The request can be sent, add the model to the repo + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + } else { + // c. The model do not exist AND this request cannot be sent, drop this Request + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSubscriptionOperationExecutor.init dropped: \(request)") + continue + } + requestQueue.append(request) + } + self.addRequestQueue = requestQueue + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + + } + + private func linkDeleteSubscriptionRequests(requests: inout [OSRequestDeleteSubscription]) { + // Hook each uncached Request to the model in the store + for (index, request) in requests.enumerated().reversed() { + // 1. Hook up the subscription model + if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { + // a. The model exists in the store, set it to be the Request's model + request.subscriptionModel = subscriptionModel + } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { + // b. The model exists in the dict of seen subscription models + request.subscriptionModel = subscriptionModel + } else if !request.prepareForExecution(newRecordsState: newRecordsState) { + // c. The model does not exist AND this request cannot be sent, drop this Request + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.init dropped \(request)") + requests.remove(at: index) + } + // 2. Hook up the identity model + if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { + // a. The model exist in the repo + request.identityModel = identityModel + } else if request.prepareForExecution(newRecordsState: newRecordsState) { + // b. The request can be sent, add the model to the repo + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + } else { + // c. The model do not exist AND this request cannot be sent, drop this Request + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSubscriptionOperationExecutor.init dropped: \(request)") + requests.remove(at: index) } - self.removeRequestQueue = removeRequestQueue - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) - } else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor error encountered reading from cache for \(OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY)") } + self.removeRequestQueue = requests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) } private func uncacheUpdateSubscriptionRequests() { @@ -180,6 +219,22 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } + /** + This method does not handle concurrency; it should be called with thread-safe usage. + */ + private func removeFromRequestQueueAndPersist(_ request: OSUserRequest) { + if request.isKind(of: OSRequestCreateSubscription.self) { + self.addRequestQueue.removeAll(where: { $0 == request}) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } else if request.isKind(of: OSRequestDeleteSubscription.self) { + self.removeRequestQueue.removeAll(where: { $0 == request}) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } else if request.isKind(of: OSRequestUpdateSubscription.self) { + self.updateRequestQueue.removeAll(where: { $0 == request}) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + } + } + func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { if !self.deltaQueue.isEmpty { @@ -195,18 +250,37 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { switch delta.name { case OS_ADD_SUBSCRIPTION_DELTA: // Only create the request if the identity model exists - if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) { - let request = OSRequestCreateSubscription( - subscriptionModel: subModel, - identityModel: identityModel - ) - self.addRequestQueue.append(request) - } else { + guard let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.processDeltaQueue dropped \(delta)") + continue } + + // If JWT is on but the external ID does not exist, drop this Delta + if self.jwtConfig.isRequired == true, identityModel.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSSubscriptionOperationExecutor.processDeltaQueue dropped \(delta)") + } + + let request = OSRequestCreateSubscription( + subscriptionModel: subModel, + identityModel: identityModel + ) + self.addRequestQueue.append(request) + case OS_REMOVE_SUBSCRIPTION_DELTA: + // Only create the request if the identity model exists + guard let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.processDeltaQueue dropped \(delta)") + continue + } + + // If JWT is on but the external ID does not exist, drop this Delta + if self.jwtConfig.isRequired == true, identityModel.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSSubscriptionOperationExecutor.processDeltaQueue dropped \(delta)") + } + let request = OSRequestDeleteSubscription( - subscriptionModel: subModel + subscriptionModel: subModel, + identityModel: identityModel ) self.removeRequestQueue.append(request) @@ -268,11 +342,19 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } } +} +// MARK: - Execution + +extension OSSubscriptionOperationExecutor { func executeCreateSubscriptionRequest(_ request: OSRequestCreateSubscription, inBackground: Bool) { guard !request.sentToClient else { return } + guard request.addJWTHeaderIsValid(identityModel: request.identityModel) else { + pendRequestUntilAuthUpdated(request, externalId: request.identityModel.externalId) + return + } guard request.prepareForExecution(newRecordsState: newRecordsState) else { return } @@ -287,9 +369,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { OneSignalCoreImpl.sharedClient().execute(request) { result in // On success, remove request from cache (even if not hydrating model), and hydrate model self.dispatchQueue.async { - self.addRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) - + self.removeFromRequestQueueAndPersist(request) guard let response = result?["subscription"] as? [String: Any] else { OneSignalLog.onesignalLog(.LL_ERROR, message: "Unabled to parse response to create subscription request") if inBackground { @@ -308,8 +388,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { if let nsError = error as? NSError { let responseType = OSNetworkingUtils.getResponseStatusType(nsError.code) if responseType == .missing { - self.addRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + self.removeFromRequestQueueAndPersist(request) // Logout if the user in the SDK is the same guard OneSignalUserManagerImpl.sharedInstance.isCurrentUser(request.identityModel) else { @@ -321,10 +400,14 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized && (self.jwtConfig.isRequired ?? false) { + if let externalId = request.identityModel.externalId { + self.handleUnauthorizedError(externalId: externalId, error: nsError, request: request) + } + request.sentToClient = false } else if responseType != .retryable { // Fail, no retry, remove from cache and queue - self.addRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + self.removeFromRequestQueueAndPersist(request) } } if inBackground { @@ -338,6 +421,11 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } + // ECM TODO - Delete Subscription, not supported on JWT yet (9-23-2024) +// guard request.addJWTHeaderIsValid(identityModel: request.identityModel) else { +// pendRequestUntilAuthUpdated(request, externalId:request.identityModel.externalId) +// return +// } guard request.prepareForExecution(newRecordsState: newRecordsState) else { return } @@ -354,8 +442,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { // On success, remove request from cache. No model hydration occurs. // For example, if app restarts and we read in operations between sending this off and getting the response self.dispatchQueue.async { - self.removeRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + self.removeFromRequestQueueAndPersist(request) if inBackground { OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) } @@ -365,11 +452,15 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { self.dispatchQueue.async { if let nsError = error as? NSError { let responseType = OSNetworkingUtils.getResponseStatusType(nsError.code) - if responseType != .retryable { + if responseType == .unauthorized && (self.jwtConfig.isRequired ?? false) { + if let externalId = request.identityModel.externalId { + self.handleUnauthorizedError(externalId: externalId, error: nsError, request: request) + } + request.sentToClient = false + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue // If this request returns a missing status, that is ok as this is a delete request - self.removeRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + self.removeFromRequestQueueAndPersist(request) } } if inBackground { @@ -397,21 +488,20 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { // On success, remove request from cache. No model hydration occurs. // For example, if app restarts and we read in operations between sending this off and getting the response self.dispatchQueue.async { - self.updateRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + self.removeFromRequestQueueAndPersist(request) if inBackground { OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) } } } onFailure: { error in - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor update subscription request failed with error: \(error.debugDescription)") self.dispatchQueue.async { if let nsError = error as? NSError { let responseType = OSNetworkingUtils.getResponseStatusType(nsError.code) - if responseType != .retryable { + if responseType == .unauthorized && (self.jwtConfig.isRequired ?? false) { + // TODO: Jwt, do we need to handle this case, as this request does not use user JWT + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue - self.updateRequestQueue.removeAll(where: { $0 == request}) - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + self.removeFromRequestQueueAndPersist(request) } } if inBackground { @@ -422,8 +512,109 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } +extension OSSubscriptionOperationExecutor: OSUserJwtConfigListener { + func onRequiresUserAuthChanged(from: OneSignalOSCore.OSRequiresUserAuth, to: OneSignalOSCore.OSRequiresUserAuth) { + if to == .on { + removeInvalidDeltasAndRequests() + } + } + + func onJwtUpdated(externalId: String, token: String?) { + reQueuePendingRequestsForExternalId(externalId: externalId) + } + + func handleUnauthorizedError(externalId: String, error: NSError, request: OSUserRequest) { + if jwtConfig.isRequired ?? false { + self.pendRequestUntilAuthUpdated(request, externalId: externalId) + OneSignalUserManagerImpl.sharedInstance.invalidateJwtForExternalId(externalId: externalId, error: error) + } + } + + func pendRequestUntilAuthUpdated(_ request: OSUserRequest, externalId: String?) { + self.dispatchQueue.async { + self.removeFromRequestQueueAndPersist(request) + guard let externalId = externalId else { + return + } + var requests = self.pendingAuthRequests[externalId] ?? [] + let inQueue = requests.contains(where: {$0 == request}) + guard !inQueue else { + return + } + requests.append(request) + self.pendingAuthRequests[externalId] = requests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + } + } + + private func reQueuePendingRequestsForExternalId(externalId: String) { + self.dispatchQueue.async { + guard let requests = self.pendingAuthRequests[externalId] else { + return + } + for request in requests { + if let addRequest = request as? OSRequestCreateSubscription { + self.addRequestQueue.append(addRequest) + } else if let removeRequest = request as? OSRequestDeleteSubscription { + self.removeRequestQueue.append(removeRequest) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + self.pendingAuthRequests[externalId] = nil + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + self.processRequestQueue(inBackground: false) + } + } + + /** + Drops deltas and requests that add and remove subscriptions on unidentified users. + Subscription updates are used only for push subscriptions, which are kept as they do not use User JWT. + */ + private func removeInvalidDeltasAndRequests() { + self.dispatchQueue.async { + for (index, delta) in self.deltaQueue.enumerated().reversed() { + if delta.name != OS_UPDATE_SUBSCRIPTION_DELTA, + let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId), + identityModel.externalId == nil + { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSSubscriptionOperationExecutor.removeInvalidDeltasAndRequests dropped \(delta)") + self.deltaQueue.remove(at: index) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + + for (index, request) in self.addRequestQueue.enumerated().reversed() { + if request.identityModel.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSSubscriptionOperationExecutor.removeInvalidDeltasAndRequests dropped \(request)") + self.addRequestQueue.remove(at: index) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + + for (index, request) in self.removeRequestQueue.enumerated().reversed() { + if request.identityModel.externalId == nil { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSSubscriptionOperationExecutor.removeInvalidDeltasAndRequests dropped \(request)") + self.removeRequestQueue.remove(at: index) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + } + } +} + extension OSSubscriptionOperationExecutor: OSLoggable { func logSelf() { - // TODO: You fill in + OneSignalLog.onesignalLog(.LL_VERBOSE, message: + """ + OSSubscriptionOperationExecutor has the following queues: + addRequestQueue: \(self.addRequestQueue) + removeRequestQueue: \(self.removeRequestQueue) + updateRequestQueue: \(self.updateRequestQueue) + deltaQueue: \(self.deltaQueue) + pendingAuthRequests: \(self.pendingAuthRequests) + + """ + ) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 704150099..19cc066ff 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -35,15 +35,20 @@ import OneSignalOSCore */ class OSUserExecutor { var userRequestQueue: [OSUserRequest] = [] + var pendingAuthRequests: [String: [OSUserRequest]] = [String: [OSUserRequest]]() private let newRecordsState: OSNewRecordsState + let jwtConfig: OSUserJwtConfig + /// Delay by the "cool down" period plus a buffer of a set amount of milliseconds private let flushDelayMilliseconds = Int(OP_REPO_POST_CREATE_DELAY_SECONDS * 1_000 + 200) // TODO: This could come from a config, plist, method, remote params /// The User executor dispatch queue, serial. This synchronizes access to the request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSUserExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, jwtConfig: OSUserJwtConfig) { self.newRecordsState = newRecordsState + self.jwtConfig = jwtConfig + self.jwtConfig.subscribe(self, key: OS_USER_EXECUTOR) uncacheUserRequests() migrateTransferSubscriptionRequests() executePendingRequests() @@ -52,57 +57,88 @@ class OSUserExecutor { /// Read in requests from the cache, do not read in FetchUser requests as this is not needed. private func uncacheUserRequests() { var userRequestQueue: [OSUserRequest] = [] + var cachedRequestQueue: [OSUserRequest] = [] // Read unfinished Create User + Identify User + Get Identity By Subscription requests from cache, if any... - if let cachedRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSUserRequest] { - // Hook each uncached Request to the right model reference - for request in cachedRequestQueue { - if request.isKind(of: OSRequestFetchIdentityBySubscription.self), let req = request as? OSRequestFetchIdentityBySubscription { - if let identityModel = getIdentityModel(req.identityModel.modelId) { - // 1. The model exist in the repo, set it to be the Request's model - // It is the current user or the model has already been processed - req.identityModel = identityModel - } else { - // 2. The model do not exist, use the model on the request, and add to repo. - addIdentityModel(req.identityModel) - } - userRequestQueue.append(req) + if let cache = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSUserRequest] { + cachedRequestQueue = cache + } - } else if request.isKind(of: OSRequestCreateUser.self), let req = request as? OSRequestCreateUser { - if let identityModel = getIdentityModel(req.identityModel.modelId) { - // 1. The model exist in the repo, set it to be the Request's model - req.identityModel = identityModel - } else { - // 2. The models do not exist, use the model on the request, and add to repo. - addIdentityModel(req.identityModel) - } - userRequestQueue.append(req) - - } else if request.isKind(of: OSRequestIdentifyUser.self), let req = request as? OSRequestIdentifyUser { - - if let identityModelToIdentify = getIdentityModel(req.identityModelToIdentify.modelId), - let identityModelToUpdate = getIdentityModel(req.identityModelToUpdate.modelId) { - // 1. Both models exist in the repo, set it to be the Request's models - req.identityModelToIdentify = identityModelToIdentify - req.identityModelToUpdate = identityModelToUpdate - } else if let identityModelToIdentify = getIdentityModel(req.identityModelToIdentify.modelId), - getIdentityModel(req.identityModelToUpdate.modelId) == nil { - // 2. A model is in the repo, the other model does not exist - req.identityModelToIdentify = identityModelToIdentify - addIdentityModel(req.identityModelToUpdate) - } else { - // 3. Both models don't exist yet - // Drop the request if the identityModelToIdentify does not already exist AND the request is missing OSID - // Otherwise, this request will forever fail `prepareForExecution` and block pending requests such as recovery calls to `logout` or `login` - guard request.prepareForExecution(newRecordsState: newRecordsState) else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor.start() dropped: \(request)") - continue - } - addIdentityModel(req.identityModelToIdentify) - addIdentityModel(req.identityModelToUpdate) + if let pendingRequests = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_PENDING_QUEUE_KEY, defaultValue: [:]) as? [String: [OSUserRequest]] { + for requests in pendingRequests.values { + for request in requests { + cachedRequestQueue.append(request) + } + } + } + + // Hook each uncached Request to the right model reference + for request in cachedRequestQueue { + if request.isKind(of: OSRequestFetchIdentityBySubscription.self), let req = request as? OSRequestFetchIdentityBySubscription { + // Remove this request if JWT is enabled + guard jwtConfig.isRequired != true else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSUserExecutor.uncacheUserRequests dropped \(req)") + continue + } + if let identityModel = getIdentityModel(req.identityModel.modelId) { + // 1. The model exist in the repo, set it to be the Request's model + // It is the current user or the model has already been processed + req.identityModel = identityModel + } else { + // 2. The model do not exist, use the model on the request, and add to repo. + addIdentityModel(req.identityModel) + } + userRequestQueue.append(req) + + } else if request.isKind(of: OSRequestCreateUser.self), let req = request as? OSRequestCreateUser { + + if jwtConfig.isRequired == true, + req.identityModel.externalId == nil + { + // Remove this request if there is no EUID + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSUserExecutor.uncacheUserRequests dropped \(req)") + continue + } + + if let identityModel = getIdentityModel(req.identityModel.modelId) { + // 1. The model exist in the repo, set it to be the Request's model + req.identityModel = identityModel + } else { + // 2. The models do not exist, use the model on the request, and add to repo. + addIdentityModel(req.identityModel) + } + userRequestQueue.append(req) + + } else if request.isKind(of: OSRequestIdentifyUser.self), let req = request as? OSRequestIdentifyUser { + + // If JWT is enabled, we migrate this request into a Create User request + guard jwtConfig.isRequired != true else { + convertIdentifyUserToCreateUser(req) + continue + } + + if let identityModelToIdentify = getIdentityModel(req.identityModelToIdentify.modelId), + let identityModelToUpdate = getIdentityModel(req.identityModelToUpdate.modelId) { + // 1. Both models exist in the repo, set it to be the Request's models + req.identityModelToIdentify = identityModelToIdentify + req.identityModelToUpdate = identityModelToUpdate + } else if let identityModelToIdentify = getIdentityModel(req.identityModelToIdentify.modelId), + getIdentityModel(req.identityModelToUpdate.modelId) == nil { + // 2. A model is in the repo, the other model does not exist + req.identityModelToIdentify = identityModelToIdentify + addIdentityModel(req.identityModelToUpdate) + } else { + // 3. Both models don't exist yet + // Drop the request if the identityModelToIdentify does not already exist AND the request is missing OSID + // Otherwise, this request will forever fail `prepareForExecution` and block pending requests such as recovery calls to `logout` or `login` + guard request.prepareForExecution(newRecordsState: newRecordsState) else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor.start() dropped: \(request)") + continue } - userRequestQueue.append(req) + addIdentityModel(req.identityModelToIdentify) + addIdentityModel(req.identityModelToUpdate) } + userRequestQueue.append(req) } } self.userRequestQueue = userRequestQueue @@ -125,6 +161,15 @@ class OSUserExecutor { } } + private func convertIdentifyUserToCreateUser(_ request: OSRequestIdentifyUser) { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserExecutor.convertIdentifyUserToCreateUser for \(request)") + if OneSignalUserManagerImpl.sharedInstance.isCurrentUser(request.aliasId) { + self.createUser(OneSignalUserManagerImpl.sharedInstance.user) + } else { + self.createUser(aliasLabel: request.aliasLabel, aliasId: request.aliasId, identityModel: request.identityModelToUpdate) + } + } + private func getIdentityModel(_ modelId: String) -> OSIdentityModel? { return OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId) } @@ -133,6 +178,26 @@ class OSUserExecutor { OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(model) } + /// Checks if two requests are creating the same user by external ID + private func isDuplicateCreateUser(_ request: OSRequestCreateUser, _ other: OSUserRequest) -> Bool { + guard let other = other as? OSRequestCreateUser, + request.identityModel.externalId != nil, + other.identityModel.externalId != nil + else { + return false + } + return request.identityModel.externalId == other.identityModel.externalId + } + + /// Before enqueueing a Create User request, check for duplicates and remove previous matching duplicates + private func appendCreateUserToQueue(_ request: OSRequestCreateUser) { + self.dispatchQueue.async { + self.userRequestQueue.removeAll(where: { self.isDuplicateCreateUser(request, $0) }) + self.userRequestQueue.append(request) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: self.userRequestQueue) + } + } + func appendToQueue(_ request: OSUserRequest) { self.dispatchQueue.async { self.userRequestQueue.append(request) @@ -140,17 +205,41 @@ class OSUserExecutor { } } - func removeFromQueue(_ request: OSUserRequest) { + func removeFromRequestQueueAndPersist(_ request: OSUserRequest) { self.dispatchQueue.async { self.userRequestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: self.userRequestQueue) } } + /** + When Identity Verification is on, only `OSRequestCreateUser` and `OSRequestFetchUser` can be executed. + Other requests should already be removed or translated into an executable type by the time this method runs. + */ + private func executePendingRequestsWithAuth() { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserExecutor.executePendingRequestsWithAuth called with queue \(self.userRequestQueue)") + + for request in self.userRequestQueue { + if request.isKind(of: OSRequestCreateUser.self), let createUserRequest = request as? OSRequestCreateUser { + self.executeCreateUserRequest(createUserRequest) + } else if request.isKind(of: OSRequestFetchUser.self), let fetchUserRequest = request as? OSRequestFetchUser { + self.executeFetchUserRequest(fetchUserRequest) + } else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor met incompatible Request type that cannot be executed.") + self.removeFromRequestQueueAndPersist(request) + } + } + } + /** Requests are flushed after a delay when they need to wait for the "cool down" period to access a user or subscription after its creation. */ func executePendingRequests(withDelay: Bool = false) { + guard jwtConfig.isRequired != nil else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor.executePendingRequests returning early due to unknown Identity Verification status") + return + } + if withDelay { self.dispatchQueue.asyncAfter(deadline: .now() + .milliseconds(flushDelayMilliseconds)) { [weak self] in self?._executePendingRequests() @@ -163,7 +252,20 @@ class OSUserExecutor { } private func _executePendingRequests() { - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserExecutor.executePendingRequests called with queue \(self.userRequestQueue)") + guard let requiresAuth = jwtConfig.isRequired else { + return + } + + if requiresAuth { + executePendingRequestsWithAuth() + } else { + executePendingRequestsWithoutAuth() + } + } + + private func executePendingRequestsWithoutAuth() { + // same as executePendingRequests currently + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserExecutor.executePendingRequestsWithoutAuth called with queue \(self.userRequestQueue)") for request in self.userRequestQueue { // Return as soon as we reach an un-executable request @@ -188,6 +290,7 @@ class OSUserExecutor { return } else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor met incompatible Request type that cannot be executed.") + self.removeFromRequestQueueAndPersist(request) } } } @@ -200,8 +303,7 @@ extension OSUserExecutor { let originalPushToken = user.pushSubscriptionModel.address let request = OSRequestCreateUser(identityModel: user.identityModel, propertiesModel: user.propertiesModel, pushSubscriptionModel: user.pushSubscriptionModel, originalPushToken: originalPushToken) - appendToQueue(request) - + appendCreateUserToQueue(request) executePendingRequests() } @@ -210,20 +312,52 @@ extension OSUserExecutor { */ func createUser(aliasLabel: String, aliasId: String, identityModel: OSIdentityModel) { let request = OSRequestCreateUser(aliasLabel: aliasLabel, aliasId: aliasId, identityModel: identityModel) - appendToQueue(request) + appendCreateUserToQueue(request) executePendingRequests() } + func pendRequestUntilAuthUpdated(_ request: OSUserRequest, externalId: String?) { + self.dispatchQueue.async { + self.userRequestQueue.removeAll(where: { $0 == request}) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: self.userRequestQueue) + guard let externalId = externalId else { + return + } + var requests = self.pendingAuthRequests[externalId] ?? [] + let inQueue = requests.contains(where: {$0 == request}) + guard !inQueue else { + return + } + requests.append(request) + self.pendingAuthRequests[externalId] = requests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + } + } + func executeCreateUserRequest(_ request: OSRequestCreateUser) { guard !request.sentToClient else { return } - // Hook up push subscription model if exists, it may be updated with a subscription_id, etc. - if let modelId = request.pushSubscriptionModel?.modelId, - let pushSubscriptionModel = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.getModel(modelId: modelId) { - request.pushSubscriptionModel = pushSubscriptionModel - request.updatePushSubscriptionModel(pushSubscriptionModel) + if OneSignalUserManagerImpl.sharedInstance.isCurrentUser(request.identityModel) { + // Hook up push subscription model if exists, it may be updated with a subscription_id, etc. + if let modelId = request.pushSubscriptionModel?.modelId, + let pushSubscriptionModel = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.getModel(modelId: modelId) { + request.pushSubscriptionModel = pushSubscriptionModel + request.updatePushSubscriptionModel(pushSubscriptionModel) + } + } else if request.identityModel.externalId != nil { + /* + Remove the push subscription if not current user; we don't want to transfer the push sub. + However, don't remove if the user is anonymous or else the create will fail. + This detail is meant to handle JWT on, and previous failed user creates can be sent even though the user has changed. + */ + request.parameters?.removeValue(forKey: "subscriptions") + } + + guard request.addJWTHeaderIsValid(identityModel: request.identityModel) else { + pendRequestUntilAuthUpdated(request, externalId: request.identityModel.externalId) + return } guard request.prepareForExecution(newRecordsState: newRecordsState) @@ -235,7 +369,7 @@ extension OSUserExecutor { request.sentToClient = true OneSignalCoreImpl.sharedClient().execute(request) { response in - self.removeFromQueue(request) + self.removeFromRequestQueueAndPersist(request) // Create User's response won't send us the user's complete info if this user already exists if let response = response { @@ -254,21 +388,29 @@ extension OSUserExecutor { let identity = request.parameters?["identity"] as? [String: String], let onesignalId = request.identityModel.onesignalId, identity[OS_EXTERNAL_ID] != nil { - self.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: onesignalId, identityModel: request.identityModel) + self.fetchUser(onesignalId: onesignalId, identityModel: request.identityModel) } else { self.executePendingRequests() } } - OSOperationRepo.sharedInstance.paused = false + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false } onFailure: { error in - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor create user request failed with error: \(error.debugDescription)") if let nsError = error as? NSError { let responseType = OSNetworkingUtils.getResponseStatusType(nsError.code) - if responseType != .retryable { + if responseType == .unauthorized { + guard let externalId = request.identityModel.externalId else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor no externalId for unauthorized request.") + return + } + self.handleUnauthorizedError(externalId: externalId, error: nsError, request: request) + request.sentToClient = false + } else if responseType != .retryable { // A failed create user request would leave the SDK in a bad state // Don't remove the request from cache and pause the operation repo // We will retry this request on a new session - OSOperationRepo.sharedInstance.paused = true + + // We can't do this anymore for 401s + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true request.sentToClient = false } } else { @@ -277,6 +419,13 @@ extension OSUserExecutor { } } + func handleUnauthorizedError(externalId: String, error: NSError, request: OSUserRequest) { + if jwtConfig.isRequired ?? false { + self.pendRequestUntilAuthUpdated(request, externalId: externalId) + OneSignalUserManagerImpl.sharedInstance.invalidateJwtForExternalId(externalId: externalId, error: error) + } + } + func fetchIdentityBySubscription(_ user: OSUserInternal) { let request = OSRequestFetchIdentityBySubscription(identityModel: user.identityModel, pushSubscriptionModel: user.pushSubscriptionModel) @@ -286,6 +435,7 @@ extension OSUserExecutor { /** For migrating legacy players from 3.x to 5.x. This request will fetch the identity object for a subscription ID, and we will use the returned onesignalId to fetch and hydrate the local user. + ECM can this ever succeed with identity verification on? */ func executeFetchIdentityBySubscriptionRequest(_ request: OSRequestFetchIdentityBySubscription) { guard !request.sentToClient else { @@ -301,7 +451,7 @@ extension OSUserExecutor { request.sentToClient = true OneSignalCoreImpl.sharedClient().execute(request) { response in - self.removeFromQueue(request) + self.removeFromRequestQueueAndPersist(request) if let identityObject = self.parseIdentityObjectResponse(response), let onesignalId = identityObject[OS_ONESIGNAL_ID] { @@ -314,7 +464,7 @@ extension OSUserExecutor { return } - self.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: onesignalId, identityModel: request.identityModel) + self.fetchUser(onesignalId: onesignalId, identityModel: request.identityModel) } } onFailure: { error in OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor executeFetchIdentityBySubscriptionRequest failed with error: \(error.debugDescription)") @@ -323,7 +473,7 @@ extension OSUserExecutor { if responseType != .retryable { // Fail, no retry, remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil - self.removeFromQueue(request) + self.removeFromRequestQueueAndPersist(request) } } self.executePendingRequests() @@ -356,7 +506,7 @@ extension OSUserExecutor { request.sentToClient = true OneSignalCoreImpl.sharedClient().execute(request) { _ in - self.removeFromQueue(request) + self.removeFromRequestQueueAndPersist(request) guard let onesignalId = request.identityModelToIdentify.onesignalId else { OneSignalLog.onesignalLog(.LL_ERROR, message: "executeIdentifyUserRequest succeeded but is now missing OneSignal ID!") @@ -375,7 +525,7 @@ extension OSUserExecutor { if OneSignalUserManagerImpl.sharedInstance.isCurrentUser(request.identityModelToUpdate) { // Add onesignal ID to new records because an immediate fetch may not return the newly-applied external ID self.newRecordsState.add(onesignalId, true) - self.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: onesignalId, identityModel: request.identityModelToUpdate) + self.fetchUser(onesignalId: onesignalId, identityModel: request.identityModelToUpdate) } else { self.executePendingRequests() } @@ -386,7 +536,7 @@ extension OSUserExecutor { // Returns 409 if any provided (label, id) pair exists on another User, so the SDK will switch to this user. OneSignalLog.onesignalLog(.LL_DEBUG, message: "executeIdentifyUserRequest returned error code user-2. Now handling user-2 error response... switch to this user.") - self.removeFromQueue(request) + self.removeFromRequestQueueAndPersist(request) if OneSignalUserManagerImpl.sharedInstance.isCurrentUser(request.identityModelToUpdate) { // Generate a Create User request, if it's still the current user @@ -395,12 +545,12 @@ extension OSUserExecutor { // This will hydrate the OneSignal ID for any pending requests self.createUser(aliasLabel: request.aliasLabel, aliasId: request.aliasId, identityModel: request.identityModelToUpdate) } - } else if responseType == .invalid || responseType == .unauthorized { + } else if responseType == .invalid || responseType == .unauthorized { // Identify User should never be called with identity verification on // Failed, no retry - self.removeFromQueue(request) + self.removeFromRequestQueueAndPersist(request) self.executePendingRequests() } else if responseType == .missing { - self.removeFromQueue(request) + self.removeFromRequestQueueAndPersist(request) self.executePendingRequests() // Logout if the user in the SDK is the same guard OneSignalUserManagerImpl.sharedInstance.isCurrentUser(request.identityModelToUpdate) @@ -417,8 +567,8 @@ extension OSUserExecutor { } } - func fetchUser(aliasLabel: String, aliasId: String, identityModel: OSIdentityModel, onNewSession: Bool = false) { - let request = OSRequestFetchUser(identityModel: identityModel, aliasLabel: aliasLabel, aliasId: aliasId, onNewSession: onNewSession) + func fetchUser(onesignalId: String, identityModel: OSIdentityModel, onNewSession: Bool = false) { + let request = OSRequestFetchUser(identityModel: identityModel, onesignalId: onesignalId, onNewSession: onNewSession) appendToQueue(request) @@ -431,6 +581,11 @@ extension OSUserExecutor { return } + guard request.addJWTHeaderIsValid(identityModel: request.identityModel) else { + pendRequestUntilAuthUpdated(request, externalId: request.identityModel.externalId) + return + } + guard request.prepareForExecution(newRecordsState: newRecordsState) else { executePendingRequests(withDelay: true) return @@ -439,10 +594,11 @@ extension OSUserExecutor { request.sentToClient = true OneSignalCoreImpl.sharedClient().execute(request) { response in - self.removeFromQueue(request) + self.removeFromRequestQueueAndPersist(request) if let response = response { // Clear local data in preparation for hydration + // TODO: JWT 🔐 the following line feels wrong... maybe the user's changed by now OneSignalUserManagerImpl.sharedInstance.clearUserData() self.parseFetchUserResponse(response: response, identityModel: request.identityModel, originalPushToken: OneSignalUserManagerImpl.sharedInstance.pushSubscriptionImpl.token) @@ -469,11 +625,10 @@ extension OSUserExecutor { } self.executePendingRequests() } onFailure: { error in - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor executeFetchUserRequest failed with error: \(error.debugDescription)") if let nsError = error as? NSError { let responseType = OSNetworkingUtils.getResponseStatusType(nsError.code) if responseType == .missing { - self.removeFromQueue(request) + self.removeFromRequestQueueAndPersist(request) // Logout if the user in the SDK is the same guard OneSignalUserManagerImpl.sharedInstance.isCurrentUser(request.identityModel) else { @@ -482,9 +637,14 @@ extension OSUserExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized && (self.jwtConfig.isRequired ?? false) { + if let externalId = request.identityModel.externalId { + self.handleUnauthorizedError(externalId: externalId, error: nsError, request: request) + } + request.sentToClient = false } else if responseType != .retryable { // If the error is not retryable, remove from cache and queue - self.removeFromQueue(request) + self.removeFromRequestQueueAndPersist(request) } } self.executePendingRequests() @@ -601,8 +761,82 @@ extension OSUserExecutor { } } +extension OSUserExecutor: OSUserJwtConfigListener { + func onRequiresUserAuthChanged(from: OSRequiresUserAuth, to: OSRequiresUserAuth) { + // If auth changed from false or unknown to true, process requests + if to == .on { + removeInvalidRequests() + } + self.executePendingRequests() + } + + func onJwtUpdated(externalId: String, token: String?) { + reQueuePendingRequestsForExternalId(externalId: externalId) + } + + private func reQueuePendingRequestsForExternalId(externalId: String) { + self.dispatchQueue.async { + guard let requests = self.pendingAuthRequests[externalId] else { + return + } + for request in requests { + self.userRequestQueue.append(request) + } + self.pendingAuthRequests[externalId] = nil + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: self.userRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + self.executePendingRequests(withDelay: true) + } + } + + private func removeInvalidRequests() { + self.dispatchQueue.async { + for request in self.userRequestQueue { + guard self.isRequestValidWithAuth(request) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: OSUserExecutor.removeInvalidRequests dropped \(request)") + self.userRequestQueue.removeAll(where: { $0 == request}) + continue + } + + if request.isKind(of: OSRequestIdentifyUser.self), let req = request as? OSRequestIdentifyUser { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Invalid with JWT: \(request) is IdentifyUser, being converted") + self.userRequestQueue.removeAll(where: { $0 == request}) + self.convertIdentifyUserToCreateUser(req) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: self.userRequestQueue) + } + } + + /// Returns if the Request is valid when Identity Verification is on + private func isRequestValidWithAuth(_ request: OSUserRequest) -> Bool { + if request.isKind(of: OSRequestFetchIdentityBySubscription.self) { + return false + } + if request.isKind(of: OSRequestCreateUser.self), + let createUserRequest = request as? OSRequestCreateUser, + createUserRequest.identityModel.externalId == nil + { + return false + } + if request.isKind(of: OSRequestFetchUser.self), + let fetchUserRequest = request as? OSRequestFetchUser, + fetchUserRequest.identityModel.externalId == nil { + return false + } + return true + } +} + extension OSUserExecutor: OSLoggable { func logSelf() { - // TODO: You fill in + OneSignalLog.onesignalLog(.LL_VERBOSE, message: + """ + OSUserExecutor has the following queues: + userRequestQueue: \(self.userRequestQueue) + pendingAuthRequests: \(self.pendingAuthRequests) + + """ + ) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift similarity index 76% rename from iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift rename to iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift index 6e70b5057..dcbe778d7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift @@ -42,8 +42,20 @@ class OSIdentityModel: OSModel { var aliases: [String: String] = [:] private let aliasesLock = NSRecursiveLock() - // TODO: We need to make this token secure - public var jwtBearerToken: String? + // MARK: - JWT + + public var jwtBearerToken: String? { + didSet { + guard jwtBearerToken != oldValue else { + return + } + self.set(property: OS_JWT_BEARER_TOKEN, newValue: jwtBearerToken) + } + } + + func isJwtValid() -> Bool { + return jwtBearerToken != nil && jwtBearerToken != "" && jwtBearerToken != OS_JWT_TOKEN_INVALID + } // MARK: - Initialization @@ -57,6 +69,7 @@ class OSIdentityModel: OSModel { aliasesLock.withLock { super.encode(with: coder) coder.encode(aliases, forKey: "aliases") + coder.encode(jwtBearerToken, forKey: OS_JWT_BEARER_TOKEN) } } @@ -66,6 +79,7 @@ class OSIdentityModel: OSModel { // log error return nil } + self.jwtBearerToken = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String self.aliases = aliases } @@ -120,26 +134,6 @@ class OSIdentityModel: OSModel { let newExternalId = remoteAliases[OS_EXTERNAL_ID] internalAddAliases(remoteAliases) - fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) - } - - /** - Fires the user observer if `onesignal_id` OR `external_id` has changed from the previous snapshot (previous hydration). - */ - private func fireUserStateChanged(newOnesignalId: String?, newExternalId: String?) { - let prevOnesignalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil) - let prevExternalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_EXTERNAL_ID, defaultValue: nil) - - guard prevOnesignalId != newOnesignalId || prevExternalId != newExternalId else { - return - } - - OneSignalUserDefaults.initShared().saveString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, withValue: newOnesignalId) - OneSignalUserDefaults.initShared().saveString(forKey: OS_SNAPSHOT_EXTERNAL_ID, withValue: newExternalId) - - let curUserState = OSUserState(onesignalId: newOnesignalId, externalId: newExternalId) - let changedState = OSUserChangedState(current: curUserState) - - OneSignalUserManagerImpl.sharedInstance.userStateChangesObserver.notifyChange(changedState) + OSUserUtils.fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModelStoreListener.swift similarity index 90% rename from iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift rename to iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModelStoreListener.swift index 4edabb321..d41b839fd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModelStoreListener.swift @@ -30,9 +30,11 @@ import OneSignalCore import OneSignalOSCore class OSIdentityModelStoreListener: OSModelStoreListener { + let operationRepo: OSOperationRepo var store: OSModelStore - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { + self.operationRepo = operationRepo self.store = store } @@ -48,8 +50,8 @@ class OSIdentityModelStoreListener: OSModelStoreListener { Determines if this update is adding aliases or removing aliases. */ func getUpdateModelDelta(_ args: OSModelChangedArgs) -> OSDelta? { - // TODO: Let users call addAliases with "" IDs? If so, this will change... guard + args.property == "aliases", // avoids JWT token updates let aliasesDict = args.newValue as? [String: String], let (_, id) = aliasesDict.first else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSPropertiesModel.swift similarity index 100% rename from iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift rename to iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSPropertiesModel.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSPropertiesModelStoreListener.swift similarity index 92% rename from iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift rename to iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSPropertiesModelStoreListener.swift index 611228801..cec233e43 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSPropertiesModelStoreListener.swift @@ -30,9 +30,11 @@ import OneSignalCore import OneSignalOSCore class OSPropertiesModelStoreListener: OSModelStoreListener { + let operationRepo: OSOperationRepo var store: OSModelStore - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { + self.operationRepo = operationRepo self.store = store } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSSubscriptionModel.swift similarity index 95% rename from iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift rename to iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSSubscriptionModel.swift index 0a930320a..53510fa63 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSSubscriptionModel.swift @@ -98,6 +98,10 @@ enum OSSubscriptionType: String { Internal subscription model. */ class OSSubscriptionModel: OSModel { + struct Constants { + static let isDisabledInternallyKey = "isDisabledInternallyKey" + } + var type: OSSubscriptionType var address: String? { // This is token on push subs so must remain Optional @@ -194,6 +198,21 @@ class OSSubscriptionModel: OSModel { } } + /** + Set to `true` by the SDK when logout is called with Identity Verification turned on. + The properties of `_isDisabled` and `notificationTypes` remain unchanged, to maintain correct data. + When a subscription update is made, this value will be read and `enabled = false` and `notification_types = -2` will be sent. + When a user logs in, this property will be set to `false` and the subscription will be included in the User Create request.. + */ + var _isDisabledInternally = false { + didSet { + guard _isDisabledInternally != oldValue else { + return + } + self.set(property: Constants.isDisabledInternallyKey, newValue: _isDisabledInternally) + } + } + // Properties for push subscription var testType: Int? { didSet { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSSubscriptionModelStoreListener.swift similarity index 82% rename from iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift rename to iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSSubscriptionModelStoreListener.swift index 6ffe8c9ac..9d1651071 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSSubscriptionModelStoreListener.swift @@ -30,9 +30,11 @@ import OneSignalCore import OneSignalOSCore class OSSubscriptionModelStoreListener: OSModelStoreListener { + let operationRepo: OSOperationRepo var store: OSModelStore - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { + self.operationRepo = operationRepo self.store = store } @@ -60,6 +62,14 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { } func getUpdateModelDelta(_ args: OSModelChangedArgs) -> OSDelta? { + /* + Don't generate a Delta if setting internal disable to false, which will generate a subscription update. + This means a user is logging in and a create user will be sent with the updated subscription included. + */ + if args.property == OSSubscriptionModel.Constants.isDisabledInternallyKey && args.newValue as? Bool == false { + return nil + } + return OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: OneSignalUserManagerImpl.sharedInstance.user.identityModel.modelId, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift index f59f94687..ef82e264e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift @@ -26,6 +26,7 @@ */ import Foundation +import OneSignalCore import OneSignalOSCore /** @@ -45,6 +46,8 @@ class OSIdentityModelRepo { func add(model: OSIdentityModel) { lock.withLock { models[model.modelId] = model + // listen for changes to model's JWT Token + model.changeNotifier.subscribe(self, key: OS_IDENTITY_MODEL_REPO) } } @@ -53,10 +56,63 @@ class OSIdentityModelRepo { return models[modelId] } } + + func get(externalId: String) -> OSIdentityModel? { + lock.withLock { + for model in models.values { + if model.externalId == externalId { + return model + } + } + return nil + } + } + + /** + There may be multiple Identity Models with the same external ID, so update them all. + This can be optimized in the future to re-use an Identity Model if multiple logins are made for the same user. + */ + func updateJwtToken(externalId: String, token: String) { + var found = false + lock.withLock { + for model in models.values { + if model.externalId == externalId { + model.jwtBearerToken = token + found = true + } + } + } + if !found { + OneSignalLog.onesignalLog(ONE_S_LOG_LEVEL.LL_ERROR, message: "Update User JWT called for external ID \(externalId) that does not exist") + } + } +} + +extension OSIdentityModelRepo: OSModelChangedHandler { + /** + Listen for updates to the JWT Token and notify the User Manager of this change. + */ + public func onModelUpdated(args: OSModelChangedArgs, hydrating: Bool) { + guard + args.property == OS_JWT_BEARER_TOKEN, + let model = args.model as? OSIdentityModel, + let externalId = model.externalId, + let token = args.newValue as? String, + token != OS_JWT_TOKEN_INVALID // Don't notify when token is invalidated internally + else { + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSIdentityModelRepo onModelUpdated for \(externalId) with token \(token)") + OneSignalUserManagerImpl.sharedInstance.jwtConfig.onJwtTokenChanged(externalId: externalId, token: token) + } } extension OSIdentityModelRepo: OSLoggable { func logSelf() { - // TODO: You fill in + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSIdentityModelRepo has the following models:") + + for model in models.values { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: " modelID: \(model.modelId), alises: \(model.aliases) token: \(model.jwtBearerToken ?? "nil")") + } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+OSLoggable.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+OSLoggable.swift new file mode 100644 index 000000000..63acbd27d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+OSLoggable.swift @@ -0,0 +1,82 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import OneSignalCore +import OneSignalOSCore + +extension OneSignalUserManagerImpl: OSLoggable { + @objc public func logSelf() { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: + """ + OneSignalUserManagerImpl: + _user: \(String(describing: _user)) + + identityModel: + aliases: \(String(describing: _user?.identityModel.aliases)) + jwt: \(String(describing: _user?.identityModel.jwtBearerToken)) + modelId: \(String(describing: _user?.identityModel.modelId)) + + propertiesModel: + tags: \(String(describing: _user?.propertiesModel.tags)) + language: \(String(describing: _user?.propertiesModel.language)) + modelId: \(String(describing: _user?.propertiesModel.modelId)) + + """ + ) + + let subscriptionModels = subscriptionModelStore.getModels().values + for sub in subscriptionModels { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: + """ + subscription model from store + addess: \(String(describing: sub.address)) + subscriptionId: \(String(describing: sub.subscriptionId)) + enabled: \(sub.enabled) + modelId: \(sub.modelId) + + """ + ) + } + + let pushSubModel = pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY) + OneSignalLog.onesignalLog(.LL_VERBOSE, message: + """ + push sub model from store + token: \(String(describing: pushSubModel?.address)) + subscriptionId: \(String(describing: pushSubModel?.subscriptionId)) + enabled: \(String(describing: pushSubModel?.enabled)) + notification_types: \(String(describing: pushSubModel?.notificationTypes)) + optedIn: \(String(describing: pushSubModel?.optedIn)) + modelId: \(String(describing: pushSubModel?.modelId)) + + """ + ) + operationRepo.logSelf() + userExecutor?.logSelf() + identityModelRepo.logSelf() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index fde4f72f5..378fbbfa5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -30,74 +30,23 @@ import OneSignalOSCore import OneSignalNotifications /** - Public-facing API to access the User Manager. + Internal API to access the User Manager. */ @objc protocol OneSignalUserManager { // swiftlint:disable identifier_name var User: OSUser { get } func login(externalId: String, token: String?) func logout() + func updateUserJwt(externalId: String, token: String) // Location func setLocation(latitude: Float, longitude: Float) // Purchase Tracking func sendPurchases(_ purchases: [[String: AnyObject]]) } -/** - This is the user interface exposed to the public. - */ -@objc public protocol OSUser { - var pushSubscription: OSPushSubscription { get } - var onesignalId: String? { get } - var externalId: String? { get } - /** - Add an observer to the user state, allowing the provider to be notified when the user state has changed. - Important: When using the observer to retrieve the `onesignalId`, check the `externalId` as well to confirm the values are associated with the expected user. - */ - func addObserver(_ observer: OSUserStateObserver) - func removeObserver(_ observer: OSUserStateObserver) - // Aliases - func addAlias(label: String, id: String) - func addAliases(_ aliases: [String: String]) - func removeAlias(_ label: String) - func removeAliases(_ labels: [String]) - // Tags - func addTag(key: String, value: String) - func addTags(_ tags: [String: String]) - func removeTag(_ tag: String) - func removeTags(_ tags: [String]) - func getTags() -> [String: String] - // Email - func addEmail(_ email: String) - func removeEmail(_ email: String) - // SMS - func addSms(_ number: String) - func removeSms(_ number: String) - // Language - func setLanguage(_ language: String) - // JWT Token Expire - typealias OSJwtCompletionBlock = (_ newJwtToken: String) -> Void - typealias OSJwtExpiredHandler = (_ externalId: String, _ completion: OSJwtCompletionBlock) -> Void - func onJwtExpired(expiredHandler: @escaping OSJwtExpiredHandler) -} - -/** - This is the push subscription interface exposed to the public. - */ -@objc public protocol OSPushSubscription { - var id: String? { get } - var token: String? { get } - var optedIn: Bool { get } - - func optIn() - func optOut() - func addObserver(_ observer: OSPushSubscriptionObserver) - func removeObserver(_ observer: OSPushSubscriptionObserver) -} - @objc public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { - @objc public static let sharedInstance = OneSignalUserManagerImpl() + @objc public static let sharedInstance = OneSignalUserManagerImpl(jwtConfig: OSUserJwtConfig()) /** Convenience accessor. We access the push subscription model via the model store instead of via`user.pushSubscriptionModel`. @@ -124,7 +73,18 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { var hasCalledStart = false - private var jwtExpiredHandler: OSJwtExpiredHandler? + let jwtConfig: OSUserJwtConfig + + private var _userJwtInvalidatedObserver: OSObservable? + var userJwtInvalidatedObserver: OSObservable { + if let observer = _userJwtInvalidatedObserver { + return observer + } + let userJwtInvalidatedObserver = OSObservable(change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:))) + _userJwtInvalidatedObserver = userJwtInvalidatedObserver + + return userJwtInvalidatedObserver + } var user: OSUserInternal { guard !OneSignalConfigManager.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { @@ -150,8 +110,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), pushSubscriptionModel: OSSubscriptionModel(type: .push, address: nil, subscriptionId: nil, reachable: false, isDisabled: true, changeNotifier: OSEventProducer())) - @objc public var requiresUserAuth = false - // User State Observer private var _userStateChangesObserver: OSObservable? var userStateChangesObserver: OSObservable { @@ -173,6 +131,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let pushSubscriptionModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY) // These must be initialized in init() + let operationRepo: OSOperationRepo let identityModelStoreListener: OSIdentityModelStoreListener let propertiesModelStoreListener: OSPropertiesModelStoreListener let subscriptionModelStoreListener: OSSubscriptionModelStoreListener @@ -184,11 +143,13 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { var identityExecutor: OSIdentityOperationExecutor? var subscriptionExecutor: OSSubscriptionOperationExecutor? - private override init() { - self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore) - self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore) - self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore) - self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore) + private init(jwtConfig: OSUserJwtConfig) { + self.jwtConfig = jwtConfig + self.operationRepo = OSOperationRepo(jwtConfig: jwtConfig) + self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore, operationRepo: operationRepo) + self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore, operationRepo: operationRepo) + self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore, operationRepo: operationRepo) + self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore, operationRepo: operationRepo) self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } @@ -224,19 +185,19 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // Setup the executors // The OSUserExecutor has to run first, before other executors - self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState) - OSOperationRepo.sharedInstance.start() + self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState, jwtConfig: jwtConfig) + operationRepo.start() // Cannot initialize these executors in `init` as they reference the sharedInstance - let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState) - let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState) - let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState) + let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, jwtConfig: jwtConfig) + let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, jwtConfig: jwtConfig) + let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, jwtConfig: jwtConfig) self.propertyExecutor = propertyExecutor self.identityExecutor = identityExecutor self.subscriptionExecutor = subscriptionExecutor - OSOperationRepo.sharedInstance.addExecutor(identityExecutor) - OSOperationRepo.sharedInstance.addExecutor(propertyExecutor) - OSOperationRepo.sharedInstance.addExecutor(subscriptionExecutor) + operationRepo.addExecutor(identityExecutor) + operationRepo.addExecutor(propertyExecutor) + operationRepo.addExecutor(subscriptionExecutor) // Path 2. There is a legacy player to migrate if let legacyPlayerId = OneSignalUserDefaults.initShared().getSavedString(forKey: OSUD_LEGACY_PLAYER_ID, defaultValue: nil) { @@ -305,6 +266,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { if let user = _user { guard user.identityModel.externalId != externalId || externalId == nil else { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager.createNewUser: not creating new user due to logging into the same user.)") + if externalId != nil, token != nil { + // save the jwtToken, it can be updated + user.identityModel.jwtBearerToken = token + } return user } } @@ -365,6 +330,54 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { return user.identityModel.externalId == externalId } + + @objc + public func getAliasForCurrentUser() -> OSAliasPair? { + guard let identityModel = _user?.identityModel else { + return nil + } + + return OSUserUtils.getAlias( + identityModel: identityModel, + jwtConfig: jwtConfig + ) + } + + /** + Helper method used by other modules in Objective-C such as fetching in app messages. + - Returns: The complete user header including push headers and jwt headers, if valid. + Returns `nil` if this request is not yet valid due to auth or null user instance. + + TODO: Alternative is to refactor and let OSRequestGetInAppMessages implement the OSUserRequest protocol + and have access to the extension methods on OneSignalRequest that handles the header. + */ + @objc + public func getCurrentUserFullHeader() -> [String: String]? { + guard let required = jwtConfig.isRequired else { + return nil + } + + guard let _user = _user else { + return nil + } + + var fullHeader = OSUserUtils.getFullPushHeader() + + if !required { + return fullHeader + } + + // JWT is required + + if _user.identityModel.isJwtValid(), + let token = _user.identityModel.jwtBearerToken + { + fullHeader["Authorization"] = "Bearer \(token)" + return fullHeader + } + return nil + } + /** Clears the existing user's data in preparation for hydration via a fetch user call. */ @@ -377,23 +390,41 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore.clearModelsFromStore() } + /** + Entry point to creating and setting a user. It is called by the SDK to generate an anonymous user and also + by clients to login a user. + */ private func _login(externalId: String?, token: String?) -> OSUserInternal { guard !OneSignalConfigManager.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return _mockUser } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager internal _login called with externalId: \(externalId ?? "nil")") - // If have token, validate token. Account for this being a requirement. - // Logging into an identified user from an anonymous user + if externalId != nil { + pushSubscriptionModel?._isDisabledInternally = false + } + + /* + Logging in to a "new-to-the-sdk" externalId from an anonymous user, if JWT is OFF or UNKNOWN. + + Note: If we are logging in to an externalId that already exists in the SDK, from an anon user, we know the client has called: + login(userA) -> logout -> login(userA) + The userA is expected to exist and will not result in successfully identifying the anonymous user; + this login flow will instead fall into the createUser path below. + */ if let externalId = externalId, let user = _user, - user.isAnonymous { + user.isAnonymous, + jwtConfig.isRequired != true, + identityModelRepo.get(externalId: externalId) == nil + { user.identityModel.jwtBearerToken = token identifyUser(externalId: externalId, currentUser: user) return self.user } - // Logging into anon -> anon, identified -> anon, identified -> identified, or nil -> any user + // JWT Off: Logging into anon -> anon, identified -> anon, identified -> identified, or nil -> any user + // JWT On: All return createNewUser(externalId: externalId, token: token) } @@ -413,6 +444,17 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { prepareForNewUser() _user = nil createUserIfNil() + + /* + If Identity Verification is on, disable the push subscription. + Since the anonymous placeholder user will not be created to the backend, + fire the user observer here to represent "no user" in the SDK. + This is necessary so internal user observers can know when a user logs out and then back in. + */ + if jwtConfig.isRequired == true { + user.pushSubscriptionModel._isDisabledInternally = true + OSUserUtils.fireUserStateChanged(newOnesignalId: nil, newExternalId: nil) + } } @objc @@ -444,15 +486,25 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { */ func setNewInternalUser(externalId: String?, pushSubscriptionModel: OSSubscriptionModel?) -> OSUserInternal { let aliases: [String: String]? + let identityModel: OSIdentityModel + if let externalIdToUse = externalId { aliases = [OS_EXTERNAL_ID: externalIdToUse] } else { aliases = nil } - let identityModel = OSIdentityModel(aliases: aliases, changeNotifier: OSEventProducer()) + // If there is an existing identity model with the same external ID, use it + if let externalId = externalId, + let existingIdentityModel = identityModelRepo.get(externalId: externalId) + { + identityModel = existingIdentityModel + } else { + identityModel = OSIdentityModel(aliases: aliases, changeNotifier: OSEventProducer()) + self.addIdentityModelToRepo(identityModel) + } + self.identityModelStore.add(id: OS_IDENTITY_MODEL_KEY, model: identityModel, hydrating: false) - self.addIdentityModelToRepo(identityModel) let propertiesModel = OSPropertiesModel(changeNotifier: OSEventProducer()) self.propertiesModelStore.add(id: OS_PROPERTIES_MODEL_KEY, model: propertiesModel, hydrating: false) @@ -524,18 +576,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } updatePropertiesDeltas(property: .purchases, value: purchases) } - - private func fireJwtExpired() { - guard let externalId = user.identityModel.externalId, let jwtExpiredHandler = self.jwtExpiredHandler else { - return - } - jwtExpiredHandler(externalId) { [self] (newToken) -> Void in - guard user.identityModel.externalId == externalId else { - return - } - user.identityModel.jwtBearerToken = newToken - } - } } // MARK: - Sessions @@ -550,12 +590,12 @@ extension OneSignalUserManagerImpl { start() userExecutor!.executePendingRequests() - OSOperationRepo.sharedInstance.paused = false + operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1) // Fetch the user's data if there is a onesignal_id if let onesignalId = onesignalId { - userExecutor!.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: onesignalId, identityModel: user.identityModel, onNewSession: true) + userExecutor!.fetchUser(onesignalId: onesignalId, identityModel: user.identityModel, onNewSession: true) } else { // It is possible to init a user from cache who is missing the onesignalId // This can happen if any createUser or identifyUser requests are cached @@ -583,7 +623,7 @@ extension OneSignalUserManagerImpl { property: property.rawValue, value: value ) - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } /// Time processors forward the session time to this method. @@ -601,15 +641,93 @@ extension OneSignalUserManagerImpl { */ @objc public func runBackgroundTasks() { - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue(inBackground: true) + operationRepo.addFlushDeltaQueueToDispatchQueue(inBackground: true) } } -extension OneSignalUserManagerImpl: OSUser { - public func onJwtExpired(expiredHandler: @escaping OSJwtExpiredHandler) { - jwtExpiredHandler = expiredHandler +// MARK: - JWT + +extension OneSignalUserManagerImpl { + @objc + public func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + self.userJwtInvalidatedObserver.addObserver(listener) + } + + @objc + public func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + self.userJwtInvalidatedObserver.removeObserver(listener) + } + + @objc + public func setRequiresUserAuth(_ required: Bool) { + jwtConfig.isRequired = required + } + + /** + This is called when remote params does not return the property `IOS_JWT_REQUIRED`. + It is likely this feature is not enabled for the app, so we will assume it is off. + However, don't overwrite the value if this has already been set. + */ + @objc + public func remoteParamsReturnedUnknownRequiresUserAuth() { + guard jwtConfig.isRequired == nil else { + return + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "remoteParamsReturnedUnknownRequiresUserAuth called") + jwtConfig.isRequired = false } + @objc + public func subscribeToJwtConfig(_ listener: OSUserJwtConfigListener, key: String) { + jwtConfig.subscribe(listener, key: key) + } + + @objc + public func updateUserJwt(externalId: String, token: String) { + guard !OneSignalConfigManager.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "updateUserJwt") else { + return + } + OneSignalLog.onesignalLog(ONE_S_LOG_LEVEL.LL_VERBOSE, message: "Update User JWT called with externalId: \(externalId) and token: \(token)") + + identityModelRepo.updateJwtToken(externalId: externalId, token: token) + } + + @objc + public func invalidateJwtForExternalId(externalId: String, error: NSError) { + guard jwtConfig.isRequired == true else { + return + } + guard let identityModel = identityModelRepo.get(externalId: externalId) else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "Unable to find identity model for externalId: \(externalId)") + return + } + + // Return, if the token has already been invalidated + guard identityModel.jwtBearerToken != OS_JWT_TOKEN_INVALID else { + return + } + + identityModel.jwtBearerToken = OS_JWT_TOKEN_INVALID + + fireJwtExpired(externalId: externalId) + } + + private func fireJwtExpired(externalId: String) { + let event = OSUserJwtInvalidatedEvent(externalId: externalId) + userJwtInvalidatedObserver.notifyChange(event) + } + + private func getMessageFromJwtError(_ error: NSError) -> String { + if let returnedObject = error.userInfo["returned"] as? [String: AnyObject] { + if let errors = returnedObject["errors"] as? [[String: AnyObject]] { + return errors[0]["title"] as? String ?? error.localizedDescription + } + } + return error.localizedDescription + } +} + +extension OneSignalUserManagerImpl: OSUser { public var User: OSUser { start() return self @@ -873,9 +991,3 @@ extension OneSignalUserManagerImpl: OneSignalNotificationsDelegate { user.pushSubscriptionModel.address = pushToken } } - -extension OneSignalUserManagerImpl: OSLoggable { - @objc public func logSelf() { - // TODO: You fill in - } -} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSPushSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSPushSubscription.swift new file mode 100644 index 000000000..0f2bddef7 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSPushSubscription.swift @@ -0,0 +1,40 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/** + This is the push subscription interface exposed to the public. + */ +@objc public protocol OSPushSubscription { + var id: String? { get } + var token: String? { get } + var optedIn: Bool { get } + + func optIn() + func optOut() + func addObserver(_ observer: OSPushSubscriptionObserver) + func removeObserver(_ observer: OSPushSubscriptionObserver) +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSUser.swift new file mode 100644 index 000000000..9693ff25b --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSUser.swift @@ -0,0 +1,60 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/** + This is the user interface exposed to the public. + */ +@objc public protocol OSUser { + var pushSubscription: OSPushSubscription { get } + var onesignalId: String? { get } + var externalId: String? { get } + /** + Add an observer to the user state, allowing the provider to be notified when the user state has changed. + Important: When using the observer to retrieve the `onesignalId`, check the `externalId` as well to confirm the values are associated with the expected user. + */ + func addObserver(_ observer: OSUserStateObserver) + func removeObserver(_ observer: OSUserStateObserver) + // Aliases + func addAlias(label: String, id: String) + func addAliases(_ aliases: [String: String]) + func removeAlias(_ label: String) + func removeAliases(_ labels: [String]) + // Tags + func addTag(key: String, value: String) + func addTags(_ tags: [String: String]) + func removeTag(_ tag: String) + func removeTags(_ tags: [String]) + func getTags() -> [String: String] + // Email + func addEmail(_ email: String) + func removeEmail(_ email: String) + // SMS + func addSms(_ number: String) + func removeSms(_ number: String) + // Language + func setLanguage(_ language: String) +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSUserJwtInvalidatedEvent.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSUserJwtInvalidatedEvent.swift new file mode 100644 index 000000000..b4b14f054 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSUserJwtInvalidatedEvent.swift @@ -0,0 +1,44 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +@objc public class OSUserJwtInvalidatedEvent: NSObject { + @objc public let externalId: String + + init(externalId: String) { + self.externalId = externalId + } + + @objc public func jsonRepresentation() -> NSDictionary { + return [ + "externalId": externalId + ] + } +} + +@objc public protocol OSUserJwtInvalidatedListener { + @objc func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserState.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSUserState.swift similarity index 100% rename from iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserState.swift rename to iOS_SDK/OneSignalSDK/OneSignalUser/Source/Public/OSUserState.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift index c52a84e20..9c3d80cd1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift @@ -38,18 +38,17 @@ class OSRequestAddAliases: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel let aliases: [String: String] - /// requires a `onesignal_id` to send this request + /// Needs `onesignal_id` without JWT on or `external_id` with valid JWT to send this request func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - if let onesignalId = identityModel.onesignalId, - newRecordsState.canAccess(onesignalId), - let appId = OneSignalConfigManager.getAppId() - { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity" - return true - } else { + guard + let alias = checkUserRequirementsAndReturnAlias(identityModel, newRecordsState), + let appId = OneSignalConfigManager.getAppId() + else { return false } + + self.path = "apps/\(appId)/users/by/\(alias.label)/\(alias.id)/identity" + return true } init(aliases: [String: String], identityModel: OSIdentityModel) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift index 51b383d6e..0bce71257 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift @@ -43,18 +43,17 @@ class OSRequestCreateSubscription: OneSignalRequest, OSUserRequest { var subscriptionModel: OSSubscriptionModel var identityModel: OSIdentityModel - // Need the onesignal_id of the user + /// Needs the `onesignal_id` without JWT on or `external_id` with valid JWT to send this request func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - if let onesignalId = identityModel.onesignalId, - newRecordsState.canAccess(onesignalId), - let appId = OneSignalConfigManager.getAppId() - { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/subscriptions" - return true - } else { + guard + let alias = checkUserRequirementsAndReturnAlias(identityModel, newRecordsState), + let appId = OneSignalConfigManager.getAppId() + else { return false } + + self.path = "apps/\(appId)/users/by/\(alias.label)/\(alias.id)/subscriptions" + return true } init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift index ef4e4a5e3..b568b83e6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift @@ -45,6 +45,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { var pushSubscriptionModel: OSSubscriptionModel? var originalPushToken: String? + // TODO: JWT 🔐 confirm existence of external ID is already addressed before getting here /// Checks if the subscription ID can be accessed, if a subscription is being included in the request func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { guard let appId = OneSignalConfigManager.getAppId() else { @@ -59,8 +60,12 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { return false } - _ = self.addPushSubscriptionIdToAdditionalHeaders() - self.addJWTHeader(identityModel: identityModel) + guard addJWTHeaderIsValid(identityModel: identityModel) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the create user request yet due to auth.") + return false + } + + _ = self.addPushSubscriptionToAdditionalHeaders() self.path = "apps/\(appId)/users" return true } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift index 35c36c2ad..91adb6eee 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift @@ -41,22 +41,26 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { } var subscriptionModel: OSSubscriptionModel + var identityModel: OSIdentityModel - // Need the subscription_id func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - if let subscriptionId = subscriptionModel.subscriptionId, - newRecordsState.canAccess(subscriptionId), - let appId = OneSignalConfigManager.getAppId() - { - self.path = "apps/\(appId)/subscriptions/\(subscriptionId)" - return true - } else { + guard + let subscriptionId = subscriptionModel.subscriptionId, + let token = subscriptionModel.address, + newRecordsState.canAccess(subscriptionId), + let appId = OneSignalConfigManager.getAppId(), + let _ = checkUserRequirementsAndReturnAlias(identityModel, newRecordsState) + else { return false } + + self.path = "apps/\(appId)/subscriptions/by/type/\(subscriptionModel.type)/token/\(token)" + return true } - init(subscriptionModel: OSSubscriptionModel) { + init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel) { self.subscriptionModel = subscriptionModel + self.identityModel = identityModel self.stringDescription = "" super.init() self.method = DELETE @@ -64,6 +68,7 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(subscriptionModel, forKey: "subscriptionModel") + coder.encode(identityModel, forKey: "identityModel") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") } @@ -71,6 +76,7 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { required init?(coder: NSCoder) { guard let subscriptionModel = coder.decodeObject(forKey: "subscriptionModel") as? OSSubscriptionModel, + let identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel, let rawMethod = coder.decodeObject(forKey: "method") as? UInt32, let timestamp = coder.decodeObject(forKey: "timestamp") as? Date else { @@ -78,6 +84,7 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { return nil } self.subscriptionModel = subscriptionModel + self.identityModel = identityModel self.stringDescription = "" super.init() self.method = HTTPMethod(rawValue: rawMethod) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift index e26c7855d..7cf4d7268 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift @@ -39,22 +39,19 @@ class OSRequestFetchIdentityBySubscription: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel var pushSubscriptionModel: OSSubscriptionModel + /// Only send this request if Identity Verification is off. func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - // newRecordsState is unused for this request - guard let appId = OneSignalConfigManager.getAppId() else { - OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the FetchIdentityBySubscription request due to null app ID.") + guard + let appId = OneSignalConfigManager.getAppId(), + let subscriptionId = pushSubscriptionModel.subscriptionId, + OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired == false + else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "Cannot generate the FetchIdentityBySubscription request.") return false } - if let subscriptionId = pushSubscriptionModel.subscriptionId { - self.path = "apps/\(appId)/subscriptions/\(subscriptionId)/user/identity" - return true - } else { - // This is an error, and should never happen - OneSignalLog.onesignalLog(.LL_ERROR, message: "Cannot generate the FetchIdentityBySubscription request due to null subscriptionId.") - self.path = "" - return false - } + self.path = "apps/\(appId)/subscriptions/\(subscriptionId)/user/identity" + return true } init(identityModel: OSIdentityModel, pushSubscriptionModel: OSSubscriptionModel) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift index 2e03e0c3e..aa1ef1e1d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift @@ -40,35 +40,36 @@ class OSRequestFetchUser: OneSignalRequest, OSUserRequest { } let identityModel: OSIdentityModel - let aliasLabel: String - let aliasId: String let onNewSession: Bool + /// This should always be `OS_ONESIGNAL_ID` even with JWT on, as a way to know if the user has been created on the server and for the post-create cool off period + let onesignalId: String + + // TODO: JWT 🔐 Is external ID already handled by this time? Or do we need to check the alias here? + /// Needs `onesignal_id` without JWT on or `external_id` with valid JWT to send this request func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - guard let appId = OneSignalConfigManager.getAppId(), - newRecordsState.canAccess(aliasId) + guard + let alias = checkUserRequirementsAndReturnAlias(identityModel, newRecordsState), + let appId = OneSignalConfigManager.getAppId() else { - OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the fetch user request for \(aliasLabel): \(aliasId) yet.") + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the fetch user request for \(identityModel.aliases) yet.") return false } - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(aliasLabel)/\(aliasId)" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(alias.id)" return true } - init(identityModel: OSIdentityModel, aliasLabel: String, aliasId: String, onNewSession: Bool) { + init(identityModel: OSIdentityModel, onesignalId: String, onNewSession: Bool) { self.identityModel = identityModel - self.aliasLabel = aliasLabel - self.aliasId = aliasId + self.onesignalId = onesignalId self.onNewSession = onNewSession - self.stringDescription = "" + self.stringDescription = "" super.init() self.method = GET } func encode(with coder: NSCoder) { - coder.encode(aliasLabel, forKey: "aliasLabel") - coder.encode(aliasId, forKey: "aliasId") + coder.encode(onesignalId, forKey: "onesignalId") coder.encode(identityModel, forKey: "identityModel") coder.encode(onNewSession, forKey: "onNewSession") coder.encode(method.rawValue, forKey: "method") // Encodes as String @@ -78,8 +79,7 @@ class OSRequestFetchUser: OneSignalRequest, OSUserRequest { required init?(coder: NSCoder) { guard let identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel, - let aliasLabel = coder.decodeObject(forKey: "aliasLabel") as? String, - let aliasId = coder.decodeObject(forKey: "aliasId") as? String, + let onesignalId = coder.decodeObject(forKey: "onesignalId") as? String, let rawMethod = coder.decodeObject(forKey: "method") as? UInt32, let timestamp = coder.decodeObject(forKey: "timestamp") as? Date else { @@ -87,10 +87,9 @@ class OSRequestFetchUser: OneSignalRequest, OSUserRequest { return nil } self.identityModel = identityModel - self.aliasLabel = aliasLabel - self.aliasId = aliasId + self.onesignalId = onesignalId self.onNewSession = coder.decodeBool(forKey: "onNewSession") - self.stringDescription = "" + self.stringDescription = "" super.init() self.method = HTTPMethod(rawValue: rawMethod) self.timestamp = timestamp diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift index b63c275a2..32806bb13 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift @@ -48,20 +48,20 @@ class OSRequestIdentifyUser: OneSignalRequest, OSUserRequest { let aliasId: String /// requires a `onesignal_id` to send this request + /// Only send this request if Identity Verification is off func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - if let onesignalId = identityModelToIdentify.onesignalId, - newRecordsState.canAccess(onesignalId), - let appId = OneSignalConfigManager.getAppId() - { - self.addJWTHeader(identityModel: identityModelToIdentify) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity" - return true - } else { - // self.path is non-nil, so set to empty string - self.path = "" + guard + let onesignalId = identityModelToIdentify.onesignalId, + newRecordsState.canAccess(onesignalId), + let appId = OneSignalConfigManager.getAppId(), + OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired == false + else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the Identify User request yet.") return false } + + self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity" + return true } /** diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift index adf98e568..3608a1082 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift @@ -38,17 +38,17 @@ class OSRequestRemoveAlias: OneSignalRequest, OSUserRequest { let labelToRemove: String var identityModel: OSIdentityModel + /// Needs `onesignal_id` without JWT on or `external_id` with valid JWT to send this request func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - if let onesignalId = identityModel.onesignalId, - newRecordsState.canAccess(onesignalId), - let appId = OneSignalConfigManager.getAppId() - { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity/\(labelToRemove)" - return true - } else { + guard + let alias = checkUserRequirementsAndReturnAlias(identityModel, newRecordsState), + let appId = OneSignalConfigManager.getAppId() + else { return false } + + self.path = "apps/\(appId)/users/by/\(alias.label)/\(alias.id)/identity/\(labelToRemove)" + return true } init(labelToRemove: String, identityModel: OSIdentityModel) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift index b46ff3894..572042733 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift @@ -37,20 +37,18 @@ class OSRequestUpdateProperties: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel - // TODO: Decide if addPushSubscriptionIdToAdditionalHeadersIfNeeded should block. - // Note Android adds it to requests, if the push sub ID exists + /// Needs `onesignal_id` without JWT on or `external_id` with valid JWT to send this request func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - if let onesignalId = identityModel.onesignalId, - newRecordsState.canAccess(onesignalId), - let appId = OneSignalConfigManager.getAppId() - { - _ = self.addPushSubscriptionIdToAdditionalHeaders() - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)" - return true - } else { + guard + let alias = checkUserRequirementsAndReturnAlias(identityModel, newRecordsState), + let appId = OneSignalConfigManager.getAppId() + else { return false } + + _ = self.addPushSubscriptionToAdditionalHeaders() + self.path = "apps/\(appId)/users/by/\(alias.label)/\(alias.id)" + return true } init(params: [String: Any], identityModel: OSIdentityModel) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift index 39fba8c5c..b4efa481d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift @@ -42,6 +42,7 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { // Need the subscription_id func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + addPushSubscriptionToAdditionalHeaders() if let subscriptionId = subscriptionModel.subscriptionId, newRecordsState.canAccess(subscriptionId), let appId = OneSignalConfigManager.getAppId() @@ -64,17 +65,23 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { var subscriptionParams = subscriptionObject subscriptionParams.removeValue(forKey: "address") subscriptionParams.removeValue(forKey: "notificationTypes") + subscriptionParams.removeValue(forKey: OSSubscriptionModel.Constants.isDisabledInternallyKey) subscriptionParams["token"] = subscriptionModel.address subscriptionParams["device_os"] = subscriptionModel.deviceOs subscriptionParams["sdk"] = subscriptionModel.sdk subscriptionParams["app_version"] = subscriptionModel.appVersion - // notificationTypes defaults to -1 instead of nil, don't send if it's -1 - if subscriptionModel.notificationTypes != -1 { - subscriptionParams["notification_types"] = subscriptionModel.notificationTypes + if subscriptionModel._isDisabledInternally { + subscriptionParams["enabled"] = false + subscriptionParams["notification_types"] = -2 + } else { + // notificationTypes defaults to -1 instead of nil, don't send if it's -1 + if subscriptionModel.notificationTypes != -1 { + subscriptionParams["notification_types"] = subscriptionModel.notificationTypes + } + subscriptionParams["enabled"] = subscriptionModel.enabled } - subscriptionParams["enabled"] = subscriptionModel.enabled // TODO: The above is not quite right. If we hydrate, we will over-write any pending updates // May use subscriptionObject, but enabled and notification_types should be sent together... diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index 133473ba8..52bebf57e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -34,24 +34,64 @@ protocol OSUserRequest: OneSignalRequest, NSCoding { } internal extension OneSignalRequest { - func addJWTHeader(identityModel: OSIdentityModel) { -// guard let token = identityModel.jwtBearerToken else { -// return -// } -// var additionalHeaders = self.additionalHeaders ?? [String:String]() -// additionalHeaders["Authorization"] = "Bearer \(token)" -// self.additionalHeaders = additionalHeaders + /** + Handles a full check of user-related requirements. + - The existence of onesignal ID and the ability to access it. + - The existence of an appropriate alias. + - Checks JWT requirements and sets header. + + - Returns: The alias pair to use to send this request. + */ + func checkUserRequirementsAndReturnAlias(_ identityModel: OSIdentityModel, _ newRecordsState: OSNewRecordsState) -> OSAliasPair? { + guard + let onesignalId = identityModel.onesignalId, + newRecordsState.canAccess(onesignalId), + let aliasPair = getAlias(identityModel: identityModel, jwtConfig: OneSignalUserManagerImpl.sharedInstance.jwtConfig), + addJWTHeaderIsValid(identityModel: identityModel) + else { + return nil + } + + return aliasPair + } + + private func getAlias(identityModel: OSIdentityModel, jwtConfig: OSUserJwtConfig) -> OSAliasPair? { + return OSUserUtils.getAlias(identityModel: identityModel, jwtConfig: jwtConfig) } - /** Returns if the `OneSignal-Subscription-Id` header was added successfully. */ - func addPushSubscriptionIdToAdditionalHeaders() -> Bool { - if let pushSubscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId { + /** + Adds JWT token to header if valid, regardless of requirement. + Returns false if JWT requirement is unknown, or turned on but the token is missing or invalid. + + | | unknown | on | off | + | --------------- | -------------- | ------- | ------- | + | hasToken | | ✔️ | ✔️ | + | noToken | | | ✔️ | + | --------------- | -------------- | ------- | ------- | + */ + func addJWTHeaderIsValid(identityModel: OSIdentityModel) -> Bool { + let tokenIsValid = identityModel.isJwtValid() + let required = OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired + let canBeSent = (required == false) || (required == true && tokenIsValid) + if canBeSent && tokenIsValid, + let token = identityModel.jwtBearerToken + { + // Add the JWT token if it is valid, regardless of requirements var additionalHeaders = self.additionalHeaders ?? [String: String]() - additionalHeaders["OneSignal-Subscription-Id"] = pushSubscriptionId + additionalHeaders["Authorization"] = "Bearer \(token)" self.additionalHeaders = additionalHeaders - return true - } else { - return false } + return canBeSent + } + + /** + The `OneSignal-Subscription-Id` header supports improved `last_active` tracking for subscriptions that were actually active. + The `Device-Auth-Push-Token` header includes the push token if available. + */ + func addPushSubscriptionToAdditionalHeaders() { + let pushHeader = OSUserUtils.getFullPushHeader() + var additionalHeaders = self.additionalHeaders ?? [String: String]() + additionalHeaders = additionalHeaders.merging(pushHeader) { (_, new) in new } + self.additionalHeaders = additionalHeaders } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Support/OSUserUtils.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Support/OSUserUtils.swift new file mode 100644 index 000000000..dc70751fc --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Support/OSUserUtils.swift @@ -0,0 +1,94 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import OneSignalCore +import OneSignalOSCore + +class OSUserUtils { + /** + Returns the alias pair to use to send a request for. + When Identity Verification is unknown, or IDs are missing, this will be null. + When Identity Verification is disabled, this should be the onesignal ID. + When Identity Verification is enabled, this should be the external ID. + */ + static func getAlias(identityModel: OSIdentityModel, jwtConfig: OSUserJwtConfig) -> OSAliasPair? { + guard let jwtRequired = jwtConfig.isRequired else { + return nil + } + + if jwtRequired, let externalId = identityModel.externalId + { + // JWT is on and external ID exists + return OSAliasPair(OS_EXTERNAL_ID, externalId) + } else if !jwtRequired, let onesignalId = identityModel.onesignalId { + // JWT is off and onesignal ID exists + return OSAliasPair(OS_ONESIGNAL_ID, onesignalId) + } + + // Missing onesignal ID or external ID, when expected + return nil + } + + /** + The `OneSignal-Subscription-Id` header supports improved `last_active` tracking for subscriptions that were actually active. + The `Device-Auth-Push-Token` header includes the push token if available. + */ + static func getFullPushHeader() -> [String: String] { + var headers = [String: String]() + + if let pushSubscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId { + headers["OneSignal-Subscription-Id"] = pushSubscriptionId + } + if let token = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.address, + let data = token.data(using: .utf8) + { + let base64String = data.base64EncodedString() + headers["Device-Auth-Push-Token"] = "Basic \(base64String)" + } + return headers + } + + /** + Fires the user observer if `onesignal_id` OR `external_id` has changed from the previous snapshot (previous hydration). + */ + static func fireUserStateChanged(newOnesignalId: String?, newExternalId: String?) { + let prevOnesignalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil) + let prevExternalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_EXTERNAL_ID, defaultValue: nil) + + guard prevOnesignalId != newOnesignalId || prevExternalId != newExternalId else { + return + } + + OneSignalUserDefaults.initShared().saveString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, withValue: newOnesignalId) + OneSignalUserDefaults.initShared().saveString(forKey: OS_SNAPSHOT_EXTERNAL_ID, withValue: newExternalId) + + let curUserState = OSUserState(onesignalId: newOnesignalId, externalId: newExternalId) + let changedState = OSUserChangedState(current: curUserState) + + OneSignalUserManagerImpl.sharedInstance.userStateChangesObserver.notifyChange(changedState) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserDefines.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserDefines.swift index 86ccd5823..3ed267d3e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserDefines.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserDefines.swift @@ -5,3 +5,14 @@ public let userB_OSID = "test_user_b_onesignal_id" public let userB_EUID = "test_user_b_external_id" public let testPushSubId = "test_push_subscription_id" +public let testEmailSubId = "test_email_subscription_id" +public let testPushToken = "2b7347630b72265c83b1c1d2227f563ce6169d5aaf274b06f1a1fadf3a04be69" +public let userA_InvalidJwtToken = "byJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIwMTM5YmQ2Zi00NTFmLTQzOGMtODg4Ni00ZTBmMGZlM2EwODUiLCJleHAiOjE3MjUzOTY3NTksImlkZW50aXR5Ijp7ImV4dGVybmFsX2lkIjoiZWxsaW90MTE0MCJ9LCJzdWJzY3JpcHRpb25zIjpbeyJ0eXBlIjoiRW1haWwiLCJ0b2tlbiI6InRlc3RAZG9tYWluLmNvbSJ9LHsidHlwZSI6IlNNUyIsInRva2VuIjoiKzEyMzQ1Njc4In1dfQ.wmtt8mH7wYpxmUDyx_l8ktfF4Eg-6y_4iOSsIEl3AxuQ5pEriCIRj-3P-NmSPO3jsSAGPeBRZQ-rRS5j-LbN1w" + +public let userA_ValidJwtToken = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIwMTM5YmQ2Zi00NTFmLTQzOGMtODg4Ni00ZTBmMGZlM2EwODUiLCJleHAiOjE3MjUzOTY3NTksImlkZW50aXR5Ijp7ImV4dGVybmFsX2lkIjoiZWxsaW90MTE0MCJ9LCJzdWJzY3JpcHRpb25zIjpbeyJ0eXBlIjoiRW1haWwiLCJ0b2tlbiI6InRlc3RAZG9tYWluLmNvbSJ9LHsidHlwZSI6IlNNUyIsInRva2VuIjoiKzEyMzQ1Njc4In1dfQ.wmtt8mH7wYpxmUDyx_l8ktfF4Eg-6y_4iOSsIEl3AxuQ5pEriCIRj-3P-NmSPO3jsSAGPeBRZQ-rRS5j-LbN1w" +public let userB_ValidJwtToken = "fyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIwMTM5YmQ2Zi00NTFmLTQzOGMtODg4Ni00ZTBmMGZlM2EwODUiLCJleHAiOjE3MjUzOTY3NTksImlkZW50aXR5Ijp7ImV4dGVybmFsX2lkIjoiZWxsaW90MTE0MCJ9LCJzdWJzY3JpcHRpb25zIjpbeyJ0eXBlIjoiRW1haWwiLCJ0b2tlbiI6InRlc3RAZG9tYWluLmNvbSJ9LHsidHlwZSI6IlNNUyIsInRva2VuIjoiKzEyMzQ1Njc4In1dfQ.wmtt8mH7wYpxmUDyx_l8ktfF4Eg-6y_4iOSsIEl3AxuQ5pEriCIRj-3P-NmSPO3jsSAGPeBRZQ-rRS5j-LbN1w" + +public let userA_email = "userA@onesignal.com" + +public let userA_AliasLabel = "testAliasLabel" +public let userA_Aliases = [userA_AliasLabel: "userAValue"] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift new file mode 100644 index 000000000..0abd0d675 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift @@ -0,0 +1,46 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection +with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import OneSignalUser + +public class MockUserJwtInvalidatedListener: OSUserJwtInvalidatedListener { + public var invalidatedCallbackWasCalled = false + private var callback: (() -> Void)? + + public init() { } + + public func setCallback(_ callback: @escaping () -> Void) { + self.callback = callback + } + + public func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { + invalidatedCallbackWasCalled = true + + guard let callback = callback else { return } + callback() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserRequests.swift index 77560bd28..b9776bee1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserRequests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserRequests.swift @@ -59,6 +59,14 @@ public class MockUserRequests: NSObject { "properties": properties ] } + + public static func testUnauthorizedailureError() -> NSError { + let userInfo = ["returned": [ + "errors": [["title": "token has invalid claims: token is expired", "code": "auth-0"]], + "httpStatusCode": 401 + ]] + return NSError(domain: "not-important", code: 401, userInfo: userInfo) + } } // MARK: - Set Up Default Client Responses @@ -98,6 +106,54 @@ extension MockUserRequests { ) } + public static func setUnauthorizedCreateUserFailureResponses(with client: MockOneSignalClient, externalId: String) { + let error = testUnauthorizedailureError() + client.setMockFailureResponseForRequest(request: "", error: error) + } + + public static func setUnauthorizedFetchUserFailureResponses(with client: MockOneSignalClient, onesignalId: String) { + let error = testUnauthorizedailureError() + client.setMockFailureResponseForRequest(request: "", error: error) + } + + public static func setUnauthorizedUpdatePropertiesFailureResponses(with client: MockOneSignalClient, tags: [String: String]) { + let error = testUnauthorizedailureError() + + let params: NSDictionary = [ + "properties": [ + "tags": tags + ], + "refresh_device_metadata": false + ] + + client.setMockFailureResponseForRequest(request: "", error: error) + } + + public static func setUnauthorizedAddEmailFailureResponse(with client: MockOneSignalClient, email: String) { + let error = testUnauthorizedailureError() + client.setMockFailureResponseForRequest(request: "", error: error) + } + + public static func setUnauthorizedRemoveEmailFailureResponse(with client: MockOneSignalClient, email: String) { + let error = testUnauthorizedailureError() + client.setMockFailureResponseForRequest(request: "", error: error) + } + + public static func setUnauthorizedUpdateSubscriptionFailureResponse(with client: MockOneSignalClient, token: String) { + let error = testUnauthorizedailureError() + client.setMockFailureResponseForRequest(request: "OSRequestUpdateSubscription with subscriptionObject: [\"token\": \"\(token)\"]", error: error) + } + + public static func setUnauthorizedAddAliasFailureResponse(with client: MockOneSignalClient, aliases: [String: String]) { + let error = testUnauthorizedailureError() + client.setMockFailureResponseForRequest(request: "", error: error) + } + + public static func setUnauthorizedRemoveAliasFailureResponse(with client: MockOneSignalClient, aliasLabel: String) { + let error = testUnauthorizedailureError() + client.setMockFailureResponseForRequest(request: "OSRequestRemoveAlias with aliasLabel: \(aliasLabel)", error: error) + } + public static func setDefaultIdentifyUserResponses(with client: MockOneSignalClient, externalId: String, conflicted: Bool = false) { var osid: String var fetchResponse: [String: [String: String]] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalExecutorMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalExecutorMocks.swift new file mode 100644 index 000000000..4709a0163 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalExecutorMocks.swift @@ -0,0 +1,69 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import OneSignalCore +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +@testable import OneSignalUser + +@objc +open class OneSignalExecutorMocks: NSObject { + public let client = MockOneSignalClient() + public let newRecordsState = MockNewRecordsState() + public let jwtConfig = OSUserJwtConfig() + + override public init() { + super.init() + OneSignalCoreImpl.setSharedClient(client) + } + + @objc + open func setAuthRequired(_ required: Bool) { + // Set User Manager's JWT to off, or it blocks requests in prepareForExecution + OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired = required + jwtConfig.isRequired = required + } + + open func createUserInstance(externalId: String) -> OSUserInternal { + let identityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: externalId], changeNotifier: OSEventProducer()) + let propertiesModel = OSPropertiesModel(changeNotifier: OSEventProducer()) + let pushModel = OSSubscriptionModel(type: .push, address: "", subscriptionId: nil, reachable: false, isDisabled: false, changeNotifier: OSEventProducer()) + return OSUserInternalImpl(identityModel: identityModel, propertiesModel: propertiesModel, pushSubscriptionModel: pushModel) + } + + open func setUserManagerInternalUser(externalId: String, onesignalId: String? = nil) -> OSUserInternal { + let user = OneSignalUserManagerImpl.sharedInstance.setNewInternalUser( + externalId: externalId, + pushSubscriptionModel: OSSubscriptionModel(type: .push, address: "", subscriptionId: testPushSubId, reachable: false, isDisabled: false, changeNotifier: OSEventProducer())) + if let onesignalId = onesignalId { + // Setting the OSID directly avoids generating a Delta + user.identityModel.aliases[OS_ONESIGNAL_ID] = onesignalId + } + return user + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift index 3230ac5dc..92981061d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -36,7 +36,6 @@ public class OneSignalUserMocks: NSObject { // TODO: create mocked server responses to user requests @objc public static func reset() { - OSCoreMocks.resetOperationRepo() OneSignalUserManagerImpl.sharedInstance.reset() } } @@ -55,6 +54,7 @@ extension OneSignalUserManagerImpl { */ func reset() { identityModelRepo.reset() + operationRepo.reset() // Model store listeners unsubscribe to their models // User Manager start() will subscribe them diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift new file mode 100644 index 000000000..7c181f350 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift @@ -0,0 +1,281 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection +with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +private class Mocks: OneSignalExecutorMocks { + var identityExecutor: OSIdentityOperationExecutor! + + override init() { + super.init() + identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, jwtConfig: jwtConfig) + } +} + +final class IdentityExecutorTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + // App ID is set because requests have guards against null App ID + OneSignalConfigManager.setAppId("test-app-id") + // Temp. logging to help debug during testing + OneSignalLog.setLogLevel(.LL_VERBOSE) + } + + override func tearDownWithError() throws { } + + func testAddAliasSendsWhenProcessed() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(false) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + let aliases = userA_Aliases + MockUserRequests.setAddAliasesResponse(with: mocks.client, aliases: aliases) + mocks.identityExecutor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: user.identityModel.modelId, model: user.identityModel, property: "aliases", value: aliases)) + + /* When */ + mocks.identityExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestAddAliases.self)) + } + + func testAddAlias_IdentityVerificationRequired_butNoToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + let aliases = userA_Aliases + MockUserRequests.setAddAliasesResponse(with: mocks.client, aliases: aliases) + mocks.identityExecutor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: user.identityModel.modelId, model: user.identityModel, property: "aliases", value: aliases)) + + /* When */ + mocks.identityExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestAddAliases.self)) + } + + func testAddAlias_IdentityVerificationRequired_withToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + let aliases = userA_Aliases + MockUserRequests.setAddAliasesResponse(with: mocks.client, aliases: aliases) + mocks.identityExecutor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: user.identityModel.modelId, model: user.identityModel, property: "aliases", value: aliases)) + + /* When */ + mocks.identityExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestAddAliases.self)) + } + + func testAddAlias_IdentityVerificationRequired_withInvalidToken_firesCallback() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + let aliases = userA_Aliases + MockUserRequests.setUnauthorizedAddAliasFailureResponse(with: mocks.client, aliases: aliases) + mocks.identityExecutor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: user.identityModel.modelId, model: user.identityModel, property: "aliases", value: aliases)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + mocks.identityExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestAddAliases.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } + + func testRemoveAlias_IdentityVerificationRequired_withInvalidToken_firesCallback() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + let aliases = userA_Aliases + MockUserRequests.setUnauthorizedRemoveAliasFailureResponse(with: mocks.client, aliasLabel: userA_AliasLabel) + mocks.identityExecutor.enqueueDelta(OSDelta(name: OS_REMOVE_ALIAS_DELTA, identityModelId: user.identityModel.modelId, model: user.identityModel, property: "aliases", value: aliases)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + mocks.identityExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestRemoveAlias.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } + + func testAddAliasRequests_Retry_OnTokenUpdate() { + + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.identityExecutor! + + let aliases = userA_Aliases + MockUserRequests.setUnauthorizedAddAliasFailureResponse(with: mocks.client, aliases: userA_Aliases) + executor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: user.identityModel.modelId, model: user.identityModel, property: "aliases", value: aliases)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + userJwtInvalidatedListener.setCallback { + MockUserRequests.setAddAliasesResponse(with: mocks.client, aliases: aliases) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: userA_ValidJwtToken) + } + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestAddAliases.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + XCTAssertEqual(mocks.client.networkRequestCount, 2) + } + + func testAddAliasRequests_RetryRequests_OnTokenUpdate_ForOnlyUpdatedUser() { + /* Setup */ + let mocks = Mocks() + + mocks.setAuthRequired(true) + + let userA = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + userA.identityModel.jwtBearerToken = userA_InvalidJwtToken + + let userB = mocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: userB_OSID) + userB.identityModel.jwtBearerToken = userA_InvalidJwtToken + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.identityExecutor! + + let aliases = userA_Aliases + MockUserRequests.setUnauthorizedAddAliasFailureResponse(with: mocks.client, aliases: userA_Aliases) + + executor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: userA.identityModel.modelId, model: userA.identityModel, property: "aliases", value: aliases)) + executor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: userB.identityModel.modelId, model: userB.identityModel, property: "aliases", value: aliases)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + MockUserRequests.setAddAliasesResponse(with: mocks.client, aliases: aliases) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userB_EUID, token: userB_ValidJwtToken) + + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestAddAliases.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + let addAliasRequests = mocks.client.executedRequests.filter { request in + request.isKind(of: OSRequestAddAliases.self) + } + XCTAssertEqual(addAliasRequests.count, 3) + } + + func testRemoveAliasRequests_RetryRequests_OnTokenUpdate_ForOnlyUpdatedUser() { + /* Setup */ + let mocks = Mocks() + + mocks.setAuthRequired(true) + + let userA = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + userA.identityModel.jwtBearerToken = userA_InvalidJwtToken + + let userB = mocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: userB_OSID) + userB.identityModel.jwtBearerToken = userA_InvalidJwtToken + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.identityExecutor! + + let aliases = userA_Aliases + MockUserRequests.setUnauthorizedRemoveAliasFailureResponse(with: mocks.client, aliasLabel: userA_AliasLabel) + + executor.enqueueDelta(OSDelta(name: OS_REMOVE_ALIAS_DELTA, identityModelId: userA.identityModel.modelId, model: userA.identityModel, property: "aliases", value: aliases)) + executor.enqueueDelta(OSDelta(name: OS_REMOVE_ALIAS_DELTA, identityModelId: userB.identityModel.modelId, model: userB.identityModel, property: "aliases", value: aliases)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userB_EUID, token: userB_ValidJwtToken) + + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestRemoveAlias.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + let removeAliasRequests = mocks.client.executedRequests.filter { request in + request.isKind(of: OSRequestRemoveAlias.self) + } + + XCTAssertEqual(removeAliasRequests.count, 3) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/PropertyExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/PropertyExecutorTests.swift new file mode 100644 index 000000000..85dcef5f4 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/PropertyExecutorTests.swift @@ -0,0 +1,216 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection +with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +private class Mocks: OneSignalExecutorMocks { + var propertyExecutor: OSPropertyOperationExecutor! + + override init() { + super.init() + propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, jwtConfig: jwtConfig) + } +} + +final class PropertyExecutorTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + // App ID is set because requests have guards against null App ID + OneSignalConfigManager.setAppId("test-app-id") + // Temp. logging to help debug during testing + OneSignalLog.setLogLevel(.LL_VERBOSE) + } + + override func tearDownWithError() throws { } + + func testUpdateTagsSendsWhenProcessed() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(false) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + let tags = ["testUserA": "true"] + MockUserRequests.setAddTagsResponse(with: mocks.client, tags: tags) + mocks.propertyExecutor.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: user.identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "tags", value: tags)) + + /* When */ + mocks.propertyExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self)) + } + + func testUpdateTags_IdentityVerificationRequired_butNoToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + let tags = ["testUserA": "true"] + MockUserRequests.setAddTagsResponse(with: mocks.client, tags: tags) + mocks.propertyExecutor.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: user.identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "tags", value: tags)) + + /* When */ + mocks.propertyExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self)) + } + + func testUpdateTags_IdentityVerificationRequired_withToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + let tags = ["testUserA": "true"] + MockUserRequests.setAddTagsResponse(with: mocks.client, tags: tags) + mocks.propertyExecutor.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: user.identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "tags", value: tags)) + + /* When */ + mocks.propertyExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self)) + } + + func testUpdateProperty_IdentityVerificationRequired_withInvalidToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + + let tags = ["testUserA": "true"] + MockUserRequests.setUnauthorizedUpdatePropertiesFailureResponses(with: mocks.client, tags: tags) + mocks.propertyExecutor.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: user.identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "tags", value: tags)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + mocks.propertyExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } + + func testUpdateRequests_Retry_OnTokenUpdate() { + + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.propertyExecutor! + + let tags = ["testUserA": "true"] + MockUserRequests.setUnauthorizedUpdatePropertiesFailureResponses(with: mocks.client, tags: tags) + executor.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: user.identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "tags", value: tags)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + userJwtInvalidatedListener.setCallback { + MockUserRequests.setAddTagsResponse(with: mocks.client, tags: tags) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: userA_ValidJwtToken) + } + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + XCTAssertEqual(mocks.client.networkRequestCount, 2) + } + + func testUpdateRequests_RetryRequests_OnTokenUpdate_ForOnlyUpdatedUser() { + /* Setup */ + let mocks = Mocks() + + mocks.setAuthRequired(true) + + let userA = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + userA.identityModel.jwtBearerToken = userA_InvalidJwtToken + + let userB = mocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: userB_OSID) + userB.identityModel.jwtBearerToken = userA_InvalidJwtToken + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.propertyExecutor! + + let tags = ["testUserA": "true"] + MockUserRequests.setUnauthorizedUpdatePropertiesFailureResponses(with: mocks.client, tags: tags) + + executor.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: userA.identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "tags", value: tags)) + executor.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: userB.identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "tags", value: tags)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + MockUserRequests.setAddTagsResponse(with: mocks.client, tags: tags) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userB_EUID, token: userB_ValidJwtToken) + + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + let updateRequests = mocks.client.executedRequests.filter { request in + request.isKind(of: OSRequestUpdateProperties.self) + } + XCTAssertEqual(updateRequests.count, 3) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionsExecutorTests.swift new file mode 100644 index 000000000..c3fc5ab9c --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionsExecutorTests.swift @@ -0,0 +1,280 @@ +/* + Modified MIT License + + Copyright 2024 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection +with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +private class Mocks: OneSignalExecutorMocks { + var subscriptionExecutor: OSSubscriptionOperationExecutor! + + override init() { + super.init() + subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, jwtConfig: jwtConfig) + } +} + +final class SubscriptionExecutorTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + // App ID is set because requests have guards against null App ID + OneSignalConfigManager.setAppId("test-app-id") + // Temp. logging to help debug during testing + OneSignalLog.setLogLevel(.LL_VERBOSE) + } + + override func tearDownWithError() throws { } + + func testAddEmailSendsWhenProcessed() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(false) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + let email = userA_email + MockUserRequests.setAddEmailResponse(with: mocks.client, email: email) + mocks.subscriptionExecutor.enqueueDelta(OSDelta(name: OS_ADD_SUBSCRIPTION_DELTA, identityModelId: user.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: nil, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + + /* When */ + mocks.subscriptionExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self)) + } + + func testAddEmail_IdentityVerificationRequired_butNoToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + let email = userA_email + MockUserRequests.setAddEmailResponse(with: mocks.client, email: email) + mocks.subscriptionExecutor.enqueueDelta(OSDelta(name: OS_ADD_SUBSCRIPTION_DELTA, identityModelId: user.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: nil, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + + /* When */ + mocks.subscriptionExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self)) + } + + func testAddEmail_IdentityVerificationRequired_withToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + let email = userA_email + MockUserRequests.setAddEmailResponse(with: mocks.client, email: email) + mocks.subscriptionExecutor.enqueueDelta(OSDelta(name: OS_ADD_SUBSCRIPTION_DELTA, identityModelId: user.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: nil, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + + /* When */ + mocks.subscriptionExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self)) + } + + func testAddEmail_IdentityVerificationRequired_withInvalidToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + let email = userA_email + MockUserRequests.setUnauthorizedAddEmailFailureResponse(with: mocks.client, email: email) + mocks.subscriptionExecutor.enqueueDelta(OSDelta(name: OS_ADD_SUBSCRIPTION_DELTA, identityModelId: user.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: nil, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + mocks.subscriptionExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } + + func testDeleteEmail_IdentityVerificationRequired_withInvalidToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + let email = userA_email + MockUserRequests.setUnauthorizedRemoveEmailFailureResponse(with: mocks.client, email: email) + mocks.subscriptionExecutor.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: user.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: testEmailSubId, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + mocks.subscriptionExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestDeleteSubscription.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } + + func testCreateSubscriptionRequests_Retry_OnTokenUpdate() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.subscriptionExecutor! + + let email = userA_email + MockUserRequests.setUnauthorizedAddEmailFailureResponse(with: mocks.client, email: email) + executor.enqueueDelta(OSDelta(name: OS_ADD_SUBSCRIPTION_DELTA, identityModelId: user.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: nil, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + userJwtInvalidatedListener.setCallback { + MockUserRequests.setAddEmailResponse(with: mocks.client, email: email) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: userA_ValidJwtToken) + } + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + XCTAssertEqual(mocks.client.networkRequestCount, 2) + } + + func testCreateSubscriptionRequests_RetryRequests_OnTokenUpdate_ForOnlyUpdatedUser() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + + let userA = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + userA.identityModel.jwtBearerToken = userA_InvalidJwtToken + + let userB = mocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: userB_OSID) + userB.identityModel.jwtBearerToken = userA_InvalidJwtToken + + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.subscriptionExecutor! + + let email = userA_email + MockUserRequests.setUnauthorizedAddEmailFailureResponse(with: mocks.client, email: email) + + executor.enqueueDelta(OSDelta(name: OS_ADD_SUBSCRIPTION_DELTA, identityModelId: userA.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: nil, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + executor.enqueueDelta(OSDelta(name: OS_ADD_SUBSCRIPTION_DELTA, identityModelId: userB.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: nil, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userB_EUID, token: userB_ValidJwtToken) + + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + let addRequests = mocks.client.executedRequests.filter { request in + request.isKind(of: OSRequestCreateSubscription.self) + } + + XCTAssertEqual(addRequests.count, 3) + } + + func testDeleteSubscriptionRequests_RetryRequests_OnTokenUpdate_ForOnlyUpdatedUser() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + + let userA = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + userA.identityModel.jwtBearerToken = userA_InvalidJwtToken + + let userB = mocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: userB_OSID) + userB.identityModel.jwtBearerToken = userA_InvalidJwtToken + + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.subscriptionExecutor! + + let email = userA_email + MockUserRequests.setUnauthorizedRemoveEmailFailureResponse(with: mocks.client, email: email) + + executor.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: userA.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + executor.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: userB.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userB_EUID, token: userB_ValidJwtToken) + + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestDeleteSubscription.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + let deleteRequests = mocks.client.executedRequests.filter { request in + request.isKind(of: OSRequestDeleteSubscription.self) + } + + XCTAssertEqual(deleteRequests.count, 3) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 31dd74e29..63ffcde3d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -33,29 +33,12 @@ import OneSignalOSCoreMocks import OneSignalUserMocks @testable import OneSignalUser -/// This class has helpers that can be used in other tests and can be extracted out, as they are used -private class Mocks { - let client = MockOneSignalClient() - let newRecordsState = MockNewRecordsState() - let userExecutor: OSUserExecutor +private class Mocks: OneSignalExecutorMocks { + var userExecutor: OSUserExecutor! - init() { - OneSignalCoreImpl.setSharedClient(client) - userExecutor = OSUserExecutor(newRecordsState: newRecordsState) - } - - func createUserInstance(externalId: String) -> OSUserInternal { - let identityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: externalId], changeNotifier: OSEventProducer()) - let propertiesModel = OSPropertiesModel(changeNotifier: OSEventProducer()) - let pushModel = OSSubscriptionModel(type: .push, address: "", subscriptionId: nil, reachable: false, isDisabled: false, changeNotifier: OSEventProducer()) - return OSUserInternalImpl(identityModel: identityModel, propertiesModel: propertiesModel, pushSubscriptionModel: pushModel) - } - - func setUserManagerInternalUser(externalId: String) -> OSUserInternal { - return OneSignalUserManagerImpl.sharedInstance.setNewInternalUser( - externalId: externalId, - pushSubscriptionModel: OSSubscriptionModel(type: .push, address: "", subscriptionId: testPushSubId, reachable: false, isDisabled: false, changeNotifier: OSEventProducer()) - ) + override init() { + super.init() + userExecutor = OSUserExecutor(newRecordsState: newRecordsState, jwtConfig: jwtConfig) } } @@ -75,6 +58,7 @@ final class UserExecutorTests: XCTestCase { func testCreateUser_withPushSubscription_addsToNewRecords() { /* Setup */ let mocks = Mocks() + mocks.setAuthRequired(false) MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID, subscriptionId: "push-sub-id") /* When */ @@ -89,6 +73,7 @@ final class UserExecutorTests: XCTestCase { func testCreateUser_withoutPushSubscription_doesNot_addToNewRecords() { /* Setup */ let mocks = Mocks() + mocks.setAuthRequired(false) MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID) /* When */ @@ -110,6 +95,7 @@ final class UserExecutorTests: XCTestCase { func testIdentifyUser_successfully_forcesAddToNewRecords() { /* Setup */ let mocks = Mocks() + mocks.setAuthRequired(false) MockUserRequests.setDefaultIdentifyUserResponses(with: mocks.client, externalId: userA_EUID, conflicted: false) /* When */ @@ -135,6 +121,7 @@ final class UserExecutorTests: XCTestCase { func testIdentifyUserSuccessful_butUserHasChangedSince_doesNotAddToNewRecords() { /* Setup */ let mocks = Mocks() + mocks.setAuthRequired(false) MockUserRequests.setDefaultIdentifyUserResponses(with: mocks.client, externalId: userA_EUID, conflicted: false) /* When */ @@ -156,6 +143,7 @@ final class UserExecutorTests: XCTestCase { func testIdentifyUser_withConflict_addsToNewRecords() { /* Setup */ let mocks = Mocks() + mocks.setAuthRequired(false) let user = mocks.setUserManagerInternalUser(externalId: userB_EUID) let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) @@ -177,6 +165,7 @@ final class UserExecutorTests: XCTestCase { func testIdentifyUserWithConflict_butUserHasChangedSince_doesNot_addToNewRecords() { /* Setup */ let mocks = Mocks() + mocks.setAuthRequired(false) let user = mocks.setUserManagerInternalUser(externalId: "new-eid") let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) @@ -194,4 +183,245 @@ final class UserExecutorTests: XCTestCase { XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) XCTAssertTrue(mocks.newRecordsState.records.isEmpty) } + + func testCreateUser_IdentityVerificationRequired_butNoToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + + _ = mocks.setUserManagerInternalUser(externalId: "") + let newIdentityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID) + + /* When */ + mocks.userExecutor.createUser(aliasLabel: OS_EXTERNAL_ID, aliasId: userA_EUID, identityModel: newIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should not execute this request since identity verification is required, but no token was set + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + XCTAssertEqual(mocks.newRecordsState.records.count, 0) + } + + func testCreateUser_IdentityVerificationRequired_withToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + + _ = mocks.setUserManagerInternalUser(externalId: "") + let newIdentityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + newIdentityModel.jwtBearerToken = userA_InvalidJwtToken + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID) + + /* When */ + mocks.userExecutor.createUser(aliasLabel: OS_EXTERNAL_ID, aliasId: userA_EUID, identityModel: newIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + func testCreateUser_IdentityVerificationRequired_withInvalidToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + + _ = mocks.setUserManagerInternalUser(externalId: userA_EUID) + let newIdentityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + newIdentityModel.jwtBearerToken = userA_InvalidJwtToken + MockUserRequests.setUnauthorizedCreateUserFailureResponses(with: mocks.client, externalId: userA_EUID) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + mocks.userExecutor.createUser(aliasLabel: OS_EXTERNAL_ID, aliasId: userA_EUID, identityModel: newIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } + + func testFetchUser_IdentityVerificationRequired_butNoToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + + _ = mocks.setUserManagerInternalUser(externalId: "") + let newIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) + MockUserRequests.setDefaultFetchUserResponseForHydration(with: mocks.client, externalId: userA_EUID) + + /* When */ + mocks.userExecutor.fetchUser(onesignalId: userA_OSID, identityModel: newIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should not execute this request since identity verification is required, but no token was set + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestFetchUser.self)) + } + + func testFetchUser_IdentityVerificationRequired_withToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + + _ = mocks.setUserManagerInternalUser(externalId: "") + let newIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID, OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + newIdentityModel.jwtBearerToken = userA_InvalidJwtToken + MockUserRequests.setDefaultFetchUserResponseForHydration(with: mocks.client, externalId: userA_EUID) + + /* When */ + mocks.userExecutor.fetchUser(onesignalId: userA_OSID, identityModel: newIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should not execute this request since identity verification is required, but no token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestFetchUser.self)) + } + + func testFetchUser_IdentityVerificationRequired_withInvalidToken() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + + _ = mocks.setUserManagerInternalUser(externalId: userA_EUID) + let newIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID, OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + newIdentityModel.jwtBearerToken = userA_InvalidJwtToken + MockUserRequests.setUnauthorizedFetchUserFailureResponses(with: mocks.client, onesignalId: userA_OSID) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + mocks.userExecutor.fetchUser(onesignalId: userA_OSID, identityModel: newIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestFetchUser.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } + + func testUserRequests_Retry_OnTokenUpdate() { + /* Setup */ + let mocks = Mocks() + + mocks.setAuthRequired(true) + + _ = mocks.setUserManagerInternalUser(externalId: userA_EUID) + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.userExecutor! + + let userAIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID, OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + userAIdentityModel.jwtBearerToken = userA_InvalidJwtToken + + MockUserRequests.setUnauthorizedFetchUserFailureResponses(with: mocks.client, onesignalId: userA_OSID) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + userJwtInvalidatedListener.setCallback { + MockUserRequests.setDefaultFetchUserResponseForHydration(with: mocks.client, externalId: userA_EUID) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: userA_ValidJwtToken) + } + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.fetchUser(onesignalId: userA_OSID, identityModel: userAIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 1) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestFetchUser.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + XCTAssertEqual(mocks.client.networkRequestCount, 2) + } + + func testUserRequests_RetryAllRequests_OnTokenUpdate() { + /* Setup */ + let mocks = Mocks() + + mocks.setAuthRequired(true) + + let userA = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.userExecutor! + + userA.identityModel.jwtBearerToken = userA_InvalidJwtToken + + MockUserRequests.setUnauthorizedFetchUserFailureResponses(with: mocks.client, onesignalId: userA_OSID) + MockUserRequests.setUnauthorizedCreateUserFailureResponses(with: mocks.client, externalId: userA_EUID) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.fetchUser(onesignalId: userA_OSID, identityModel: userA.identityModel) + executor.createUser(aliasLabel: OS_EXTERNAL_ID, aliasId: userA_EUID, identityModel: userA.identityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + MockUserRequests.setDefaultFetchUserResponseForHydration(with: mocks.client, externalId: userA_EUID) + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: userA_ValidJwtToken) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestFetchUser.self)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + /* + Create and Fetch requests that fail + Create and Fetch requests that pass + Follow up Fetch made after the success of the Create request + */ + XCTAssertEqual(mocks.client.networkRequestCount, 5) + } + + /** + This test executes a Fetch on userA, and a Create on userB, encountering an unauthorized response for both requests. + The test next updates the JWT token for userA only. + It expects only the Fetch userA request to be sent next. + */ + func testUserRequests_RetryRequests_OnTokenUpdate_ForOnlyUpdatedUser() { + /* Setup */ + let mocks = Mocks() + + mocks.setAuthRequired(true) + + let userA = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.userExecutor! + + userA.identityModel.jwtBearerToken = userA_InvalidJwtToken + + let userBIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userB_OSID, OS_EXTERNAL_ID: userB_EUID], changeNotifier: OSEventProducer()) + userBIdentityModel.jwtBearerToken = userA_InvalidJwtToken + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(userBIdentityModel) + + MockUserRequests.setUnauthorizedFetchUserFailureResponses(with: mocks.client, onesignalId: userA_OSID) + MockUserRequests.setUnauthorizedCreateUserFailureResponses(with: mocks.client, externalId: userB_EUID) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When */ + executor.fetchUser(onesignalId: userA_OSID, identityModel: userA.identityModel) + executor.createUser(aliasLabel: OS_EXTERNAL_ID, aliasId: userB_EUID, identityModel: userBIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + MockUserRequests.setDefaultFetchUserResponseForHydration(with: mocks.client, externalId: userA_EUID) + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userB_EUID) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: userA_ValidJwtToken) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestFetchUser.self)) + XCTAssertTrue(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + XCTAssertEqual(mocks.client.networkRequestCount, 3) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m index b367edbea..44193aaf5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m @@ -61,6 +61,8 @@ - (void)testSendPurchases { }; [arrayOfPurchases addObject:purchase2]; + // Set JWT to off, before accessing the User Manager + [OneSignalUserManagerImpl.sharedInstance setRequiresUserAuth:false]; [OneSignalUserManagerImpl.sharedInstance sendPurchases:arrayOfPurchases]; // Run background threads diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 3a4bf1745..cefc8971b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -78,8 +78,11 @@ final class OneSignalUserTests: XCTestCase { MockUserRequests.setDefaultCreateAnonUserResponses(with: client) OneSignalCoreImpl.setSharedClient(client) + // Set JWT to off, before accessing the User Manager + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + // Increase flush interval to allow all the updates to batch - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 + OneSignalUserManagerImpl.sharedInstance.operationRepo.pollIntervalMilliseconds = 300 // Wait to let any pending flushes in the Operation Repo to run OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.1) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift index 95859d323..b50d3a576 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift @@ -40,6 +40,9 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) + // Set JWT to off, before accessing the User Manager + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + /* When */ // 1. Login to user A and add tag @@ -108,6 +111,9 @@ final class SwitchUserIntegrationTests: XCTestCase { // Returns mocked user data to test hydration MockUserRequests.setDefaultFetchUserResponseForHydration(with: client, externalId: userA_EUID) + // Set JWT to off, before accessing the User Manager + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + /* When */ // 1. Anonymous user @@ -215,6 +221,9 @@ final class SwitchUserIntegrationTests: XCTestCase { MockUserRequests.setAddAliasesResponse(with: client, aliases: ["alias_b": "id_b"]) MockUserRequests.setAddEmailResponse(with: client, email: "email_b@example.com") + // Set JWT to off, before accessing the User Manager + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + /* When */ // 1. Anonymous user starts @@ -318,8 +327,12 @@ final class SwitchUserIntegrationTests: XCTestCase { let client = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(client) + // Set JWT to off, before accessing the User Manager + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + // Increase flush interval to allow all the updates to batch - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 + OneSignalUserManagerImpl.sharedInstance.operationRepo.pollIntervalMilliseconds = 300 + // Wait to let any pending flushes in the Operation Repo to run OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.3) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift index bcf076a74..52ffb5a79 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift @@ -57,6 +57,9 @@ final class UserConcurrencyTests: XCTestCase { /* Setup */ OneSignalCoreImpl.setSharedClient(MockOneSignalClient()) + // Set JWT to off, before accessing the User Manager + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + /* When */ // 1. Enqueue 10 Deltas to the Operation Repo @@ -68,7 +71,7 @@ final class UserConcurrencyTests: XCTestCase { for _ in 1...4 { DispatchQueue.global().async { print("🧪 flushDeltaQueue on thread \(Thread.current)") - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() } } @@ -87,36 +90,44 @@ final class UserConcurrencyTests: XCTestCase { let client = MockOneSignalClient() client.setMockResponseForRequest( - request: "", + request: "", response: [:] ) OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + // Set JWT to off, before accessing the User Manager + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + + let identityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(identityModel) + + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), jwtConfig: OneSignalUserManagerImpl.sharedInstance.jwtConfig) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Remove Subscription Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: identityModel.modelId, model: OSSubscriptionModel(type: .email, address: userA_email, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: userA_email)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: identityModel.modelId, model: OSSubscriptionModel(type: .email, address: userA_email, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: userA_email)) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response - executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer())), inBackground: false) - executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer())), inBackground: false) + executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: userA_email, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), identityModel: identityModel), inBackground: false) + executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: userA_email, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), identityModel: identityModel), inBackground: false) } // 4. Run background threads - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 2) /* Then */ // Previously caused crash: signal SIGABRT - malloc: double free for ptr // Assert that every request SDK makes has a response set, and is handled XCTAssertTrue(client.allRequestsHandled) + // Ensure the requests are actually made, future proofing + XCTAssertEqual(client.executedRequests.count, 200) } /** @@ -131,18 +142,22 @@ final class UserConcurrencyTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) MockUserRequests.setAddAliasesResponse(with: client, aliases: aliases) - let executor = OSIdentityOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + // Set User Manager's JWT to off, or it blocks requests + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + + let executor = OSIdentityOperationExecutor(newRecordsState: OSNewRecordsState(), jwtConfig: OneSignalUserManagerImpl.sharedInstance.jwtConfig) + let operationRepo = OSOperationRepo(jwtConfig: OneSignalUserManagerImpl.sharedInstance.jwtConfig) + operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Add Alias Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + operationRepo.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + operationRepo.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer())), inBackground: false) @@ -150,12 +165,14 @@ final class UserConcurrencyTests: XCTestCase { } // 4. Run background threads - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 2) /* Then */ // Previously caused crash: signal SIGABRT - malloc: double free for ptr // Assert that every request SDK makes has a response set, and is handled XCTAssertTrue(client.allRequestsHandled) + // Ensure the requests are actually made, future proofing + XCTAssertGreaterThanOrEqual(client.executedRequests.count, 150) } /** @@ -169,21 +186,24 @@ final class UserConcurrencyTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) + // Set JWT to off, before accessing the User Manager + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + let identityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(identityModel) - let executor = OSPropertyOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + let executor = OSPropertyOperationExecutor(newRecordsState: OSNewRecordsState(), jwtConfig: OneSignalUserManagerImpl.sharedInstance.jwtConfig) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response executor.executeUpdatePropertiesRequest(OSRequestUpdateProperties(params: ["properties": ["language": UUID().uuidString], "refresh_device_metadata": false], identityModel: identityModel), inBackground: false) @@ -194,6 +214,8 @@ final class UserConcurrencyTests: XCTestCase { /* Then */ // No crash + // Ensure the requests are actually made, future proofing + XCTAssertGreaterThanOrEqual(client.executedRequests.count, 75) } /** @@ -213,13 +235,16 @@ final class UserConcurrencyTests: XCTestCase { let identityModel1 = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) let identityModel2 = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) - let userExecutor = OSUserExecutor(newRecordsState: OSNewRecordsState()) + // Set User Manager's JWT to off, or it blocks requests + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + + let userExecutor = OSUserExecutor(newRecordsState: OSNewRecordsState(), jwtConfig: OneSignalUserManagerImpl.sharedInstance.jwtConfig) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in let identifyRequest = OSRequestIdentifyUser(aliasLabel: OS_EXTERNAL_ID, aliasId: UUID().uuidString, identityModelToIdentify: identityModel1, identityModelToUpdate: identityModel2) - let fetchRequest = OSRequestFetchUser(identityModel: identityModel1, aliasLabel: OS_ONESIGNAL_ID, aliasId: UUID().uuidString, onNewSession: false) + let fetchRequest = OSRequestFetchUser(identityModel: identityModel1, onesignalId: UUID().uuidString, onNewSession: false) // Append and execute requests simultaneously userExecutor.appendToQueue(identifyRequest) @@ -233,6 +258,8 @@ final class UserConcurrencyTests: XCTestCase { /* Then */ // No crash + // Ensure the requests are actually made, future proofing + XCTAssertGreaterThanOrEqual(client.executedRequests.count, 75) } /** diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 0d068e82f..088a8538f 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -208,6 +208,23 @@ + (void)logout { [OneSignalUserManagerImpl.sharedInstance logout]; } ++ (void)addUserJwtInvalidatedListener:(id)listener { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"User Jwt Invalidated Listener added successfully"]; + [OneSignalUserManagerImpl.sharedInstance addUserJwtInvalidatedListener:listener]; +} + ++ (void)removeUserJwtInvalidatedListener:(id)listener { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"User Jwt Invalidated Listener removed successfully"]; + [OneSignalUserManagerImpl.sharedInstance removeUserJwtInvalidatedListener:listener]; +} + ++ (void)updateUserJwt:(NSString * _Nonnull)externalId withToken:(NSString * _Nonnull)token { + if ([OneSignalConfigManager shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:@"updateUserJwt"]) { + return; + } + [OneSignalUserManagerImpl.sharedInstance updateUserJwtWithExternalId:externalId token:token]; +} + #pragma mark: Namespaces + (Class)Notifications { @@ -409,16 +426,8 @@ + (void)startNewSessionInternal { // TODO: Figure out if Create User also sets session_count automatically on backend [OneSignalUserManagerImpl.sharedInstance startNewSession]; - - // This is almost always going to be nil the first time. - // The OSMessagingController is an OSPushSubscriptionObserver so that we pull IAMs once we have the sub id - NSString *subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId; - if (subscriptionId) { - let oneSignalInAppMessages = NSClassFromString(ONE_SIGNAL_IN_APP_MESSAGES_CLASS_NAME); - if (oneSignalInAppMessages != nil && [oneSignalInAppMessages respondsToSelector:@selector(getInAppMessagesFromServer:)]) { - [oneSignalInAppMessages performSelector:@selector(getInAppMessagesFromServer:) withObject:subscriptionId]; - } - } + + [self fetchInAppMessages]; // The below means there are NO IAMs until on_session returns // because they can be ended, paused, or deleted from the server, or your segment has changed and you're no longer eligible @@ -433,6 +442,13 @@ + (void)startNewSessionInternal { // [OSMessagingController.sharedInstance updateInAppMessagesFromCache]; // go to controller } ++ (void)fetchInAppMessages { + let oneSignalInAppMessages = NSClassFromString(ONE_SIGNAL_IN_APP_MESSAGES_CLASS_NAME); + if (oneSignalInAppMessages != nil && [oneSignalInAppMessages respondsToSelector:@selector(getInAppMessagesFromServer)]) { + [oneSignalInAppMessages performSelector:@selector(getInAppMessagesFromServer)]; + } +} + + (void)startInAppMessages { let oneSignalInAppMessages = NSClassFromString(ONE_SIGNAL_IN_APP_MESSAGES_CLASS_NAME); if (oneSignalInAppMessages != nil && [oneSignalInAppMessages respondsToSelector:@selector(start)]) { @@ -632,9 +648,11 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { NSString *userId = nil; [OneSignalCoreImpl.sharedClient executeRequest:[OSRequestGetIosParams withUserId:userId appId:appId] onSuccess:^(NSDictionary *result) { - - if (result[IOS_REQUIRES_USER_ID_AUTHENTICATION]) { - OneSignalUserManagerImpl.sharedInstance.requiresUserAuth = [result[IOS_REQUIRES_USER_ID_AUTHENTICATION] boolValue]; + if (result[IOS_JWT_REQUIRED]) { + OneSignalUserManagerImpl.sharedInstance.requiresUserAuth = [result[IOS_JWT_REQUIRED] boolValue]; + } else { + // Remote params did not return IOS_JWT_REQUIRED + [OneSignalUserManagerImpl.sharedInstance remoteParamsReturnedUnknownRequiresUserAuth]; } if (result[IOS_USES_PROVISIONAL_AUTHORIZATION] != (id)[NSNull null]) { diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h index fd5f70c55..31dd53aa1 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h @@ -70,6 +70,10 @@ typedef void (^OSFailureBlock)(NSError* error); + (void)login:(NSString * _Nonnull)externalId; + (void)login:(NSString * _Nonnull)externalId withToken:(NSString * _Nullable)token NS_SWIFT_NAME(login(externalId:token:)); ++ (void)addUserJwtInvalidatedListener:(id _Nonnull)listener NS_REFINED_FOR_SWIFT; ++ (void)removeUserJwtInvalidatedListener:(id_Nonnull)listener NS_REFINED_FOR_SWIFT; ++ (void)updateUserJwt:(NSString * _Nonnull)externalId withToken:(NSString * _Nonnull)token +NS_SWIFT_NAME(updateUserJwt(externalId:token:)); + (void)logout; #pragma mark Notifications diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift index b29c525cb..ca386a8c3 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift @@ -33,6 +33,14 @@ import OneSignalNotifications import OneSignalCore public extension OneSignal { + static func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + __add(listener) + } + + static func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + __remove(listener) + } + static var User: OSUser { return __user() }