diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 20cca93f3..1cbfc73a9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -94,13 +94,13 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */; }; A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */; }; - A5162F01A1B2C3D4E5F60014 /* OSSessionsRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */; }; - A5162F01A1B2C3D4E5F60012 /* OSSessionsBackendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */; }; + A5162F01A1B2C3D4E5F6001C /* OSOperationRetryTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F6001B /* OSOperationRetryTrigger.swift */; }; + A5162F01A1B2C3D4E5F60014 /* OSSessionRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60013 /* OSSessionRequests.swift */; }; A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */; }; A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */; }; A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */; }; A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */; }; - A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */; }; + A5162F01A1B2C3D4E5F6001E /* OSOperationRetryTriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F6001D /* OSOperationRetryTriggerTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -157,6 +157,7 @@ 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 */; }; + A5162F01A1B2C3D4E5F60018 /* OSSessionOperationExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60017 /* OSSessionOperationExecutor.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, ); }; }; @@ -216,6 +217,7 @@ 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 */; }; + A5162F01A1B2C3D4E5F6001A /* OSSessionOperationExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60019 /* OSSessionOperationExecutorTests.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 */; }; @@ -1467,6 +1469,7 @@ 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 = ""; }; + A5162F01A1B2C3D4E5F60017 /* OSSessionOperationExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionOperationExecutor.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 = ""; }; @@ -1507,6 +1510,7 @@ 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 = ""; }; + A5162F01A1B2C3D4E5F60019 /* OSSessionOperationExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionOperationExecutorTests.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 = ""; }; @@ -1912,13 +1916,13 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionService.swift; sourceTree = ""; }; - A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsRequests.swift; sourceTree = ""; }; - A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsBackendService.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F6001B /* OSOperationRetryTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRetryTrigger.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F60013 /* OSSessionRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequests.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuard.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuardTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionServiceTests.swift; sourceTree = ""; }; - A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsBackendServiceTests.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F6001D /* OSOperationRetryTriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRetryTriggerTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; @@ -2424,8 +2428,7 @@ 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */, - A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */, - A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */, + A5162F01A1B2C3D4E5F6001B /* OSOperationRetryTrigger.swift */, A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, @@ -2496,6 +2499,7 @@ children = ( 3C8E6E0028AC0BA10031E48A /* OSIdentityOperationExecutor.swift */, 3C68EFE42D93195E00F0896B /* OSCustomEventsExecutor.swift */, + A5162F01A1B2C3D4E5F60017 /* OSSessionOperationExecutor.swift */, 3C8E6DFE28AB09AE0031E48A /* OSPropertyOperationExecutor.swift */, 3CE795FA28DBDCE700736BD4 /* OSSubscriptionOperationExecutor.swift */, 3C9AD6BB2B2285FB00BC1540 /* OSUserExecutor.swift */, @@ -2519,6 +2523,7 @@ 3C9AD6C02B22886600BC1540 /* OSRequestUpdateSubscription.swift */, 3C9AD6C42B228A7300BC1540 /* OSRequestDeleteSubscription.swift */, 3C68EFE62D931BA600F0896B /* OSRequestCustomEvents.swift */, + A5162F01A1B2C3D4E5F60013 /* OSSessionRequests.swift */, ); path = Requests; sourceTree = ""; @@ -2604,6 +2609,7 @@ C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */, 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, + A5162F01A1B2C3D4E5F60019 /* OSSessionOperationExecutorTests.swift */, FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */, ); @@ -2755,7 +2761,7 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, - A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */, + A5162F01A1B2C3D4E5F6001D /* OSOperationRetryTriggerTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -4676,8 +4682,7 @@ A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */, - A5162F01A1B2C3D4E5F60014 /* OSSessionsRequests.swift in Sources */, - A5162F01A1B2C3D4E5F60012 /* OSSessionsBackendService.swift in Sources */, + A5162F01A1B2C3D4E5F6001C /* OSOperationRetryTrigger.swift in Sources */, A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, @@ -4778,6 +4783,7 @@ 3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, + A5162F01A1B2C3D4E5F6001A /* OSSessionOperationExecutorTests.swift in Sources */, 3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */, E85C1C500D87196C5E20144F /* ExecutorReadYourWriteTests.swift in Sources */, 3C5181A1B2C3D4E5F6A7B802 /* SubscriptionCreateResponseTests.swift in Sources */, @@ -4875,7 +4881,7 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */, - A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */, + A5162F01A1B2C3D4E5F6001E /* OSOperationRetryTriggerTests.swift in Sources */, A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, @@ -4996,6 +5002,8 @@ 3C0EF49E28A1DBCB00E5434B /* OSUserInternalImpl.swift in Sources */, 3C8E6DFF28AB09AE0031E48A /* OSPropertyOperationExecutor.swift in Sources */, 3C68EFE52D93195E00F0896B /* OSCustomEventsExecutor.swift in Sources */, + A5162F01A1B2C3D4E5F60018 /* OSSessionOperationExecutor.swift in Sources */, + A5162F01A1B2C3D4E5F60014 /* OSSessionRequests.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/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 8a8263e79..22aa524cd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -208,6 +208,7 @@ typedef enum {ATTRIBUTED, NOT_ATTRIBUTED} FocusAttributionState; #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_" +#define SESSION_EXECUTOR_BACKGROUND_TASK @"SESSION_EXECUTOR_BACKGROUND_TASK_" // OneSignal constants #define OS_PUSH @"push" @@ -353,6 +354,9 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CUSTOM_EVENT_DELTA @"OS_CUSTOM_EVENT_DELTA" +#define OS_CREATE_SESSION_DELTA @"OS_CREATE_SESSION_DELTA" +#define OS_UPDATE_SESSION_DELTA @"OS_UPDATE_SESSION_DELTA" + // Operation Repo #define OS_OPERATION_REPO_DELTA_QUEUE_KEY @"OS_OPERATION_REPO_DELTA_QUEUE_KEY" @@ -379,6 +383,10 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #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" +// Session Executor +#define OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY @"OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY" +#define OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY @"OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY" + // A codeable value larger than this is not written, and a cached queue blob larger than it is dropped without // decoding it. Real queues are a few KB. CFPreferences rejects a value of 4 MB or more on iOS. #define OS_USER_DEFAULTS_MAX_VALUE_BYTES 1048576 diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift index 0c0a753c9..7286fe7ae 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift @@ -42,4 +42,12 @@ public protocol OSOperationExecutor { so the policy stays there; only the storage is per-executor. */ func removeOperationsWithoutExternalId() + + /// End any failure backoff early, still waiting out a backend `Retry-After`. Driven by + /// `OSOperationRepo.retryNow`, which flushes right after. + func retryNow() +} + +public extension OSOperationExecutor { + func retryNow() {} } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index c1274ef84..3f44eca16 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -57,10 +57,17 @@ public class OSOperationRepo: NSObject { var pollIntervalMilliseconds = Int(POLL_INTERVAL_MS) public var paused = false + /// Driven by app focus. Calls `retryNow` on foreground and when the network returns. + public let retryTrigger: OSOperationRetryTrigger + // Uncache in init so an enqueue before start cannot persist over a previous session's queue. public init(identityVerificationService: OSIdentityVerificationService) { self.identityVerificationService = identityVerificationService + self.retryTrigger = OSOperationRetryTrigger() super.init() + retryTrigger.onRetry = { [weak self] in + self?.retryNow() + } uncacheDeltaQueue() } @@ -205,6 +212,19 @@ public class OSOperationRepo: NSObject { } } + /// Ends executors' failure backoff early and flushes. A backend `Retry-After` is still waited out. + public func retryNow() { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { + return + } + dispatchQueue.async { + for executor in self.executors { + executor.retryNow() + } + self.flushDeltaQueue() + } + } + @objc public func addFlushDeltaQueueToDispatchQueue(inBackground: Bool = false) { self.dispatchQueue.async { self.flushDeltaQueue(inBackground: inBackground) @@ -232,11 +252,20 @@ public class OSOperationRepo: NSObject { added and removed, never updated. Should an update path for them appear, this has to narrow to the push type, which the repo cannot see from here: `OSSubscriptionModel` lives in OneSignalUser, so the Delta would have to carry the distinction the way it carries `externalId`. + + Session Deltas are exempt too: the sessions API takes no user JWT, so Identity Verification does + not apply to it. */ private func shouldDropAnonymousDelta(_ delta: OSDelta, ivActive: Bool) -> Bool { - return ivActive && delta.externalId == nil && delta.name != OS_UPDATE_SUBSCRIPTION_DELTA + return ivActive && delta.externalId == nil && !Self.deltasExemptFromIdentityVerification.contains(delta.name) } + private static let deltasExemptFromIdentityVerification: Set = [ + OS_UPDATE_SUBSCRIPTION_DELTA, + OS_CREATE_SESSION_DELTA, + OS_UPDATE_SESSION_DELTA + ] + private func flushDeltaQueue(inBackground: Bool = false) { guard !paused else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo not flushing queue due to being paused") diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift new file mode 100644 index 000000000..3d58c9e99 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift @@ -0,0 +1,96 @@ +/* + 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 Foundation +import Network + +protocol OSNetworkPathMonitoring: AnyObject { + /// Calls `onAvailable` each time the network becomes available after being unavailable. + func start(onAvailable: @escaping () -> Void) +} + +final class OSNetworkPathMonitor: OSNetworkPathMonitoring { + private let monitor = NWPathMonitor() + + func start(onAvailable: @escaping () -> Void) { + var wasSatisfied: Bool? + monitor.pathUpdateHandler = { path in + let isSatisfied = path.status == .satisfied + defer { wasSatisfied = isSatisfied } + if isSatisfied, wasSatisfied == false { + onAvailable() + } + } + monitor.start(queue: DispatchQueue(label: "OneSignal.OSNetworkPathMonitor")) + } + + deinit { + monitor.cancel() + } +} + +/** + Wakes failed operations early: calls `onRetry` when the app comes to the foreground, and when a + network becomes available while it is in the foreground. Network changes in the background are + ignored, since a suspended app could not finish the attempt. + */ +public final class OSOperationRetryTrigger { + private let pathMonitor: OSNetworkPathMonitoring + /// Set once by the owner, before the trigger is handed out. + var onRetry: () -> Void = {} + private let lock = NSLock() + private var isForeground = false + private var isMonitoring = false + + init(pathMonitor: OSNetworkPathMonitoring = OSNetworkPathMonitor()) { + self.pathMonitor = pathMonitor + } + + public func onFocus() { + let shouldStartMonitoring = lock.withLock { + isForeground = true + defer { isMonitoring = true } + return !isMonitoring + } + if shouldStartMonitoring { + pathMonitor.start { [weak self] in + self?.onNetworkAvailable() + } + } + onRetry() + } + + public func onUnfocus() { + lock.withLock { isForeground = false } + } + + private func onNetworkAvailable() { + guard lock.withLock({ isForeground }) else { + return + } + onRetry() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 9c6ef0a07..e3ab505ef 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -48,6 +48,11 @@ public protocol OSSessionUserProvider: AnyObject { /// Taken from one read of the current user, so a concurrent login cannot mix two users. var sessionCurrentUser: OSSessionUser { get } func sessionOnesignalId(identityModelId: String) -> String? + /// Queues the sessions API create for a session. + func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String?) + /// Queues a session's cumulative duration, ending the session when `endTime` (seconds since + /// 1970) is set. + func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: TimeInterval?) } /// New fields must be Optional. A record stored by an earlier version lacks them, and synthesized @@ -61,7 +66,7 @@ public struct OSSessionRecord: Codable, Equatable { /// Fixed for the whole session so it never mixes paths. Read this rather than the feature /// manager, whose value can change mid-session. public let usesSessionsApi: Bool - let identityModelId: String? + public let identityModelId: String? /// Pinned at session start, so it stays the same after a login or user switch. public internal(set) var onesignalId: String? public internal(set) var subscriptionId: String? @@ -159,6 +164,8 @@ public final class OSSessionService: NSObject { /// Leaves any open foreground interval running: the tracker reports focus before the session /// starts asynchronously, and that visit belongs to the new session. A launch in the /// background has no interval open, so nothing counts until the app becomes active. + /// With the sessions API, the create is enqueued here. The executor holds it until the + /// session's IDs are assigned. public func startNewSession(userProvider: OSSessionUserProvider) { let user = userProvider.sessionCurrentUser let newRecord = OSSessionRecord( @@ -177,6 +184,9 @@ public final class OSSessionService: NSObject { record = newRecord persist() } + if newRecord.usesSessionsApi { + userProvider.enqueueSessionCreate(newRecord, directAttributionId: nil) + } } func onFocus() { @@ -248,6 +258,18 @@ public final class OSSessionService: NSObject { persist() } + /// Set once the sessions API creates the session, if it is still the current one. + public func setServerSessionId(_ serverSessionId: String, forSessionId sessionId: String) { + stateLock.withLock { + loadIfNeeded() + guard record?.sessionId == sessionId, record?.serverSessionId == nil else { + return + } + record?.serverSessionId = serverSessionId + persist() + } + } + private func loadIfNeeded() { guard !loaded else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift deleted file mode 100644 index e9d35944e..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift +++ /dev/null @@ -1,166 +0,0 @@ -/* - 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 Foundation -import OneSignalCore - -struct OSCreateSessionRequestBody: Equatable { - let onesignalId: String - let subscriptionId: String - let startTime: Date - let idempotencyKey: String - let directAttributionId: String? - - init(onesignalId: String, subscriptionId: String, startTime: Date, idempotencyKey: String, directAttributionId: String? = nil) { - self.onesignalId = onesignalId - self.subscriptionId = subscriptionId - self.startTime = startTime - self.idempotencyKey = idempotencyKey - self.directAttributionId = directAttributionId - } -} - -struct OSUpdateSessionRequestBody: Equatable { - let onesignalId: String - let subscriptionId: String - /// Cumulative for the whole session, so a retried or reordered update cannot double count. - let durationSeconds: Int64 - let idempotencyKey: String - /// Set only on the update that ends the session. - let endTime: Date? - - init(onesignalId: String, subscriptionId: String, durationSeconds: Int64, idempotencyKey: String, endTime: Date? = nil) { - self.onesignalId = onesignalId - self.subscriptionId = subscriptionId - self.durationSeconds = durationSeconds - self.idempotencyKey = idempotencyKey - self.endTime = endTime - } -} - -enum OSSessionsApiResult { - case success(Value) - /// No response, 5xx, 408, 429, or a create success without a session ID. - /// Wait at least `retryAfterSeconds` when set. - case retry(statusCode: Int, retryAfterSeconds: Int?) - /// Any other 4xx, or a request the client rejected before sending. It will never succeed as sent. - case drop(statusCode: Int) -} - -extension OSSessionsApiResult: Equatable where Value: Equatable {} - -/// Typed client for the sessions API. -final class OSSessionsBackendService { - static let defaultRetryAfterSeconds = 60 - - private let client: () -> IOneSignalClient - - convenience init() { - self.init(client: { OneSignalCoreImpl.sharedClient() }) - } - - init(client: @escaping () -> IOneSignalClient) { - self.client = client - } - - /// On success the result holds the backend session ID. - func createSession( - appId: String, - body: OSCreateSessionRequestBody, - completion: @escaping (OSSessionsApiResult) -> Void - ) { - let request = OSRequestCreateSession(appId: appId, body: body) - client().execute(request) { response in - guard let sessionId = Self.sessionId(from: response) else { - // Retrying with the same idempotency key returns the session the backend already created. - OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionsBackendService: create session response is missing data.session_id") - completion(.retry(statusCode: Self.statusCode(of: response), retryAfterSeconds: nil)) - return - } - completion(.success(sessionId)) - } onFailure: { error in - completion(Self.classifyFailure(error, emptySuccess: .retry(statusCode: error.code, retryAfterSeconds: nil))) - } - } - - /// Reports the session's cumulative duration, and ends it when `body.endTime` is set. - func updateSession( - appId: String, - sessionId: String, - body: OSUpdateSessionRequestBody, - completion: @escaping (OSSessionsApiResult) -> Void - ) { - let request = OSRequestUpdateSession(appId: appId, sessionId: sessionId, body: body) - client().execute(request) { _ in - completion(.success(())) - } onFailure: { error in - completion(Self.classifyFailure(error, emptySuccess: .success(()))) - } - } - - private static func sessionId(from response: [AnyHashable: Any]?) -> String? { - guard let data = response?["data"] as? [String: Any], - let sessionId = data["session_id"] as? String, - !sessionId.isEmpty - else { - return nil - } - return sessionId - } - - private static func statusCode(of response: [AnyHashable: Any]?) -> Int { - (response?["httpStatusCode"] as? NSNumber)?.intValue ?? 202 - } - - /// `emptySuccess` covers a 2xx whose body failed to parse, which `OneSignalClient` reports as a failure. - private static func classifyFailure( - _ error: OneSignalClientError, - emptySuccess: OSSessionsApiResult - ) -> OSSessionsApiResult { - let code = error.code - if (200..<300).contains(code) { - return emptySuccess - } - // 0 means no HTTP response: no network, timeout, or missing privacy consent. The client's - // only negative code is a missing app ID, which a retry cannot fix. - if code == 0 || code == 408 || code == 429 || code >= 500 { - return .retry(statusCode: code, retryAfterSeconds: retryAfterSeconds(error)) - } - return .drop(statusCode: code) - } - - /// Only the delay-seconds form is supported, not the HTTP-date form. - static func retryAfterSeconds(_ error: OneSignalClientError) -> Int? { - let value = error.responseHeaders?.first { key, _ in - (key as? String)?.caseInsensitiveCompare("Retry-After") == .orderedSame - }?.value - if let value { - return Int("\(value)".trimmingCharacters(in: .whitespaces)) ?? defaultRetryAfterSeconds - } - return error.code == 429 ? defaultRetryAfterSeconds : nil - } -} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift deleted file mode 100644 index a861a09e9..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift +++ /dev/null @@ -1,89 +0,0 @@ -/* - 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 Foundation -import OneSignalCore - -/// RFC 3339 in UTC with milliseconds, e.g. `2023-11-14T22:13:20.000Z`. -enum OSSessionTimestamp { - private static let formatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - static func string(from date: Date) -> String { - formatter.string(from: date) - } -} - -/// `POST apps/{app_id}/sessions` -final class OSRequestCreateSession: OneSignalRequest { - override var description: String { - "" - } - - init(appId: String, body: OSCreateSessionRequestBody) { - super.init() - var parameters: [String: Any] = [ - "onesignal_id": body.onesignalId, - "subscription_id": body.subscriptionId, - "device_type": DEVICE_TYPE_PUSH, - "start_time": OSSessionTimestamp.string(from: body.startTime), - "idempotency_key": body.idempotencyKey - ] - if let directAttributionId = body.directAttributionId { - parameters["direct_attribution_id"] = directAttributionId - } - self.parameters = parameters - self.method = POST - self.path = "apps/\(appId)/sessions" - } -} - -/// `PATCH apps/{app_id}/sessions/{session_id}` -final class OSRequestUpdateSession: OneSignalRequest { - override var description: String { - "" - } - - init(appId: String, sessionId: String, body: OSUpdateSessionRequestBody) { - super.init() - var parameters: [String: Any] = [ - "onesignal_id": body.onesignalId, - "subscription_id": body.subscriptionId, - "duration_seconds": body.durationSeconds, - "idempotency_key": body.idempotencyKey - ] - if let endTime = body.endTime { - parameters["end_time"] = OSSessionTimestamp.string(from: endTime) - } - self.parameters = parameters - self.method = PATCH - self.path = "apps/\(appId)/sessions/\(sessionId)" - } -} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index 52cc1c79d..9bce161d5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -141,4 +141,17 @@ final class OSOperationRepoFlushTests: XCTestCase { private func makeDelta(name: String, property: String) -> OSDelta { OSOperationRepoTestEnvironment.makeDelta(name: name, externalId: nil, property: property) } + + func testRetryNowEndsExecutorBackoffBeforeFlushing() { + let executor = MockOperationExecutor(supportedDeltas: [knownDelta]) + var retryNowCallsAtFlush: [Int] = [] + executor.onProcessDeltaQueue = { retryNowCallsAtFlush.append(executor.retryNowCallCount) } + repo.addExecutor(executor) + repo.paused = false + + repo.retryNow() + repo.flushAndWait() + + XCTAssertEqual(retryNowCallsAtFlush.first, 1) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift index 7b3e405c3..eea4dad37 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift @@ -178,6 +178,21 @@ final class OSOperationRepoIdentityVerificationTests: XCTestCase { XCTAssertEqual(executor.enqueued.map(\.property), ["token"]) } + /// The sessions API takes no user JWT, so session Deltas survive the enqueue drop. + func testAnonymousSessionDeltasAreExemptFromTheEnqueueDrop() { + jwtConfig.hydrate(requiresUserAuth: true) + let repo = makeRepo() + repo.paused = true + + repo.enqueueDelta(OSOperationRepoTestEnvironment.makeDelta(name: OS_CREATE_SESSION_DELTA, externalId: nil, property: "create")) + repo.enqueueDelta(OSOperationRepoTestEnvironment.makeDelta(name: OS_UPDATE_SESSION_DELTA, externalId: nil, property: "update")) + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "identified")) + + OneSignalCoreMocks.waitUntil("identified delta enqueued") { repo.snapshotDeltaQueue().count == 3 } + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["create", "update", "identified"]) + } + /// The rollout flag alone must not suppress; only `jwt_required` turns it on. func testAnonymousDeltasSurviveWhenTheFlagIsOnButTheAppDoesNotRequireAuth() { flagsStore.applyRemoteFlags([FeatureFlag.sdkIdentityVerification.key], metadata: nil) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift index bb84d4ec3..28907e08c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift @@ -76,6 +76,7 @@ final class MockOperationExecutor: OSOperationExecutor { let supportedDeltas: [String] private(set) var enqueued: [OSDelta] = [] private(set) var removeOperationsWithoutExternalIdCallCount = 0 + private(set) var retryNowCallCount = 0 var onProcessDeltaQueue: (() -> Void)? init(supportedDeltas: [String]) { @@ -95,4 +96,8 @@ final class MockOperationExecutor: OSOperationExecutor { func removeOperationsWithoutExternalId() { removeOperationsWithoutExternalIdCallCount += 1 } + + func retryNow() { + retryNowCallCount += 1 + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift new file mode 100644 index 000000000..0f57e928e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift @@ -0,0 +1,89 @@ +/* + 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 +@testable import OneSignalOSCore + +private final class FakePathMonitor: OSNetworkPathMonitoring { + private(set) var startCount = 0 + private var onAvailable: (() -> Void)? + + func start(onAvailable: @escaping () -> Void) { + startCount += 1 + self.onAvailable = onAvailable + } + + func networkBecomesAvailable() { + onAvailable?() + } +} + +final class OSOperationRetryTriggerTests: XCTestCase { + private var monitor: FakePathMonitor! + private var trigger: OSOperationRetryTrigger! + private var retryCount = 0 + + override func setUp() { + super.setUp() + monitor = FakePathMonitor() + trigger = OSOperationRetryTrigger(pathMonitor: monitor) + retryCount = 0 + trigger.onRetry = { [unowned self] in self.retryCount += 1 } + } + + func testRetriesOnFocus() { + trigger.onFocus() + + XCTAssertEqual(retryCount, 1) + } + + func testRetriesWhenTheNetworkReturnsWhileFocused() { + trigger.onFocus() + + monitor.networkBecomesAvailable() + + XCTAssertEqual(retryCount, 2) + } + + func testIgnoresTheNetworkReturningInTheBackground() { + trigger.onFocus() + trigger.onUnfocus() + + monitor.networkBecomesAvailable() + + XCTAssertEqual(retryCount, 1) + } + + func testStartsMonitoringOnceOnTheFirstFocus() { + XCTAssertEqual(monitor.startCount, 0) + + trigger.onFocus() + trigger.onUnfocus() + trigger.onFocus() + + XCTAssertEqual(monitor.startCount, 1) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 305655133..3aad2f398 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -47,6 +47,14 @@ private final class FakeUserProvider: OSSessionUserProvider { func sessionOnesignalId(identityModelId: String) -> String? { onesignalIds[identityModelId] } + + private(set) var createdSessions: [OSSessionRecord] = [] + + func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String?) { + createdSessions.append(record) + } + + func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: TimeInterval?) {} } final class OSSessionServiceTests: XCTestCase { @@ -338,4 +346,46 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(restarted.currentRecord?.activeDuration, 0) } + + // MARK: - Session start + + func testNewSessionEnqueuesItsCreateWithPinnedIds() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeService() + + service.startNewSession(userProvider: user) + + XCTAssertEqual(user.createdSessions, [service.currentRecord!]) + XCTAssertEqual(user.createdSessions.first?.startTime, 5_000) + XCTAssertEqual(user.createdSessions.first?.onesignalId, "onesignal-a") + XCTAssertEqual(user.createdSessions.first?.subscriptionId, "subscription-a") + } + + func testEachNewSessionEnqueuesItsOwnCreate() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeService() + + service.startNewSession(userProvider: user) + service.startNewSession(userProvider: user) + + XCTAssertEqual(Set(user.createdSessions.map(\.sessionId)).count, 2) + } + + func testResumingASessionDoesNotEnqueueAnotherCreate() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeForegroundSession() + + service.onUnfocus() + service.onFocus() + + XCTAssertEqual(user.createdSessions.count, 1) + } + + func testNewSessionEnqueuesNothingWhenFlagIsOff() { + let service = makeService() + + service.startNewSession(userProvider: user) + + XCTAssertTrue(user.createdSessions.isEmpty) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift deleted file mode 100644 index 8fb76ba04..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift +++ /dev/null @@ -1,237 +0,0 @@ -/* - 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 Foundation -import OneSignalCore -@testable import OneSignalOSCore -import XCTest - -private final class FakeClient: NSObject, IOneSignalClient { - enum Outcome { - case success([AnyHashable: Any]?) - case failure(code: Int, headers: [String: String]?) - } - - var outcome: Outcome = .success(nil) - private(set) var requests: [OneSignalRequest] = [] - - func execute( - _ request: OneSignalRequest, - onSuccess successBlock: @escaping OSResultSuccessBlock, - onFailure failureBlock: @escaping OSClientFailureBlock - ) { - requests.append(request) - switch outcome { - case .success(let response): - successBlock(response) - case .failure(let code, let headers): - failureBlock(OneSignalClientError(code: code, message: "failure", responseHeaders: headers, response: nil, underlyingError: nil)) - } - } -} - -final class OSSessionsBackendServiceTests: XCTestCase { - private let createBody = OSCreateSessionRequestBody( - onesignalId: "onesignal-id", - subscriptionId: "subscription-id", - startTime: Date(timeIntervalSince1970: 1_700_000_000), - idempotencyKey: "create-key" - ) - private let updateBody = OSUpdateSessionRequestBody( - onesignalId: "onesignal-id", - subscriptionId: "subscription-id", - durationSeconds: 42, - idempotencyKey: "update-key" - ) - private var client: FakeClient! - private var service: OSSessionsBackendService! - - override func setUp() { - super.setUp() - client = FakeClient() - service = OSSessionsBackendService(client: { [unowned self] in self.client }) - } - - private func create(_ body: OSCreateSessionRequestBody? = nil) -> OSSessionsApiResult? { - var result: OSSessionsApiResult? - service.createSession(appId: "app-id", body: body ?? createBody) { result = $0 } - return result - } - - private func update(_ body: OSUpdateSessionRequestBody? = nil) -> OSSessionsApiResult? { - var result: OSSessionsApiResult? - service.updateSession(appId: "app-id", sessionId: "server-id", body: body ?? updateBody) { result = $0 } - return result - } - - /// `Void` is not `Equatable`, so compare update results through their failure fields. - private func assertUpdate( - _ result: OSSessionsApiResult?, - matches expected: OSSessionsApiResult, - file: StaticString = #filePath, - line: UInt = #line - ) { - let mapped: OSSessionsApiResult? = result.map { - switch $0 { - case .success: return .success("") - case .retry(let statusCode, let retryAfterSeconds): return .retry(statusCode: statusCode, retryAfterSeconds: retryAfterSeconds) - case .drop(let statusCode): return .drop(statusCode: statusCode) - } - } - XCTAssertEqual(mapped, expected, file: file, line: line) - } - - func testCreateSessionPostsRequestBody() { - client.outcome = .success(["data": ["session_id": "server-id"]]) - - _ = create() - - let request = client.requests.first - XCTAssertTrue(request is OSRequestCreateSession) - XCTAssertEqual(request?.method, POST) - XCTAssertEqual(request?.path, "apps/app-id/sessions") - XCTAssertEqual(request?.parameters as NSDictionary?, [ - "onesignal_id": "onesignal-id", - "subscription_id": "subscription-id", - "device_type": 0, - "start_time": "2023-11-14T22:13:20.000Z", - "idempotency_key": "create-key" - ]) - } - - func testCreateSessionIncludesDirectAttributionWhenSet() { - client.outcome = .success(["data": ["session_id": "server-id"]]) - - _ = create(OSCreateSessionRequestBody( - onesignalId: "onesignal-id", - subscriptionId: "subscription-id", - startTime: Date(timeIntervalSince1970: 1_700_000_000), - idempotencyKey: "create-key", - directAttributionId: "notification-id" - )) - - XCTAssertEqual(client.requests.first?.parameters?["direct_attribution_id"] as? String, "notification-id") - } - - func testCreateSessionReturnsSessionIdFrom202() { - client.outcome = .success(["data": ["session_id": "server-id"], "httpStatusCode": 202]) - - XCTAssertEqual(create(), .success("server-id")) - } - - func testCreateSessionRetriesSuccessWithoutSessionId() { - let responses: [[AnyHashable: Any]?] = [ - nil, - ["httpStatusCode": 202], - ["data": [String: Any](), "httpStatusCode": 202], - ["data": ["session_id": ""], "httpStatusCode": 202], - ["data": ["session_id": NSNull()], "httpStatusCode": 202], - ["data": ["session_id": 123], "httpStatusCode": 202], - ["data": "server-id", "httpStatusCode": 202] - ] - for response in responses { - client.outcome = .success(response) - - XCTAssertEqual(create(), .retry(statusCode: 202, retryAfterSeconds: nil), "\(String(describing: response))") - } - } - - func testCreateSessionRetriesUnparsableSuccessBody() { - client.outcome = .failure(code: 202, headers: nil) - - XCTAssertEqual(create(), .retry(statusCode: 202, retryAfterSeconds: nil)) - } - - func testUpdateSessionPatchesRequestBody() { - client.outcome = .success(nil) - - assertUpdate(update(), matches: .success("")) - - let request = client.requests.first - XCTAssertTrue(request is OSRequestUpdateSession) - XCTAssertEqual(request?.method, PATCH) - XCTAssertEqual(request?.path, "apps/app-id/sessions/server-id") - XCTAssertEqual(request?.parameters as NSDictionary?, [ - "onesignal_id": "onesignal-id", - "subscription_id": "subscription-id", - "duration_seconds": 42, - "idempotency_key": "update-key" - ]) - } - - func testUpdateSessionIncludesEndTimeWhenSet() { - _ = update(OSUpdateSessionRequestBody( - onesignalId: "onesignal-id", - subscriptionId: "subscription-id", - durationSeconds: 42, - idempotencyKey: "update-key", - endTime: Date(timeIntervalSince1970: 1_700_000_042.5) - )) - - XCTAssertEqual(client.requests.first?.parameters?["end_time"] as? String, "2023-11-14T22:14:02.500Z") - } - - func testUpdateSessionSucceedsWithUnparsableSuccessBody() { - client.outcome = .failure(code: 202, headers: nil) - - assertUpdate(update(), matches: .success("")) - } - - func testFailuresAreRetriedOnNoResponse5xx408And429() { - for code in [0, 408, 500, 502, 503] { - client.outcome = .failure(code: code, headers: nil) - - XCTAssertEqual(create(), .retry(statusCode: code, retryAfterSeconds: nil), "\(code)") - assertUpdate(update(), matches: .retry(statusCode: code, retryAfterSeconds: nil)) - } - client.outcome = .failure(code: 429, headers: nil) - XCTAssertEqual(create(), .retry(statusCode: 429, retryAfterSeconds: OSSessionsBackendService.defaultRetryAfterSeconds)) - } - - func testOther4xxAndMissingAppIdFailuresAreDropped() { - for code in [-1, 400, 401, 403, 404, 409, 410, 422] { - client.outcome = .failure(code: code, headers: ["Retry-After": "10"]) - - XCTAssertEqual(create(), .drop(statusCode: code), "\(code)") - assertUpdate(update(), matches: .drop(statusCode: code)) - } - } - - func testRetryExposesRetryAfterFromResponse() { - client.outcome = .failure(code: 429, headers: ["Retry-After": "30"]) - XCTAssertEqual(create(), .retry(statusCode: 429, retryAfterSeconds: 30)) - - client.outcome = .failure(code: 503, headers: ["retry-after": "15"]) - assertUpdate(update(), matches: .retry(statusCode: 503, retryAfterSeconds: 15)) - } - - func testUnparsableRetryAfterFallsBackToDefault() { - client.outcome = .failure(code: 503, headers: ["Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"]) - - XCTAssertEqual(create(), .retry(statusCode: 503, retryAfterSeconds: OSSessionsBackendService.defaultRetryAfterSeconds)) - } -} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift new file mode 100644 index 000000000..ae3f29c5a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -0,0 +1,461 @@ +/* + 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 Foundation +import OneSignalCore +import OneSignalOSCore + +/// Keys of the value an `OS_CREATE_SESSION_DELTA` or `OS_UPDATE_SESSION_DELTA` carries. The Delta's +/// `property` is the local session ID. +enum OSSessionDeltaKey { + static let appId = "app_id" + static let startTime = "start_time" + static let directAttributionId = "direct_attribution_id" + static let activeDuration = "active_duration" + static let endTime = "end_time" + static let onesignalId = "onesignal_id" + static let subscriptionId = "subscription_id" + static let serverSessionId = "server_session_id" +} + +/** + Sends sessions API Requests: a session's create, then its updates once the create returns the server + session ID. Only the first queued Request of a session is sent, so they reach the backend in order. + + Unlike the other executors, a failed Request is retried in the same run, on a later flush once its + backoff has passed, rather than waiting for a relaunch. + */ +class OSSessionOperationExecutor: OSOperationExecutor { + static let maxQueuedRequests = 100 + static let maxFailedAttempts = 5 + static let baseBackoffSeconds: TimeInterval = 5 + static let maxBackoffSeconds: TimeInterval = 300 + static let defaultRetryAfterSeconds: TimeInterval = 60 + + var supportedDeltas: [String] = [OS_CREATE_SESSION_DELTA, OS_UPDATE_SESSION_DELTA] + private var deltaQueue: [OSDelta] = [] + private var requestQueue: [OSSessionRequest] = [] + /// By local session ID, for an update built after its create already succeeded. + private var serverSessionIds: [String: String] = [:] + private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing + private let nowProvider: () -> Date + private let uptime: () -> TimeInterval + private let jitter: () -> Double + + // The executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `requestQueue`. + private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSessionOperationExecutor", target: .global()) + + init( + newRecordsState: OSNewRecordsState, + auth: OSRequestAuthorizing, + nowProvider: @escaping () -> Date = { Date() }, + uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + jitter: @escaping () -> Double = { Double.random(in: 0.5...1) } + ) { + self.newRecordsState = newRecordsState + self.auth = auth + self.nowProvider = nowProvider + self.uptime = uptime + self.jitter = jitter + uncacheDeltas() + uncacheRequests() + } + + private func uncacheDeltas() { + guard var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor error encountered reading from cache for \(OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY)") + return + } + deltaQueue.removeAll { delta in + guard OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil else { + return false + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor.init dropped: \(delta)") + return true + } + self.deltaQueue = deltaQueue + cacheDeltas() + } + + private func uncacheRequests() { + guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [Any] else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor error encountered reading from cache for \(OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY)") + return + } + // Hook each uncached Request to the model in the store + requestQueue = cached.compactMap { $0 as? OSSessionRequest }.filter { request in + if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { + request.identityModel = identityModel + return true + } + if request.identityModel.onesignalId != nil { + // Its user is no longer loaded but was created, so the Request can still be sent + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + return true + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor.init dropped: \(request)") + return false + } + removeOrphanedUpdates() + for case let update as OSRequestUpdateSession in requestQueue { + serverSessionIds[update.localSessionId] = update.serverSessionId ?? serverSessionIds[update.localSessionId] + } + cacheRequests() + } + + func enqueueDelta(_ delta: OSDelta) { + self.dispatchQueue.async { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSSessionOperationExecutor enqueue delta \(delta)") + self.deltaQueue.append(delta) + } + } + + func cacheDeltaQueue() { + self.dispatchQueue.async { + self.cacheDeltas() + } + } + + /** + The sessions API takes no user JWT, so Identity Verification does not apply to session Requests. + But an anonymous user is never created while it is required, so a create still waiting for that + user's `onesignal_id` can never be sent. Drop those, with their updates. + */ + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let countBefore = self.requestQueue.count + self.requestQueue.removeAll { request in + request is OSRequestCreateSession + && request.identityModel.externalId == nil + && request.identityModel.onesignalId == nil + } + guard self.requestQueue.count != countBefore else { + return + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(countBefore - self.requestQueue.count) creates for anonymous users that will never be created") + self.removeOrphanedUpdates() + self.cacheRequests() + } + } + + func retryNow() { + self.dispatchQueue.async { + for request in self.requestQueue { + request.retryNotBefore = request.retryAfterNotBefore + } + } + } + + func processDeltaQueue(inBackground: Bool) { + self.dispatchQueue.async { + if !self.deltaQueue.isEmpty { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSSessionOperationExecutor processDeltaQueue with queue: \(self.deltaQueue)") + for delta in self.deltaQueue { + if let request = self.makeRequest(from: delta) { + self.admit(request) + } + } + self.deltaQueue.removeAll() + self.cacheRequests() + self.cacheDeltas() + } + self.processRequestQueue(inBackground: inBackground) + } + } + + /// The Requests waiting to be sent, for tests. + var queuedRequests: [OSSessionRequest] { + dispatchQueue.sync { requestQueue } + } + + private func cacheDeltas() { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY, withValue: deltaQueue) + } + + private func cacheRequests() { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY, withValue: requestQueue) + } +} + +// MARK: - Queueing + +private extension OSSessionOperationExecutor { + func makeRequest(from delta: OSDelta) -> OSSessionRequest? { + guard let value = delta.value as? [String: Any], + let appId = value[OSSessionDeltaKey.appId] as? String, + let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) + else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor.processDeltaQueue dropped: \(delta)") + return nil + } + let localSessionId = delta.property + let onesignalId = value[OSSessionDeltaKey.onesignalId] as? String + let subscriptionId = value[OSSessionDeltaKey.subscriptionId] as? String + if delta.name == OS_CREATE_SESSION_DELTA { + return OSRequestCreateSession( + appId: appId, + localSessionId: localSessionId, + startTime: Date(timeIntervalSince1970: value[OSSessionDeltaKey.startTime] as? TimeInterval ?? 0), + directAttributionId: value[OSSessionDeltaKey.directAttributionId] as? String, + identityModel: identityModel, + ownerExternalId: delta.externalId, + onesignalId: onesignalId, + subscriptionId: subscriptionId + ) + } + return OSRequestUpdateSession( + appId: appId, + localSessionId: localSessionId, + serverSessionId: value[OSSessionDeltaKey.serverSessionId] as? String ?? serverSessionIds[localSessionId], + activeDuration: value[OSSessionDeltaKey.activeDuration] as? TimeInterval ?? 0, + endTime: (value[OSSessionDeltaKey.endTime] as? TimeInterval).map { Date(timeIntervalSince1970: $0) }, + identityModel: identityModel, + ownerExternalId: delta.externalId, + onesignalId: onesignalId, + subscriptionId: subscriptionId + ) + } + + func admit(_ request: OSSessionRequest) { + if let update = request as? OSRequestUpdateSession, !admitUpdate(update) { + return + } + requestQueue.append(request) + while requestQueue.count > Self.maxQueuedRequests { + let oldest = requestQueue[0] + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor more than \(Self.maxQueuedRequests) Requests queued, dropping oldest: \(oldest)") + drop(oldest) + } + } + + /** + Folds an update into its session's queued ones, which are all cumulative. Returns false when the + update should not be queued: its session already ended, it can never get a server session ID, or + a queued update already has the same body. + */ + func admitUpdate(_ update: OSRequestUpdateSession) -> Bool { + let sessionId = update.localSessionId + let queuedUpdates = requestQueue.compactMap { $0 as? OSRequestUpdateSession }.filter { $0.localSessionId == sessionId } + guard !queuedUpdates.contains(where: \.isEnd) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), the session already ended") + return false + } + update.serverSessionId = update.serverSessionId ?? queuedUpdates.lazy.compactMap(\.serverSessionId).first + guard update.serverSessionId != nil || hasCreate(forSession: sessionId) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), its session has no create or server session ID") + return false + } + // An update in flight still counts, so a later one cannot report less time. + update.activeDuration = queuedUpdates.map(\.activeDuration).reduce(update.activeDuration, max) + + let pending = queuedUpdates.filter { !$0.sentToClient } + update.failedAttempts = pending.map(\.failedAttempts).reduce(update.failedAttempts, max) + update.onesignalId = update.onesignalId ?? pending.lazy.compactMap(\.onesignalId).first + update.subscriptionId = update.subscriptionId ?? pending.lazy.compactMap(\.subscriptionId).first + if pending.count == 1, let queued = pending.first, + queued.activeDuration == update.activeDuration, queued.endTime == update.endTime { + // Keep the queued update and its idempotency key, since the body did not change. + queued.onesignalId = queued.onesignalId ?? update.onesignalId + queued.subscriptionId = queued.subscriptionId ?? update.subscriptionId + return false + } + requestQueue.removeAll { request in pending.contains { $0 === request } } + return true + } + + func hasCreate(forSession sessionId: String) -> Bool { + requestQueue.contains { $0 is OSRequestCreateSession && $0.localSessionId == sessionId } + } + + /// Removes a Request that will not be sent. A dropped create takes its session's updates with it, + /// since they could never get a server session ID. + func drop(_ request: OSSessionRequest) { + requestQueue.removeAll { $0 === request } + removeOrphanedUpdates() + } + + func removeOrphanedUpdates() { + let sessionsWithCreate = Set(requestQueue.filter { $0 is OSRequestCreateSession }.map(\.localSessionId)) + requestQueue.removeAll { request in + guard let update = request as? OSRequestUpdateSession, + update.serverSessionId == nil, + !sessionsWithCreate.contains(update.localSessionId) + else { + return false + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(update), its session has no create") + return true + } + } +} + +// MARK: - Sending + +private extension OSSessionOperationExecutor { + /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. + func processRequestQueue(inBackground: Bool) { + if removeUnsendableRequests() { + cacheRequests() + } + var sessionsSeen = Set() + for request in requestQueue { + guard sessionsSeen.insert(request.localSessionId).inserted, + !request.sentToClient, + uptime() >= request.retryNotBefore ?? 0, + request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) + else { + continue + } + executeRequest(request, inBackground: inBackground) + } + } + + /// Drops Requests recorded for another app, and those past their age limit (see `OSRequestAging`). + func removeUnsendableRequests() -> Bool { + let countBefore = requestQueue.count + let currentAppId = OneSignalIdentifiers.currentAppId + let now = nowProvider() + let currentExternalId = OSRequestAging.currentExternalId + for request in requestQueue { + if let currentAppId, request.appId != currentAppId { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(request), it belongs to another app") + drop(request) + } else if let age = OSRequestAging.staleAge(timestamp: request.timestamp, owner: request.ownerExternalId, typeLimit: nil, now: now, currentExternalId: currentExternalId) { + OSRequestAging.logDrop(of: "\(type(of: request))", owner: request.ownerExternalId, age: age, executor: "OSSessionOperationExecutor") + drop(request) + } + } + return requestQueue.count != countBefore + } + + func executeRequest(_ request: OSSessionRequest, inBackground: Bool) { + request.sentToClient = true + + let backgroundTaskIdentifier = SESSION_EXECUTOR_BACKGROUND_TASK + UUID().uuidString + if inBackground { + OSBackgroundTaskManager.beginBackgroundTask(backgroundTaskIdentifier) + } + + OneSignalCoreImpl.sharedClient().execute(request) { response in + self.dispatchQueue.async { + self.handleSuccess(request, response: response) + if inBackground { + OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) + } + } + } onFailure: { error in + self.dispatchQueue.async { + self.handleFailure(request, error: error) + if inBackground { + OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) + } + } + } + } + + func handleSuccess(_ request: OSSessionRequest, response: [AnyHashable: Any]?) { + guard let create = request as? OSRequestCreateSession else { + requestQueue.removeAll { $0 === request } + cacheRequests() + return + } + guard let data = response?["data"] as? [String: Any], + let serverSessionId = data["session_id"] as? String, + !serverSessionId.isEmpty + else { + // Retrying with the same idempotency key returns the session the backend already created. + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor create session response is missing data.session_id") + retry(request, statusCode: (response?["httpStatusCode"] as? NSNumber)?.intValue ?? 202, retryAfter: nil) + return + } + requestQueue.removeAll { $0 === request } + serverSessionIds[create.localSessionId] = serverSessionId + for case let update as OSRequestUpdateSession in requestQueue where update.localSessionId == create.localSessionId { + update.serverSessionId = update.serverSessionId ?? serverSessionId + } + // Updates wait out the post-create delay, so the backend has the session first. + newRecordsState.add(serverSessionId) + OSSessionService.shared.setServerSessionId(serverSessionId, forSessionId: create.localSessionId) + cacheRequests() + } + + func handleFailure(_ request: OSSessionRequest, error: OneSignalClientError) { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor \(request) failed with status \(error.code)") + let code = error.code + if (200..<300).contains(code) { + // `OneSignalClient` reports a 2xx whose body failed to parse as a failure. + if request is OSRequestCreateSession { + retry(request, statusCode: code, retryAfter: nil) + } else { + requestQueue.removeAll { $0 === request } + cacheRequests() + } + } else if code == 0 || code == 408 || code == 429 || code >= 500 { + // 0 means no HTTP response: no network, timeout, or missing privacy consent. + retry(request, statusCode: code, retryAfter: Self.retryAfterSeconds(error)) + } else { + // Any other 4xx, or a missing app ID, which the client reports as a negative code. + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor dropping \(request) after a \(code)") + drop(request) + cacheRequests() + } + } + + /// Backs off exponentially, waiting at least `retryAfter`. Only attempts the backend answered count + /// toward the limit, so a Request made offline keeps retrying until the network returns, when + /// `retryNow` ends the backoff. + func retry(_ request: OSSessionRequest, statusCode: Int, retryAfter: TimeInterval?) { + request.sentToClient = false + if statusCode != 0 { + request.failedAttempts += 1 + } + guard request.failedAttempts < Self.maxFailedAttempts else { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor dropping \(request) after too many failures") + drop(request) + cacheRequests() + return + } + request.retryAttempts += 1 + let exponential = Self.baseBackoffSeconds * pow(2, Double(request.retryAttempts - 1)) + let backoff = min(exponential, Self.maxBackoffSeconds) * jitter() + let now = uptime() + request.retryAfterNotBefore = retryAfter.map { now + $0 } + request.retryNotBefore = max(now + backoff, request.retryAfterNotBefore ?? 0) + cacheRequests() + } + + /// Only the delay-seconds form is supported, not the HTTP-date form. + static func retryAfterSeconds(_ error: OneSignalClientError) -> TimeInterval? { + let value = error.responseHeaders?.first { key, _ in + (key as? String)?.caseInsensitiveCompare("Retry-After") == .orderedSame + }?.value + if let value { + return TimeInterval("\(value)".trimmingCharacters(in: .whitespaces)) ?? defaultRetryAfterSeconds + } + return error.code == 429 ? defaultRetryAfterSeconds : nil + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index fca1f94d0..2009c6f9a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -41,4 +41,42 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { public func sessionOnesignalId(identityModelId: String) -> String? { identityModelRepo.get(modelId: identityModelId)?.onesignalId } + + /// Does nothing unless the session uses the sessions API. + public func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String?) { + var value: [String: Any] = [OSSessionDeltaKey.startTime: record.startTime] + value[OSSessionDeltaKey.directAttributionId] = directAttributionId + enqueueSessionDelta(OS_CREATE_SESSION_DELTA, record: record, value: value) + } + + /// Does nothing unless the session uses the sessions API. + public func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: TimeInterval?) { + var value: [String: Any] = [OSSessionDeltaKey.activeDuration: record.activeDuration] + value[OSSessionDeltaKey.endTime] = endTime + value[OSSessionDeltaKey.serverSessionId] = record.serverSessionId + enqueueSessionDelta(OS_UPDATE_SESSION_DELTA, record: record, value: value) + } + + private func enqueueSessionDelta(_ name: String, record: OSSessionRecord, value: [String: Any]) { + guard record.usesSessionsApi, + let identityModelId = record.identityModelId, + let identityModel = identityModelRepo.get(modelId: identityModelId), + let appId = OneSignalIdentifiers.currentAppId + else { + return + } + var value = value + value[OSSessionDeltaKey.appId] = appId + value[OSSessionDeltaKey.onesignalId] = record.onesignalId + value[OSSessionDeltaKey.subscriptionId] = record.subscriptionId + let delta = OSDelta( + name: name, + identityModelId: identityModelId, + externalId: identityModel.externalId, + model: identityModel, + property: record.sessionId, + value: value + ) + operationRepo.enqueueDelta(delta) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 7d9745679..82c4836d0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -217,6 +217,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { var identityExecutor: OSIdentityOperationExecutor? var subscriptionExecutor: OSSubscriptionOperationExecutor? var customEventsExecutor: OSCustomEventsExecutor? + var sessionExecutor: OSSessionOperationExecutor? private override init() { let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) @@ -320,14 +321,17 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let sessionExecutor = OSSessionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) self.propertyExecutor = propertyExecutor self.identityExecutor = identityExecutor self.subscriptionExecutor = subscriptionExecutor self.customEventsExecutor = customEventsExecutor + self.sessionExecutor = sessionExecutor operationRepo.addExecutor(identityExecutor) operationRepo.addExecutor(propertyExecutor) operationRepo.addExecutor(subscriptionExecutor) operationRepo.addExecutor(customEventsExecutor) + operationRepo.addExecutor(sessionExecutor) // After the executors: a cached requirement makes `start()` flush right away, and the // Deltas restored at launch can only route once the map above is populated. @@ -739,6 +743,17 @@ extension OneSignalUserManagerImpl { updatePropertiesDeltas(property: .session_time, value: sessionTime.intValue) } + /// Retries failed operations right away, and again whenever the network returns while focused. + @objc + public func onFocus() { + operationRepo.retryTrigger.onFocus() + } + + @objc + public func onUnfocus() { + operationRepo.retryTrigger.onUnfocus() + } + /** App has been backgrounded. Run background tasks such to flush the operation repo and hydrating models. Need to consider app killed vs app backgrounded and handle gracefully. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift new file mode 100644 index 000000000..9885a5f50 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift @@ -0,0 +1,315 @@ +/* + 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 Foundation +import OneSignalCore +import OneSignalOSCore + +/// RFC 3339 in UTC with milliseconds, e.g. `2023-11-14T22:13:20.000Z`. +enum OSSessionTimestamp { + private static let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func string(from date: Date) -> String { + formatter.string(from: date) + } +} + +/// A sessions API Request, sent and retried by `OSSessionOperationExecutor`. +protocol OSSessionRequest: OSUserRequest { + /// The app the session was recorded for. A Request for any other app is dropped. + var appId: String { get } + /// `OSSessionRecord.sessionId`, which stands in for the backend session ID until the create succeeds. + var localSessionId: String { get } + var identityModel: OSIdentityModel { get set } + /// Failed attempts the backend answered, persisted so the limit holds across restarts. + var failedAttempts: Int { get set } + /// Retries in this run, including those with no response. Sets the backoff, and is not persisted + /// since a relaunch starts over. + var retryAttempts: Int { get set } + /// System uptime before which a retry is not sent. + var retryNotBefore: TimeInterval? { get set } + /// The part of `retryNotBefore` a backend `Retry-After` asked for, which `retryNow` keeps. + var retryAfterNotBefore: TimeInterval? { get set } +} + +private extension OneSignalRequest { + /// The executor owns retries, with backoff that honors `Retry-After`. Starting at the client's + /// last attempt stops `OneSignalClient` from also retrying status 0 and 5xx underneath it. + func skipClientRetries() { + reattemptCount = MAX_ATTEMPT_COUNT - 1 + } +} + +/// The session's pinned user, falling back to the identity model once the backend creates it. +/// The push subscription is the same device subscription across logins, so it falls back to the +/// current one. +private func resolvedIds(onesignalId: String?, subscriptionId: String?, identityModel: OSIdentityModel) -> (String, String)? { + guard let onesignalId = onesignalId ?? identityModel.onesignalId, + let subscriptionId = subscriptionId ?? OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId + else { + return nil + } + return (onesignalId, subscriptionId) +} + +/// `POST apps/{app_id}/sessions` +final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { + var sentToClient = false + let appId: String + let localSessionId: String + let startTime: Date + let directAttributionId: String? + var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + private(set) var onesignalId: String? + private(set) var subscriptionId: String? + var failedAttempts = 0 + var retryAttempts = 0 + var retryNotBefore: TimeInterval? + var retryAfterNotBefore: TimeInterval? + + /// The sessions API takes no user JWT. + var sendsUnsigned: Bool { true } + + override var description: String { + "" + } + + init( + appId: String, + localSessionId: String, + startTime: Date, + directAttributionId: String?, + identityModel: OSIdentityModel, + ownerExternalId: String?, + onesignalId: String?, + subscriptionId: String? + ) { + self.appId = appId + self.localSessionId = localSessionId + self.startTime = startTime + self.directAttributionId = directAttributionId + self.identityModel = identityModel + self.ownerExternalId = ownerExternalId + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + super.init() + self.method = POST + skipClientRetries() + } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + guard let (onesignalId, subscriptionId) = resolvedIds(onesignalId: onesignalId, subscriptionId: subscriptionId, identityModel: identityModel), + newRecordsState.canAccess(onesignalId), + newRecordsState.canAccess(subscriptionId), + auth.authorize(self) + else { + return false + } + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + var parameters: [String: Any] = [ + "onesignal_id": onesignalId, + "subscription_id": subscriptionId, + "device_type": DEVICE_TYPE_PUSH, + "start_time": OSSessionTimestamp.string(from: startTime), + // The local session ID, so a create replayed from any process returns the same session. + "idempotency_key": localSessionId + ] + parameters["direct_attribution_id"] = directAttributionId + self.parameters = parameters + self.path = "apps/\(appId)/sessions" + return true + } + + func encode(with coder: NSCoder) { + coder.encode(appId, forKey: "appId") + coder.encode(localSessionId, forKey: "localSessionId") + coder.encode(startTime, forKey: "startTime") + coder.encode(directAttributionId, forKey: "directAttributionId") + coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") + coder.encode(onesignalId, forKey: "onesignalId") + coder.encode(subscriptionId, forKey: "subscriptionId") + coder.encode(failedAttempts, forKey: "failedAttempts") + coder.encode(timestamp, forKey: "timestamp") + } + + required init?(coder: NSCoder) { + guard let appId = coder.decodeObject(forKey: "appId") as? String, + let localSessionId = coder.decodeObject(forKey: "localSessionId") as? String, + let startTime = coder.decodeObject(forKey: "startTime") as? Date, + let identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel, + let timestamp = coder.decodeObject(forKey: "timestamp") as? Date + else { + return nil + } + self.appId = appId + self.localSessionId = localSessionId + self.startTime = startTime + self.directAttributionId = coder.decodeObject(forKey: "directAttributionId") as? String + self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String + self.onesignalId = coder.decodeObject(forKey: "onesignalId") as? String + self.subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String + self.failedAttempts = coder.decodeInteger(forKey: "failedAttempts") + super.init() + self.method = POST + self.timestamp = timestamp + skipClientRetries() + } +} + +/// `PATCH apps/{app_id}/sessions/{session_id}` +final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { + var sentToClient = false + let appId: String + let localSessionId: String + /// Set once the session's create succeeds. + var serverSessionId: String? + /// Cumulative for the whole session, so a retried or reordered update cannot double count. + var activeDuration: TimeInterval + /// Set only on the update that ends the session. + let endTime: Date? + var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + var onesignalId: String? + var subscriptionId: String? + /// Generated once and persisted, so every retry, including after a restart, is deduplicated by the backend. + let idempotencyKey: String + var failedAttempts = 0 + var retryAttempts = 0 + var retryNotBefore: TimeInterval? + var retryAfterNotBefore: TimeInterval? + + /// The sessions API takes no user JWT. + var sendsUnsigned: Bool { true } + + var isEnd: Bool { endTime != nil } + + override var description: String { + "" + } + + init( + appId: String, + localSessionId: String, + serverSessionId: String?, + activeDuration: TimeInterval, + endTime: Date?, + identityModel: OSIdentityModel, + ownerExternalId: String?, + onesignalId: String?, + subscriptionId: String? + ) { + self.appId = appId + self.localSessionId = localSessionId + self.serverSessionId = serverSessionId + self.activeDuration = activeDuration + self.endTime = endTime + self.identityModel = identityModel + self.ownerExternalId = ownerExternalId + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + self.idempotencyKey = UUID().uuidString + super.init() + self.method = PATCH + skipClientRetries() + } + + /// Waits out the post-create delay on the server session ID, so the backend has the session first. + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + guard let serverSessionId, + let sessionIdSegment = OSUrlPath.segment(serverSessionId), + newRecordsState.canAccess(serverSessionId), + let (onesignalId, subscriptionId) = resolvedIds(onesignalId: onesignalId, subscriptionId: subscriptionId, identityModel: identityModel), + auth.authorize(self) + else { + return false + } + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + var parameters: [String: Any] = [ + "onesignal_id": onesignalId, + "subscription_id": subscriptionId, + "duration_seconds": Int64(activeDuration), + "idempotency_key": idempotencyKey + ] + if let endTime { + parameters["end_time"] = OSSessionTimestamp.string(from: endTime) + } + self.parameters = parameters + self.path = "apps/\(appId)/sessions/\(sessionIdSegment)" + return true + } + + func encode(with coder: NSCoder) { + coder.encode(appId, forKey: "appId") + coder.encode(localSessionId, forKey: "localSessionId") + coder.encode(serverSessionId, forKey: "serverSessionId") + coder.encode(activeDuration, forKey: "activeDuration") + coder.encode(endTime, forKey: "endTime") + coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") + coder.encode(onesignalId, forKey: "onesignalId") + coder.encode(subscriptionId, forKey: "subscriptionId") + coder.encode(idempotencyKey, forKey: "idempotencyKey") + coder.encode(failedAttempts, forKey: "failedAttempts") + coder.encode(timestamp, forKey: "timestamp") + } + + required init?(coder: NSCoder) { + guard let appId = coder.decodeObject(forKey: "appId") as? String, + let localSessionId = coder.decodeObject(forKey: "localSessionId") as? String, + let identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel, + let idempotencyKey = coder.decodeObject(forKey: "idempotencyKey") as? String, + let timestamp = coder.decodeObject(forKey: "timestamp") as? Date + else { + return nil + } + self.appId = appId + self.localSessionId = localSessionId + self.serverSessionId = coder.decodeObject(forKey: "serverSessionId") as? String + self.activeDuration = coder.decodeDouble(forKey: "activeDuration") + self.endTime = coder.decodeObject(forKey: "endTime") as? Date + self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String + self.onesignalId = coder.decodeObject(forKey: "onesignalId") as? String + self.subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String + self.idempotencyKey = idempotencyKey + self.failedAttempts = coder.decodeInteger(forKey: "failedAttempts") + super.init() + self.method = PATCH + self.timestamp = timestamp + skipClientRetries() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift new file mode 100644 index 000000000..65419b8f3 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -0,0 +1,451 @@ +/* + 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 OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Holds every Request until the test answers it. +private final class FakeSessionsClient: NSObject, IOneSignalClient { + struct Call { + let request: OneSignalRequest + let onSuccess: OSResultSuccessBlock + let onFailure: OSClientFailureBlock + } + + private let lock = NSLock() + private var _calls: [Call] = [] + + var calls: [Call] { lock.withLock { _calls } } + var requests: [OneSignalRequest] { calls.map(\.request) } + + func execute(_ request: OneSignalRequest, onSuccess: @escaping OSResultSuccessBlock, onFailure: @escaping OSClientFailureBlock) { + lock.withLock { _calls.append(Call(request: request, onSuccess: onSuccess, onFailure: onFailure)) } + } + + func succeedLast(_ response: [AnyHashable: Any]? = nil) { + calls.last?.onSuccess(response) + } + + func failLast(code: Int, headers: [String: String]? = nil) { + calls.last?.onFailure(OneSignalClientError(code: code, message: "failure", responseHeaders: headers, response: nil, underlyingError: nil)) + } +} + +final class OSSessionOperationExecutorTests: XCTestCase { + private let externalId = "session-external-id" + private let onesignalId = "session-onesignal-id" + private var client: FakeSessionsClient! + private var newRecordsState: MockNewRecordsState! + private var user: OSUserInternal! + private var uptime: TimeInterval = 1000 + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OSSessionService.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + client = FakeSessionsClient() + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + user = OneSignalUserMocks.setUserManagerInternalUser(externalId: externalId, onesignalId: onesignalId) + uptime = 1000 + } + + override func tearDownWithError() throws { + OSSessionService.reset() + OneSignalCoreMocks.clearUserDefaults() + } + + private func makeExecutor() -> OSSessionOperationExecutor { + OSSessionOperationExecutor( + newRecordsState: newRecordsState, + auth: OneSignalUserManagerImpl.sharedInstance.requestAuth, + uptime: { [unowned self] in self.uptime }, + jitter: { 1 } + ) + } + + private func createDelta(session: String = "session-1", directAttributionId: String? = nil, identityModel: OSIdentityModel? = nil) -> OSDelta { + var value: [String: Any] = [ + OSSessionDeltaKey.appId: "test-app-id", + OSSessionDeltaKey.startTime: 1_700_000_000.0 + ] + value[OSSessionDeltaKey.directAttributionId] = directAttributionId + return delta(OS_CREATE_SESSION_DELTA, session: session, value: value, identityModel: identityModel) + } + + private func updateDelta(session: String = "session-1", duration: TimeInterval, endTime: TimeInterval? = nil, serverSessionId: String? = nil) -> OSDelta { + var value: [String: Any] = [ + OSSessionDeltaKey.appId: "test-app-id", + OSSessionDeltaKey.activeDuration: duration + ] + value[OSSessionDeltaKey.endTime] = endTime + value[OSSessionDeltaKey.serverSessionId] = serverSessionId + return delta(OS_UPDATE_SESSION_DELTA, session: session, value: value, identityModel: nil) + } + + private func delta(_ name: String, session: String, value: [String: Any], identityModel: OSIdentityModel?) -> OSDelta { + let identityModel = identityModel ?? user.identityModel + return OSDelta(name: name, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: identityModel, property: session, value: value) + } + + /// Enqueues the Deltas, runs one flush, and waits for it. + @discardableResult + private func flush(_ executor: OSSessionOperationExecutor, _ deltas: OSDelta...) -> [OSSessionRequest] { + deltas.forEach(executor.enqueueDelta) + executor.processDeltaQueue(inBackground: false) + return executor.queuedRequests + } + + private func createSucceeds(_ executor: OSSessionOperationExecutor, serverSessionId: String = "server-1") { + client.succeedLast(["data": ["session_id": serverSessionId]]) + _ = executor.queuedRequests + } + + private func fail(_ executor: OSSessionOperationExecutor, code: Int, headers: [String: String]? = nil) { + client.failLast(code: code, headers: headers) + _ = executor.queuedRequests + } + + // MARK: - Requests + + func testCreateSendsSessionBodyKeyedByLocalSessionId() throws { + let executor = makeExecutor() + + flush(executor, createDelta(directAttributionId: "notification-id")) + + let request = try XCTUnwrap(client.requests.last as? OSRequestCreateSession) + XCTAssertEqual(request.path, "apps/test-app-id/sessions") + XCTAssertEqual(request.method, POST) + XCTAssertEqual(request.parameters?["onesignal_id"] as? String, onesignalId) + XCTAssertEqual(request.parameters?["subscription_id"] as? String, testPushSubId) + XCTAssertEqual(request.parameters?["start_time"] as? String, "2023-11-14T22:13:20.000Z") + XCTAssertEqual(request.parameters?["idempotency_key"] as? String, "session-1") + XCTAssertEqual(request.parameters?["direct_attribution_id"] as? String, "notification-id") + XCTAssertGreaterThan(request.reattemptCount, 0, "OneSignalClient must not retry underneath the executor") + } + + func testCreateWaitsForItsUserToBeCreated() { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + let executor = makeExecutor() + + flush(executor, createDelta()) + XCTAssertTrue(client.requests.isEmpty) + + user.identityModel.addAliases([OS_ONESIGNAL_ID: onesignalId]) + flush(executor) + XCTAssertEqual((client.requests.last as? OSRequestCreateSession)?.parameters?["onesignal_id"] as? String, onesignalId) + } + + func testUpdateWaitsForTheCreateThenUsesItsServerSessionId() throws { + let executor = makeExecutor() + flush(executor, createDelta(), updateDelta(duration: 42, endTime: 1_700_000_100)) + XCTAssertEqual(client.requests.count, 1) + + createSucceeds(executor) + flush(executor) + + let update = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(update.path, "apps/test-app-id/sessions/server-1") + XCTAssertEqual(update.method, PATCH) + XCTAssertEqual(update.parameters?["duration_seconds"] as? Int64, 42) + XCTAssertEqual(update.parameters?["end_time"] as? String, "2023-11-14T22:15:00.000Z") + XCTAssertEqual(update.parameters?["idempotency_key"] as? String, update.idempotencyKey) + } + + func testUpdateWaitsOutThePostCreateDelay() { + newRecordsState.holdWhilePresent = true + let executor = makeExecutor() + flush(executor, createDelta(), updateDelta(duration: 10)) + + createSucceeds(executor) + flush(executor) + + XCTAssertEqual(client.requests.count, 1) + XCTAssertFalse(newRecordsState.canAccess("server-1")) + } + + func testUpdateBuiltAfterItsCreateSucceededGetsTheServerSessionId() { + let executor = makeExecutor() + flush(executor, createDelta()) + createSucceeds(executor) + + flush(executor, updateDelta(duration: 10)) + + XCTAssertEqual((client.requests.last as? OSRequestUpdateSession)?.serverSessionId, "server-1") + } + + // MARK: - Coalescing + + func testUpdatesCoalesceToTheHighestDuration() { + let executor = makeExecutor() + + let queued = flush(executor, createDelta(), updateDelta(duration: 10), updateDelta(duration: 5)) + + let updates = queued.compactMap { $0 as? OSRequestUpdateSession } + XCTAssertEqual(updates.map(\.activeDuration), [10]) + } + + func testEndReplacesQueuedUpdatesAndLaterUpdatesAreDiscarded() { + let executor = makeExecutor() + + let queued = flush(executor, createDelta(), updateDelta(duration: 10), updateDelta(duration: 20, endTime: 1_700_000_100), updateDelta(duration: 30)) + + let updates = queued.compactMap { $0 as? OSRequestUpdateSession } + XCTAssertEqual(updates.count, 1) + XCTAssertEqual(updates.first?.activeDuration, 20) + XCTAssertTrue(updates.first?.isEnd == true) + } + + func testUnchangedUpdateKeepsTheQueuedIdempotencyKey() { + let executor = makeExecutor() + let first = flush(executor, createDelta(), updateDelta(duration: 10)).compactMap { $0 as? OSRequestUpdateSession } + + let second = flush(executor, updateDelta(duration: 10)).compactMap { $0 as? OSRequestUpdateSession } + + XCTAssertEqual(second.map(\.idempotencyKey), first.map(\.idempotencyKey)) + } + + func testUpdateInFlightCountsTowardTheHighestDuration() { + let executor = makeExecutor() + flush(executor, updateDelta(duration: 30, serverSessionId: "server-1")) + XCTAssertEqual(client.requests.count, 1) + + let queued = flush(executor, updateDelta(duration: 20)) + + XCTAssertEqual(queued.compactMap { $0 as? OSRequestUpdateSession }.map(\.activeDuration), [30, 30]) + } + + func testUpdateWithoutACreateOrServerSessionIdIsDiscarded() { + let executor = makeExecutor() + + XCTAssertTrue(flush(executor, updateDelta(duration: 10)).isEmpty) + } + + func testCapDropsTheOldestCreateWithItsUpdates() { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + let executor = makeExecutor() + flush(executor, createDelta(session: "oldest"), updateDelta(session: "oldest", duration: 10)) + + let deltas = (0..