diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/Base.lproj/Main.storyboard b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/Base.lproj/Main.storyboard index 8eb04046a..867eb7681 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/Base.lproj/Main.storyboard +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/Base.lproj/Main.storyboard @@ -23,13 +23,10 @@ - + - - - - + - + - + - + - + + + + + @@ -259,6 +257,9 @@ + + + - + - + @@ -301,6 +305,9 @@ - - - - + - + + + + - + - - + - + - - + - + - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + - - - - + + + + @@ -721,6 +762,7 @@ + diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/SwiftTest.swift b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/SwiftTest.swift index 6b8d705b4..722cceb4b 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/SwiftTest.swift +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/SwiftTest.swift @@ -39,4 +39,43 @@ class SwiftTest: NSObject, OSLogListener { OneSignal.Debug.addLogListener(self) OneSignal.Debug.removeLogListener(self) } + + /** + Track multiple events with different properties. + Properties must pass `JSONSerialization.isValidJSONObject` to be accepted. + */ + @objc + static func trackCustomEvents() { + print("Dev App: track an event with nil properties") + OneSignal.User.trackEvent(name: "null properties", properties: nil) + + print("Dev App: track an event with empty properties") + OneSignal.User.trackEvent(name: "empty properties", properties: [:]) + + let formatter = DateFormatter() + formatter.dateStyle = .short + + let mixedTypes = [ + "string": "somestring", + "number": 5, + "bool": false, + "dateStr": formatter.string(from: Date()) + ] as [String: Any] + + let nestedDict = [ + "someDict": mixedTypes, + "anotherDict": [ + "foo": "bar", + "booleanVal": true, + "float": Float("3.14")! + ] + ] + let invalidProperties = ["date": Date()] + + print("Dev App: track an event with a valid nested dictionary") + OneSignal.User.trackEvent(name: "nested dictionary", properties: nestedDict) + + print("Dev App: track an event with invalid dictionary types") + OneSignal.User.trackEvent(name: "invalid dictionary", properties: invalidProperties) + } } diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.h b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.h index 8e4b15988..85ae0de42 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.h +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.h @@ -78,6 +78,7 @@ @property (weak, nonatomic) IBOutlet UITextField *activityId; @property (weak, nonatomic) IBOutlet UITextField *languageTextField; +@property (weak, nonatomic) IBOutlet UITextField *customEventsTextField; @end diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m index 642caeb79..083b34a67 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m @@ -281,4 +281,21 @@ - (IBAction)dontRequireConsent:(id)sender { [OneSignal setConsentRequired:false]; } +- (IBAction)trackCustomEvents:(id)sender { + NSLog(@"Dev App: tracking some preset custom events"); + [OneSignal.User trackEventWithName:@"simple event" properties:@{@"foobarbaz": @"foobarbaz"}]; + NSMutableDictionary *dict = [NSMutableDictionary new]; + dict[@"dict"] = @{@"abc" : @"def"}; + dict[@"false"] = false; + dict[@"int"] = @99; + [OneSignal.User trackEventWithName:@"complex event" properties:dict]; + [SwiftTest trackCustomEvents]; +} + +- (IBAction)trackNamedCustomEvent:(id)sender { + NSString *name = self.customEventsTextField.text; + NSLog(@"Dev App: Tracking custom event with name: %@", name); + [OneSignal.User trackEventWithName:name properties:nil]; +} + @end diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 95c0432cd..9271b0963 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -102,6 +102,8 @@ 3C6299AB2BEEA4C000649187 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C6299AA2BEEA4C000649187 /* PrivacyInfo.xcprivacy */; }; 3C64C3322F1066D700693230 /* LiveActivitiesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C64C3312F1066D700693230 /* LiveActivitiesManagerTests.swift */; }; 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */; }; + 3C68EFE52D93195E00F0896B /* OSCustomEventsExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C68EFE42D93195E00F0896B /* OSCustomEventsExecutor.swift */; }; + 3C68EFE72D931BA600F0896B /* OSRequestCustomEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C68EFE62D931BA600F0896B /* OSRequestCustomEvents.swift */; }; 3C7021E32ECF0821001768C6 /* OneSignalFramework.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */; }; 3C7021E42ECF0821001768C6 /* OneSignalFramework.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */; }; @@ -154,6 +156,8 @@ 3CA8B8822BEC2FCB0010ADA1 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; }; 3CA8B8832BEC2FCB0010ADA1 /* XCTest.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */; }; + 3CB331682F281679000E1801 /* CustomEventsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */; }; + 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */; }; 3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */; }; 3CBB6C262ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */; }; 3CC063942B6D6B6B002BB07F /* OneSignalCore.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063932B6D6B6B002BB07F /* OneSignalCore.m */; }; @@ -1330,6 +1334,8 @@ 3C6299AA2BEEA4C000649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3C64C3312F1066D700693230 /* LiveActivitiesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitiesManagerTests.swift; sourceTree = ""; }; 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchUserIntegrationTests.swift; sourceTree = ""; }; + 3C68EFE42D93195E00F0896B /* OSCustomEventsExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSCustomEventsExecutor.swift; sourceTree = ""; }; + 3C68EFE62D931BA600F0896B /* OSRequestCustomEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestCustomEvents.swift; sourceTree = ""; }; 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OneSignalInAppMessagesTests-Bridging-Header.h"; sourceTree = ""; }; 3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAMIntegrationTests.swift; sourceTree = ""; }; 3C70221C2ECF124B001768C6 /* OneSignalInAppMessagesMocks.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalInAppMessagesMocks.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1363,6 +1369,8 @@ 3C9AD6D22B228BB000BC1540 /* OSRequestUpdateProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestUpdateProperties.swift; sourceTree = ""; }; 3CA6CE0928E4F19B00CA0585 /* OSUserRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserRequest.swift; sourceTree = ""; }; 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTests.swift; sourceTree = ""; }; + 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventsIntegrationTests.swift; sourceTree = ""; }; + 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSCustomEventsExecutorTests.swift; sourceTree = ""; }; 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMessagingControllerUserStateTests.swift; sourceTree = ""; }; 3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsistencyManagerTestHelpers.swift; sourceTree = ""; }; 3CC063932B6D6B6B002BB07F /* OneSignalCore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalCore.m; sourceTree = ""; }; @@ -2240,6 +2248,7 @@ isa = PBXGroup; children = ( 3C8E6E0028AC0BA10031E48A /* OSIdentityOperationExecutor.swift */, + 3C68EFE42D93195E00F0896B /* OSCustomEventsExecutor.swift */, 3C8E6DFE28AB09AE0031E48A /* OSPropertyOperationExecutor.swift */, 3CE795FA28DBDCE700736BD4 /* OSSubscriptionOperationExecutor.swift */, 3C9AD6BB2B2285FB00BC1540 /* OSUserExecutor.swift */, @@ -2262,6 +2271,7 @@ 3C9AD6C62B228A9800BC1540 /* OSRequestTransferSubscription.swift */, 3C9AD6C02B22886600BC1540 /* OSRequestUpdateSubscription.swift */, 3C9AD6C42B228A7300BC1540 /* OSRequestDeleteSubscription.swift */, + 3C68EFE62D931BA600F0896B /* OSRequestCustomEvents.swift */, ); path = Requests; sourceTree = ""; @@ -2307,6 +2317,7 @@ 3CF11E3E2C6D61AC002856F5 /* Executors */, 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */, 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */, + 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */, 3CDE664B2BFC2A56006DA114 /* OneSignalUserObjcTests.m */, ); @@ -2325,6 +2336,7 @@ isa = PBXGroup; children = ( 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, + 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, ); path = Executors; sourceTree = ""; @@ -4402,10 +4414,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3CB331682F281679000E1801 /* CustomEventsIntegrationTests.swift in Sources */, 3CF11E3D2C6D6155002856F5 /* UserExecutorTests.swift in Sources */, 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, + 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, 3CDE664C2BFC2A56006DA114 /* OneSignalUserObjcTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4576,8 +4590,10 @@ 3C277D7E2BD76E0000857606 /* OSIdentityModelRepo.swift in Sources */, 3CEE90A72BFE6ABD00B0FB5B /* OSPropertiesSupportedProperty.swift in Sources */, 3C9AD6C12B22886600BC1540 /* OSRequestUpdateSubscription.swift in Sources */, + 3C68EFE72D931BA600F0896B /* OSRequestCustomEvents.swift in Sources */, 3C0EF49E28A1DBCB00E5434B /* OSUserInternalImpl.swift in Sources */, 3C8E6DFF28AB09AE0031E48A /* OSPropertyOperationExecutor.swift in Sources */, + 3C68EFE52D93195E00F0896B /* OSCustomEventsExecutor.swift in Sources */, 3C9AD6CB2B228B5200BC1540 /* OSRequestIdentifyUser.swift in Sources */, 3C9AD6BC2B2285FB00BC1540 /* OSUserExecutor.swift in Sources */, 3C9AD6C32B22887700BC1540 /* OSRequestCreateUser.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClientError.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClientError.m index e096eab13..fd0968407 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClientError.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClientError.m @@ -46,4 +46,8 @@ - (instancetype)initWithCode:(NSInteger)code message:(NSString* _Nonnull)message return self; } +- (NSString *)description { + return [NSString stringWithFormat:@"", (long)_code, _message, _response, _underlyingError]; +} + @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 7efc95730..559be0b9f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -205,6 +205,7 @@ typedef enum {ATTRIBUTED, NOT_ATTRIBUTED} FocusAttributionState; #define IDENTITY_EXECUTOR_BACKGROUND_TASK @"IDENTITY_EXECUTOR_BACKGROUND_TASK_" #define PROPERTIES_EXECUTOR_BACKGROUND_TASK @"PROPERTIES_EXECUTOR_BACKGROUND_TASK_" #define SUBSCRIPTION_EXECUTOR_BACKGROUND_TASK @"SUBSCRIPTION_EXECUTOR_BACKGROUND_TASK_" +#define CUSTOM_EVENTS_EXECUTOR_BACKGROUND_TASK @"CUSTOM_EVENTS_EXECUTOR_BACKGROUND_TASK_" // OneSignal constants #define OS_PUSH @"push" @@ -339,6 +340,8 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_REMOVE_SUBSCRIPTION_DELTA @"OS_REMOVE_SUBSCRIPTION_DELTA" #define OS_UPDATE_SUBSCRIPTION_DELTA @"OS_UPDATE_SUBSCRIPTION_DELTA" +#define OS_CUSTOM_EVENT_DELTA @"OS_CUSTOM_EVENT_DELTA" + // Operation Repo #define OS_OPERATION_REPO_DELTA_QUEUE_KEY @"OS_OPERATION_REPO_DELTA_QUEUE_KEY" @@ -361,6 +364,10 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #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" +// Custom Events Executor +#define OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY" +#define OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY" + // Live Activies Executor #define OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY" #define OS_LIVE_ACTIVITIES_EXECUTOR_START_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_START_TOKENS_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index bfc661d7c..e4ec5585d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -52,7 +52,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { OneSignalUserMocks.reset() OSConsistencyManager.shared.reset() OSMessagingController.removeInstance() - + // Set up basic configuration OneSignalConfigManager.setAppId(testAppId) OneSignalLog.setLogLevel(.LL_VERBOSE) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift index 63c5e7a5d..4afcf0ec7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift @@ -32,11 +32,8 @@ import OneSignalCore */ public protocol OSOperationExecutor { var supportedDeltas: [String] { get } - var deltaQueue: [OSDelta] { get } func enqueueDelta(_ delta: OSDelta) func cacheDeltaQueue() func processDeltaQueue(inBackground: Bool) - - func processRequestQueue(inBackground: Bool) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift new file mode 100644 index 000000000..7feb01a05 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -0,0 +1,299 @@ +/* + Modified MIT License + + Copyright 2025 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 OneSignalOSCore +import OneSignalCore + +class OSCustomEventsExecutor: OSOperationExecutor { + private enum EventConstants { + static let name = "name" + static let onesignalId = "onesignal_id" + static let timestamp = "timestamp" + static let payload = "payload" + static let deviceType = "device_type" + static let sdk = "sdk" + static let appVersion = "app_version" + static let type = "type" + static let deviceModel = "device_model" + static let deviceOs = "device_os" + static let osSdk = "os_sdk" + static let ios = "ios" + static let iOSPush = "iOSPush" + } + + var supportedDeltas: [String] = [OS_CUSTOM_EVENT_DELTA] + private var deltaQueue: [OSDelta] = [] + private var requestQueue: [OSRequestCustomEvents] = [] + private let newRecordsState: OSNewRecordsState + + // The executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `requestQueue`. + private let dispatchQueue = DispatchQueue(label: "OneSignal.OSCustomEventsExecutor", target: .global()) + + init(newRecordsState: OSNewRecordsState) { + self.newRecordsState = newRecordsState + // Read unfinished deltas and requests from cache, if any... + uncacheDeltas() + uncacheRequests() + } + + private func uncacheDeltas() { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + for (index, delta) in deltaQueue.enumerated().reversed() { + if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { + // The identity model does not exist, drop this Delta + OneSignalLog.onesignalLog(.LL_WARN, message: "OSCustomEventsExecutor.init dropped: \(delta)") + deltaQueue.remove(at: index) + } + } + self.deltaQueue = deltaQueue + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSCustomEventsExecutor error encountered reading from cache for \(OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY)") + self.deltaQueue = [] + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor successfully uncached Deltas: \(deltaQueue)") + } + + private func uncacheRequests() { + if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestCustomEvents] { + // Hook each uncached Request to the model in the store + for (index, request) in requestQueue.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: "OSCustomEventsExecutor.init dropped: \(request)") + requestQueue.remove(at: index) + } + } + self.requestQueue = requestQueue + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) + } else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSCustomEventsExecutor error encountered reading from cache for \(OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY)") + self.requestQueue = [] + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor successfully uncached Requests: \(requestQueue)") + } + + func enqueueDelta(_ delta: OSDelta) { + self.dispatchQueue.async { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor enqueue delta \(delta)") + self.deltaQueue.append(delta) + } + } + + func cacheDeltaQueue() { + self.dispatchQueue.async { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + } + + /// The `deltaQueue` can contain events for multiple users. They will remain as Deltas if there is no onesignal ID yet for its user. + /// This method will be used in an upcoming release that combine multiple events. + func processDeltaQueueWithBatching(inBackground: Bool) { + self.dispatchQueue.async { + if self.deltaQueue.isEmpty { + // Delta queue is empty but there may be pending requests + self.processRequestQueue(inBackground: inBackground) + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor processDeltaQueue with queue: \(self.deltaQueue)") + + // Holds mapping of identity model ID to the events for it + var combinedEvents: [String: [[String: Any]]] = [:] + + // 1. Combine the events for every distinct user + for (index, delta) in self.deltaQueue.enumerated().reversed() { + guard let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId), + let onesignalId = identityModel.onesignalId + else { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor.processDeltaQueue skipping: \(delta)") + // keep this Delta in the queue, as it is not yet ready to be processed + continue + } + + guard let properties = delta.value as? [String: Any] else { + // This should not happen as there are preventative typing measures before this step + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSCustomEventsExecutor.processDeltaQueue dropped due to invalid properties: \(delta)") + self.deltaQueue.remove(at: index) + continue + } + + let event: [String: Any] = [ + EventConstants.name: delta.property, + EventConstants.onesignalId: onesignalId, + EventConstants.timestamp: ISO8601DateFormatter().string(from: delta.timestamp), + EventConstants.payload: self.addSdkMetadata(properties: properties) + ] + + combinedEvents[identityModel.modelId, default: []].append(event) + self.deltaQueue.remove(at: index) + } + + // 2. Turn each user's events into a Request + for (modelId, events) in combinedEvents { + guard let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId) + else { + // This should never happen as we already checked this during Deltas processing above + continue + } + let request = OSRequestCustomEvents( + events: events, + identityModel: identityModel + ) + self.requestQueue.append(request) + } + + // Persist executor's requests (including new request) to storage + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + + self.processRequestQueue(inBackground: inBackground) + } + } + + func processDeltaQueue(inBackground: Bool) { + self.dispatchQueue.async { + if self.deltaQueue.isEmpty { + // Delta queue is empty but there may be pending requests + self.processRequestQueue(inBackground: inBackground) + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor processDeltaQueue with queue: \(self.deltaQueue)") + + for (index, delta) in self.deltaQueue.enumerated().reversed() { + guard let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId), + let onesignalId = identityModel.onesignalId + else { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor.processDeltaQueue skipping: \(delta)") + // keep this Delta in the queue, as it is not yet ready to be processed + continue + } + + guard let properties = delta.value as? [String: Any] else { + // This should not happen as there are preventative typing measures before this step + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSCustomEventsExecutor.processDeltaQueue dropped due to invalid properties: \(delta)") + self.deltaQueue.remove(at: index) + continue + } + + let event: [String: Any] = [ + EventConstants.name: delta.property, + EventConstants.onesignalId: onesignalId, + EventConstants.timestamp: ISO8601DateFormatter().string(from: delta.timestamp), + EventConstants.payload: self.addSdkMetadata(properties: properties) + ] + + self.deltaQueue.remove(at: index) + + let request = OSRequestCustomEvents( + events: [event], + identityModel: identityModel + ) + self.requestQueue.append(request) + } + + // Persist executor's requests (including new request) to storage + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + + self.processRequestQueue(inBackground: inBackground) + } + } + + /** + Adds additional data about the SDK to the event payload. + */ + private func addSdkMetadata(properties: [String: Any]) -> [String: Any] { + // TODO: Exact information contained in payload should be confirmed before the custom events GA release + let metadata = [ + EventConstants.deviceType: EventConstants.ios, + EventConstants.sdk: ONESIGNAL_VERSION, + EventConstants.appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + EventConstants.type: EventConstants.iOSPush, + EventConstants.deviceModel: OSDeviceUtils.getDeviceVariant(), + EventConstants.deviceOs: UIDevice.current.systemVersion + ] + var payload = properties + payload[EventConstants.osSdk] = metadata + return payload + } + + /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. + private func processRequestQueue(inBackground: Bool) { + if requestQueue.isEmpty { + return + } + + for request in requestQueue { + executeRequest(request, inBackground: inBackground) + } + } + + private func executeRequest(_ request: OSRequestCustomEvents, inBackground: Bool) { + guard !request.sentToClient else { + return + } + guard request.prepareForExecution(newRecordsState: newRecordsState) else { + return + } + request.sentToClient = true + + let backgroundTaskIdentifier = CUSTOM_EVENTS_EXECUTOR_BACKGROUND_TASK + UUID().uuidString + if inBackground { + OSBackgroundTaskManager.beginBackgroundTask(backgroundTaskIdentifier) + } + + OneSignalCoreImpl.sharedClient().execute(request) { _ in + self.dispatchQueue.async { + self.requestQueue.removeAll(where: { $0 == request}) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) + if inBackground { + OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) + } + } + } onFailure: { error in + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSCustomEventsExecutor request failed with error: \(error.debugDescription)") + self.dispatchQueue.async { + let responseType = OSNetworkingUtils.getResponseStatusType(error.code) + if responseType != .retryable { + // Fail, no retry, remove from cache and queue + self.requestQueue.removeAll(where: { $0 == request}) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) + } + // TODO: Handle payload too large (not necessary for alpha release) + if inBackground { + OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) + } + } + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index 2af8ea812..1a3c3e839 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -30,11 +30,11 @@ import OneSignalCore class OSIdentityOperationExecutor: OSOperationExecutor { var supportedDeltas: [String] = [OS_ADD_ALIAS_DELTA, OS_REMOVE_ALIAS_DELTA] - var deltaQueue: [OSDelta] = [] + private var deltaQueue: [OSDelta] = [] // To simplify uncaching, we maintain separate request queues for each type - var addRequestQueue: [OSRequestAddAliases] = [] - var removeRequestQueue: [OSRequestRemoveAlias] = [] - let newRecordsState: OSNewRecordsState + private var addRequestQueue: [OSRequestAddAliases] = [] + private var removeRequestQueue: [OSRequestRemoveAlias] = [] + private let newRecordsState: OSNewRecordsState // The Identity executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSIdentityOperationExecutor", target: .global()) @@ -168,7 +168,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. - func processRequestQueue(inBackground: Bool) { + private func processRequestQueue(inBackground: Bool) { let requestQueue: [OneSignalRequest] = addRequestQueue + removeRequestQueue if requestQueue.isEmpty { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 427a930dc..e4220e0be 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -62,9 +62,9 @@ private struct OSCombinedProperties { class OSPropertyOperationExecutor: OSOperationExecutor { var supportedDeltas: [String] = [OS_UPDATE_PROPERTIES_DELTA] - var deltaQueue: [OSDelta] = [] - var updateRequestQueue: [OSRequestUpdateProperties] = [] - let newRecordsState: OSNewRecordsState + private var deltaQueue: [OSDelta] = [] + private var updateRequestQueue: [OSRequestUpdateProperties] = [] + private let newRecordsState: OSNewRecordsState // The property executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `updateRequestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSPropertyOperationExecutor", target: .global()) @@ -221,7 +221,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. - func processRequestQueue(inBackground: Bool) { + private func processRequestQueue(inBackground: Bool) { if updateRequestQueue.isEmpty { return } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index f44d84157..18e66de80 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -30,13 +30,13 @@ import OneSignalCore class OSSubscriptionOperationExecutor: OSOperationExecutor { var supportedDeltas: [String] = [OS_ADD_SUBSCRIPTION_DELTA, OS_REMOVE_SUBSCRIPTION_DELTA, OS_UPDATE_SUBSCRIPTION_DELTA] - var deltaQueue: [OSDelta] = [] + private var deltaQueue: [OSDelta] = [] // To simplify uncaching, we maintain separate request queues for each type - var addRequestQueue: [OSRequestCreateSubscription] = [] - var removeRequestQueue: [OSRequestDeleteSubscription] = [] - var updateRequestQueue: [OSRequestUpdateSubscription] = [] - var subscriptionModels: [String: OSSubscriptionModel] = [:] - let newRecordsState: OSNewRecordsState + private var addRequestQueue: [OSRequestCreateSubscription] = [] + private var removeRequestQueue: [OSRequestDeleteSubscription] = [] + private var updateRequestQueue: [OSRequestUpdateSubscription] = [] + private var subscriptionModels: [String: OSSubscriptionModel] = [:] + private let newRecordsState: OSNewRecordsState // The Subscription executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSubscriptionOperationExecutor", target: .global()) @@ -157,7 +157,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { /** Since there are 2 subscription stores, we need to check both stores for the model with a particular `modelId`. */ - func getSubscriptionModelFromStores(modelId: String) -> OSSubscriptionModel? { + private func getSubscriptionModelFromStores(modelId: String) -> OSSubscriptionModel? { if let modelInStore = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.getModel(modelId: modelId) { return modelInStore } @@ -246,7 +246,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. - func processRequestQueue(inBackground: Bool) { + private func processRequestQueue(inBackground: Bool) { let requestQueue: [OneSignalRequest] = addRequestQueue + removeRequestQueue + updateRequestQueue if requestQueue.isEmpty { @@ -269,7 +269,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } - func executeCreateSubscriptionRequest(_ request: OSRequestCreateSubscription, inBackground: Bool) { + private func executeCreateSubscriptionRequest(_ request: OSRequestCreateSubscription, inBackground: Bool) { guard !request.sentToClient else { return } @@ -391,7 +391,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } - func executeUpdateSubscriptionRequest(_ request: OSRequestUpdateSubscription, inBackground: Bool) { + private func executeUpdateSubscriptionRequest(_ request: OSRequestUpdateSubscription, inBackground: Bool) { guard !request.sentToClient else { return } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index c662254bf..28b68d232 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -75,6 +75,15 @@ import OneSignalNotifications func removeSms(_ number: String) // Language func setLanguage(_ language: String) + // Events + /** + Track an event performed by the current user. + - Parameters: + - name: Name of the event, e.g., 'Started Free Trial' + - properties: Optional properties specific to the event. For example, an event with the name 'Started Free Trial' might have properties like promo code used or expiration date. + */ + func trackEvent(name: String, properties: [String: Any]?) + // ^ TODO: After alpha feedback, confirm value type for properties dict // JWT Token Expire typealias OSJwtCompletionBlock = (_ newJwtToken: String) -> Void typealias OSJwtExpiredHandler = (_ externalId: String, _ completion: OSJwtCompletionBlock) -> Void @@ -182,6 +191,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { var propertyExecutor: OSPropertyOperationExecutor? var identityExecutor: OSIdentityOperationExecutor? var subscriptionExecutor: OSSubscriptionOperationExecutor? + var customEventsExecutor: OSCustomEventsExecutor? private override init() { self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore) @@ -231,12 +241,15 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState) let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState) let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState) + let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState) self.propertyExecutor = propertyExecutor self.identityExecutor = identityExecutor self.subscriptionExecutor = subscriptionExecutor + self.customEventsExecutor = customEventsExecutor OSOperationRepo.sharedInstance.addExecutor(identityExecutor) OSOperationRepo.sharedInstance.addExecutor(propertyExecutor) OSOperationRepo.sharedInstance.addExecutor(subscriptionExecutor) + OSOperationRepo.sharedInstance.addExecutor(customEventsExecutor) // Path 2. There is a legacy player to migrate if let legacyPlayerId = OneSignalUserDefaults.initShared().getSavedString(forKey: OSUD_LEGACY_PLAYER_ID, defaultValue: nil) { @@ -797,6 +810,32 @@ extension OneSignalUserManagerImpl: OSUser { user.setLanguage(language) } + + public func trackEvent(name: String, properties: [String: Any]?) { + guard !OneSignalConfigManager.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "trackEvent") else { + return + } + + let processedProperties = properties ?? [:] + + // Make sure the properties are serializable as JSON object + guard JSONSerialization.isValidJSONObject(processedProperties) else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "trackEvent called with invalid properties \(processedProperties), dropping this event.") + return + } + + // Get the identity model of the current user + let identityModel = user.identityModel + + let delta = OSDelta( + name: OS_CUSTOM_EVENT_DELTA, + identityModelId: identityModel.modelId, + model: identityModel, + property: name, + value: processedProperties + ) + OSOperationRepo.sharedInstance.enqueueDelta(delta) + } } extension OneSignalUserManagerImpl { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift new file mode 100644 index 000000000..6dbe78abb --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift @@ -0,0 +1,87 @@ +/* + Modified MIT License + + Copyright 2025 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 OSRequestCustomEvents: OneSignalRequest, OSUserRequest { + var sentToClient = false + let stringDescription: String + override var description: String { + return stringDescription + } + + var identityModel: OSIdentityModel + + func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + if let onesignalId = identityModel.onesignalId, + newRecordsState.canAccess(onesignalId), + let appId = OneSignalConfigManager.getAppId() + { + _ = self.addPushSubscriptionIdToAdditionalHeaders() + self.path = "apps/\(appId)/custom_events" + return true + } else { + return false + } + } + + init(events: [[String: Any]], identityModel: OSIdentityModel) { + self.identityModel = identityModel + self.stringDescription = "" + super.init() + self.parameters = [ + "events": events + ] + self.method = POST + } + + func encode(with coder: NSCoder) { + coder.encode(identityModel, forKey: "identityModel") + coder.encode(parameters, forKey: "parameters") + coder.encode(method.rawValue, forKey: "method") // Encodes as String + coder.encode(timestamp, forKey: "timestamp") + } + + required init?(coder: NSCoder) { + guard + let identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel, + let rawMethod = coder.decodeObject(forKey: "method") as? UInt32, + let parameters = coder.decodeObject(forKey: "parameters") as? [String: Any], + let timestamp = coder.decodeObject(forKey: "timestamp") as? Date + else { + // Log error + return nil + } + self.identityModel = identityModel + self.stringDescription = "" + super.init() + self.parameters = parameters + self.method = HTTPMethod(rawValue: rawMethod) + self.timestamp = timestamp + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift index 3230ac5dc..da94af841 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -26,6 +26,7 @@ */ import Foundation +import OneSignalCore import OneSignalOSCore import OneSignalOSCoreMocks @testable import OneSignalUser @@ -39,6 +40,17 @@ public class OneSignalUserMocks: NSObject { OSCoreMocks.resetOperationRepo() OneSignalUserManagerImpl.sharedInstance.reset() } + + public static func setUserManagerInternalUser(externalId: String = "test-external-id", onesignalId: String?) -> 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 { + user.identityModel.addAliases([OS_ONESIGNAL_ID: onesignalId]) + } + return user + } } extension OSIdentityModelRepo { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift new file mode 100644 index 000000000..043519c8a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift @@ -0,0 +1,240 @@ +/* + Modified MIT License + + Copyright 2026 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 + +final class CustomEventsIntegrationTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalConfigManager.setAppId("test-app-id") + OneSignalLog.setLogLevel(.LL_VERBOSE) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Public API Tests + + func testTrackEvent_withValidProperties_enqueuesdelta() { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + client.fireSuccessForAllRequests = true + + let userManager = OneSignalUserManagerImpl.sharedInstance + + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + + let properties = ["string_key": "value", "number_key": 42, "bool_key": true] as [String: Any] + + /* When */ + userManager.trackEvent(name: "test_event", properties: properties) + OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + } + + func testTrackEvent_withNilProperties_enqueuesdelta() { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + client.fireSuccessForAllRequests = true + + let userManager = OneSignalUserManagerImpl.sharedInstance + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + + /* When */ + userManager.trackEvent(name: "test_event", properties: nil) + OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + } + + func testTrackEvent_withEmptyProperties_enqueuesdelta() { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + client.fireSuccessForAllRequests = true + + let userManager = OneSignalUserManagerImpl.sharedInstance + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + + /* When */ + userManager.trackEvent(name: "test_event", properties: [:]) + OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + } + + func testTrackEvent_withInvalidProperties_doesNotEnqueueDelta() { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + client.fireSuccessForAllRequests = true + + let userManager = OneSignalUserManagerImpl.sharedInstance + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + + // Create an invalid property (Date is not JSON serializable) + let invalidProperties = ["date": Date()] as [String: Any] + + /* When */ + userManager.trackEvent(name: "test_event", properties: invalidProperties) + OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then - No request should be made */ + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + XCTAssertEqual(client.executedRequests.count, 0) + } + + // MARK: - Property Validation Tests + + func testTrackEvent_withComplexNestedStructure_sendsCorrectly() { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + client.fireSuccessForAllRequests = true + + let userManager = OneSignalUserManagerImpl.sharedInstance + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + + let complexProperties = [ + "level1": "string_value", + "level1_number": 123, + "level1_bool": false, + "level1_nested": [ + "level2_key": "level2_value", + "level2_number": 456, + "level2_nested": [ + "level3_key": "level3_value", + "level3_array": [1, 2, 3] + ] + ] + ] as [String: Any] + + /* When */ + userManager.trackEvent(name: "complex_event", properties: complexProperties) + OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + + guard let request = client.executedRequests.first as? OSRequestCustomEvents, + let events = request.parameters!["events"] as? [[String: Any]], + let event = events.first, + let payload = event["payload"] as? [String: Any] else { + XCTFail("Expected valid payload") + return + } + + // Verify event-level fields + XCTAssertEqual(event["name"] as? String, "complex_event") + XCTAssertEqual(event["onesignal_id"] as? String, userA_OSID) + + // Verify level 1 properties + XCTAssertEqual(payload["level1"] as? String, "string_value") + XCTAssertEqual(payload["level1_number"] as? Int, 123) + XCTAssertEqual(payload["level1_bool"] as? Bool, false) + + // Verify level 1 nested object exists and has correct structure + guard let level1Nested = payload["level1_nested"] as? [String: Any] else { + XCTFail("Expected level1_nested object") + return + } + + // Verify level 2 properties + XCTAssertEqual(level1Nested["level2_key"] as? String, "level2_value") + XCTAssertEqual(level1Nested["level2_number"] as? Int, 456) + + // Verify level 2 nested object exists + guard let level2Nested = level1Nested["level2_nested"] as? [String: Any] else { + XCTFail("Expected level2_nested object") + return + } + + // Verify level 3 properties + XCTAssertEqual(level2Nested["level3_key"] as? String, "level3_value") + + // Verify level 3 array + guard let level3Array = level2Nested["level3_array"] as? [Int] else { + XCTFail("Expected level3_array as array of Int") + return + } + XCTAssertEqual(level3Array.count, 3) + } + + func testTrackEvent_withArrayProperties_sendsCorrectly() { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + client.fireSuccessForAllRequests = true + + let userManager = OneSignalUserManagerImpl.sharedInstance + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + + let properties = [ + "items": ["item1", "item2", "item3"], + "numbers": [1, 2, 3, 4, 5] + ] as [String: Any] + + /* When */ + userManager.trackEvent(name: "array_event", properties: properties) + OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + + guard let request = client.executedRequests.first as? OSRequestCustomEvents, + let events = request.parameters!["events"] as? [[String: Any]], + let event = events.first, + let payload = event["payload"] as? [String: Any] else { + XCTFail("Expected valid request structure") + return + } + + XCTAssertNotNil(payload["items"] as? [String]) + XCTAssertNotNil(payload["numbers"] as? [Int]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift new file mode 100644 index 000000000..d61da089a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift @@ -0,0 +1,424 @@ +/* + Modified MIT License + + Copyright 2026 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 CustomEventsMocks { + let client = MockOneSignalClient() + let newRecordsState = MockNewRecordsState() + let customEventsExecutor: OSCustomEventsExecutor + + init() { + OneSignalCoreImpl.setSharedClient(client) + customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState) + } +} + +final class OSCustomEventsExecutorTests: XCTestCase { + func createCustomEventDelta( + name: String, + properties: [String: Any]?, + identityModel: OSIdentityModel + ) -> OSDelta { + return OSDelta( + name: OS_CUSTOM_EVENT_DELTA, + identityModelId: identityModel.modelId, + model: identityModel, + property: name, + value: properties ?? [:] + ) + } + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalConfigManager.setAppId("test-app-id") + OneSignalLog.setLogLevel(.LL_VERBOSE) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Basic Event Tracking Tests + + func testTrackEvent_withProperties_sendsRequest() { + /* Setup */ + let mocks = CustomEventsMocks() + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: userA_OSID) + + let properties = ["key1": "value1", "key2": 123, "key3": true] as [String: Any] + let delta = createCustomEventDelta(name: "test_event", properties: properties, identityModel: user.identityModel) + + mocks.client.fireSuccessForAllRequests = true + + /* When */ + mocks.customEventsExecutor.enqueueDelta(delta) + mocks.customEventsExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + XCTAssertEqual(mocks.client.executedRequests.count, 1) + + guard let request = mocks.client.executedRequests.first as? OSRequestCustomEvents else { + XCTFail("Expected OSRequestCustomEvents") + return + } + + // Verify the request contains the event with correct structure + guard let events = request.parameters!["events"] as? [[String: Any]], + let event = events.first else { + XCTFail("Expected events array in request parameters") + return + } + + // Verify event-level fields + XCTAssertEqual(events.count, 1) + XCTAssertEqual(event["name"] as! String, "test_event") + XCTAssertEqual(event["onesignal_id"] as! String, userA_OSID) + + // Verify timestamp exists and is a valid ISO8601 formatted string + guard let timestampString = event["timestamp"] as? String else { + XCTFail("Expected timestamp as String") + return + } + + // Verify it can be parsed as ISO8601 + let iso8601Formatter = ISO8601DateFormatter() + guard let parsedDate = iso8601Formatter.date(from: timestampString) else { + XCTFail("Expected timestamp to be valid ISO8601 format, got: \(timestampString)") + return + } + XCTAssertNotNil(parsedDate) + + // Verify payload contains user properties and os_sdk metadata + guard let payload = event["payload"] as? [String: Any] else { + XCTFail("Expected payload in event") + return + } + + // Verify user-provided properties + XCTAssertEqual(payload["key1"] as! String, "value1") + XCTAssertEqual(payload["key2"] as! Int, 123) + XCTAssertEqual(payload["key3"] as! Bool, true) + + // Verify payload contains exactly the expected keys (user properties + os_sdk) + let expectedKeys = Set(["key1", "key2", "key3", "os_sdk"]) + let actualKeys = Set(payload.keys) + XCTAssertEqual(actualKeys, expectedKeys, "Payload should contain only user properties and os_sdk") + } + + func testTrackEvent_withEmptyProperties_sendsRequestWithEmptyPayload() { + /* Setup */ + let mocks = CustomEventsMocks() + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: userA_OSID) + + let delta = createCustomEventDelta(name: "event_empty_props", properties: [:], identityModel: user.identityModel) + + mocks.client.fireSuccessForAllRequests = true + + /* When */ + mocks.customEventsExecutor.enqueueDelta(delta) + mocks.customEventsExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + + guard let request = mocks.client.executedRequests.first as? OSRequestCustomEvents, + let events = request.parameters!["events"] as? [[String: Any]], + let event = events.first, + let payload = event["payload"] as? [String: Any] else { + XCTFail("Expected valid request structure") + return + } + + // Should only contain os_sdk metadata + XCTAssertNotNil(payload["os_sdk"]) + XCTAssertEqual(payload.count, 1) + } + + func testTrackEvent_withNestedProperties_sendsRequestWithNestedStructure() { + /* Setup */ + let mocks = CustomEventsMocks() + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: userA_OSID) + + let properties = [ + "topLevel": "value", + "nested": [ + "foo": "bar", + "booleanVal": true, + "number": 3.14 + ] + ] as [String: Any] + + let delta = createCustomEventDelta(name: "nested_event", properties: properties, identityModel: user.identityModel) + + mocks.client.fireSuccessForAllRequests = true + + /* When */ + mocks.customEventsExecutor.enqueueDelta(delta) + mocks.customEventsExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + XCTAssertEqual(mocks.client.executedRequests.count, 1) + + guard let request = mocks.client.executedRequests.first as? OSRequestCustomEvents, + let events = request.parameters!["events"] as? [[String: Any]], + let event = events.first, + let payload = event["payload"] as? [String: Any], + let nested = payload["nested"] as? [String: Any] else { + XCTFail("Expected valid nested structure") + return + } + + XCTAssertEqual(payload["topLevel"] as? String, "value") + XCTAssertEqual(nested["foo"] as? String, "bar") + XCTAssertEqual(nested["booleanVal"] as? Bool, true) + XCTAssertEqual(nested["number"] as? Double, 3.14) + } + + // MARK: - Multiple Events Tests (No Batching) + + func testProcessDeltaQueue_withMultipleEventsForSameUser_createsSeparateRequests() { + /* Setup */ + let mocks = CustomEventsMocks() + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: userA_OSID) + + let delta1 = createCustomEventDelta(name: "event1", properties: ["key": "value1"], identityModel: user.identityModel) + let delta2 = createCustomEventDelta(name: "event2", properties: ["key": "value2"], identityModel: user.identityModel) + let delta3 = createCustomEventDelta(name: "event3", properties: nil, identityModel: user.identityModel) + + mocks.client.fireSuccessForAllRequests = true + + /* When */ + mocks.customEventsExecutor.enqueueDelta(delta1) + mocks.customEventsExecutor.enqueueDelta(delta2) + mocks.customEventsExecutor.enqueueDelta(delta3) + mocks.customEventsExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // Should have 3 separate requests, one per event (no batching) + XCTAssertEqual(mocks.client.executedRequests.count, 3) + + let requests = mocks.client.executedRequests.compactMap { $0 as? OSRequestCustomEvents } + XCTAssertEqual(requests.count, 3) + + // Verify each request has exactly 1 event + for request in requests { + guard let events = request.parameters!["events"] as? [[String: Any]] else { + XCTFail("Expected events array in request") + return + } + XCTAssertEqual(events.count, 1, "Each request should contain exactly 1 event") + } + + // Verify all event names are present + let eventNames = requests.compactMap { request -> String? in + guard let events = request.parameters!["events"] as? [[String: Any]], + let event = events.first else { + return nil + } + return event["name"] as? String + }.sorted() + + XCTAssertEqual(eventNames, ["event1", "event2", "event3"]) + } + + func testProcessDeltaQueue_withEventsForMultipleUsers_createsSeparateRequestsPerEvent() { + /* Setup */ + let mocks = CustomEventsMocks() + let userA = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: userA_OSID) + let userB = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: userB_OSID) + + let deltaUserA1 = createCustomEventDelta(name: "userA_event1", properties: ["user": "A"], identityModel: userA.identityModel) + let deltaUserA2 = createCustomEventDelta(name: "userA_event2", properties: ["user": "A"], identityModel: userA.identityModel) + let deltaUserB1 = createCustomEventDelta(name: "userB_event1", properties: ["user": "B"], identityModel: userB.identityModel) + + mocks.client.fireSuccessForAllRequests = true + + /* When */ + mocks.customEventsExecutor.enqueueDelta(deltaUserA1) + mocks.customEventsExecutor.enqueueDelta(deltaUserA2) + mocks.customEventsExecutor.enqueueDelta(deltaUserB1) + mocks.customEventsExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // Should have 3 separate requests, one per event (no batching) + XCTAssertEqual(mocks.client.executedRequests.count, 3) + + let requests = mocks.client.executedRequests.compactMap { $0 as? OSRequestCustomEvents } + XCTAssertEqual(requests.count, 3) + + // Verify each request has exactly 1 event + for request in requests { + guard let events = request.parameters!["events"] as? [[String: Any]] else { + XCTFail("Expected events array in request") + return + } + XCTAssertEqual(events.count, 1, "Each request should contain exactly 1 event") + } + + // Count events by user + let eventsByUser = requests.reduce(into: [String: Int]()) { counts, request in + guard let events = request.parameters!["events"] as? [[String: Any]], + let event = events.first, + let onesignalId = event["onesignal_id"] as? String else { + return + } + counts[onesignalId, default: 0] += 1 + } + + XCTAssertEqual(eventsByUser[userA_OSID], 2, "Should have 2 events for userA") + XCTAssertEqual(eventsByUser[userB_OSID], 1, "Should have 1 event for userB") + } + + // MARK: - Missing OneSignal ID Tests + + func testProcessDeltaQueue_withoutOnesignalId_doesNotSendRequest() { + /* Setup */ + let mocks = CustomEventsMocks() + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: nil) + + let delta = createCustomEventDelta(name: "blocked_event", properties: ["key": "value"], identityModel: user.identityModel) + + mocks.client.fireSuccessForAllRequests = true + + /* When */ + mocks.customEventsExecutor.enqueueDelta(delta) + mocks.customEventsExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // No request should be made + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + XCTAssertEqual(mocks.client.executedRequests.count, 0) + } + + // MARK: - Caching Tests + + func testCacheDeltaQueue_persistsDeltasToStorage() { + /* Setup */ + let mocks = CustomEventsMocks() + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: userA_OSID) + + let delta = createCustomEventDelta(name: "cached_event", properties: ["key": "value"], identityModel: user.identityModel) + + /* When */ + mocks.customEventsExecutor.enqueueDelta(delta) + mocks.customEventsExecutor.cacheDeltaQueue() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.3) + + /* Then - Verify delta is cached */ + let cachedDeltas = OneSignalUserDefaults.initShared().getSavedCodeableData( + forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, + defaultValue: [] + ) as? [OSDelta] + + XCTAssertEqual(cachedDeltas?.count, 1) + XCTAssertEqual(cachedDeltas?.first?.property, "cached_event") + } + + func testUncacheDeltas_restoresDeltasFromStorage() { + /* Setup */ + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: userA_OSID) + let delta = createCustomEventDelta(name: "restored_event", properties: ["key": "value1"], identityModel: user.identityModel) + + OneSignalUserDefaults.initShared().saveCodeableData( + forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, + withValue: [delta] + ) + + /* When - Create new executor which uncaches deltas in init */ + let mocks = CustomEventsMocks() + mocks.client.fireSuccessForAllRequests = true + + mocks.customEventsExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + + guard let request = mocks.client.executedRequests.first as? OSRequestCustomEvents, + let events = request.parameters?["events"] as? [[String: Any]], + let event = events.first else { + XCTFail("Expected valid request") + return + } + + XCTAssertEqual(event["name"] as? String, "restored_event") + } + + // MARK: - SDK Metadata Tests + + func testTrackEvent_includesSdkMetadata() { + /* Setup */ + let mocks = CustomEventsMocks() + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: userA_OSID) + + let delta = createCustomEventDelta(name: "metadata_event", properties: ["user_key": "user_value"], identityModel: user.identityModel) + + mocks.client.fireSuccessForAllRequests = true + + /* When */ + mocks.customEventsExecutor.enqueueDelta(delta) + mocks.customEventsExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + guard let request = mocks.client.executedRequests.first as? OSRequestCustomEvents, + let events = request.parameters!["events"] as? [[String: Any]], + let event = events.first, + let payload = event["payload"] as? [String: Any], + let osSdk = payload["os_sdk"] as? [String: Any] else { + XCTFail("Expected valid request with os_sdk metadata") + return + } + + // Verify os_sdk metadata fields + XCTAssertEqual(osSdk["device_type"] as? String, "ios") + XCTAssertEqual(osSdk["type"] as? String, "iOSPush") + XCTAssertNotNil(osSdk["sdk"]) + XCTAssertNotNil(osSdk["device_os"]) + XCTAssertNotNil(osSdk["device_model"]) + XCTAssertNotNil(osSdk["app_version"]) + + // Verify user properties are still present + XCTAssertEqual(payload["user_key"] as? String, "user_value") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 31dd74e29..aee71dc02 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -14,7 +14,7 @@ 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. + 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, @@ -50,13 +50,6 @@ private class Mocks { 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()) - ) - } } final class UserExecutorTests: XCTestCase { @@ -156,7 +149,7 @@ final class UserExecutorTests: XCTestCase { func testIdentifyUser_withConflict_addsToNewRecords() { /* Setup */ let mocks = Mocks() - let user = mocks.setUserManagerInternalUser(externalId: userB_EUID) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: nil) let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) let newIdentityModel = user.identityModel @@ -178,7 +171,7 @@ final class UserExecutorTests: XCTestCase { /* Setup */ let mocks = Mocks() - let user = mocks.setUserManagerInternalUser(externalId: "new-eid") + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: "new-eid", onesignalId: nil) let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) let newIdentityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userB_EUID], changeNotifier: OSEventProducer())