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 eae4a119f..2db90a09c 100644
--- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/SwiftTest.swift
+++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/SwiftTest.swift
@@ -28,9 +28,49 @@
import Foundation
import OneSignalFramework
+@objc
class SwiftTest: NSObject {
func testSwiftUserModel() {
let token1 = OneSignal.User.pushSubscription.token
let token = OneSignal.User.pushSubscription.token
}
+
+ /**
+ 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 0034b13d2..8192e3c31 100644
--- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m
+++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m
@@ -277,4 +277,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 cabab70a0..aabb06eb9 100644
--- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj
+++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj
@@ -91,6 +91,8 @@
3C6299A92BEEA46C00649187 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C6299A82BEEA46C00649187 /* PrivacyInfo.xcprivacy */; };
3C6299AB2BEEA4C000649187 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C6299AA2BEEA4C000649187 /* PrivacyInfo.xcprivacy */; };
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 */; };
3C70FA672D0B68A100031066 /* OneSignalClientError.h in Headers */ = {isa = PBXBuildFile; fileRef = 3C70FA652D0B68A100031066 /* OneSignalClientError.h */; settings = {ATTRIBUTES = (Public, ); }; };
3C70FA682D0B68A100031066 /* OneSignalClientError.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C70FA662D0B68A100031066 /* OneSignalClientError.m */; };
3C789DBD293C2206004CF83D /* OSFocusInfluenceParam.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A600B432453790700514A53 /* OSFocusInfluenceParam.m */; };
@@ -1265,6 +1267,8 @@
3C6299A82BEEA46C00649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; };
3C6299AA2BEEA4C000649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; 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 = ""; };
3C70FA652D0B68A100031066 /* OneSignalClientError.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalClientError.h; sourceTree = ""; };
3C70FA662D0B68A100031066 /* OneSignalClientError.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalClientError.m; sourceTree = ""; };
3C7A39D42B7C18EE0082665E /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
@@ -2140,6 +2144,7 @@
isa = PBXGroup;
children = (
3C8E6E0028AC0BA10031E48A /* OSIdentityOperationExecutor.swift */,
+ 3C68EFE42D93195E00F0896B /* OSCustomEventsExecutor.swift */,
3C8E6DFE28AB09AE0031E48A /* OSPropertyOperationExecutor.swift */,
3CE795FA28DBDCE700736BD4 /* OSSubscriptionOperationExecutor.swift */,
3C9AD6BB2B2285FB00BC1540 /* OSUserExecutor.swift */,
@@ -2162,6 +2167,7 @@
3C9AD6C62B228A9800BC1540 /* OSRequestTransferSubscription.swift */,
3C9AD6C02B22886600BC1540 /* OSRequestUpdateSubscription.swift */,
3C9AD6C42B228A7300BC1540 /* OSRequestDeleteSubscription.swift */,
+ 3C68EFE62D931BA600F0896B /* OSRequestCustomEvents.swift */,
);
path = Requests;
sourceTree = "";
@@ -4394,8 +4400,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 7fcc69057..02a4058e3 100644
--- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h
+++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h
@@ -203,6 +203,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"
@@ -337,6 +338,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"
@@ -359,6 +362,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/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..8168809f1
--- /dev/null
+++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift
@@ -0,0 +1,249 @@
+/*
+ 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.
+ 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)")
+
+ // 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)
+ }
+ }
+
+ /**
+ 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 5d22dc858..462ce7c4b 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
@@ -183,6 +192,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) {
@@ -795,6 +808,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..b29ad80cc
--- /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)/integrations/sdk/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
+ }
+}