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 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
+
@@ -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())