diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 126e96b04..f30211396 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -95,13 +95,11 @@ 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 */; }; + 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 */; }; 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 */; }; @@ -160,6 +158,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, ); }; }; @@ -219,6 +218,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 */; }; @@ -1475,6 +1475,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 = ""; }; @@ -1515,6 +1516,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 = ""; }; @@ -1924,13 +1926,11 @@ 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -2438,8 +2438,6 @@ 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */, - A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */, - A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */, A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, @@ -2510,6 +2508,7 @@ children = ( 3C8E6E0028AC0BA10031E48A /* OSIdentityOperationExecutor.swift */, 3C68EFE42D93195E00F0896B /* OSCustomEventsExecutor.swift */, + A5162F01A1B2C3D4E5F60017 /* OSSessionOperationExecutor.swift */, 3C8E6DFE28AB09AE0031E48A /* OSPropertyOperationExecutor.swift */, 3CE795FA28DBDCE700736BD4 /* OSSubscriptionOperationExecutor.swift */, 3C9AD6BB2B2285FB00BC1540 /* OSUserExecutor.swift */, @@ -2533,6 +2532,7 @@ 3C9AD6C02B22886600BC1540 /* OSRequestUpdateSubscription.swift */, 3C9AD6C42B228A7300BC1540 /* OSRequestDeleteSubscription.swift */, 3C68EFE62D931BA600F0896B /* OSRequestCustomEvents.swift */, + A5162F01A1B2C3D4E5F60013 /* OSSessionRequests.swift */, ); path = Requests; sourceTree = ""; @@ -2619,6 +2619,7 @@ C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */, 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, + A5162F01A1B2C3D4E5F60019 /* OSSessionOperationExecutorTests.swift */, FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */, ); @@ -2772,7 +2773,6 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, - A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -4694,8 +4694,6 @@ 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 */, A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, @@ -4799,6 +4797,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 */, @@ -4898,7 +4897,6 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */, - A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */, A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, @@ -5019,6 +5017,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..331f30ea7 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" @@ -278,6 +279,9 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP incorrectlyget a 404 when attempting a GET or PATCH REST API call on something just after it is created. */ #define OP_REPO_POST_CREATE_DELAY_SECONDS 3 + + // How long after a record is created a 404 on it is treated as replication lag and retried. + #define OP_REPO_POST_CREATE_RETRY_UP_TO_SECONDS 60 #else // Test defines for API Client #define REATTEMPT_DELAY 0.004 @@ -300,6 +304,7 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP // Reduce delay in tests #define OP_REPO_POST_CREATE_DELAY_SECONDS 0 + #define OP_REPO_POST_CREATE_RETRY_UP_TO_SECONDS 60 #endif // A max timeout for a request, which might include multiple reattempts @@ -353,6 +358,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 +387,11 @@ 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" +#define OS_SESSION_EXECUTOR_SESSIONS_KEY @"OS_SESSION_EXECUTOR_SESSIONS_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/OSDelta.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift index 6feb7eced..0884a197a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift @@ -46,10 +46,10 @@ open class OSDelta: NSObject, NSCoding { return "" } - public init(name: String, identityModelId: String, externalId: String?, model: OSModel, property: String, value: Any) { + public init(name: String, identityModelId: String, externalId: String?, model: OSModel, property: String, value: Any, timestamp: Date = Date()) { self.name = name self.deltaId = UUID().uuidString - self.timestamp = Date() + self.timestamp = timestamp self.identityModelId = identityModelId self.externalId = externalId self.model = model diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSNewRecordsState.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSNewRecordsState.swift index 2fc1e322f..badfbb0f7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSNewRecordsState.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSNewRecordsState.swift @@ -40,6 +40,8 @@ public class OSNewRecordsState { */ private var records: [String: Date] = [:] private let lock = NSRecursiveLock() + /// Overridden by tests to move past the retry window. + var now: () -> Date = { Date() } public init() { } @@ -62,7 +64,17 @@ public class OSNewRecordsState { let minimumTime = timeLastMovedOrCreated.addingTimeInterval(TimeInterval(OP_REPO_POST_CREATE_DELAY_SECONDS)) - return Date() >= minimumTime + return now() >= minimumTime + } + } + + /// Whether `key` was created recently enough that a 404 on it may be backend replication lag. + public func isInMissingRetryWindow(_ key: String) -> Bool { + lock.withLock { + guard let timeLastMovedOrCreated = records[key] else { + return false + } + return now().timeIntervalSince(timeLastMovedOrCreated) <= TimeInterval(OP_REPO_POST_CREATE_RETRY_UP_TO_SECONDS) } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index 810a2eef0..0862bd6dc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -241,11 +241,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/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 9c6ef0a07..eb565d4df 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -61,12 +61,10 @@ 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? - /// Nil until the sessions API creates this session. - public internal(set) var serverSessionId: String? } /// Owns the persisted record of the current session. Nothing reads the record while @@ -168,8 +166,7 @@ public final class OSSessionService: NSObject { usesSessionsApi: featureManager().isEnabled(featureKey: FeatureFlag.sdkSessionsV2ApiCutover.key), identityModelId: user.identityModelId, onesignalId: user.onesignalId, - subscriptionId: user.pushSubscriptionId, - serverSessionId: nil + subscriptionId: user.pushSubscriptionId ) stateLock.withLock { self.userProvider = userProvider 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/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/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 305655133..f8e414968 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -124,7 +124,7 @@ final class OSSessionServiceTests: XCTestCase { // MARK: - Record - func testNewSessionPinsIdsAndHasNoServerSessionId() { + func testNewSessionPinsIds() { let service = makeForegroundSession() let record = service.currentRecord @@ -132,7 +132,6 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(record?.subscriptionId, "subscription-a") XCTAssertEqual(record?.startTime, 5_000) XCTAssertEqual(record?.activeDuration, 0) - XCTAssertNil(record?.serverSessionId) } func testEachNewSessionGetsANewSessionId() { @@ -190,7 +189,6 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(record?.usesSessionsApi, true) XCTAssertEqual(record?.onesignalId, "onesignal-a") XCTAssertEqual(record?.subscriptionId, "subscription-a") - XCTAssertNil(record?.serverSessionId) } func testClearingTheStoredRecordDropsIt() { 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..ae7c83d37 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -0,0 +1,526 @@ +/* + 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 + +/** + 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. + Failures are retried through the shared `OSRequestRetryBackoff`. + */ +class OSSessionOperationExecutor: OSOperationExecutor { + static let maxQueuedRequests = 100 + /// A create answered without a `session_id` is retried this many times, then dropped. + static let maxMissingSessionIdAttempts = 3 + + var supportedDeltas: [String] = [OS_CREATE_SESSION_DELTA, OS_UPDATE_SESSION_DELTA] + private var deltaQueue: [OSDelta] = [] + private var requestQueue: [OSSessionRequest] = [] + /// The one place that knows whether a session was created and whether it ended, oldest first and + /// persisted. A session's requests can leave the queue while more of its updates are still coming. + private var sessions: [SessionState] = [] + private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing + private let retryBackoff: OSRequestRetryBackoff + private let nowProvider: () -> Date + + // 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, + retryBackoff: OSRequestRetryBackoff = OSRequestRetryBackoff(), + nowProvider: @escaping () -> Date = { Date() } + ) { + self.newRecordsState = newRecordsState + self.auth = auth + self.retryBackoff = retryBackoff + self.nowProvider = nowProvider + uncacheSessions() + uncacheDeltas() + uncacheRequests() + } + + private func uncacheSessions() { + let cached = OneSignalUserDefaults.initShared().getSavedObject(forKey: OS_SESSION_EXECUTOR_SESSIONS_KEY, defaultValue: []) as? [[String: Any]] ?? [] + sessions = cached.compactMap(SessionState.init(entry:)) + } + + 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() + 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 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.dropIfStale(request) { + 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) + } + + private func cacheSessions() { + OneSignalUserDefaults.initShared().saveObject(forKey: OS_SESSION_EXECUTOR_SESSIONS_KEY, withValue: sessions.map(\.entry)) + } +} + +// 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 request: OSSessionRequest + if delta.name == OS_CREATE_SESSION_DELTA { + request = 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: value[OSSessionDeltaKey.onesignalId] as? String, + subscriptionId: value[OSSessionDeltaKey.subscriptionId] as? String + ) + } else { + request = OSRequestUpdateSession( + appId: appId, + localSessionId: localSessionId, + activeDuration: value[OSSessionDeltaKey.activeDuration] as? TimeInterval ?? 0, + endTime: (value[OSSessionDeltaKey.endTime] as? TimeInterval).map { Date(timeIntervalSince1970: $0) }, + identityModel: identityModel, + ownerExternalId: delta.externalId + ) + } + // Aged from when the session reported it, since the delta can outlive a kill. + request.timestamp = delta.timestamp + return request + } + + /// Checked before admitting, so a stale Request cannot evict a queued one. + func dropIfStale(_ request: OSSessionRequest) -> Bool { + guard let age = staleAge(of: request) else { + return false + } + OSRequestAging.logDrop(of: "\(type(of: request))", owner: request.ownerExternalId, age: age, executor: "OSSessionOperationExecutor") + return true + } + + func staleAge(of request: OSSessionRequest) -> TimeInterval? { + OSRequestAging.staleAge(timestamp: request.timestamp, owner: request.ownerExternalId, typeLimit: OSRequestAging.sessionRequestMaxAge, now: nowProvider(), currentExternalId: OSRequestAging.currentExternalId) + } + + 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, or it can never get a server session ID. + */ + func admitUpdate(_ update: OSRequestUpdateSession) -> Bool { + let sessionId = update.localSessionId + let queuedUpdates = requestQueue.compactMap { $0 as? OSRequestUpdateSession }.filter { $0.localSessionId == sessionId } + guard state(forSession: sessionId)?.ended != true, !queuedUpdates.contains(where: \.isEnd) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), the session already ended") + return false + } + guard createdSession(forSession: sessionId) != 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 } + requestQueue.removeAll { request in pending.contains { $0 === request } } + // Carry a backoff over, or the replacement would skip it. + pending.last.map { retryBackoff.transferWait(from: $0, to: update) } + 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) { + markEndedIfEnd(request) + requestQueue.removeAll { $0 === request } + removeOrphanedUpdates() + } + + func markEndedIfEnd(_ request: OSSessionRequest) { + if let update = request as? OSRequestUpdateSession, update.isEnd { + updateState(forSession: update.localSessionId) { $0.ended = true } + } + } + + func removeOrphanedUpdates() { + let sessionsWithCreate = Set(requestQueue.filter { $0 is OSRequestCreateSession }.map(\.localSessionId)) + requestQueue.removeAll { request in + guard let update = request as? OSRequestUpdateSession, + createdSession(forSession: update.localSessionId) == nil, + !sessionsWithCreate.contains(update.localSessionId) + else { + return false + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(update), its session has no create") + return true + } + } +} + +// MARK: - Session state + +private extension OSSessionOperationExecutor { + struct SessionState { + let localSessionId: String + /// What the create sent and got back. Every update of the session repeats it. + var created: OSCreatedSession? + /// An end leaves the queue once sent or dropped, but its session stays ended. + var ended = false + + init(localSessionId: String) { + self.localSessionId = localSessionId + } + + init?(entry: [String: Any]) { + guard let localSessionId = entry["localSessionId"] as? String else { + return nil + } + self.localSessionId = localSessionId + if let serverSessionId = entry["serverSessionId"] as? String, + let onesignalId = entry["onesignalId"] as? String, + let subscriptionId = entry["subscriptionId"] as? String { + created = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) + } + ended = entry["ended"] as? Bool ?? false + } + + var entry: [String: Any] { + var entry: [String: Any] = ["localSessionId": localSessionId, "ended": ended] + entry["serverSessionId"] = created?.serverSessionId + entry["onesignalId"] = created?.onesignalId + entry["subscriptionId"] = created?.subscriptionId + return entry + } + } + + func state(forSession sessionId: String) -> SessionState? { + sessions.first { $0.localSessionId == sessionId } + } + + func createdSession(forSession sessionId: String) -> OSCreatedSession? { + state(forSession: sessionId)?.created + } + + func updateState(forSession sessionId: String, _ change: (inout SessionState) -> Void) { + if let index = sessions.firstIndex(where: { $0.localSessionId == sessionId }) { + change(&sessions[index]) + } else { + var state = SessionState(localSessionId: sessionId) + change(&state) + sessions.append(state) + } + trimSessions() + cacheSessions() + } + + /// Drops the oldest sessions past the cap, but never one with Requests still queued, which need its state. + func trimSessions() { + var excess = sessions.count - Self.maxQueuedRequests + guard excess > 0 else { + return + } + let queuedSessions = Set(requestQueue.map(\.localSessionId)) + sessions.removeAll { state in + guard excess > 0, !queuedSessions.contains(state.localSessionId) else { + return false + } + excess -= 1 + 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) { + // Without consent the client fails every send with status 0, which would retry forever. + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { + return + } + if removeUnsendableRequests() { + cacheRequests() + } + var sessionsSeen = Set() + var sentCreate = false + for request in requestQueue { + if let update = request as? OSRequestUpdateSession { + update.createdSession = createdSession(forSession: update.localSessionId) + } + guard sessionsSeen.insert(request.localSessionId).inserted, + !request.sentToClient, + !retryBackoff.isWaiting(request), + request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) + else { + continue + } + sentCreate = sentCreate || request is OSRequestCreateSession + executeRequest(request, inBackground: inBackground) + } + if sentCreate { + // Persists the IDs the create was just sent with, so a replay after a kill cannot change them. + cacheRequests() + } + } + + /// 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 + 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 dropIfStale(request) { + 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 { + markEndedIfEnd(request) + requestQueue.removeAll { $0 === request } + cacheRequests() + return + } + guard let data = response?["data"] as? [String: Any], + let serverSessionId = data["session_id"] as? String, + !serverSessionId.isEmpty, + let onesignalId = create.onesignalId, + let subscriptionId = create.subscriptionId + else { + retryMissingSessionId(create, response: response) + return + } + requestQueue.removeAll { $0 === request } + let created = OSCreatedSession(serverSessionId: serverSessionId, onesignalId: onesignalId, subscriptionId: subscriptionId) + updateState(forSession: create.localSessionId) { $0.created = created } + // Updates wait out the post-create delay, so the backend has the session first. + newRecordsState.add(serverSessionId) + cacheRequests() + } + + /// Retrying with the same idempotency key should return the session the backend already created. + func retryMissingSessionId(_ create: OSRequestCreateSession, response: [AnyHashable: Any]?) { + create.missingSessionIdAttempts += 1 + guard create.missingSessionIdAttempts < Self.maxMissingSessionIdAttempts else { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor dropping \(create), its responses never had a session_id") + drop(create) + cacheRequests() + return + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor create session response is missing data.session_id") + retry(create, error: OneSignalClientError(code: 202, message: "Missing data.session_id", responseHeaders: nil, response: response, underlyingError: nil)) + 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. + handleSuccess(request, response: nil) + } else if code == 0 || code == 408 || code == 429 || code >= 500 { + // 0 means no HTTP response: no network or a timeout. + retry(request, error: error) + } else if code == 404 && isInMissingRetryWindow(request) { + // The backend may not have the just-created record yet. + retry(request, error: 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() + } + } + + func isInMissingRetryWindow(_ request: OSSessionRequest) -> Bool { + switch request { + case let create as OSRequestCreateSession: + return [create.onesignalId, create.subscriptionId].contains { $0.map(newRecordsState.isInMissingRetryWindow) ?? false } + case let update as OSRequestUpdateSession: + return createdSession(forSession: update.localSessionId).map { newRecordsState.isInMissingRetryWindow($0.serverSessionId) } ?? false + default: + return false + } + } + + func retry(_ request: OSSessionRequest, error: OneSignalClientError) { + retryBackoff.recordFailure(request, error: error) + request.sentToClient = false + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift index 835acd0a0..7aeade758 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift @@ -35,6 +35,7 @@ enum OSRequestAging { static let propertyRequestMaxAge: TimeInterval = 60 * 60 * 24 * 90 // 90 days static let nonCurrentUserRequestMaxAge: TimeInterval = 60 * 60 * 24 * 30 // 30 days static let customEventRequestMaxAge: TimeInterval = 60 * 60 * 24 * 30 // 30 days + static let sessionRequestMaxAge: TimeInterval = 60 * 60 * 24 * 30 // 30 days /// The `external_id` of the current user, nil while there is none. Reads `_user`, since `user` calls /// `start()` and would deadlock at uncache. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index fca1f94d0..5947e527f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -28,6 +28,31 @@ import OneSignalCore import OneSignalOSCore +extension OneSignalUserManagerImpl { + @objc + public func startNewSession() { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManagerImpl starting new session") + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "_startNewSession") else { + return + } + start() + + userExecutor!.executePendingRequestsOnNewSession() + operationRepo.paused = false + updatePropertiesDeltas(property: .session_count, value: 1, flush: true) + OSSessionService.shared.startNewSession(userProvider: self) + + // Fetch the user's data if there is a onesignal_id + if let onesignalId = onesignalId { + userExecutor!.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: onesignalId, identityModel: user.identityModel, onNewSession: true) + } else { + // It is possible to init a user from cache who is missing the onesignalId + // This can happen if any createUser or identifyUser requests are cached + OneSignalLog.onesignalLog(.LL_WARN, message: "OneSignalUserManagerImpl.startNewSession() is unable to fetch user with External ID \(externalId ?? "nil") due to null OneSignal ID") + } + } +} + extension OneSignalUserManagerImpl: OSSessionUserProvider { public var sessionCurrentUser: OSSessionUser { let user = _user @@ -41,4 +66,45 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { public func sessionOnesignalId(identityModelId: String) -> String? { identityModelRepo.get(modelId: identityModelId)?.onesignalId } + + /// Queues the session's create. Does nothing unless the session uses the sessions API. + func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String? = nil) { + var value: [String: Any] = [OSSessionDeltaKey.startTime: record.startTime] + value[OSSessionDeltaKey.directAttributionId] = directAttributionId + value[OSSessionDeltaKey.onesignalId] = record.onesignalId + value[OSSessionDeltaKey.subscriptionId] = record.subscriptionId + enqueueSessionDelta(OS_CREATE_SESSION_DELTA, record: record, value: value) + } + + /// Queues the session's cumulative foreground time, ending the session when `endTime` is set. + func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: Date? = nil) { + var value: [String: Any] = [OSSessionDeltaKey.activeDuration: record.activeDuration] + value[OSSessionDeltaKey.endTime] = endTime?.timeIntervalSince1970 + enqueueSessionDelta(OS_UPDATE_SESSION_DELTA, record: record, value: value) + } + + private func enqueueSessionDelta(_ name: String, record: OSSessionRecord, value: [String: Any]) { + guard record.usesSessionsApi else { return } + guard let identityModelId = record.identityModelId, + let identityModel = identityModelRepo.get(modelId: identityModelId) + else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManagerImpl dropped \(name) for session \(record.sessionId), no identity model") + return + } + guard let appId = OneSignalIdentifiers.currentAppId else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManagerImpl dropped \(name) for session \(record.sessionId), no app ID") + return + } + var value = value + value[OSSessionDeltaKey.appId] = appId + 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 244b6b39f..3c745e308 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -219,6 +219,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) @@ -334,14 +335,17 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) + let sessionExecutor = OSSessionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) 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. @@ -692,29 +696,6 @@ extension OneSignalUserManagerImpl { // MARK: - Sessions extension OneSignalUserManagerImpl { - @objc - public func startNewSession() { - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManagerImpl starting new session") - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "_startNewSession") else { - return - } - start() - - userExecutor!.executePendingRequestsOnNewSession() - operationRepo.paused = false - updatePropertiesDeltas(property: .session_count, value: 1, flush: true) - OSSessionService.shared.startNewSession(userProvider: self) - - // Fetch the user's data if there is a onesignal_id - if let onesignalId = onesignalId { - userExecutor!.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: onesignalId, identityModel: user.identityModel, onNewSession: true) - } else { - // It is possible to init a user from cache who is missing the onesignalId - // This can happen if any createUser or identifyUser requests are cached - OneSignalLog.onesignalLog(.LL_WARN, message: "OneSignalUserManagerImpl.startNewSession() is unable to fetch user with External ID \(externalId ?? "nil") due to null OneSignal ID") - } - } - /// This method accepts properties updates that not driven by model changes. /// It enqueues an OSDelta to the Operation Repo. /// 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..d05c8d4ca --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift @@ -0,0 +1,279 @@ +/* + 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" +} + +/// What a session's create sent and got back. Its updates must carry exactly these IDs. +struct OSCreatedSession { + let serverSessionId: String + let onesignalId: String + let subscriptionId: String +} + +/// 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 } +} + +/// `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? + /// Fixed by the first send, since the backend does not allow them to change. + private(set) var onesignalId: String? + private(set) var subscriptionId: String? + var missingSessionIdAttempts = 0 + + /// 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 + } + + /// Falls back to the user once the backend creates it. The push subscription is the same device + /// subscription across logins, so it falls back to the current one. + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + guard let onesignalId = onesignalId ?? identityModel.onesignalId, + let subscriptionId = subscriptionId ?? OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId, + 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(missingSessionIdAttempts, forKey: "missingSessionIdAttempts") + 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.missingSessionIdAttempts = coder.decodeInteger(forKey: "missingSessionIdAttempts") + super.init() + self.method = POST + self.timestamp = timestamp + } +} + +/// `PATCH apps/{app_id}/sessions/{session_id}` +final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { + var sentToClient = false + let appId: String + let localSessionId: String + /// Set by the executor from its session state before each send, so it is not persisted. + var createdSession: OSCreatedSession? + /// 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? + /// Generated once and persisted, so every retry, including after a restart, is deduplicated by the backend. + let idempotencyKey: String + + /// 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, + activeDuration: TimeInterval, + endTime: Date?, + identityModel: OSIdentityModel, + ownerExternalId: String? + ) { + self.appId = appId + self.localSessionId = localSessionId + self.activeDuration = activeDuration + self.endTime = endTime + self.identityModel = identityModel + self.ownerExternalId = ownerExternalId + self.idempotencyKey = UUID().uuidString + super.init() + self.method = PATCH + } + + /// 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 createdSession, + let sessionIdSegment = OSUrlPath.segment(createdSession.serverSessionId), + newRecordsState.canAccess(createdSession.serverSessionId), + auth.authorize(self) + else { + return false + } + var parameters: [String: Any] = [ + "onesignal_id": createdSession.onesignalId, + "subscription_id": createdSession.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(activeDuration, forKey: "activeDuration") + coder.encode(endTime, forKey: "endTime") + coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") + coder.encode(idempotencyKey, forKey: "idempotencyKey") + 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.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.idempotencyKey = idempotencyKey + super.init() + self.method = PATCH + self.timestamp = timestamp + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift new file mode 100644 index 000000000..43f9d75d7 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -0,0 +1,655 @@ +/* + 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 + private var retryBackoff: OSRequestRetryBackoff! + + 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 + retryBackoff = OSRequestRetryBackoff(uptime: { [unowned self] in self.uptime }, jitter: { 1 }) + } + + override func tearDownWithError() throws { + OSPrivacyConsentController.setRequiresPrivacyConsent(false) + OSSessionService.reset() + OneSignalCoreMocks.clearUserDefaults() + } + + private func makeExecutor(now: @escaping () -> Date = { Date() }) -> OSSessionOperationExecutor { + OSSessionOperationExecutor( + newRecordsState: newRecordsState, + auth: OneSignalUserManagerImpl.sharedInstance.requestAuth, + retryBackoff: retryBackoff, + nowProvider: now + ) + } + + private func createDelta( + session: String = "session-1", + directAttributionId: String? = nil, + identityModel: OSIdentityModel? = nil, + timestamp: Date = Date() + ) -> 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, timestamp: timestamp) + } + + private func updateDelta(session: String = "session-1", duration: TimeInterval, endTime: TimeInterval? = nil) -> OSDelta { + var value: [String: Any] = [ + OSSessionDeltaKey.appId: "test-app-id", + OSSessionDeltaKey.activeDuration: duration + ] + value[OSSessionDeltaKey.endTime] = endTime + return delta(OS_UPDATE_SESSION_DELTA, session: session, value: value, identityModel: nil) + } + + private func delta(_ name: String, session: String, value: [String: Any], identityModel: OSIdentityModel?, timestamp: Date = Date()) -> OSDelta { + let identityModel = identityModel ?? user.identityModel + return OSDelta( + name: name, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: identityModel, + property: session, + value: value, + timestamp: timestamp + ) + } + + /// 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 + } + + /// Sends the session's create and lets it succeed, leaving nothing queued. + private func createSession(_ executor: OSSessionOperationExecutor) { + flush(executor, createDelta()) + createSucceeds(executor) + } + + 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") + } + + 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)?.createdSession?.serverSessionId, "server-1") + } + + func testUpdatesCarryTheIdsTheCreateSent() throws { + let executor = makeExecutor() + flush(executor, createDelta(), updateDelta(duration: 10)) + OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = "new-subscription-id" + + createSucceeds(executor) + flush(executor, updateDelta(duration: 20)) + + let update = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(update.parameters?["onesignal_id"] as? String, onesignalId) + XCTAssertEqual(update.parameters?["subscription_id"] as? String, testPushSubId) + } + + func testNothingIsSentBeforePrivacyConsent() { + OSPrivacyConsentController.setRequiresPrivacyConsent(true) + let executor = makeExecutor() + + flush(executor, createDelta()) + let manager = OneSignalUserManagerImpl.sharedInstance + manager.enqueueSessionCreate(OSSessionRecord( + sessionId: "session-2", + startTime: 1_700_000_000, + activeDuration: 0, + usesSessionsApi: true, + identityModelId: user.identityModel.modelId, + onesignalId: nil, + subscriptionId: nil + )) + manager.operationRepo.dispatchQueue.sync {} + + XCTAssertTrue(client.requests.isEmpty) + XCTAssertFalse(manager.operationRepo.deltaQueue.contains { $0.name == OS_CREATE_SESSION_DELTA }) + } + + // 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 testUpdateAfterASentEndIsDiscarded() { + let executor = makeExecutor() + createSession(executor) + flush(executor, updateDelta(duration: 20, endTime: 1_700_000_100)) + client.succeedLast() + + let queued = flush(executor, updateDelta(duration: 30)) + + XCTAssertTrue(queued.isEmpty) + XCTAssertEqual(client.requests.count, 2) + } + + 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 testUpdateInFlightCountsTowardTheHighestDuration() { + let executor = makeExecutor() + createSession(executor) + flush(executor, updateDelta(duration: 30)) + XCTAssertEqual(client.requests.count, 2) + + 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..