From 07b7861cfcc38894b80330602bd6809f90015097 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 8 Oct 2026 13:00:16 -0700 Subject: [PATCH 1/9] feat: [SDK-5379] persistent queue and retry for sessions API requests Co-Authored-By: Claude Opus 5.5 --- .../OneSignal.xcodeproj/project.pbxproj | 12 + .../Source/OneSignalCommonDefines.h | 1 + .../Source/OSSessionRequest.swift | 122 ++++ .../Source/OSSessionRequestQueue.swift | 454 +++++++++++++++ .../Source/OSSessionService.swift | 34 ++ .../Source/OSSessionsBackendService.swift | 7 +- .../OSSessionRequestQueueTests.swift | 545 ++++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 1 + 8 files changed, 1175 insertions(+), 1 deletion(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 20cca93f3..373a5df89 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -96,11 +96,14 @@ 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 */; }; + A5162F01A1B2C3D4E5F60018 /* OSSessionRequestQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60017 /* OSSessionRequestQueue.swift */; }; + A5162F01A1B2C3D4E5F6001C /* OSSessionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F6001B /* OSSessionRequest.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 */; }; + A5162F01A1B2C3D4E5F6001A /* OSSessionRequestQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.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 */; }; @@ -1914,11 +1917,14 @@ 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 = ""; }; + A5162F01A1B2C3D4E5F60017 /* OSSessionRequestQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequestQueue.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F6001B /* OSSessionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequest.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 = ""; }; + A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequestQueueTests.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 = ""; }; @@ -2426,6 +2432,8 @@ A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */, A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */, A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */, + A5162F01A1B2C3D4E5F60017 /* OSSessionRequestQueue.swift */, + A5162F01A1B2C3D4E5F6001B /* OSSessionRequest.swift */, A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, @@ -2756,6 +2764,7 @@ A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */, + A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -4678,6 +4687,8 @@ A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */, A5162F01A1B2C3D4E5F60014 /* OSSessionsRequests.swift in Sources */, A5162F01A1B2C3D4E5F60012 /* OSSessionsBackendService.swift in Sources */, + A5162F01A1B2C3D4E5F60018 /* OSSessionRequestQueue.swift in Sources */, + A5162F01A1B2C3D4E5F6001C /* OSSessionRequest.swift in Sources */, A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, @@ -4876,6 +4887,7 @@ A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */, A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */, + A5162F01A1B2C3D4E5F6001A /* OSSessionRequestQueueTests.swift in Sources */, A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 8a8263e79..95e393675 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -113,6 +113,7 @@ #define OSUD_UNSENT_ACTIVE_TIME @"GT_UNSENT_ACTIVE_TIME" // * OSUD_UNSENT_ACTIVE_TIME #define OSUD_UNSENT_ACTIVE_TIME_ATTRIBUTED @"GT_UNSENT_ACTIVE_TIME_ATTRIBUTED" // * OSUD_UNSENT_ACTIVE_TIME_ATTRIBUTED #define OSUD_SESSION_RECORD @"OSUD_SESSION_RECORD" +#define OSUD_SESSION_REQUEST_QUEUE @"OSUD_SESSION_REQUEST_QUEUE" // To avoid undefined symbol compiler errors on older versions of Xcode, // instead of using UNAuthorizationOptionProvisional directly, we will use diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift new file mode 100644 index 000000000..a02bcc770 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift @@ -0,0 +1,122 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import Network + +struct OSSessionRequest: Codable, Equatable { + enum Kind: Codable, Equatable { + /// `startTime` is seconds since 1970. + case create(startTime: TimeInterval, directAttributionId: String?) + /// `activeDuration` is cumulative for the session. `endTime` is seconds since 1970, set + /// only on the update that ends the session. + case update(activeDuration: TimeInterval, endTime: TimeInterval?) + } + + let appId: String + /// `OSSessionRecord.sessionId`, which stands in for the backend session ID until the create succeeds. + let localSessionId: String + /// Set on an update once its session's create succeeds. + var serverSessionId: String? + let identityModelId: String? + var onesignalId: String? + var subscriptionId: String? + var kind: Kind + /// Generated once and persisted, so every retry, including after a restart, is deduplicated by the backend. + let idempotencyKey: String + /// Failed attempts the backend answered, persisted so the limit holds across restarts. + var failedAttempts = 0 + + var isCreate: Bool { + if case .create = kind { + return true + } + return false + } + + var isEnd: Bool { + if case .update(_, let endTime) = kind { + return endTime != nil + } + return false + } + + var activeDuration: TimeInterval? { + if case .update(let activeDuration, _) = kind { + return activeDuration + } + return nil + } +} + +protocol OSSessionNetworkMonitoring: AnyObject { + func start(onAvailable: @escaping () -> Void) + func stop() +} + +final class OSSessionNetworkMonitor: OSSessionNetworkMonitoring { + private var monitor: NWPathMonitor? + + func start(onAvailable: @escaping () -> Void) { + guard monitor == nil else { + return + } + let monitor = NWPathMonitor() + var wasSatisfied: Bool? + monitor.pathUpdateHandler = { path in + let isSatisfied = path.status == .satisfied + defer { wasSatisfied = isSatisfied } + if isSatisfied, wasSatisfied == false { + onAvailable() + } + } + monitor.start(queue: DispatchQueue(label: "OneSignal.OSSessionNetworkMonitor")) + self.monitor = monitor + } + + func stop() { + monitor?.cancel() + monitor = nil + } + + deinit { + monitor?.cancel() + } +} + +extension OSSessionsApiResult { + func map(_ transform: (Value) -> T) -> OSSessionsApiResult { + switch self { + case .success(let value): + return .success(transform(value)) + case .retry(let statusCode, let retryAfterSeconds): + return .retry(statusCode: statusCode, retryAfterSeconds: retryAfterSeconds) + case .drop(let statusCode): + return .drop(statusCode: statusCode) + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift new file mode 100644 index 000000000..799ae8a4c --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift @@ -0,0 +1,454 @@ +/* + 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 + +/// Persisted queue of sessions API requests. A request is saved before it is sent and removed only +/// once the backend accepts it or rejects it for good, so it survives restarts and offline periods. +/// Requests for a session go out in order, one at a time across the queue. +@objc(OSSessionRequestQueue) +public final class OSSessionRequestQueue: NSObject { + static let maxQueuedRequests = 100 + static let maxFailedAttempts = 5 + static let baseBackoffSeconds: TimeInterval = 5 + static let maxBackoffSeconds: TimeInterval = 300 + + private static let lock = NSLock() + private static var _shared: OSSessionRequestQueue? + + public static var shared: OSSessionRequestQueue { + lock.withLock { + if let existing = _shared { + return existing + } + let created = OSSessionRequestQueue() + _shared = created + return created + } + } + + @objc public static func reset() { + lock.withLock { _shared = nil } + } + + private let storage: OneSignalUserDefaults + private let backend: OSSessionsBackend + private let sessionService: () -> OSSessionService + private let appId: () -> String? + private let networkMonitor: OSSessionNetworkMonitoring + private let now: () -> TimeInterval + private let jitter: () -> Double + private let schedule: (TimeInterval, @escaping () -> Void) -> Void + + // Serial. Synchronizes all state below. + private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSessionRequestQueue", target: .global()) + + private var requests: [OSSessionRequest] = [] + private var inFlightKey: String? + /// Lets an update enqueued while its create was in flight pick up the backend session ID. + private var serverSessionIds: [String: String] = [:] + private var consecutiveFailures = 0 + private var isBackingOff = false + private var backoffGeneration = 0 + /// A backend `Retry-After` that `retryNow` must not cut short. + private var notBefore: TimeInterval = 0 + + init( + storage: OneSignalUserDefaults = .initStandard(), + backend: OSSessionsBackend = OSSessionsBackendService(), + sessionService: @escaping () -> OSSessionService = { .shared }, + appId: @escaping () -> String? = { OneSignalIdentifiers.currentAppId }, + networkMonitor: OSSessionNetworkMonitoring = OSSessionNetworkMonitor(), + now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }, + jitter: @escaping () -> Double = { Double.random(in: 0.5...1) }, + schedule: ((TimeInterval, @escaping () -> Void) -> Void)? = nil + ) { + self.storage = storage + self.backend = backend + self.sessionService = sessionService + self.appId = appId + self.networkMonitor = networkMonitor + self.now = now + self.jitter = jitter + let dispatchQueue = self.dispatchQueue + self.schedule = schedule ?? { delay, block in + dispatchQueue.asyncAfter(deadline: .now() + delay, execute: block) + } + super.init() + dispatchQueue.async { + self.load() + self.updateNetworkMonitoring() + } + } +} + +// MARK: - Enqueue + +extension OSSessionRequestQueue { + func enqueueCreate(record: OSSessionRecord, directAttributionId: String? = nil) { + guard record.usesSessionsApi, let appId = appId() else { + return + } + let request = OSSessionRequest( + appId: appId, + localSessionId: record.sessionId, + serverSessionId: nil, + identityModelId: record.identityModelId, + onesignalId: record.onesignalId, + subscriptionId: record.subscriptionId, + kind: .create(startTime: record.startTime, directAttributionId: directAttributionId), + idempotencyKey: UUID().uuidString + ) + dispatchQueue.async { + let isCreated = record.serverSessionId != nil || self.serverSessionIds[record.sessionId] != nil + guard !isCreated, !self.requests.contains(where: { $0.isCreate && $0.localSessionId == record.sessionId }) else { + return + } + self.append(request) + } + } + + /// Reports the session's cumulative duration, and ends the session when `endTime` is set. + func enqueueUpdate(record: OSSessionRecord, endTime: TimeInterval? = nil) { + guard record.usesSessionsApi, let appId = appId() else { + return + } + var request = OSSessionRequest( + appId: appId, + localSessionId: record.sessionId, + serverSessionId: record.serverSessionId, + identityModelId: record.identityModelId, + onesignalId: record.onesignalId, + subscriptionId: record.subscriptionId, + kind: .update(activeDuration: record.activeDuration, endTime: endTime), + idempotencyKey: UUID().uuidString + ) + dispatchQueue.async { + guard self.admitUpdate(&request) else { + return + } + self.append(request) + } + } + + /// Folds queued, unsent updates for the same session into `request`, which then carries the + /// highest duration. An update that ends the session replaces them the same way. + /// Returns false if `request` should be discarded: its session's end is already queued, or it + /// has no backend session ID and no create remains to supply one. + private func admitUpdate(_ request: inout OSSessionRequest) -> Bool { + let session = requests.filter { $0.localSessionId == request.localSessionId } + request.serverSessionId = request.serverSessionId ?? serverSessionIds[request.localSessionId] + let hasEnd = session.contains { $0.isEnd } + let isOrphaned = request.serverSessionId == nil && !session.contains { $0.isCreate } + if hasEnd || isOrphaned { + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OSSessionRequestQueue discarding update for \(request.localSessionId), ended: \(hasEnd), never created: \(isOrphaned)" + ) + return false + } + + let queued = session.filter { !$0.isCreate && $0.idempotencyKey != inFlightKey } + guard !queued.isEmpty else { + return true + } + if case .update(let activeDuration, let endTime) = request.kind { + let highest = queued.compactMap(\.activeDuration).max() ?? activeDuration + request.kind = .update(activeDuration: max(activeDuration, highest), endTime: endTime) + } + let removedKeys = Set(queued.map(\.idempotencyKey)) + requests.removeAll { removedKeys.contains($0.idempotencyKey) } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionRequestQueue coalesced \(queued.count) update(s) for \(request.localSessionId)") + return true + } + + private func append(_ request: OSSessionRequest) { + requests.append(request) + enforceCap() + persist() + updateNetworkMonitoring() + processNext() + } + + private func enforceCap() { + while requests.count > Self.maxQueuedRequests, + let oldest = requests.first(where: { $0.idempotencyKey != inFlightKey }) { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OSSessionRequestQueue more than \(Self.maxQueuedRequests) requests queued, dropping oldest: \(oldest)" + ) + remove(oldest) + } + } + + /// Updates for a session whose create is removed without succeeding can never get a backend + /// session ID, so they go with it. + private func remove(_ request: OSSessionRequest) { + requests.removeAll { $0.idempotencyKey == request.idempotencyKey } + guard request.isCreate else { + return + } + let orphans = requests.filter { $0.localSessionId == request.localSessionId && $0.serverSessionId == nil } + guard !orphans.isEmpty else { + return + } + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OSSessionRequestQueue dropping \(orphans.count) update(s) for session \(request.localSessionId), which was never created" + ) + let orphanKeys = Set(orphans.map(\.idempotencyKey)) + requests.removeAll { orphanKeys.contains($0.idempotencyKey) } + } +} + +// MARK: - Sending + +extension OSSessionRequestQueue { + /// Ends the failure backoff early, but still waits out a backend `Retry-After`. + @objc public func retryNow() { + dispatchQueue.async { + if self.isBackingOff { + let remaining = self.notBefore - self.now() + guard remaining <= 0 else { + self.scheduleRetry(after: remaining) + return + } + self.backoffGeneration += 1 + self.isBackingOff = false + } + self.processNext() + } + } + + private func processNext() { + guard inFlightKey == nil, !isBackingOff else { + return + } + var startedSessions = Set() + for index in requests.indices { + let sessionId = requests[index].localSessionId + // Only the first request for a session may go out, which keeps create, updates, end in order. + guard startedSessions.insert(sessionId).inserted else { + continue + } + if let request = readyRequest(at: index) { + send(request) + return + } + } + } + + private func readyRequest(at index: Int) -> OSSessionRequest? { + var request = requests[index] + if request.onesignalId == nil || request.subscriptionId == nil { + (request.onesignalId, request.subscriptionId) = sessionService().pinnedIds( + sessionId: request.localSessionId, + identityModelId: request.identityModelId, + onesignalId: request.onesignalId, + subscriptionId: request.subscriptionId + ) + if request != requests[index] { + requests[index] = request + persist() + } + } + guard request.onesignalId != nil, request.subscriptionId != nil else { + return nil + } + if !request.isCreate, request.serverSessionId == nil { + return nil + } + return request + } + + private func send(_ request: OSSessionRequest) { + guard let onesignalId = request.onesignalId, let subscriptionId = request.subscriptionId else { + return + } + let backgroundTaskIdentifier = "OSSessionRequestQueue" + UUID().uuidString + let finish: (OSSessionsApiResult) -> Void = { result in + self.dispatchQueue.async { + self.handle(result, for: request) + OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) + } + } + + switch request.kind { + case .create(let startTime, let directAttributionId): + let body = OSCreateSessionRequestBody( + onesignalId: onesignalId, + subscriptionId: subscriptionId, + startTime: Date(timeIntervalSince1970: startTime), + idempotencyKey: request.idempotencyKey, + directAttributionId: directAttributionId + ) + inFlightKey = request.idempotencyKey + OSBackgroundTaskManager.beginBackgroundTask(backgroundTaskIdentifier) + backend.createSession(appId: request.appId, body: body) { finish($0.map { $0 }) } + case .update(let activeDuration, let endTime): + guard let serverSessionId = request.serverSessionId else { + return + } + let body = OSUpdateSessionRequestBody( + onesignalId: onesignalId, + subscriptionId: subscriptionId, + durationSeconds: Int64(activeDuration), + idempotencyKey: request.idempotencyKey, + endTime: endTime.map { Date(timeIntervalSince1970: $0) } + ) + inFlightKey = request.idempotencyKey + OSBackgroundTaskManager.beginBackgroundTask(backgroundTaskIdentifier) + backend.updateSession(appId: request.appId, sessionId: serverSessionId, body: body) { finish($0.map { nil }) } + } + } + + private func handle(_ result: OSSessionsApiResult, for sent: OSSessionRequest) { + inFlightKey = nil + guard let index = requests.firstIndex(where: { $0.idempotencyKey == sent.idempotencyKey }) else { + processNext() + return + } + var request = requests[index] + + switch result { + case .success(let serverSessionId): + consecutiveFailures = 0 + requests.remove(at: index) + if let serverSessionId { + applyServerSessionId(serverSessionId, from: request) + } + case .drop(let statusCode): + consecutiveFailures = 0 + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue request failed with \(statusCode), dropping: \(request)") + remove(request) + case .retry(let statusCode, let retryAfterSeconds): + // 0 means no response, e.g. offline, which must keep retrying until the network returns. + if statusCode != 0 { + request.failedAttempts += 1 + } + if request.failedAttempts >= Self.maxFailedAttempts { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OSSessionRequestQueue request failed \(request.failedAttempts) times, dropping: \(request)" + ) + remove(request) + } else { + requests[index] = request + persist() + backOff(retryAfterSeconds: retryAfterSeconds) + return + } + } + persist() + updateNetworkMonitoring() + processNext() + } + + private func applyServerSessionId(_ serverSessionId: String, from create: OSSessionRequest) { + serverSessionIds[create.localSessionId] = serverSessionId + for index in requests.indices where requests[index].localSessionId == create.localSessionId { + requests[index].serverSessionId = serverSessionId + requests[index].onesignalId = requests[index].onesignalId ?? create.onesignalId + requests[index].subscriptionId = requests[index].subscriptionId ?? create.subscriptionId + } + sessionService().setServerSessionId(serverSessionId, forSessionId: create.localSessionId) + } +} + +// MARK: - Backoff + +extension OSSessionRequestQueue { + private func backOff(retryAfterSeconds: Int?) { + consecutiveFailures += 1 + let exponential = min(Self.maxBackoffSeconds, Self.baseBackoffSeconds * pow(2, Double(consecutiveFailures - 1))) + let retryAfter = TimeInterval(retryAfterSeconds ?? 0) + notBefore = now() + retryAfter + isBackingOff = true + scheduleRetry(after: max(exponential * jitter(), retryAfter)) + } + + private func scheduleRetry(after delay: TimeInterval) { + backoffGeneration += 1 + let generation = backoffGeneration + schedule(delay) { [weak self] in + guard let self else { + return + } + self.dispatchQueue.async { + guard self.backoffGeneration == generation else { + return + } + self.isBackingOff = false + self.processNext() + } + } + } +} + +// MARK: - Storage + +extension OSSessionRequestQueue { + private func load() { + guard let data = storage.getSavedObject(forKey: OSUD_SESSION_REQUEST_QUEUE, defaultValue: nil) as? Data else { + return + } + do { + requests = try JSONDecoder().decode([OSSessionRequest].self, from: data) + } catch { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionRequestQueue failed to read saved requests: \(error)") + } + } + + private func persist() { + guard let data = try? JSONEncoder().encode(requests) else { + return + } + storage.saveObject(forKey: OSUD_SESSION_REQUEST_QUEUE, withValue: data) + } + + /// Only watched while requests are pending, so with the flag off nothing changes. + private func updateNetworkMonitoring() { + if requests.isEmpty { + networkMonitor.stop() + } else { + networkMonitor.start { [weak self] in self?.retryNow() } + } + } +} + +// MARK: - Testing + +extension OSSessionRequestQueue { + func waitUntilIdle() { + dispatchQueue.sync {} + } + + var queuedRequests: [OSSessionRequest] { + dispatchQueue.sync { requests } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 9c6ef0a07..e4a4718fb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -116,6 +116,7 @@ public final class OSSessionService: NSObject { @objc public static func onFocus() { shared.onFocus() + OSSessionRequestQueue.shared.retryNow() } @objc public static func onUnfocus() { @@ -154,6 +155,8 @@ public final class OSSessionService: NSObject { public func refreshPinnedIds() { let ids = pinnableIds() stateLock.withLock { fillPinnedIds(ids) } + // Queued session requests wait for these IDs, including those of an earlier session's user. + OSSessionRequestQueue.shared.retryNow() } /// Leaves any open foreground interval running: the tracker reports focus before the session @@ -248,6 +251,37 @@ public final class OSSessionService: NSObject { persist() } + /// The pinned IDs for a queued request. The current session's come from its record, so the + /// request and the record always agree. An earlier session's are filled by the same rules. + func pinnedIds( + sessionId: String, + identityModelId: String?, + onesignalId: String?, + subscriptionId: String? + ) -> (onesignalId: String?, subscriptionId: String?) { + if let record = currentRecord, record.sessionId == sessionId { + return (onesignalId ?? record.onesignalId, subscriptionId ?? record.subscriptionId) + } + guard let identityModelId, let provider = stateLock.withLock({ userProvider }) else { + return (onesignalId, subscriptionId) + } + return ( + onesignalId ?? provider.sessionOnesignalId(identityModelId: identityModelId), + subscriptionId ?? provider.sessionCurrentUser.pushSubscriptionId + ) + } + + func setServerSessionId(_ serverSessionId: String, forSessionId sessionId: String) { + stateLock.withLock { + loadIfNeeded() + guard record?.sessionId == sessionId, record?.serverSessionId == nil else { + return + } + record?.serverSessionId = serverSessionId + persist() + } + } + private func loadIfNeeded() { guard !loaded else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift index e9d35944e..049eb3b48 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift @@ -73,8 +73,13 @@ enum OSSessionsApiResult { extension OSSessionsApiResult: Equatable where Value: Equatable {} +protocol OSSessionsBackend { + func createSession(appId: String, body: OSCreateSessionRequestBody, completion: @escaping (OSSessionsApiResult) -> Void) + func updateSession(appId: String, sessionId: String, body: OSUpdateSessionRequestBody, completion: @escaping (OSSessionsApiResult) -> Void) +} + /// Typed client for the sessions API. -final class OSSessionsBackendService { +final class OSSessionsBackendService: OSSessionsBackend { static let defaultRetryAfterSeconds = 60 private let client: () -> IOneSignalClient diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift new file mode 100644 index 000000000..e5bd14af3 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift @@ -0,0 +1,545 @@ +/* + 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 OneSignalKMP +@testable import OneSignalOSCore +import XCTest + +private final class FakeUserProvider: OSSessionUserProvider { + var identityModelId: String? = "model-a" + var onesignalIds: [String: String] = ["model-a": "onesignal-a"] + var pushSubscriptionId: String? = "subscription-a" + + var sessionCurrentUser: OSSessionUser { + OSSessionUser( + identityModelId: identityModelId, + onesignalId: identityModelId.flatMap { onesignalIds[$0] }, + pushSubscriptionId: pushSubscriptionId + ) + } + + func sessionOnesignalId(identityModelId: String) -> String? { + onesignalIds[identityModelId] + } +} + +private final class FakeBackend: OSSessionsBackend { + enum Call { + case create(appId: String, body: OSCreateSessionRequestBody, completion: (OSSessionsApiResult) -> Void) + case update(appId: String, sessionId: String, body: OSUpdateSessionRequestBody, completion: (OSSessionsApiResult) -> Void) + + var idempotencyKey: String { + switch self { + case .create(_, let body, _): return body.idempotencyKey + case .update(_, _, let body, _): return body.idempotencyKey + } + } + } + + private let lock = NSLock() + private var _calls: [Call] = [] + var calls: [Call] { lock.withLock { _calls } } + + func createSession(appId: String, body: OSCreateSessionRequestBody, completion: @escaping (OSSessionsApiResult) -> Void) { + lock.withLock { _calls.append(.create(appId: appId, body: body, completion: completion)) } + } + + func updateSession( + appId: String, + sessionId: String, + body: OSUpdateSessionRequestBody, + completion: @escaping (OSSessionsApiResult) -> Void + ) { + lock.withLock { _calls.append(.update(appId: appId, sessionId: sessionId, body: body, completion: completion)) } + } + + func respondToLast(createdSessionId: String) { + guard case .create(_, _, let completion) = calls.last else { + return XCTFail("last call is not a create") + } + completion(.success(createdSessionId)) + } + + func respondToLastUpdate() { + guard case .update(_, _, _, let completion) = calls.last else { + return XCTFail("last call is not an update") + } + completion(.success(())) + } + + func failLast(_ result: OSSessionsApiResult) { + switch calls.last { + case .create(_, _, let completion): + switch result { + case .retry(let statusCode, let retryAfterSeconds): + completion(.retry(statusCode: statusCode, retryAfterSeconds: retryAfterSeconds)) + case .drop(let statusCode): + completion(.drop(statusCode: statusCode)) + case .success: + XCTFail("not a failure") + } + case .update(_, _, _, let completion): + completion(result) + case nil: + XCTFail("no call") + } + } +} + +private final class FakeNetworkMonitor: OSSessionNetworkMonitoring { + private(set) var onAvailable: (() -> Void)? + + func start(onAvailable: @escaping () -> Void) { + self.onAvailable = onAvailable + } + + func stop() { + onAvailable = nil + } +} + +final class OSSessionRequestQueueTests: XCTestCase { + private var user: FakeUserProvider! + private var backend: FakeBackend! + private var monitor: FakeNetworkMonitor! + private var service: OSSessionService! + private var flagsStore: OSFeatureFlagsStore! + private var now: TimeInterval = 10_000 + private var scheduled: [(delay: TimeInterval, block: () -> Void)] = [] + private var queue: OSSessionRequestQueue! + + override func setUp() { + super.setUp() + clearStorage() + user = FakeUserProvider() + backend = FakeBackend() + monitor = FakeNetworkMonitor() + flagsStore = OSFeatureFlagsStore() + flagsStore.applyRemoteFlags([FeatureFlag.sdkSessionsV2ApiCutover.key], metadata: nil) + let manager = OSFeatureManager(store: flagsStore) + service = OSSessionService(featureManager: { manager }, monotonicNow: { 0 }, wallNow: { 1_700_000_000 }) + now = 10_000 + scheduled = [] + queue = makeQueue() + } + + override func tearDown() { + queue?.waitUntilIdle() + clearStorage() + super.tearDown() + } + + private func clearStorage() { + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_REQUEST_QUEUE) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + } + + private func makeQueue() -> OSSessionRequestQueue { + OSSessionRequestQueue( + backend: backend, + sessionService: { [unowned self] in self.service }, + appId: { "app-id" }, + networkMonitor: monitor, + now: { [unowned self] in self.now }, + jitter: { 1 }, + schedule: { [unowned self] delay, block in self.scheduled.append((delay, block)) } + ) + } + + private func startSession() -> OSSessionRecord { + service.startNewSession(userProvider: user) + return service.currentRecord! + } + + private func record(duration: TimeInterval, from base: OSSessionRecord) -> OSSessionRecord { + var record = base + record.activeDuration = duration + return record + } + + private func settle() { + queue.waitUntilIdle() + queue.waitUntilIdle() + } + + private var updateDurations: [Int64] { + backend.calls.compactMap { + if case .update(_, _, let body, _) = $0 { + return body.durationSeconds + } + return nil + } + } + + // MARK: - Sending + + func testCreateIsPersistedBeforeSendingAndRemovedOnSuccess() { + let record = startSession() + + queue.enqueueCreate(record: record, directAttributionId: "notification-id") + settle() + + XCTAssertEqual(queue.queuedRequests.count, 1) + XCTAssertNotNil(OneSignalUserDefaults.initStandard().getSavedObject(forKey: OSUD_SESSION_REQUEST_QUEUE, defaultValue: nil)) + guard case .create(let appId, let body, _) = backend.calls.first else { + return XCTFail("expected a create") + } + XCTAssertEqual(appId, "app-id") + XCTAssertEqual(body.onesignalId, "onesignal-a") + XCTAssertEqual(body.subscriptionId, "subscription-a") + XCTAssertEqual(body.startTime, Date(timeIntervalSince1970: 1_700_000_000)) + XCTAssertEqual(body.directAttributionId, "notification-id") + + backend.respondToLast(createdSessionId: "server-1") + settle() + + XCTAssertTrue(queue.queuedRequests.isEmpty) + XCTAssertEqual(service.currentRecord?.serverSessionId, "server-1") + } + + func testFlagOffEnqueuesNothing() { + flagsStore.applyRemoteFlags([], metadata: nil) + let record = startSession() + + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: record) + settle() + + XCTAssertTrue(queue.queuedRequests.isEmpty) + XCTAssertTrue(backend.calls.isEmpty) + } + + func testCreateWaitsForOnesignalIdAndSubscriptionId() { + user.onesignalIds = [:] + user.pushSubscriptionId = nil + let record = startSession() + + queue.enqueueCreate(record: record) + settle() + XCTAssertTrue(backend.calls.isEmpty) + + user.onesignalIds = ["model-a": "onesignal-a"] + queue.retryNow() + settle() + XCTAssertTrue(backend.calls.isEmpty) + + user.pushSubscriptionId = "subscription-a" + queue.retryNow() + settle() + + guard case .create(_, let body, _) = backend.calls.first else { + return XCTFail("expected a create") + } + XCTAssertEqual(body.onesignalId, "onesignal-a") + XCTAssertEqual(body.subscriptionId, "subscription-a") + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + } + + func testUpdateWaitsForCreateAndUsesServerSessionId() { + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 12.9, from: record)) + settle() + XCTAssertEqual(backend.calls.count, 1) + + backend.respondToLast(createdSessionId: "server-1") + settle() + + guard case .update(_, let sessionId, let body, _) = backend.calls.last else { + return XCTFail("expected an update") + } + XCTAssertEqual(sessionId, "server-1") + XCTAssertEqual(body.durationSeconds, 12) + XCTAssertNil(body.endTime) + + backend.respondToLastUpdate() + settle() + XCTAssertTrue(queue.queuedRequests.isEmpty) + } + + func testUpdateEnqueuedWhileCreateInFlightGetsServerSessionId() { + let record = startSession() + queue.enqueueCreate(record: record) + settle() + backend.respondToLast(createdSessionId: "server-1") + settle() + + // The caller still holds the record read before the create returned. + queue.enqueueUpdate(record: self.record(duration: 5, from: record)) + settle() + + guard case .update(_, let sessionId, _, _) = backend.calls.last else { + return XCTFail("expected an update") + } + XCTAssertEqual(sessionId, "server-1") + } + + func testCreateIsNotEnqueuedTwice() { + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueCreate(record: record) + settle() + XCTAssertEqual(queue.queuedRequests.count, 1) + + backend.respondToLast(createdSessionId: "server-1") + settle() + queue.enqueueCreate(record: record) + settle() + + XCTAssertEqual(backend.calls.count, 1) + } + + // MARK: - Coalescing + + func testQueuedUpdatesCollapseToHighestDuration() { + user.onesignalIds = [:] + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 30, from: record)) + queue.enqueueUpdate(record: self.record(duration: 20, from: record)) + settle() + + let requests = queue.queuedRequests + XCTAssertEqual(requests.count, 2) + XCTAssertEqual(requests.last?.activeDuration, 30) + } + + func testEndReplacesQueuedUpdateAndLaterUpdatesAreDiscarded() { + user.onesignalIds = [:] + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + queue.enqueueUpdate(record: self.record(duration: 15, from: record), endTime: 1_700_000_100) + queue.enqueueUpdate(record: self.record(duration: 20, from: record)) + settle() + + let requests = queue.queuedRequests + XCTAssertEqual(requests.count, 2) + XCTAssertEqual(requests.last?.kind, .update(activeDuration: 15, endTime: 1_700_000_100)) + } + + func testInFlightUpdateIsNotCoalesced() { + let record = startSession() + queue.enqueueCreate(record: record) + settle() + backend.respondToLast(createdSessionId: "server-1") + settle() + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + + queue.enqueueUpdate(record: self.record(duration: 20, from: record)) + settle() + XCTAssertEqual(queue.queuedRequests.count, 2) + + backend.respondToLastUpdate() + settle() + + XCTAssertEqual(updateDurations, [10, 20]) + } + + func testUpdateWithoutCreateOrServerSessionIdIsDiscarded() { + let record = startSession() + + queue.enqueueUpdate(record: record) + settle() + + XCTAssertTrue(queue.queuedRequests.isEmpty) + } + + func testSessionsAreCoalescedIndependently() { + user.onesignalIds = [:] + let first = startSession() + queue.enqueueCreate(record: first) + queue.enqueueUpdate(record: record(duration: 10, from: first)) + let second = startSession() + queue.enqueueCreate(record: second) + queue.enqueueUpdate(record: record(duration: 5, from: second)) + settle() + + XCTAssertEqual(queue.queuedRequests.count, 4) + } + + // MARK: - Cap + + func testCapDropsOldestAndItsUpdates() { + user.onesignalIds = [:] + let first = startSession() + queue.enqueueCreate(record: first) + queue.enqueueUpdate(record: record(duration: 10, from: first)) + for _ in 0.. Date: Thu, 8 Oct 2026 14:39:27 -0700 Subject: [PATCH 2/9] fix: [SDK-5379] address session queue review feedback Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionRequest.swift | 6 + .../Source/OSSessionRequestQueue.swift | 94 ++++++++++++-- .../Source/OSSessionService.swift | 10 ++ .../Source/OSSessionsRequests.swift | 10 ++ .../OSSessionRequestQueueTests.swift | 117 ++++++++++++++++++ .../OSSessionServiceTests.swift | 6 + .../OSSessionsBackendServiceTests.swift | 10 ++ .../OneSignalUserManagerImpl+Session.swift | 6 + iOS_SDK/OneSignalSDK/Source/OneSignal.m | 1 + 9 files changed, 247 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift index a02bcc770..fdc75913c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift @@ -65,6 +65,12 @@ struct OSSessionRequest: Codable, Equatable { return false } + /// Leaves out the user and subscription IDs, since WARN logs can be uploaded. + var logDescription: String { + let kindName = isCreate ? "create" : (isEnd ? "end" : "update") + return "\(kindName) for session \(localSessionId), failed attempts: \(failedAttempts)" + } + var activeDuration: TimeInterval? { if case .update(let activeDuration, _) = kind { return activeDuration diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift index 799ae8a4c..a7d29fca2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift @@ -53,7 +53,18 @@ public final class OSSessionRequestQueue: NSObject { } @objc public static func reset() { - lock.withLock { _shared = nil } + let retired = lock.withLock { + let existing = _shared + _shared = nil + return existing + } + retired?.retire() + } + + /// Saved requests carry the previous app's ID, so they must not outlive an app-id change. + @objc public static func resetAndClearStoredQueue() { + reset() + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_REQUEST_QUEUE) } private let storage: OneSignalUserDefaults @@ -75,8 +86,10 @@ public final class OSSessionRequestQueue: NSObject { private var consecutiveFailures = 0 private var isBackingOff = false private var backoffGeneration = 0 - /// A backend `Retry-After` that `retryNow` must not cut short. + /// A backend `Retry-After` that `retryNow` must not cut short, on the `now` clock. private var notBefore: TimeInterval = 0 + /// Set once the queue is replaced, so a late completion cannot write its requests back. + private var isRetired = false init( storage: OneSignalUserDefaults = .initStandard(), @@ -84,7 +97,10 @@ public final class OSSessionRequestQueue: NSObject { sessionService: @escaping () -> OSSessionService = { .shared }, appId: @escaping () -> String? = { OneSignalIdentifiers.currentAppId }, networkMonitor: OSSessionNetworkMonitoring = OSSessionNetworkMonitor(), - now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }, + // The same clock as `DispatchTime`, so `notBefore` agrees with the scheduled retry. + now: @escaping () -> TimeInterval = { + TimeInterval(clock_gettime_nsec_np(CLOCK_UPTIME_RAW)) / TimeInterval(NSEC_PER_SEC) + }, jitter: @escaping () -> Double = { Double.random(in: 0.5...1) }, schedule: ((TimeInterval, @escaping () -> Void) -> Void)? = nil ) { @@ -103,6 +119,14 @@ public final class OSSessionRequestQueue: NSObject { dispatchQueue.async { self.load() self.updateNetworkMonitoring() + self.processNext() + } + } + + private func retire() { + dispatchQueue.sync { + isRetired = true + networkMonitor.stop() } } } @@ -122,7 +146,8 @@ extension OSSessionRequestQueue { onesignalId: record.onesignalId, subscriptionId: record.subscriptionId, kind: .create(startTime: record.startTime, directAttributionId: directAttributionId), - idempotencyKey: UUID().uuidString + // Derived from the session, so a create enqueued again after being dropped is still deduplicated. + idempotencyKey: record.sessionId ) dispatchQueue.async { let isCreated = record.serverSessionId != nil || self.serverSessionIds[record.sessionId] != nil @@ -183,6 +208,16 @@ extension OSSessionRequestQueue { } let removedKeys = Set(queued.map(\.idempotencyKey)) requests.removeAll { removedKeys.contains($0.idempotencyKey) } + // An unchanged body keeps its key, so a retry the backend may already have applied stays + // deduplicated. Either way the attempts carry over, or a session that keeps reporting + // would never reach the drop limit. + let failedAttempts = queued.map(\.failedAttempts).max() ?? 0 + if let unchanged = queued.first(where: { $0.kind == request.kind }) { + let serverSessionId = request.serverSessionId + request = unchanged + request.serverSessionId = unchanged.serverSessionId ?? serverSessionId + } + request.failedAttempts = max(request.failedAttempts, failedAttempts) OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionRequestQueue coalesced \(queued.count) update(s) for \(request.localSessionId)") return true } @@ -200,7 +235,7 @@ extension OSSessionRequestQueue { let oldest = requests.first(where: { $0.idempotencyKey != inFlightKey }) { OneSignalLog.onesignalLog( .LL_WARN, - message: "OSSessionRequestQueue more than \(Self.maxQueuedRequests) requests queued, dropping oldest: \(oldest)" + message: "OSSessionRequestQueue more than \(Self.maxQueuedRequests) requests queued, dropping oldest: \(oldest.logDescription)" ) remove(oldest) } @@ -246,6 +281,10 @@ extension OSSessionRequestQueue { } private func processNext() { + guard !isRetired else { + return + } + dropUnsendable() guard inFlightKey == nil, !isBackingOff else { return } @@ -263,6 +302,23 @@ extension OSSessionRequestQueue { } } + /// A create still waiting on a `onesignal_id` its user will never get, which happens to an + /// anonymous user under required Identity Verification. Its updates go with it. + private func dropUnsendable() { + let service = sessionService() + let unsendable = requests.filter { + $0.isCreate && $0.onesignalId == nil && $0.idempotencyKey != inFlightKey + && !service.canCreateUser(identityModelId: $0.identityModelId) + } + guard !unsendable.isEmpty else { + return + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue dropping \(unsendable.count) create(s) for a user that is never created") + unsendable.forEach(remove) + persist() + updateNetworkMonitoring() + } + private func readyRequest(at index: Int) -> OSSessionRequest? { var request = requests[index] if request.onesignalId == nil || request.subscriptionId == nil { @@ -344,7 +400,7 @@ extension OSSessionRequestQueue { } case .drop(let statusCode): consecutiveFailures = 0 - OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue request failed with \(statusCode), dropping: \(request)") + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue dropping after a \(statusCode): \(request.logDescription)") remove(request) case .retry(let statusCode, let retryAfterSeconds): // 0 means no response, e.g. offline, which must keep retrying until the network returns. @@ -352,17 +408,20 @@ extension OSSessionRequestQueue { request.failedAttempts += 1 } if request.failedAttempts >= Self.maxFailedAttempts { - OneSignalLog.onesignalLog( - .LL_WARN, - message: "OSSessionRequestQueue request failed \(request.failedAttempts) times, dropping: \(request)" - ) + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue dropping after too many failures: \(request.logDescription)") remove(request) } else { requests[index] = request - persist() + } + persist() + updateNetworkMonitoring() + // A dropped request still honors `Retry-After`, which applies to the next request too. + if request.failedAttempts < Self.maxFailedAttempts || retryAfterSeconds != nil { backOff(retryAfterSeconds: retryAfterSeconds) - return + } else { + processNext() } + return } persist() updateNetworkMonitoring() @@ -419,13 +478,18 @@ extension OSSessionRequestQueue { } do { requests = try JSONDecoder().decode([OSSessionRequest].self, from: data) + for request in requests { + if let serverSessionId = request.serverSessionId { + serverSessionIds[request.localSessionId] = serverSessionId + } + } } catch { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionRequestQueue failed to read saved requests: \(error)") } } private func persist() { - guard let data = try? JSONEncoder().encode(requests) else { + guard !isRetired, let data = try? JSONEncoder().encode(requests) else { return } storage.saveObject(forKey: OSUD_SESSION_REQUEST_QUEUE, withValue: data) @@ -448,6 +512,10 @@ extension OSSessionRequestQueue { dispatchQueue.sync {} } + func retireForTesting() { + retire() + } + var queuedRequests: [OSSessionRequest] { dispatchQueue.sync { requests } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index e4a4718fb..ed4413fb8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -48,6 +48,9 @@ public protocol OSSessionUserProvider: AnyObject { /// Taken from one read of the current user, so a concurrent login cannot mix two users. var sessionCurrentUser: OSSessionUser { get } func sessionOnesignalId(identityModelId: String) -> String? + /// False when the user can never get a `onesignal_id`, such as an anonymous user under + /// required Identity Verification. + func sessionUserCanBeCreated(identityModelId: String) -> Bool } /// New fields must be Optional. A record stored by an earlier version lacks them, and synthesized @@ -271,6 +274,13 @@ public final class OSSessionService: NSObject { ) } + func canCreateUser(identityModelId: String?) -> Bool { + guard let identityModelId, let provider = stateLock.withLock({ userProvider }) else { + return true + } + return provider.sessionUserCanBeCreated(identityModelId: identityModelId) + } + func setServerSessionId(_ serverSessionId: String, forSessionId sessionId: String) { stateLock.withLock { loadIfNeeded() diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift index a861a09e9..6500d3604 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift @@ -41,6 +41,14 @@ enum OSSessionTimestamp { } } +private extension OneSignalRequest { + /// `OSSessionRequestQueue` owns retries, with backoff that honors `Retry-After`. Starting at the + /// client's last attempt stops `OneSignalClient` from retrying status 0 and 5xx underneath it. + func skipClientRetries() { + reattemptCount = MAX_ATTEMPT_COUNT - 1 + } +} + /// `POST apps/{app_id}/sessions` final class OSRequestCreateSession: OneSignalRequest { override var description: String { @@ -61,6 +69,7 @@ final class OSRequestCreateSession: OneSignalRequest { } self.parameters = parameters self.method = POST + skipClientRetries() self.path = "apps/\(appId)/sessions" } } @@ -84,6 +93,7 @@ final class OSRequestUpdateSession: OneSignalRequest { } self.parameters = parameters self.method = PATCH + skipClientRetries() self.path = "apps/\(appId)/sessions/\(sessionId)" } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift index e5bd14af3..69cadd4cf 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift @@ -35,6 +35,7 @@ private final class FakeUserProvider: OSSessionUserProvider { var identityModelId: String? = "model-a" var onesignalIds: [String: String] = ["model-a": "onesignal-a"] var pushSubscriptionId: String? = "subscription-a" + var creatableModelIds: Set? var sessionCurrentUser: OSSessionUser { OSSessionUser( @@ -47,6 +48,10 @@ private final class FakeUserProvider: OSSessionUserProvider { func sessionOnesignalId(identityModelId: String) -> String? { onesignalIds[identityModelId] } + + func sessionUserCanBeCreated(identityModelId: String) -> Bool { + creatableModelIds?.contains(identityModelId) ?? true + } } private final class FakeBackend: OSSessionsBackend { @@ -224,6 +229,64 @@ final class OSSessionRequestQueueTests: XCTestCase { XCTAssertEqual(service.currentRecord?.serverSessionId, "server-1") } + func testCreateIdempotencyKeyIsTheSessionId() { + let record = startSession() + + queue.enqueueCreate(record: record) + settle() + + XCTAssertEqual(backend.calls.first?.idempotencyKey, record.sessionId) + } + + func testRestartRemembersServerSessionIdOfQueuedUpdates() { + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + backend.respondToLast(createdSessionId: "server-1") + settle() + backend.failLast(.retry(statusCode: 0, retryAfterSeconds: nil)) + settle() + + queue = makeQueue() + // A snapshot taken before the create returned. + queue.enqueueCreate(record: record) + settle() + + XCTAssertFalse(queue.queuedRequests.contains { $0.isCreate }) + } + + func testAppIdChangeClearsStoredQueueAndLateCompletionDoesNotRestoreIt() { + let record = startSession() + queue.enqueueCreate(record: record) + settle() + + let retired = queue! + let key = OSUD_SESSION_REQUEST_QUEUE + retired.retireForTesting() + OneSignalUserDefaults.initStandard().removeValue(forKey: key) + backend.failLast(.retry(statusCode: 500, retryAfterSeconds: nil)) + retired.waitUntilIdle() + + XCTAssertNil(OneSignalUserDefaults.initStandard().getSavedObject(forKey: key, defaultValue: nil)) + } + + func testCreateForUserThatIsNeverCreatedIsDroppedWithItsUpdates() { + user.onesignalIds = [:] + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + XCTAssertEqual(queue.queuedRequests.count, 2) + + user.creatableModelIds = [] + queue.retryNow() + settle() + + XCTAssertTrue(queue.queuedRequests.isEmpty) + XCTAssertNil(monitor.onAvailable) + } + func testFlagOffEnqueuesNothing() { flagsStore.applyRemoteFlags([], metadata: nil) let record = startSession() @@ -345,6 +408,30 @@ final class OSSessionRequestQueueTests: XCTestCase { XCTAssertEqual(requests.last?.kind, .update(activeDuration: 15, endTime: 1_700_000_100)) } + func testCoalescingKeepsUnchangedRequestAndCarriesFailedAttempts() { + let record = startSession() + queue.enqueueCreate(record: record) + settle() + backend.respondToLast(createdSessionId: "server-1") + settle() + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + let key = backend.calls.last?.idempotencyKey + backend.failLast(.retry(statusCode: 500, retryAfterSeconds: nil)) + settle() + + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + XCTAssertEqual(queue.queuedRequests.map(\.idempotencyKey), [key]) + XCTAssertEqual(queue.queuedRequests.first?.failedAttempts, 1) + + queue.enqueueUpdate(record: self.record(duration: 20, from: record)) + settle() + XCTAssertEqual(queue.queuedRequests.count, 1) + XCTAssertNotEqual(queue.queuedRequests.first?.idempotencyKey, key) + XCTAssertEqual(queue.queuedRequests.first?.failedAttempts, 1) + } + func testInFlightUpdateIsNotCoalesced() { let record = startSession() queue.enqueueCreate(record: record) @@ -518,6 +605,36 @@ final class OSSessionRequestQueueTests: XCTestCase { XCTAssertTrue(queue.queuedRequests.isEmpty) } + func testDropAtAttemptLimitStillHonorsRetryAfter() { + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueCreate(record: startSession()) + settle() + for _ in 1.. String? { onesignalIds[identityModelId] } + + func sessionUserCanBeCreated(identityModelId: String) -> Bool { + true + } } final class OSSessionServiceTests: XCTestCase { @@ -71,6 +75,8 @@ final class OSSessionServiceTests: XCTestCase { override func tearDown() { OSSessionService.reset() + // `refreshPinnedIds` wakes the shared queue. + OSSessionRequestQueue.resetAndClearStoredQueue() OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) super.tearDown() diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift index 8fb76ba04..488f005f1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift @@ -123,6 +123,16 @@ final class OSSessionsBackendServiceTests: XCTestCase { ]) } + func testSessionRequestsSkipClientRetries() { + _ = create() + _ = update() + + // `MAX_ATTEMPT_COUNT` depends on `OS_TEST`, which this target and the SDK set differently. + for request in client.requests { + XCTAssertGreaterThan(request.reattemptCount, 0) + } + } + func testCreateSessionIncludesDirectAttributionWhenSet() { client.outcome = .success(["data": ["session_id": "server-id"]]) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index fca1f94d0..96952338e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -41,4 +41,10 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { public func sessionOnesignalId(identityModelId: String) -> String? { identityModelRepo.get(modelId: identityModelId)?.onesignalId } + + /// While the requirement is still unknown the user may yet be created, so only `.on` says no. + public func sessionUserCanBeCreated(identityModelId: String) -> Bool { + identityVerificationService.requirement != .on + || identityModelRepo.get(modelId: identityModelId)?.externalId != nil + } } diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 5db98280e..9ddb63b6c 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -758,6 +758,7 @@ + (void)handleAppIdChange:(NSString*)appId { // within a process, so both the cache and the latch have to go. [OSFeatureManager resetAndClearCachedFlags]; [OSSessionService resetAndClearStoredRecord]; + [OSSessionRequestQueue resetAndClearStoredQueue]; let sharedUserDefaults = OneSignalUserDefaults.initShared; From 0b626025a5b8b99ae604cd17b05cd58891458d2e Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 8 Oct 2026 15:33:09 -0700 Subject: [PATCH 3/9] test: [SDK-5379] split session queue tests to satisfy SwiftLint body length Co-Authored-By: Claude Opus 5.5 --- .../OSSessionRequestQueueTests.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift index 69cadd4cf..6a4fba61f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift @@ -473,7 +473,11 @@ final class OSSessionRequestQueueTests: XCTestCase { XCTAssertEqual(queue.queuedRequests.count, 4) } - // MARK: - Cap +} + +// MARK: - Cap + +extension OSSessionRequestQueueTests { func testCapDropsOldestAndItsUpdates() { user.onesignalIds = [:] @@ -490,7 +494,11 @@ final class OSSessionRequestQueueTests: XCTestCase { XCTAssertFalse(requests.contains { $0.localSessionId == first.sessionId }) } - // MARK: - Retry +} + +// MARK: - Retry + +extension OSSessionRequestQueueTests { func testRetryKeepsIdempotencyKeyAcrossRestart() { let record = startSession() From 65000d082a1e80f9ef6a454e207ed169bdd14ed2 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 8 Oct 2026 15:55:18 -0700 Subject: [PATCH 4/9] fix: [SDK-5379] address second session queue review Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionRequestQueue.swift | 58 +++++++++++++++---- .../Source/OSSessionService.swift | 2 +- .../OSSessionRequestQueueTests.swift | 51 +++++++++++++++- .../OneSignalUserManagerImpl+Session.swift | 11 +++- 4 files changed, 105 insertions(+), 17 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift index a7d29fca2..ac19165b6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift @@ -63,8 +63,12 @@ public final class OSSessionRequestQueue: NSObject { /// Saved requests carry the previous app's ID, so they must not outlive an app-id change. @objc public static func resetAndClearStoredQueue() { - reset() - OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_REQUEST_QUEUE) + // Under the lock, so a new queue cannot load the old requests before they are removed. + lock.withLock { + _shared?.retire() + _shared = nil + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_REQUEST_QUEUE) + } } private let storage: OneSignalUserDefaults @@ -75,6 +79,7 @@ public final class OSSessionRequestQueue: NSObject { private let now: () -> TimeInterval private let jitter: () -> Double private let schedule: (TimeInterval, @escaping () -> Void) -> Void + private let postCreateDelay: TimeInterval // Serial. Synchronizes all state below. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSessionRequestQueue", target: .global()) @@ -88,6 +93,9 @@ public final class OSSessionRequestQueue: NSObject { private var backoffGeneration = 0 /// A backend `Retry-After` that `retryNow` must not cut short, on the `now` clock. private var notBefore: TimeInterval = 0 + /// Updates for a just-created session wait until this time, on the `now` clock, so the backend + /// has replicated the session before it is patched. + private var updatesAllowedAt: [String: TimeInterval] = [:] /// Set once the queue is replaced, so a late completion cannot write its requests back. private var isRetired = false @@ -102,7 +110,8 @@ public final class OSSessionRequestQueue: NSObject { TimeInterval(clock_gettime_nsec_np(CLOCK_UPTIME_RAW)) / TimeInterval(NSEC_PER_SEC) }, jitter: @escaping () -> Double = { Double.random(in: 0.5...1) }, - schedule: ((TimeInterval, @escaping () -> Void) -> Void)? = nil + schedule: ((TimeInterval, @escaping () -> Void) -> Void)? = nil, + postCreateDelay: TimeInterval = TimeInterval(OP_REPO_POST_CREATE_DELAY_SECONDS) ) { self.storage = storage self.backend = backend @@ -111,6 +120,7 @@ public final class OSSessionRequestQueue: NSObject { self.networkMonitor = networkMonitor self.now = now self.jitter = jitter + self.postCreateDelay = postCreateDelay let dispatchQueue = self.dispatchQueue self.schedule = schedule ?? { delay, block in dispatchQueue.asyncAfter(deadline: .now() + delay, execute: block) @@ -198,14 +208,15 @@ extension OSSessionRequestQueue { return false } + // An in-flight update counts toward the highest duration, so a stale snapshot cannot lower it. + if case .update(let activeDuration, let endTime) = request.kind { + let highest = session.compactMap(\.activeDuration).max() ?? activeDuration + request.kind = .update(activeDuration: max(activeDuration, highest), endTime: endTime) + } let queued = session.filter { !$0.isCreate && $0.idempotencyKey != inFlightKey } guard !queued.isEmpty else { return true } - if case .update(let activeDuration, let endTime) = request.kind { - let highest = queued.compactMap(\.activeDuration).max() ?? activeDuration - request.kind = .update(activeDuration: max(activeDuration, highest), endTime: endTime) - } let removedKeys = Set(queued.map(\.idempotencyKey)) requests.removeAll { removedKeys.contains($0.idempotencyKey) } // An unchanged body keeps its key, so a retry the backend may already have applied stays @@ -213,9 +224,11 @@ extension OSSessionRequestQueue { // would never reach the drop limit. let failedAttempts = queued.map(\.failedAttempts).max() ?? 0 if let unchanged = queued.first(where: { $0.kind == request.kind }) { - let serverSessionId = request.serverSessionId + let incoming = request request = unchanged - request.serverSessionId = unchanged.serverSessionId ?? serverSessionId + request.serverSessionId = unchanged.serverSessionId ?? incoming.serverSessionId + request.onesignalId = unchanged.onesignalId ?? incoming.onesignalId + request.subscriptionId = unchanged.subscriptionId ?? incoming.subscriptionId } request.failedAttempts = max(request.failedAttempts, failedAttempts) OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionRequestQueue coalesced \(queued.count) update(s) for \(request.localSessionId)") @@ -280,6 +293,14 @@ extension OSSessionRequestQueue { } } + /// Sends what has become ready, such as a request that was waiting on IDs. Unlike `retryNow`, + /// it leaves a failure backoff running, since it is called far more often than the app opens. + public func processPending() { + dispatchQueue.async { + self.processNext() + } + } + private func processNext() { guard !isRetired else { return @@ -336,8 +357,13 @@ extension OSSessionRequestQueue { guard request.onesignalId != nil, request.subscriptionId != nil else { return nil } - if !request.isCreate, request.serverSessionId == nil { - return nil + if !request.isCreate { + guard request.serverSessionId != nil else { + return nil + } + if let allowedAt = updatesAllowedAt[request.localSessionId], now() < allowedAt { + return nil + } } return request } @@ -384,6 +410,9 @@ extension OSSessionRequestQueue { } private func handle(_ result: OSSessionsApiResult, for sent: OSSessionRequest) { + guard !isRetired else { + return + } inFlightKey = nil guard let index = requests.firstIndex(where: { $0.idempotencyKey == sent.idempotencyKey }) else { processNext() @@ -436,6 +465,13 @@ extension OSSessionRequestQueue { requests[index].subscriptionId = requests[index].subscriptionId ?? create.subscriptionId } sessionService().setServerSessionId(serverSessionId, forSessionId: create.localSessionId) + guard postCreateDelay > 0 else { + return + } + updatesAllowedAt[create.localSessionId] = now() + postCreateDelay + schedule(postCreateDelay) { [weak self] in + self?.processPending() + } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index ed4413fb8..fe0755549 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -159,7 +159,7 @@ public final class OSSessionService: NSObject { let ids = pinnableIds() stateLock.withLock { fillPinnedIds(ids) } // Queued session requests wait for these IDs, including those of an earlier session's user. - OSSessionRequestQueue.shared.retryNow() + OSSessionRequestQueue.shared.processPending() } /// Leaves any open foreground interval running: the tracker reports focus before the session diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift index 6a4fba61f..c19d5e47d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift @@ -166,7 +166,7 @@ final class OSSessionRequestQueueTests: XCTestCase { OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) } - private func makeQueue() -> OSSessionRequestQueue { + private func makeQueue(postCreateDelay: TimeInterval = 0) -> OSSessionRequestQueue { OSSessionRequestQueue( backend: backend, sessionService: { [unowned self] in self.service }, @@ -174,7 +174,8 @@ final class OSSessionRequestQueueTests: XCTestCase { networkMonitor: monitor, now: { [unowned self] in self.now }, jitter: { 1 }, - schedule: { [unowned self] delay, block in self.scheduled.append((delay, block)) } + schedule: { [unowned self] delay, block in self.scheduled.append((delay, block)) }, + postCreateDelay: postCreateDelay ) } @@ -432,6 +433,39 @@ final class OSSessionRequestQueueTests: XCTestCase { XCTAssertEqual(queue.queuedRequests.first?.failedAttempts, 1) } + func testInFlightUpdateCountsTowardHighestDuration() { + let record = startSession() + queue.enqueueCreate(record: record) + settle() + backend.respondToLast(createdSessionId: "server-1") + settle() + queue.enqueueUpdate(record: self.record(duration: 30, from: record)) + settle() + + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + + XCTAssertEqual(queue.queuedRequests.last?.activeDuration, 30) + } + + func testUpdateWaitsOutPostCreateDelay() { + queue = makeQueue(postCreateDelay: 5) + let record = startSession() + queue.enqueueCreate(record: record) + queue.enqueueUpdate(record: self.record(duration: 10, from: record)) + settle() + backend.respondToLast(createdSessionId: "server-1") + settle() + XCTAssertEqual(backend.calls.count, 1) + XCTAssertEqual(scheduled.last?.delay, 5) + + now += 5 + scheduled.last?.block() + settle() + + XCTAssertEqual(updateDurations, [10]) + } + func testInFlightUpdateIsNotCoalesced() { let record = startSession() queue.enqueueCreate(record: record) @@ -542,6 +576,19 @@ extension OSSessionRequestQueueTests { XCTAssertEqual(backend.calls.count, 3) } + func testProcessPendingLeavesBackoffRunning() { + let record = startSession() + queue.enqueueCreate(record: record) + settle() + backend.failLast(.retry(statusCode: 503, retryAfterSeconds: nil)) + settle() + + queue.processPending() + settle() + + XCTAssertEqual(backend.calls.count, 1) + } + func testRetryAfterIsHonoredByRetryNow() { let record = startSession() queue.enqueueCreate(record: record) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index 96952338e..3173c1ec1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -42,9 +42,14 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { identityModelRepo.get(modelId: identityModelId)?.onesignalId } - /// While the requirement is still unknown the user may yet be created, so only `.on` says no. + /// Says no only when it knows: the requirement is `.on` and the user's model is loaded and + /// anonymous. A model that is not loaded may still be restored. public func sessionUserCanBeCreated(identityModelId: String) -> Bool { - identityVerificationService.requirement != .on - || identityModelRepo.get(modelId: identityModelId)?.externalId != nil + guard identityVerificationService.requirement == .on, + let identityModel = identityModelRepo.get(modelId: identityModelId) + else { + return true + } + return identityModel.externalId != nil } } From 29e1a4e9bfe18069e82c4c9b62fb1bf91be0c867 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 9 Oct 2026 17:10:23 -0700 Subject: [PATCH 5/9] refactor: [SDK-5379] send session requests through the operation repo Replace the standalone session request queue with OSRequestCreateSession / OSRequestUpdateSession and an OSSessionOperationExecutor registered with OSOperationRepo, persisted like the other executors. Session requests are retried in the same run on a later flush once their backoff passes, honoring Retry-After, up to 5 answered failures. The post-create delay uses OSNewRecordsState. The OSCore sessions backend service is folded into the executor and request classes. Co-Authored-By: Claude Opus 5.5 --- .../OneSignal.xcodeproj/project.pbxproj | 36 +- .../Source/OneSignalCommonDefines.h | 9 +- .../Source/OSSessionRequest.swift | 128 ---- .../Source/OSSessionRequestQueue.swift | 558 -------------- .../Source/OSSessionService.swift | 38 +- .../Source/OSSessionsBackendService.swift | 171 ----- .../Source/OSSessionsRequests.swift | 99 --- .../OSSessionRequestQueueTests.swift | 717 ------------------ .../OSSessionServiceTests.swift | 6 - .../OSSessionsBackendServiceTests.swift | 247 ------ .../OSSessionOperationExecutor.swift | 447 +++++++++++ .../OneSignalUserManagerImpl+Session.swift | 41 +- .../Source/OneSignalUserManagerImpl.swift | 4 + .../Source/Requests/OSSessionRequests.swift | 311 ++++++++ .../OSSessionOperationExecutorTests.swift | 416 ++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 2 - 16 files changed, 1235 insertions(+), 1995 deletions(-) delete mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift delete mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift delete mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift delete mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift delete mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift delete mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 373a5df89..b797f6d0c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -94,16 +94,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 */; }; - A5162F01A1B2C3D4E5F60018 /* OSSessionRequestQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60017 /* OSSessionRequestQueue.swift */; }; - A5162F01A1B2C3D4E5F6001C /* OSSessionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F6001B /* OSSessionRequest.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 */; }; - A5162F01A1B2C3D4E5F6001A /* OSSessionRequestQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.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 +155,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 +215,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 */; }; @@ -1470,6 +1467,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 = ""; }; @@ -1510,6 +1508,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 = ""; }; @@ -1915,16 +1914,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 = ""; }; - A5162F01A1B2C3D4E5F60017 /* OSSessionRequestQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequestQueue.swift; sourceTree = ""; }; - A5162F01A1B2C3D4E5F6001B /* OSSessionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequest.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 = ""; }; - A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequestQueueTests.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 = ""; }; @@ -2430,10 +2424,6 @@ 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */, - A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */, - A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */, - A5162F01A1B2C3D4E5F60017 /* OSSessionRequestQueue.swift */, - A5162F01A1B2C3D4E5F6001B /* OSSessionRequest.swift */, A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, @@ -2504,6 +2494,7 @@ children = ( 3C8E6E0028AC0BA10031E48A /* OSIdentityOperationExecutor.swift */, 3C68EFE42D93195E00F0896B /* OSCustomEventsExecutor.swift */, + A5162F01A1B2C3D4E5F60017 /* OSSessionOperationExecutor.swift */, 3C8E6DFE28AB09AE0031E48A /* OSPropertyOperationExecutor.swift */, 3CE795FA28DBDCE700736BD4 /* OSSubscriptionOperationExecutor.swift */, 3C9AD6BB2B2285FB00BC1540 /* OSUserExecutor.swift */, @@ -2527,6 +2518,7 @@ 3C9AD6C02B22886600BC1540 /* OSRequestUpdateSubscription.swift */, 3C9AD6C42B228A7300BC1540 /* OSRequestDeleteSubscription.swift */, 3C68EFE62D931BA600F0896B /* OSRequestCustomEvents.swift */, + A5162F01A1B2C3D4E5F60013 /* OSSessionRequests.swift */, ); path = Requests; sourceTree = ""; @@ -2612,6 +2604,7 @@ C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */, 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, + A5162F01A1B2C3D4E5F60019 /* OSSessionOperationExecutorTests.swift */, FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */, ); @@ -2763,8 +2756,6 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, - A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */, - A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -4685,10 +4676,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 */, - A5162F01A1B2C3D4E5F60018 /* OSSessionRequestQueue.swift in Sources */, - A5162F01A1B2C3D4E5F6001C /* OSSessionRequest.swift in Sources */, A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, @@ -4789,6 +4776,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 */, @@ -4886,8 +4874,6 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */, - A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */, - A5162F01A1B2C3D4E5F6001A /* OSSessionRequestQueueTests.swift in Sources */, A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, @@ -5008,6 +4994,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 95e393675..22aa524cd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -113,7 +113,6 @@ #define OSUD_UNSENT_ACTIVE_TIME @"GT_UNSENT_ACTIVE_TIME" // * OSUD_UNSENT_ACTIVE_TIME #define OSUD_UNSENT_ACTIVE_TIME_ATTRIBUTED @"GT_UNSENT_ACTIVE_TIME_ATTRIBUTED" // * OSUD_UNSENT_ACTIVE_TIME_ATTRIBUTED #define OSUD_SESSION_RECORD @"OSUD_SESSION_RECORD" -#define OSUD_SESSION_REQUEST_QUEUE @"OSUD_SESSION_REQUEST_QUEUE" // To avoid undefined symbol compiler errors on older versions of Xcode, // instead of using UNAuthorizationOptionProvisional directly, we will use @@ -209,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" @@ -354,6 +354,9 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CUSTOM_EVENT_DELTA @"OS_CUSTOM_EVENT_DELTA" +#define OS_CREATE_SESSION_DELTA @"OS_CREATE_SESSION_DELTA" +#define OS_UPDATE_SESSION_DELTA @"OS_UPDATE_SESSION_DELTA" + // Operation Repo #define OS_OPERATION_REPO_DELTA_QUEUE_KEY @"OS_OPERATION_REPO_DELTA_QUEUE_KEY" @@ -380,6 +383,10 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY" #define OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY" +// Session Executor +#define OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY @"OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY" +#define OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY @"OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY" + // A codeable value larger than this is not written, and a cached queue blob larger than it is dropped without // decoding it. Real queues are a few KB. CFPreferences rejects a value of 4 MB or more on iOS. #define OS_USER_DEFAULTS_MAX_VALUE_BYTES 1048576 diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift deleted file mode 100644 index fdc75913c..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift +++ /dev/null @@ -1,128 +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 Network - -struct OSSessionRequest: Codable, Equatable { - enum Kind: Codable, Equatable { - /// `startTime` is seconds since 1970. - case create(startTime: TimeInterval, directAttributionId: String?) - /// `activeDuration` is cumulative for the session. `endTime` is seconds since 1970, set - /// only on the update that ends the session. - case update(activeDuration: TimeInterval, endTime: TimeInterval?) - } - - let appId: String - /// `OSSessionRecord.sessionId`, which stands in for the backend session ID until the create succeeds. - let localSessionId: String - /// Set on an update once its session's create succeeds. - var serverSessionId: String? - let identityModelId: String? - var onesignalId: String? - var subscriptionId: String? - var kind: Kind - /// Generated once and persisted, so every retry, including after a restart, is deduplicated by the backend. - let idempotencyKey: String - /// Failed attempts the backend answered, persisted so the limit holds across restarts. - var failedAttempts = 0 - - var isCreate: Bool { - if case .create = kind { - return true - } - return false - } - - var isEnd: Bool { - if case .update(_, let endTime) = kind { - return endTime != nil - } - return false - } - - /// Leaves out the user and subscription IDs, since WARN logs can be uploaded. - var logDescription: String { - let kindName = isCreate ? "create" : (isEnd ? "end" : "update") - return "\(kindName) for session \(localSessionId), failed attempts: \(failedAttempts)" - } - - var activeDuration: TimeInterval? { - if case .update(let activeDuration, _) = kind { - return activeDuration - } - return nil - } -} - -protocol OSSessionNetworkMonitoring: AnyObject { - func start(onAvailable: @escaping () -> Void) - func stop() -} - -final class OSSessionNetworkMonitor: OSSessionNetworkMonitoring { - private var monitor: NWPathMonitor? - - func start(onAvailable: @escaping () -> Void) { - guard monitor == nil else { - return - } - let monitor = NWPathMonitor() - var wasSatisfied: Bool? - monitor.pathUpdateHandler = { path in - let isSatisfied = path.status == .satisfied - defer { wasSatisfied = isSatisfied } - if isSatisfied, wasSatisfied == false { - onAvailable() - } - } - monitor.start(queue: DispatchQueue(label: "OneSignal.OSSessionNetworkMonitor")) - self.monitor = monitor - } - - func stop() { - monitor?.cancel() - monitor = nil - } - - deinit { - monitor?.cancel() - } -} - -extension OSSessionsApiResult { - func map(_ transform: (Value) -> T) -> OSSessionsApiResult { - switch self { - case .success(let value): - return .success(transform(value)) - case .retry(let statusCode, let retryAfterSeconds): - return .retry(statusCode: statusCode, retryAfterSeconds: retryAfterSeconds) - case .drop(let statusCode): - return .drop(statusCode: statusCode) - } - } -} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift deleted file mode 100644 index ac19165b6..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequestQueue.swift +++ /dev/null @@ -1,558 +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 - -/// Persisted queue of sessions API requests. A request is saved before it is sent and removed only -/// once the backend accepts it or rejects it for good, so it survives restarts and offline periods. -/// Requests for a session go out in order, one at a time across the queue. -@objc(OSSessionRequestQueue) -public final class OSSessionRequestQueue: NSObject { - static let maxQueuedRequests = 100 - static let maxFailedAttempts = 5 - static let baseBackoffSeconds: TimeInterval = 5 - static let maxBackoffSeconds: TimeInterval = 300 - - private static let lock = NSLock() - private static var _shared: OSSessionRequestQueue? - - public static var shared: OSSessionRequestQueue { - lock.withLock { - if let existing = _shared { - return existing - } - let created = OSSessionRequestQueue() - _shared = created - return created - } - } - - @objc public static func reset() { - let retired = lock.withLock { - let existing = _shared - _shared = nil - return existing - } - retired?.retire() - } - - /// Saved requests carry the previous app's ID, so they must not outlive an app-id change. - @objc public static func resetAndClearStoredQueue() { - // Under the lock, so a new queue cannot load the old requests before they are removed. - lock.withLock { - _shared?.retire() - _shared = nil - OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_REQUEST_QUEUE) - } - } - - private let storage: OneSignalUserDefaults - private let backend: OSSessionsBackend - private let sessionService: () -> OSSessionService - private let appId: () -> String? - private let networkMonitor: OSSessionNetworkMonitoring - private let now: () -> TimeInterval - private let jitter: () -> Double - private let schedule: (TimeInterval, @escaping () -> Void) -> Void - private let postCreateDelay: TimeInterval - - // Serial. Synchronizes all state below. - private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSessionRequestQueue", target: .global()) - - private var requests: [OSSessionRequest] = [] - private var inFlightKey: String? - /// Lets an update enqueued while its create was in flight pick up the backend session ID. - private var serverSessionIds: [String: String] = [:] - private var consecutiveFailures = 0 - private var isBackingOff = false - private var backoffGeneration = 0 - /// A backend `Retry-After` that `retryNow` must not cut short, on the `now` clock. - private var notBefore: TimeInterval = 0 - /// Updates for a just-created session wait until this time, on the `now` clock, so the backend - /// has replicated the session before it is patched. - private var updatesAllowedAt: [String: TimeInterval] = [:] - /// Set once the queue is replaced, so a late completion cannot write its requests back. - private var isRetired = false - - init( - storage: OneSignalUserDefaults = .initStandard(), - backend: OSSessionsBackend = OSSessionsBackendService(), - sessionService: @escaping () -> OSSessionService = { .shared }, - appId: @escaping () -> String? = { OneSignalIdentifiers.currentAppId }, - networkMonitor: OSSessionNetworkMonitoring = OSSessionNetworkMonitor(), - // The same clock as `DispatchTime`, so `notBefore` agrees with the scheduled retry. - now: @escaping () -> TimeInterval = { - TimeInterval(clock_gettime_nsec_np(CLOCK_UPTIME_RAW)) / TimeInterval(NSEC_PER_SEC) - }, - jitter: @escaping () -> Double = { Double.random(in: 0.5...1) }, - schedule: ((TimeInterval, @escaping () -> Void) -> Void)? = nil, - postCreateDelay: TimeInterval = TimeInterval(OP_REPO_POST_CREATE_DELAY_SECONDS) - ) { - self.storage = storage - self.backend = backend - self.sessionService = sessionService - self.appId = appId - self.networkMonitor = networkMonitor - self.now = now - self.jitter = jitter - self.postCreateDelay = postCreateDelay - let dispatchQueue = self.dispatchQueue - self.schedule = schedule ?? { delay, block in - dispatchQueue.asyncAfter(deadline: .now() + delay, execute: block) - } - super.init() - dispatchQueue.async { - self.load() - self.updateNetworkMonitoring() - self.processNext() - } - } - - private func retire() { - dispatchQueue.sync { - isRetired = true - networkMonitor.stop() - } - } -} - -// MARK: - Enqueue - -extension OSSessionRequestQueue { - func enqueueCreate(record: OSSessionRecord, directAttributionId: String? = nil) { - guard record.usesSessionsApi, let appId = appId() else { - return - } - let request = OSSessionRequest( - appId: appId, - localSessionId: record.sessionId, - serverSessionId: nil, - identityModelId: record.identityModelId, - onesignalId: record.onesignalId, - subscriptionId: record.subscriptionId, - kind: .create(startTime: record.startTime, directAttributionId: directAttributionId), - // Derived from the session, so a create enqueued again after being dropped is still deduplicated. - idempotencyKey: record.sessionId - ) - dispatchQueue.async { - let isCreated = record.serverSessionId != nil || self.serverSessionIds[record.sessionId] != nil - guard !isCreated, !self.requests.contains(where: { $0.isCreate && $0.localSessionId == record.sessionId }) else { - return - } - self.append(request) - } - } - - /// Reports the session's cumulative duration, and ends the session when `endTime` is set. - func enqueueUpdate(record: OSSessionRecord, endTime: TimeInterval? = nil) { - guard record.usesSessionsApi, let appId = appId() else { - return - } - var request = OSSessionRequest( - appId: appId, - localSessionId: record.sessionId, - serverSessionId: record.serverSessionId, - identityModelId: record.identityModelId, - onesignalId: record.onesignalId, - subscriptionId: record.subscriptionId, - kind: .update(activeDuration: record.activeDuration, endTime: endTime), - idempotencyKey: UUID().uuidString - ) - dispatchQueue.async { - guard self.admitUpdate(&request) else { - return - } - self.append(request) - } - } - - /// Folds queued, unsent updates for the same session into `request`, which then carries the - /// highest duration. An update that ends the session replaces them the same way. - /// Returns false if `request` should be discarded: its session's end is already queued, or it - /// has no backend session ID and no create remains to supply one. - private func admitUpdate(_ request: inout OSSessionRequest) -> Bool { - let session = requests.filter { $0.localSessionId == request.localSessionId } - request.serverSessionId = request.serverSessionId ?? serverSessionIds[request.localSessionId] - let hasEnd = session.contains { $0.isEnd } - let isOrphaned = request.serverSessionId == nil && !session.contains { $0.isCreate } - if hasEnd || isOrphaned { - OneSignalLog.onesignalLog( - .LL_DEBUG, - message: "OSSessionRequestQueue discarding update for \(request.localSessionId), ended: \(hasEnd), never created: \(isOrphaned)" - ) - return false - } - - // An in-flight update counts toward the highest duration, so a stale snapshot cannot lower it. - if case .update(let activeDuration, let endTime) = request.kind { - let highest = session.compactMap(\.activeDuration).max() ?? activeDuration - request.kind = .update(activeDuration: max(activeDuration, highest), endTime: endTime) - } - let queued = session.filter { !$0.isCreate && $0.idempotencyKey != inFlightKey } - guard !queued.isEmpty else { - return true - } - let removedKeys = Set(queued.map(\.idempotencyKey)) - requests.removeAll { removedKeys.contains($0.idempotencyKey) } - // An unchanged body keeps its key, so a retry the backend may already have applied stays - // deduplicated. Either way the attempts carry over, or a session that keeps reporting - // would never reach the drop limit. - let failedAttempts = queued.map(\.failedAttempts).max() ?? 0 - if let unchanged = queued.first(where: { $0.kind == request.kind }) { - let incoming = request - request = unchanged - request.serverSessionId = unchanged.serverSessionId ?? incoming.serverSessionId - request.onesignalId = unchanged.onesignalId ?? incoming.onesignalId - request.subscriptionId = unchanged.subscriptionId ?? incoming.subscriptionId - } - request.failedAttempts = max(request.failedAttempts, failedAttempts) - OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionRequestQueue coalesced \(queued.count) update(s) for \(request.localSessionId)") - return true - } - - private func append(_ request: OSSessionRequest) { - requests.append(request) - enforceCap() - persist() - updateNetworkMonitoring() - processNext() - } - - private func enforceCap() { - while requests.count > Self.maxQueuedRequests, - let oldest = requests.first(where: { $0.idempotencyKey != inFlightKey }) { - OneSignalLog.onesignalLog( - .LL_WARN, - message: "OSSessionRequestQueue more than \(Self.maxQueuedRequests) requests queued, dropping oldest: \(oldest.logDescription)" - ) - remove(oldest) - } - } - - /// Updates for a session whose create is removed without succeeding can never get a backend - /// session ID, so they go with it. - private func remove(_ request: OSSessionRequest) { - requests.removeAll { $0.idempotencyKey == request.idempotencyKey } - guard request.isCreate else { - return - } - let orphans = requests.filter { $0.localSessionId == request.localSessionId && $0.serverSessionId == nil } - guard !orphans.isEmpty else { - return - } - OneSignalLog.onesignalLog( - .LL_WARN, - message: "OSSessionRequestQueue dropping \(orphans.count) update(s) for session \(request.localSessionId), which was never created" - ) - let orphanKeys = Set(orphans.map(\.idempotencyKey)) - requests.removeAll { orphanKeys.contains($0.idempotencyKey) } - } -} - -// MARK: - Sending - -extension OSSessionRequestQueue { - /// Ends the failure backoff early, but still waits out a backend `Retry-After`. - @objc public func retryNow() { - dispatchQueue.async { - if self.isBackingOff { - let remaining = self.notBefore - self.now() - guard remaining <= 0 else { - self.scheduleRetry(after: remaining) - return - } - self.backoffGeneration += 1 - self.isBackingOff = false - } - self.processNext() - } - } - - /// Sends what has become ready, such as a request that was waiting on IDs. Unlike `retryNow`, - /// it leaves a failure backoff running, since it is called far more often than the app opens. - public func processPending() { - dispatchQueue.async { - self.processNext() - } - } - - private func processNext() { - guard !isRetired else { - return - } - dropUnsendable() - guard inFlightKey == nil, !isBackingOff else { - return - } - var startedSessions = Set() - for index in requests.indices { - let sessionId = requests[index].localSessionId - // Only the first request for a session may go out, which keeps create, updates, end in order. - guard startedSessions.insert(sessionId).inserted else { - continue - } - if let request = readyRequest(at: index) { - send(request) - return - } - } - } - - /// A create still waiting on a `onesignal_id` its user will never get, which happens to an - /// anonymous user under required Identity Verification. Its updates go with it. - private func dropUnsendable() { - let service = sessionService() - let unsendable = requests.filter { - $0.isCreate && $0.onesignalId == nil && $0.idempotencyKey != inFlightKey - && !service.canCreateUser(identityModelId: $0.identityModelId) - } - guard !unsendable.isEmpty else { - return - } - OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue dropping \(unsendable.count) create(s) for a user that is never created") - unsendable.forEach(remove) - persist() - updateNetworkMonitoring() - } - - private func readyRequest(at index: Int) -> OSSessionRequest? { - var request = requests[index] - if request.onesignalId == nil || request.subscriptionId == nil { - (request.onesignalId, request.subscriptionId) = sessionService().pinnedIds( - sessionId: request.localSessionId, - identityModelId: request.identityModelId, - onesignalId: request.onesignalId, - subscriptionId: request.subscriptionId - ) - if request != requests[index] { - requests[index] = request - persist() - } - } - guard request.onesignalId != nil, request.subscriptionId != nil else { - return nil - } - if !request.isCreate { - guard request.serverSessionId != nil else { - return nil - } - if let allowedAt = updatesAllowedAt[request.localSessionId], now() < allowedAt { - return nil - } - } - return request - } - - private func send(_ request: OSSessionRequest) { - guard let onesignalId = request.onesignalId, let subscriptionId = request.subscriptionId else { - return - } - let backgroundTaskIdentifier = "OSSessionRequestQueue" + UUID().uuidString - let finish: (OSSessionsApiResult) -> Void = { result in - self.dispatchQueue.async { - self.handle(result, for: request) - OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) - } - } - - switch request.kind { - case .create(let startTime, let directAttributionId): - let body = OSCreateSessionRequestBody( - onesignalId: onesignalId, - subscriptionId: subscriptionId, - startTime: Date(timeIntervalSince1970: startTime), - idempotencyKey: request.idempotencyKey, - directAttributionId: directAttributionId - ) - inFlightKey = request.idempotencyKey - OSBackgroundTaskManager.beginBackgroundTask(backgroundTaskIdentifier) - backend.createSession(appId: request.appId, body: body) { finish($0.map { $0 }) } - case .update(let activeDuration, let endTime): - guard let serverSessionId = request.serverSessionId else { - return - } - let body = OSUpdateSessionRequestBody( - onesignalId: onesignalId, - subscriptionId: subscriptionId, - durationSeconds: Int64(activeDuration), - idempotencyKey: request.idempotencyKey, - endTime: endTime.map { Date(timeIntervalSince1970: $0) } - ) - inFlightKey = request.idempotencyKey - OSBackgroundTaskManager.beginBackgroundTask(backgroundTaskIdentifier) - backend.updateSession(appId: request.appId, sessionId: serverSessionId, body: body) { finish($0.map { nil }) } - } - } - - private func handle(_ result: OSSessionsApiResult, for sent: OSSessionRequest) { - guard !isRetired else { - return - } - inFlightKey = nil - guard let index = requests.firstIndex(where: { $0.idempotencyKey == sent.idempotencyKey }) else { - processNext() - return - } - var request = requests[index] - - switch result { - case .success(let serverSessionId): - consecutiveFailures = 0 - requests.remove(at: index) - if let serverSessionId { - applyServerSessionId(serverSessionId, from: request) - } - case .drop(let statusCode): - consecutiveFailures = 0 - OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue dropping after a \(statusCode): \(request.logDescription)") - remove(request) - case .retry(let statusCode, let retryAfterSeconds): - // 0 means no response, e.g. offline, which must keep retrying until the network returns. - if statusCode != 0 { - request.failedAttempts += 1 - } - if request.failedAttempts >= Self.maxFailedAttempts { - OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionRequestQueue dropping after too many failures: \(request.logDescription)") - remove(request) - } else { - requests[index] = request - } - persist() - updateNetworkMonitoring() - // A dropped request still honors `Retry-After`, which applies to the next request too. - if request.failedAttempts < Self.maxFailedAttempts || retryAfterSeconds != nil { - backOff(retryAfterSeconds: retryAfterSeconds) - } else { - processNext() - } - return - } - persist() - updateNetworkMonitoring() - processNext() - } - - private func applyServerSessionId(_ serverSessionId: String, from create: OSSessionRequest) { - serverSessionIds[create.localSessionId] = serverSessionId - for index in requests.indices where requests[index].localSessionId == create.localSessionId { - requests[index].serverSessionId = serverSessionId - requests[index].onesignalId = requests[index].onesignalId ?? create.onesignalId - requests[index].subscriptionId = requests[index].subscriptionId ?? create.subscriptionId - } - sessionService().setServerSessionId(serverSessionId, forSessionId: create.localSessionId) - guard postCreateDelay > 0 else { - return - } - updatesAllowedAt[create.localSessionId] = now() + postCreateDelay - schedule(postCreateDelay) { [weak self] in - self?.processPending() - } - } -} - -// MARK: - Backoff - -extension OSSessionRequestQueue { - private func backOff(retryAfterSeconds: Int?) { - consecutiveFailures += 1 - let exponential = min(Self.maxBackoffSeconds, Self.baseBackoffSeconds * pow(2, Double(consecutiveFailures - 1))) - let retryAfter = TimeInterval(retryAfterSeconds ?? 0) - notBefore = now() + retryAfter - isBackingOff = true - scheduleRetry(after: max(exponential * jitter(), retryAfter)) - } - - private func scheduleRetry(after delay: TimeInterval) { - backoffGeneration += 1 - let generation = backoffGeneration - schedule(delay) { [weak self] in - guard let self else { - return - } - self.dispatchQueue.async { - guard self.backoffGeneration == generation else { - return - } - self.isBackingOff = false - self.processNext() - } - } - } -} - -// MARK: - Storage - -extension OSSessionRequestQueue { - private func load() { - guard let data = storage.getSavedObject(forKey: OSUD_SESSION_REQUEST_QUEUE, defaultValue: nil) as? Data else { - return - } - do { - requests = try JSONDecoder().decode([OSSessionRequest].self, from: data) - for request in requests { - if let serverSessionId = request.serverSessionId { - serverSessionIds[request.localSessionId] = serverSessionId - } - } - } catch { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionRequestQueue failed to read saved requests: \(error)") - } - } - - private func persist() { - guard !isRetired, let data = try? JSONEncoder().encode(requests) else { - return - } - storage.saveObject(forKey: OSUD_SESSION_REQUEST_QUEUE, withValue: data) - } - - /// Only watched while requests are pending, so with the flag off nothing changes. - private func updateNetworkMonitoring() { - if requests.isEmpty { - networkMonitor.stop() - } else { - networkMonitor.start { [weak self] in self?.retryNow() } - } - } -} - -// MARK: - Testing - -extension OSSessionRequestQueue { - func waitUntilIdle() { - dispatchQueue.sync {} - } - - func retireForTesting() { - retire() - } - - var queuedRequests: [OSSessionRequest] { - dispatchQueue.sync { requests } - } -} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index fe0755549..0da8c68af 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -48,9 +48,6 @@ public protocol OSSessionUserProvider: AnyObject { /// Taken from one read of the current user, so a concurrent login cannot mix two users. var sessionCurrentUser: OSSessionUser { get } func sessionOnesignalId(identityModelId: String) -> String? - /// False when the user can never get a `onesignal_id`, such as an anonymous user under - /// required Identity Verification. - func sessionUserCanBeCreated(identityModelId: String) -> Bool } /// New fields must be Optional. A record stored by an earlier version lacks them, and synthesized @@ -64,7 +61,7 @@ public struct OSSessionRecord: Codable, Equatable { /// Fixed for the whole session so it never mixes paths. Read this rather than the feature /// manager, whose value can change mid-session. public let usesSessionsApi: Bool - let identityModelId: String? + public let identityModelId: String? /// Pinned at session start, so it stays the same after a login or user switch. public internal(set) var onesignalId: String? public internal(set) var subscriptionId: String? @@ -119,7 +116,6 @@ public final class OSSessionService: NSObject { @objc public static func onFocus() { shared.onFocus() - OSSessionRequestQueue.shared.retryNow() } @objc public static func onUnfocus() { @@ -158,8 +154,6 @@ public final class OSSessionService: NSObject { public func refreshPinnedIds() { let ids = pinnableIds() stateLock.withLock { fillPinnedIds(ids) } - // Queued session requests wait for these IDs, including those of an earlier session's user. - OSSessionRequestQueue.shared.processPending() } /// Leaves any open foreground interval running: the tracker reports focus before the session @@ -254,34 +248,8 @@ public final class OSSessionService: NSObject { persist() } - /// The pinned IDs for a queued request. The current session's come from its record, so the - /// request and the record always agree. An earlier session's are filled by the same rules. - func pinnedIds( - sessionId: String, - identityModelId: String?, - onesignalId: String?, - subscriptionId: String? - ) -> (onesignalId: String?, subscriptionId: String?) { - if let record = currentRecord, record.sessionId == sessionId { - return (onesignalId ?? record.onesignalId, subscriptionId ?? record.subscriptionId) - } - guard let identityModelId, let provider = stateLock.withLock({ userProvider }) else { - return (onesignalId, subscriptionId) - } - return ( - onesignalId ?? provider.sessionOnesignalId(identityModelId: identityModelId), - subscriptionId ?? provider.sessionCurrentUser.pushSubscriptionId - ) - } - - func canCreateUser(identityModelId: String?) -> Bool { - guard let identityModelId, let provider = stateLock.withLock({ userProvider }) else { - return true - } - return provider.sessionUserCanBeCreated(identityModelId: identityModelId) - } - - func setServerSessionId(_ serverSessionId: String, forSessionId sessionId: String) { + /// Set once the sessions API creates the session, if it is still the current one. + public func setServerSessionId(_ serverSessionId: String, forSessionId sessionId: String) { stateLock.withLock { loadIfNeeded() guard record?.sessionId == sessionId, record?.serverSessionId == nil else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift deleted file mode 100644 index 049eb3b48..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift +++ /dev/null @@ -1,171 +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 {} - -protocol OSSessionsBackend { - func createSession(appId: String, body: OSCreateSessionRequestBody, completion: @escaping (OSSessionsApiResult) -> Void) - func updateSession(appId: String, sessionId: String, body: OSUpdateSessionRequestBody, completion: @escaping (OSSessionsApiResult) -> Void) -} - -/// Typed client for the sessions API. -final class OSSessionsBackendService: OSSessionsBackend { - 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 6500d3604..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift +++ /dev/null @@ -1,99 +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) - } -} - -private extension OneSignalRequest { - /// `OSSessionRequestQueue` owns retries, with backoff that honors `Retry-After`. Starting at the - /// client's last attempt stops `OneSignalClient` from retrying status 0 and 5xx underneath it. - func skipClientRetries() { - reattemptCount = MAX_ATTEMPT_COUNT - 1 - } -} - -/// `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 - skipClientRetries() - 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 - skipClientRetries() - self.path = "apps/\(appId)/sessions/\(sessionId)" - } -} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift deleted file mode 100644 index c19d5e47d..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift +++ /dev/null @@ -1,717 +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 -import OneSignalKMP -@testable import OneSignalOSCore -import XCTest - -private final class FakeUserProvider: OSSessionUserProvider { - var identityModelId: String? = "model-a" - var onesignalIds: [String: String] = ["model-a": "onesignal-a"] - var pushSubscriptionId: String? = "subscription-a" - var creatableModelIds: Set? - - var sessionCurrentUser: OSSessionUser { - OSSessionUser( - identityModelId: identityModelId, - onesignalId: identityModelId.flatMap { onesignalIds[$0] }, - pushSubscriptionId: pushSubscriptionId - ) - } - - func sessionOnesignalId(identityModelId: String) -> String? { - onesignalIds[identityModelId] - } - - func sessionUserCanBeCreated(identityModelId: String) -> Bool { - creatableModelIds?.contains(identityModelId) ?? true - } -} - -private final class FakeBackend: OSSessionsBackend { - enum Call { - case create(appId: String, body: OSCreateSessionRequestBody, completion: (OSSessionsApiResult) -> Void) - case update(appId: String, sessionId: String, body: OSUpdateSessionRequestBody, completion: (OSSessionsApiResult) -> Void) - - var idempotencyKey: String { - switch self { - case .create(_, let body, _): return body.idempotencyKey - case .update(_, _, let body, _): return body.idempotencyKey - } - } - } - - private let lock = NSLock() - private var _calls: [Call] = [] - var calls: [Call] { lock.withLock { _calls } } - - func createSession(appId: String, body: OSCreateSessionRequestBody, completion: @escaping (OSSessionsApiResult) -> Void) { - lock.withLock { _calls.append(.create(appId: appId, body: body, completion: completion)) } - } - - func updateSession( - appId: String, - sessionId: String, - body: OSUpdateSessionRequestBody, - completion: @escaping (OSSessionsApiResult) -> Void - ) { - lock.withLock { _calls.append(.update(appId: appId, sessionId: sessionId, body: body, completion: completion)) } - } - - func respondToLast(createdSessionId: String) { - guard case .create(_, _, let completion) = calls.last else { - return XCTFail("last call is not a create") - } - completion(.success(createdSessionId)) - } - - func respondToLastUpdate() { - guard case .update(_, _, _, let completion) = calls.last else { - return XCTFail("last call is not an update") - } - completion(.success(())) - } - - func failLast(_ result: OSSessionsApiResult) { - switch calls.last { - case .create(_, _, let completion): - switch result { - case .retry(let statusCode, let retryAfterSeconds): - completion(.retry(statusCode: statusCode, retryAfterSeconds: retryAfterSeconds)) - case .drop(let statusCode): - completion(.drop(statusCode: statusCode)) - case .success: - XCTFail("not a failure") - } - case .update(_, _, _, let completion): - completion(result) - case nil: - XCTFail("no call") - } - } -} - -private final class FakeNetworkMonitor: OSSessionNetworkMonitoring { - private(set) var onAvailable: (() -> Void)? - - func start(onAvailable: @escaping () -> Void) { - self.onAvailable = onAvailable - } - - func stop() { - onAvailable = nil - } -} - -final class OSSessionRequestQueueTests: XCTestCase { - private var user: FakeUserProvider! - private var backend: FakeBackend! - private var monitor: FakeNetworkMonitor! - private var service: OSSessionService! - private var flagsStore: OSFeatureFlagsStore! - private var now: TimeInterval = 10_000 - private var scheduled: [(delay: TimeInterval, block: () -> Void)] = [] - private var queue: OSSessionRequestQueue! - - override func setUp() { - super.setUp() - clearStorage() - user = FakeUserProvider() - backend = FakeBackend() - monitor = FakeNetworkMonitor() - flagsStore = OSFeatureFlagsStore() - flagsStore.applyRemoteFlags([FeatureFlag.sdkSessionsV2ApiCutover.key], metadata: nil) - let manager = OSFeatureManager(store: flagsStore) - service = OSSessionService(featureManager: { manager }, monotonicNow: { 0 }, wallNow: { 1_700_000_000 }) - now = 10_000 - scheduled = [] - queue = makeQueue() - } - - override func tearDown() { - queue?.waitUntilIdle() - clearStorage() - super.tearDown() - } - - private func clearStorage() { - OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) - OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_REQUEST_QUEUE) - OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) - } - - private func makeQueue(postCreateDelay: TimeInterval = 0) -> OSSessionRequestQueue { - OSSessionRequestQueue( - backend: backend, - sessionService: { [unowned self] in self.service }, - appId: { "app-id" }, - networkMonitor: monitor, - now: { [unowned self] in self.now }, - jitter: { 1 }, - schedule: { [unowned self] delay, block in self.scheduled.append((delay, block)) }, - postCreateDelay: postCreateDelay - ) - } - - private func startSession() -> OSSessionRecord { - service.startNewSession(userProvider: user) - return service.currentRecord! - } - - private func record(duration: TimeInterval, from base: OSSessionRecord) -> OSSessionRecord { - var record = base - record.activeDuration = duration - return record - } - - private func settle() { - queue.waitUntilIdle() - queue.waitUntilIdle() - } - - private var updateDurations: [Int64] { - backend.calls.compactMap { - if case .update(_, _, let body, _) = $0 { - return body.durationSeconds - } - return nil - } - } - - // MARK: - Sending - - func testCreateIsPersistedBeforeSendingAndRemovedOnSuccess() { - let record = startSession() - - queue.enqueueCreate(record: record, directAttributionId: "notification-id") - settle() - - XCTAssertEqual(queue.queuedRequests.count, 1) - XCTAssertNotNil(OneSignalUserDefaults.initStandard().getSavedObject(forKey: OSUD_SESSION_REQUEST_QUEUE, defaultValue: nil)) - guard case .create(let appId, let body, _) = backend.calls.first else { - return XCTFail("expected a create") - } - XCTAssertEqual(appId, "app-id") - XCTAssertEqual(body.onesignalId, "onesignal-a") - XCTAssertEqual(body.subscriptionId, "subscription-a") - XCTAssertEqual(body.startTime, Date(timeIntervalSince1970: 1_700_000_000)) - XCTAssertEqual(body.directAttributionId, "notification-id") - - backend.respondToLast(createdSessionId: "server-1") - settle() - - XCTAssertTrue(queue.queuedRequests.isEmpty) - XCTAssertEqual(service.currentRecord?.serverSessionId, "server-1") - } - - func testCreateIdempotencyKeyIsTheSessionId() { - let record = startSession() - - queue.enqueueCreate(record: record) - settle() - - XCTAssertEqual(backend.calls.first?.idempotencyKey, record.sessionId) - } - - func testRestartRemembersServerSessionIdOfQueuedUpdates() { - let record = startSession() - queue.enqueueCreate(record: record) - queue.enqueueUpdate(record: self.record(duration: 10, from: record)) - settle() - backend.respondToLast(createdSessionId: "server-1") - settle() - backend.failLast(.retry(statusCode: 0, retryAfterSeconds: nil)) - settle() - - queue = makeQueue() - // A snapshot taken before the create returned. - queue.enqueueCreate(record: record) - settle() - - XCTAssertFalse(queue.queuedRequests.contains { $0.isCreate }) - } - - func testAppIdChangeClearsStoredQueueAndLateCompletionDoesNotRestoreIt() { - let record = startSession() - queue.enqueueCreate(record: record) - settle() - - let retired = queue! - let key = OSUD_SESSION_REQUEST_QUEUE - retired.retireForTesting() - OneSignalUserDefaults.initStandard().removeValue(forKey: key) - backend.failLast(.retry(statusCode: 500, retryAfterSeconds: nil)) - retired.waitUntilIdle() - - XCTAssertNil(OneSignalUserDefaults.initStandard().getSavedObject(forKey: key, defaultValue: nil)) - } - - func testCreateForUserThatIsNeverCreatedIsDroppedWithItsUpdates() { - user.onesignalIds = [:] - let record = startSession() - queue.enqueueCreate(record: record) - queue.enqueueUpdate(record: self.record(duration: 10, from: record)) - settle() - XCTAssertEqual(queue.queuedRequests.count, 2) - - user.creatableModelIds = [] - queue.retryNow() - settle() - - XCTAssertTrue(queue.queuedRequests.isEmpty) - XCTAssertNil(monitor.onAvailable) - } - - func testFlagOffEnqueuesNothing() { - flagsStore.applyRemoteFlags([], metadata: nil) - let record = startSession() - - queue.enqueueCreate(record: record) - queue.enqueueUpdate(record: record) - settle() - - XCTAssertTrue(queue.queuedRequests.isEmpty) - XCTAssertTrue(backend.calls.isEmpty) - } - - func testCreateWaitsForOnesignalIdAndSubscriptionId() { - user.onesignalIds = [:] - user.pushSubscriptionId = nil - let record = startSession() - - queue.enqueueCreate(record: record) - settle() - XCTAssertTrue(backend.calls.isEmpty) - - user.onesignalIds = ["model-a": "onesignal-a"] - queue.retryNow() - settle() - XCTAssertTrue(backend.calls.isEmpty) - - user.pushSubscriptionId = "subscription-a" - queue.retryNow() - settle() - - guard case .create(_, let body, _) = backend.calls.first else { - return XCTFail("expected a create") - } - XCTAssertEqual(body.onesignalId, "onesignal-a") - XCTAssertEqual(body.subscriptionId, "subscription-a") - XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") - } - - func testUpdateWaitsForCreateAndUsesServerSessionId() { - let record = startSession() - queue.enqueueCreate(record: record) - queue.enqueueUpdate(record: self.record(duration: 12.9, from: record)) - settle() - XCTAssertEqual(backend.calls.count, 1) - - backend.respondToLast(createdSessionId: "server-1") - settle() - - guard case .update(_, let sessionId, let body, _) = backend.calls.last else { - return XCTFail("expected an update") - } - XCTAssertEqual(sessionId, "server-1") - XCTAssertEqual(body.durationSeconds, 12) - XCTAssertNil(body.endTime) - - backend.respondToLastUpdate() - settle() - XCTAssertTrue(queue.queuedRequests.isEmpty) - } - - func testUpdateEnqueuedWhileCreateInFlightGetsServerSessionId() { - let record = startSession() - queue.enqueueCreate(record: record) - settle() - backend.respondToLast(createdSessionId: "server-1") - settle() - - // The caller still holds the record read before the create returned. - queue.enqueueUpdate(record: self.record(duration: 5, from: record)) - settle() - - guard case .update(_, let sessionId, _, _) = backend.calls.last else { - return XCTFail("expected an update") - } - XCTAssertEqual(sessionId, "server-1") - } - - func testCreateIsNotEnqueuedTwice() { - let record = startSession() - queue.enqueueCreate(record: record) - queue.enqueueCreate(record: record) - settle() - XCTAssertEqual(queue.queuedRequests.count, 1) - - backend.respondToLast(createdSessionId: "server-1") - settle() - queue.enqueueCreate(record: record) - settle() - - XCTAssertEqual(backend.calls.count, 1) - } - - // MARK: - Coalescing - - func testQueuedUpdatesCollapseToHighestDuration() { - user.onesignalIds = [:] - let record = startSession() - queue.enqueueCreate(record: record) - queue.enqueueUpdate(record: self.record(duration: 30, from: record)) - queue.enqueueUpdate(record: self.record(duration: 20, from: record)) - settle() - - let requests = queue.queuedRequests - XCTAssertEqual(requests.count, 2) - XCTAssertEqual(requests.last?.activeDuration, 30) - } - - func testEndReplacesQueuedUpdateAndLaterUpdatesAreDiscarded() { - user.onesignalIds = [:] - let record = startSession() - queue.enqueueCreate(record: record) - queue.enqueueUpdate(record: self.record(duration: 10, from: record)) - queue.enqueueUpdate(record: self.record(duration: 15, from: record), endTime: 1_700_000_100) - queue.enqueueUpdate(record: self.record(duration: 20, from: record)) - settle() - - let requests = queue.queuedRequests - XCTAssertEqual(requests.count, 2) - XCTAssertEqual(requests.last?.kind, .update(activeDuration: 15, endTime: 1_700_000_100)) - } - - func testCoalescingKeepsUnchangedRequestAndCarriesFailedAttempts() { - let record = startSession() - queue.enqueueCreate(record: record) - settle() - backend.respondToLast(createdSessionId: "server-1") - settle() - queue.enqueueUpdate(record: self.record(duration: 10, from: record)) - settle() - let key = backend.calls.last?.idempotencyKey - backend.failLast(.retry(statusCode: 500, retryAfterSeconds: nil)) - settle() - - queue.enqueueUpdate(record: self.record(duration: 10, from: record)) - settle() - XCTAssertEqual(queue.queuedRequests.map(\.idempotencyKey), [key]) - XCTAssertEqual(queue.queuedRequests.first?.failedAttempts, 1) - - queue.enqueueUpdate(record: self.record(duration: 20, from: record)) - settle() - XCTAssertEqual(queue.queuedRequests.count, 1) - XCTAssertNotEqual(queue.queuedRequests.first?.idempotencyKey, key) - XCTAssertEqual(queue.queuedRequests.first?.failedAttempts, 1) - } - - func testInFlightUpdateCountsTowardHighestDuration() { - let record = startSession() - queue.enqueueCreate(record: record) - settle() - backend.respondToLast(createdSessionId: "server-1") - settle() - queue.enqueueUpdate(record: self.record(duration: 30, from: record)) - settle() - - queue.enqueueUpdate(record: self.record(duration: 10, from: record)) - settle() - - XCTAssertEqual(queue.queuedRequests.last?.activeDuration, 30) - } - - func testUpdateWaitsOutPostCreateDelay() { - queue = makeQueue(postCreateDelay: 5) - let record = startSession() - queue.enqueueCreate(record: record) - queue.enqueueUpdate(record: self.record(duration: 10, from: record)) - settle() - backend.respondToLast(createdSessionId: "server-1") - settle() - XCTAssertEqual(backend.calls.count, 1) - XCTAssertEqual(scheduled.last?.delay, 5) - - now += 5 - scheduled.last?.block() - settle() - - XCTAssertEqual(updateDurations, [10]) - } - - func testInFlightUpdateIsNotCoalesced() { - let record = startSession() - queue.enqueueCreate(record: record) - settle() - backend.respondToLast(createdSessionId: "server-1") - settle() - queue.enqueueUpdate(record: self.record(duration: 10, from: record)) - settle() - - queue.enqueueUpdate(record: self.record(duration: 20, from: record)) - settle() - XCTAssertEqual(queue.queuedRequests.count, 2) - - backend.respondToLastUpdate() - settle() - - XCTAssertEqual(updateDurations, [10, 20]) - } - - func testUpdateWithoutCreateOrServerSessionIdIsDiscarded() { - let record = startSession() - - queue.enqueueUpdate(record: record) - settle() - - XCTAssertTrue(queue.queuedRequests.isEmpty) - } - - func testSessionsAreCoalescedIndependently() { - user.onesignalIds = [:] - let first = startSession() - queue.enqueueCreate(record: first) - queue.enqueueUpdate(record: record(duration: 10, from: first)) - let second = startSession() - queue.enqueueCreate(record: second) - queue.enqueueUpdate(record: record(duration: 5, from: second)) - settle() - - XCTAssertEqual(queue.queuedRequests.count, 4) - } - -} - -// MARK: - Cap - -extension OSSessionRequestQueueTests { - - func testCapDropsOldestAndItsUpdates() { - user.onesignalIds = [:] - let first = startSession() - queue.enqueueCreate(record: first) - queue.enqueueUpdate(record: record(duration: 10, from: first)) - for _ in 0.. String? { onesignalIds[identityModelId] } - - func sessionUserCanBeCreated(identityModelId: String) -> Bool { - true - } } final class OSSessionServiceTests: XCTestCase { @@ -75,8 +71,6 @@ final class OSSessionServiceTests: XCTestCase { override func tearDown() { OSSessionService.reset() - // `refreshPinnedIds` wakes the shared queue. - OSSessionRequestQueue.resetAndClearStoredQueue() OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) super.tearDown() diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift deleted file mode 100644 index 488f005f1..000000000 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift +++ /dev/null @@ -1,247 +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 testSessionRequestsSkipClientRetries() { - _ = create() - _ = update() - - // `MAX_ATTEMPT_COUNT` depends on `OS_TEST`, which this target and the SDK set differently. - for request in client.requests { - XCTAssertGreaterThan(request.reattemptCount, 0) - } - } - - 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..2eaa27c67 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -0,0 +1,447 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ +import Foundation +import OneSignalCore +import OneSignalOSCore + +/// Keys of the value an `OS_CREATE_SESSION_DELTA` or `OS_UPDATE_SESSION_DELTA` carries. The Delta's +/// `property` is the local session ID. +enum OSSessionDeltaKey { + static let appId = "app_id" + static let startTime = "start_time" + static let directAttributionId = "direct_attribution_id" + static let activeDuration = "active_duration" + static let endTime = "end_time" + static let onesignalId = "onesignal_id" + static let subscriptionId = "subscription_id" + static let serverSessionId = "server_session_id" +} + +/** + Sends sessions API Requests: a session's create, then its updates once the create returns the server + session ID. Only the first queued Request of a session is sent, so they reach the backend in order. + + Unlike the other executors, a failed Request is retried in the same run, on a later flush once its + backoff has passed, rather than waiting for a relaunch. + */ +class OSSessionOperationExecutor: OSOperationExecutor { + static let maxQueuedRequests = 100 + static let maxFailedAttempts = 5 + static let baseBackoffSeconds: TimeInterval = 5 + static let maxBackoffSeconds: TimeInterval = 300 + static let defaultRetryAfterSeconds: TimeInterval = 60 + + var supportedDeltas: [String] = [OS_CREATE_SESSION_DELTA, OS_UPDATE_SESSION_DELTA] + private var deltaQueue: [OSDelta] = [] + private var requestQueue: [OSSessionRequest] = [] + /// By local session ID, for an update built after its create already succeeded. + private var serverSessionIds: [String: String] = [:] + private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing + private let nowProvider: () -> Date + private let uptime: () -> TimeInterval + private let jitter: () -> Double + + // The executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `requestQueue`. + private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSessionOperationExecutor", target: .global()) + + init( + newRecordsState: OSNewRecordsState, + auth: OSRequestAuthorizing, + nowProvider: @escaping () -> Date = { Date() }, + uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + jitter: @escaping () -> Double = { Double.random(in: 0.5...1) } + ) { + self.newRecordsState = newRecordsState + self.auth = auth + self.nowProvider = nowProvider + self.uptime = uptime + self.jitter = jitter + uncacheDeltas() + uncacheRequests() + } + + private func uncacheDeltas() { + guard var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor error encountered reading from cache for \(OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY)") + return + } + deltaQueue.removeAll { delta in + guard OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil else { + return false + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor.init dropped: \(delta)") + return true + } + self.deltaQueue = deltaQueue + cacheDeltas() + } + + private func uncacheRequests() { + guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [Any] else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor error encountered reading from cache for \(OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY)") + return + } + // Hook each uncached Request to the model in the store + requestQueue = cached.compactMap { $0 as? OSSessionRequest }.filter { request in + if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { + request.identityModel = identityModel + return true + } + if request.identityModel.onesignalId != nil { + // Its user is no longer loaded but was created, so the Request can still be sent + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + return true + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor.init dropped: \(request)") + return false + } + removeOrphanedUpdates() + for case let update as OSRequestUpdateSession in requestQueue { + serverSessionIds[update.localSessionId] = update.serverSessionId ?? serverSessionIds[update.localSessionId] + } + cacheRequests() + } + + func enqueueDelta(_ delta: OSDelta) { + self.dispatchQueue.async { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSSessionOperationExecutor enqueue delta \(delta)") + self.deltaQueue.append(delta) + } + } + + func cacheDeltaQueue() { + self.dispatchQueue.async { + self.cacheDeltas() + } + } + + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + self.cacheDeltas() + } + + let remainingRequests = self.requestQueue.filter { $0.ownerExternalId != nil } + if remainingRequests.count != self.requestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(self.requestQueue.count - remainingRequests.count) anonymous Requests, Identity Verification is required") + self.requestQueue = remainingRequests + 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.admit(request) + } + } + self.deltaQueue.removeAll() + self.cacheRequests() + self.cacheDeltas() + } + self.processRequestQueue(inBackground: inBackground) + } + } + + /// The Requests waiting to be sent, for tests. + var queuedRequests: [OSSessionRequest] { + dispatchQueue.sync { requestQueue } + } + + private func cacheDeltas() { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY, withValue: deltaQueue) + } + + private func cacheRequests() { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY, withValue: requestQueue) + } +} + +// MARK: - Queueing + +private extension OSSessionOperationExecutor { + func makeRequest(from delta: OSDelta) -> OSSessionRequest? { + guard let value = delta.value as? [String: Any], + let appId = value[OSSessionDeltaKey.appId] as? String, + let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) + else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor.processDeltaQueue dropped: \(delta)") + return nil + } + let localSessionId = delta.property + let onesignalId = value[OSSessionDeltaKey.onesignalId] as? String + let subscriptionId = value[OSSessionDeltaKey.subscriptionId] as? String + if delta.name == OS_CREATE_SESSION_DELTA { + return OSRequestCreateSession( + appId: appId, + localSessionId: localSessionId, + startTime: Date(timeIntervalSince1970: value[OSSessionDeltaKey.startTime] as? TimeInterval ?? 0), + directAttributionId: value[OSSessionDeltaKey.directAttributionId] as? String, + identityModel: identityModel, + ownerExternalId: delta.externalId, + onesignalId: onesignalId, + subscriptionId: subscriptionId + ) + } + return OSRequestUpdateSession( + appId: appId, + localSessionId: localSessionId, + serverSessionId: value[OSSessionDeltaKey.serverSessionId] as? String ?? serverSessionIds[localSessionId], + activeDuration: value[OSSessionDeltaKey.activeDuration] as? TimeInterval ?? 0, + endTime: (value[OSSessionDeltaKey.endTime] as? TimeInterval).map { Date(timeIntervalSince1970: $0) }, + identityModel: identityModel, + ownerExternalId: delta.externalId, + onesignalId: onesignalId, + subscriptionId: subscriptionId + ) + } + + func admit(_ request: OSSessionRequest) { + if let update = request as? OSRequestUpdateSession, !admitUpdate(update) { + return + } + requestQueue.append(request) + while requestQueue.count > Self.maxQueuedRequests { + let oldest = requestQueue[0] + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor more than \(Self.maxQueuedRequests) Requests queued, dropping oldest: \(oldest)") + drop(oldest) + } + } + + /** + Folds an update into its session's queued ones, which are all cumulative. Returns false when the + update should not be queued: its session already ended, it can never get a server session ID, or + a queued update already has the same body. + */ + func admitUpdate(_ update: OSRequestUpdateSession) -> Bool { + let sessionId = update.localSessionId + let queuedUpdates = requestQueue.compactMap { $0 as? OSRequestUpdateSession }.filter { $0.localSessionId == sessionId } + guard !queuedUpdates.contains(where: \.isEnd) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), the session already ended") + return false + } + update.serverSessionId = update.serverSessionId ?? queuedUpdates.lazy.compactMap(\.serverSessionId).first + guard update.serverSessionId != nil || hasCreate(forSession: sessionId) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor discarding \(update), its session has no create or server session ID") + return false + } + // An update in flight still counts, so a later one cannot report less time. + update.activeDuration = queuedUpdates.map(\.activeDuration).reduce(update.activeDuration, max) + + let pending = queuedUpdates.filter { !$0.sentToClient } + update.failedAttempts = pending.map(\.failedAttempts).reduce(update.failedAttempts, max) + update.onesignalId = update.onesignalId ?? pending.lazy.compactMap(\.onesignalId).first + update.subscriptionId = update.subscriptionId ?? pending.lazy.compactMap(\.subscriptionId).first + if pending.count == 1, let queued = pending.first, + queued.activeDuration == update.activeDuration, queued.endTime == update.endTime { + // Keep the queued update and its idempotency key, since the body did not change. + queued.onesignalId = queued.onesignalId ?? update.onesignalId + queued.subscriptionId = queued.subscriptionId ?? update.subscriptionId + return false + } + requestQueue.removeAll { request in pending.contains { $0 === request } } + return true + } + + func hasCreate(forSession sessionId: String) -> Bool { + requestQueue.contains { $0 is OSRequestCreateSession && $0.localSessionId == sessionId } + } + + /// Removes a Request that will not be sent. A dropped create takes its session's updates with it, + /// since they could never get a server session ID. + func drop(_ request: OSSessionRequest) { + requestQueue.removeAll { $0 === request } + removeOrphanedUpdates() + } + + func removeOrphanedUpdates() { + let sessionsWithCreate = Set(requestQueue.filter { $0 is OSRequestCreateSession }.map(\.localSessionId)) + requestQueue.removeAll { request in + guard let update = request as? OSRequestUpdateSession, + update.serverSessionId == nil, + !sessionsWithCreate.contains(update.localSessionId) + else { + return false + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(update), its session has no create") + return true + } + } +} + +// MARK: - Sending + +private extension OSSessionOperationExecutor { + /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. + func processRequestQueue(inBackground: Bool) { + if removeUnsendableRequests() { + cacheRequests() + } + var sessionsSeen = Set() + for request in requestQueue { + guard sessionsSeen.insert(request.localSessionId).inserted, + !request.sentToClient, + uptime() >= request.retryNotBefore ?? 0, + request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) + else { + continue + } + executeRequest(request, inBackground: inBackground) + } + } + + /// Drops Requests recorded for another app, and those past their age limit (see `OSRequestAging`). + func removeUnsendableRequests() -> Bool { + let countBefore = requestQueue.count + let currentAppId = OneSignalIdentifiers.currentAppId + let now = nowProvider() + let currentExternalId = OSRequestAging.currentExternalId + for request in requestQueue { + if let currentAppId, request.appId != currentAppId { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(request), it belongs to another app") + drop(request) + } else if let age = OSRequestAging.staleAge(timestamp: request.timestamp, owner: request.ownerExternalId, typeLimit: nil, now: now, currentExternalId: currentExternalId) { + OSRequestAging.logDrop(of: "\(type(of: request))", owner: request.ownerExternalId, age: age, executor: "OSSessionOperationExecutor") + drop(request) + } + } + return requestQueue.count != countBefore + } + + func executeRequest(_ request: OSSessionRequest, inBackground: Bool) { + request.sentToClient = true + + let backgroundTaskIdentifier = SESSION_EXECUTOR_BACKGROUND_TASK + UUID().uuidString + if inBackground { + OSBackgroundTaskManager.beginBackgroundTask(backgroundTaskIdentifier) + } + + OneSignalCoreImpl.sharedClient().execute(request) { response in + self.dispatchQueue.async { + self.handleSuccess(request, response: response) + if inBackground { + OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) + } + } + } onFailure: { error in + self.dispatchQueue.async { + self.handleFailure(request, error: error) + if inBackground { + OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) + } + } + } + } + + func handleSuccess(_ request: OSSessionRequest, response: [AnyHashable: Any]?) { + guard let create = request as? OSRequestCreateSession else { + requestQueue.removeAll { $0 === request } + cacheRequests() + return + } + guard let data = response?["data"] as? [String: Any], + let serverSessionId = data["session_id"] as? String, + !serverSessionId.isEmpty + else { + // Retrying with the same idempotency key returns the session the backend already created. + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor create session response is missing data.session_id") + retry(request, statusCode: (response?["httpStatusCode"] as? NSNumber)?.intValue ?? 202, retryAfter: nil) + return + } + requestQueue.removeAll { $0 === request } + serverSessionIds[create.localSessionId] = serverSessionId + for case let update as OSRequestUpdateSession in requestQueue where update.localSessionId == create.localSessionId { + update.serverSessionId = update.serverSessionId ?? serverSessionId + } + // Updates wait out the post-create delay, so the backend has the session first. + newRecordsState.add(serverSessionId) + OSSessionService.shared.setServerSessionId(serverSessionId, forSessionId: create.localSessionId) + cacheRequests() + } + + func handleFailure(_ request: OSSessionRequest, error: OneSignalClientError) { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor \(request) failed with status \(error.code)") + let code = error.code + if (200..<300).contains(code) { + // `OneSignalClient` reports a 2xx whose body failed to parse as a failure. + if request is OSRequestCreateSession { + retry(request, statusCode: code, retryAfter: nil) + } else { + requestQueue.removeAll { $0 === request } + cacheRequests() + } + } else if code == 0 || code == 408 || code == 429 || code >= 500 { + // 0 means no HTTP response: no network, timeout, or missing privacy consent. + retry(request, statusCode: code, retryAfter: Self.retryAfterSeconds(error)) + } else { + // Any other 4xx, or a missing app ID, which the client reports as a negative code. + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor dropping \(request) after a \(code)") + drop(request) + cacheRequests() + } + } + + /// Backs off exponentially, waiting at least `retryAfter`. Only attempts the backend answered count + /// toward the limit, so a Request made offline keeps retrying until the network returns. + func retry(_ request: OSSessionRequest, statusCode: Int, retryAfter: TimeInterval?) { + request.sentToClient = false + if statusCode != 0 { + request.failedAttempts += 1 + } + guard request.failedAttempts < Self.maxFailedAttempts else { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor dropping \(request) after too many failures") + drop(request) + cacheRequests() + return + } + request.retryAttempts += 1 + let exponential = Self.baseBackoffSeconds * pow(2, Double(request.retryAttempts - 1)) + let backoff = min(exponential, Self.maxBackoffSeconds) * jitter() + request.retryNotBefore = uptime() + max(backoff, retryAfter ?? 0) + cacheRequests() + } + + /// Only the delay-seconds form is supported, not the HTTP-date form. + static func retryAfterSeconds(_ error: OneSignalClientError) -> TimeInterval? { + let value = error.responseHeaders?.first { key, _ in + (key as? String)?.caseInsensitiveCompare("Retry-After") == .orderedSame + }?.value + if let value { + return TimeInterval("\(value)".trimmingCharacters(in: .whitespaces)) ?? defaultRetryAfterSeconds + } + return error.code == 429 ? defaultRetryAfterSeconds : nil + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index 3173c1ec1..409d061c6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -42,14 +42,41 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { identityModelRepo.get(modelId: identityModelId)?.onesignalId } - /// Says no only when it knows: the requirement is `.on` and the user's model is loaded and - /// anonymous. A model that is not loaded may still be restored. - public func sessionUserCanBeCreated(identityModelId: String) -> Bool { - guard identityVerificationService.requirement == .on, - let identityModel = identityModelRepo.get(modelId: identityModelId) + /// 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 + 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 + value[OSSessionDeltaKey.serverSessionId] = record.serverSessionId + enqueueSessionDelta(OS_UPDATE_SESSION_DELTA, record: record, value: value) + } + + private func enqueueSessionDelta(_ name: String, record: OSSessionRecord, value: [String: Any]) { + guard record.usesSessionsApi, + let identityModelId = record.identityModelId, + let identityModel = identityModelRepo.get(modelId: identityModelId), + let appId = OneSignalIdentifiers.currentAppId else { - return true + return } - return identityModel.externalId != nil + var value = value + value[OSSessionDeltaKey.appId] = appId + value[OSSessionDeltaKey.onesignalId] = record.onesignalId + value[OSSessionDeltaKey.subscriptionId] = record.subscriptionId + let delta = OSDelta( + name: name, + identityModelId: identityModelId, + externalId: identityModel.externalId, + model: identityModel, + property: record.sessionId, + value: value + ) + operationRepo.enqueueDelta(delta) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 7d9745679..dcb9ddea9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -217,6 +217,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { var identityExecutor: OSIdentityOperationExecutor? var subscriptionExecutor: OSSubscriptionOperationExecutor? var customEventsExecutor: OSCustomEventsExecutor? + var sessionExecutor: OSSessionOperationExecutor? private override init() { let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) @@ -320,14 +321,17 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let sessionExecutor = OSSessionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) self.propertyExecutor = propertyExecutor self.identityExecutor = identityExecutor self.subscriptionExecutor = subscriptionExecutor self.customEventsExecutor = customEventsExecutor + self.sessionExecutor = sessionExecutor operationRepo.addExecutor(identityExecutor) operationRepo.addExecutor(propertyExecutor) operationRepo.addExecutor(subscriptionExecutor) operationRepo.addExecutor(customEventsExecutor) + operationRepo.addExecutor(sessionExecutor) // After the executors: a cached requirement makes `start()` flush right away, and the // Deltas restored at launch can only route once the map above is populated. 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..75d5d8987 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift @@ -0,0 +1,311 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ +import Foundation +import OneSignalCore +import OneSignalOSCore + +/// RFC 3339 in UTC with milliseconds, e.g. `2023-11-14T22:13:20.000Z`. +enum OSSessionTimestamp { + private static let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func string(from date: Date) -> String { + formatter.string(from: date) + } +} + +/// A sessions API Request, sent and retried by `OSSessionOperationExecutor`. +protocol OSSessionRequest: OSUserRequest { + /// The app the session was recorded for. A Request for any other app is dropped. + var appId: String { get } + /// `OSSessionRecord.sessionId`, which stands in for the backend session ID until the create succeeds. + var localSessionId: String { get } + var identityModel: OSIdentityModel { get set } + /// Failed attempts the backend answered, persisted so the limit holds across restarts. + var failedAttempts: Int { get set } + /// Retries in this run, including those with no response. Sets the backoff, and is not persisted + /// since a relaunch starts over. + var retryAttempts: Int { get set } + /// System uptime before which a retry is not sent. + var retryNotBefore: TimeInterval? { get set } +} + +private extension OneSignalRequest { + /// The executor owns retries, with backoff that honors `Retry-After`. Starting at the client's + /// last attempt stops `OneSignalClient` from also retrying status 0 and 5xx underneath it. + func skipClientRetries() { + reattemptCount = MAX_ATTEMPT_COUNT - 1 + } +} + +/// The session's pinned user, falling back to the identity model once the backend creates it. +/// The push subscription is the same device subscription across logins, so it falls back to the +/// current one. +private func resolvedIds(onesignalId: String?, subscriptionId: String?, identityModel: OSIdentityModel) -> (String, String)? { + guard let onesignalId = onesignalId ?? identityModel.onesignalId, + let subscriptionId = subscriptionId ?? OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId + else { + return nil + } + return (onesignalId, subscriptionId) +} + +/// `POST apps/{app_id}/sessions` +final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { + var sentToClient = false + let appId: String + let localSessionId: String + let startTime: Date + let directAttributionId: String? + var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + private(set) var onesignalId: String? + private(set) var subscriptionId: String? + var failedAttempts = 0 + var retryAttempts = 0 + var retryNotBefore: TimeInterval? + + /// The sessions API takes no user JWT. + var sendsUnsigned: Bool { true } + + override var description: String { + "" + } + + init( + appId: String, + localSessionId: String, + startTime: Date, + directAttributionId: String?, + identityModel: OSIdentityModel, + ownerExternalId: String?, + onesignalId: String?, + subscriptionId: String? + ) { + self.appId = appId + self.localSessionId = localSessionId + self.startTime = startTime + self.directAttributionId = directAttributionId + self.identityModel = identityModel + self.ownerExternalId = ownerExternalId + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + super.init() + self.method = POST + skipClientRetries() + } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + guard let (onesignalId, subscriptionId) = resolvedIds(onesignalId: onesignalId, subscriptionId: subscriptionId, identityModel: identityModel), + newRecordsState.canAccess(onesignalId), + newRecordsState.canAccess(subscriptionId), + auth.authorize(self) + else { + return false + } + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + var parameters: [String: Any] = [ + "onesignal_id": onesignalId, + "subscription_id": subscriptionId, + "device_type": DEVICE_TYPE_PUSH, + "start_time": OSSessionTimestamp.string(from: startTime), + // The local session ID, so a create replayed from any process returns the same session. + "idempotency_key": localSessionId + ] + parameters["direct_attribution_id"] = directAttributionId + self.parameters = parameters + self.path = "apps/\(appId)/sessions" + return true + } + + func encode(with coder: NSCoder) { + coder.encode(appId, forKey: "appId") + coder.encode(localSessionId, forKey: "localSessionId") + coder.encode(startTime, forKey: "startTime") + coder.encode(directAttributionId, forKey: "directAttributionId") + coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") + coder.encode(onesignalId, forKey: "onesignalId") + coder.encode(subscriptionId, forKey: "subscriptionId") + coder.encode(failedAttempts, forKey: "failedAttempts") + coder.encode(timestamp, forKey: "timestamp") + } + + required init?(coder: NSCoder) { + guard let appId = coder.decodeObject(forKey: "appId") as? String, + let localSessionId = coder.decodeObject(forKey: "localSessionId") as? String, + let startTime = coder.decodeObject(forKey: "startTime") as? Date, + let identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel, + let timestamp = coder.decodeObject(forKey: "timestamp") as? Date + else { + return nil + } + self.appId = appId + self.localSessionId = localSessionId + self.startTime = startTime + self.directAttributionId = coder.decodeObject(forKey: "directAttributionId") as? String + self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String + self.onesignalId = coder.decodeObject(forKey: "onesignalId") as? String + self.subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String + self.failedAttempts = coder.decodeInteger(forKey: "failedAttempts") + super.init() + self.method = POST + self.timestamp = timestamp + skipClientRetries() + } +} + +/// `PATCH apps/{app_id}/sessions/{session_id}` +final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { + var sentToClient = false + let appId: String + let localSessionId: String + /// Set once the session's create succeeds. + var serverSessionId: String? + /// Cumulative for the whole session, so a retried or reordered update cannot double count. + var activeDuration: TimeInterval + /// Set only on the update that ends the session. + let endTime: Date? + var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + var onesignalId: String? + var subscriptionId: String? + /// Generated once and persisted, so every retry, including after a restart, is deduplicated by the backend. + let idempotencyKey: String + var failedAttempts = 0 + var retryAttempts = 0 + var retryNotBefore: TimeInterval? + + /// The sessions API takes no user JWT. + var sendsUnsigned: Bool { true } + + var isEnd: Bool { endTime != nil } + + override var description: String { + "" + } + + init( + appId: String, + localSessionId: String, + serverSessionId: String?, + activeDuration: TimeInterval, + endTime: Date?, + identityModel: OSIdentityModel, + ownerExternalId: String?, + onesignalId: String?, + subscriptionId: String? + ) { + self.appId = appId + self.localSessionId = localSessionId + self.serverSessionId = serverSessionId + self.activeDuration = activeDuration + self.endTime = endTime + self.identityModel = identityModel + self.ownerExternalId = ownerExternalId + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + self.idempotencyKey = UUID().uuidString + super.init() + self.method = PATCH + skipClientRetries() + } + + /// Waits out the post-create delay on the server session ID, so the backend has the session first. + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + guard let serverSessionId, + let sessionIdSegment = OSUrlPath.segment(serverSessionId), + newRecordsState.canAccess(serverSessionId), + let (onesignalId, subscriptionId) = resolvedIds(onesignalId: onesignalId, subscriptionId: subscriptionId, identityModel: identityModel), + auth.authorize(self) + else { + return false + } + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + var parameters: [String: Any] = [ + "onesignal_id": onesignalId, + "subscription_id": subscriptionId, + "duration_seconds": Int64(activeDuration), + "idempotency_key": idempotencyKey + ] + if let endTime { + parameters["end_time"] = OSSessionTimestamp.string(from: endTime) + } + self.parameters = parameters + self.path = "apps/\(appId)/sessions/\(sessionIdSegment)" + return true + } + + func encode(with coder: NSCoder) { + coder.encode(appId, forKey: "appId") + coder.encode(localSessionId, forKey: "localSessionId") + coder.encode(serverSessionId, forKey: "serverSessionId") + coder.encode(activeDuration, forKey: "activeDuration") + coder.encode(endTime, forKey: "endTime") + coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") + coder.encode(onesignalId, forKey: "onesignalId") + coder.encode(subscriptionId, forKey: "subscriptionId") + coder.encode(idempotencyKey, forKey: "idempotencyKey") + coder.encode(failedAttempts, forKey: "failedAttempts") + coder.encode(timestamp, forKey: "timestamp") + } + + required init?(coder: NSCoder) { + guard let appId = coder.decodeObject(forKey: "appId") as? String, + let localSessionId = coder.decodeObject(forKey: "localSessionId") as? String, + let identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel, + let idempotencyKey = coder.decodeObject(forKey: "idempotencyKey") as? String, + let timestamp = coder.decodeObject(forKey: "timestamp") as? Date + else { + return nil + } + self.appId = appId + self.localSessionId = localSessionId + self.serverSessionId = coder.decodeObject(forKey: "serverSessionId") as? String + self.activeDuration = coder.decodeDouble(forKey: "activeDuration") + self.endTime = coder.decodeObject(forKey: "endTime") as? Date + self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String + self.onesignalId = coder.decodeObject(forKey: "onesignalId") as? String + self.subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String + self.idempotencyKey = idempotencyKey + self.failedAttempts = coder.decodeInteger(forKey: "failedAttempts") + super.init() + self.method = PATCH + self.timestamp = timestamp + skipClientRetries() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift new file mode 100644 index 000000000..50b9dcc10 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -0,0 +1,416 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ +import XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Holds every Request until the test answers it. +private final class FakeSessionsClient: NSObject, IOneSignalClient { + struct Call { + let request: OneSignalRequest + let onSuccess: OSResultSuccessBlock + let onFailure: OSClientFailureBlock + } + + private let lock = NSLock() + private var _calls: [Call] = [] + + var calls: [Call] { lock.withLock { _calls } } + var requests: [OneSignalRequest] { calls.map(\.request) } + + func execute(_ request: OneSignalRequest, onSuccess: @escaping OSResultSuccessBlock, onFailure: @escaping OSClientFailureBlock) { + lock.withLock { _calls.append(Call(request: request, onSuccess: onSuccess, onFailure: onFailure)) } + } + + func succeedLast(_ response: [AnyHashable: Any]? = nil) { + calls.last?.onSuccess(response) + } + + func failLast(code: Int, headers: [String: String]? = nil) { + calls.last?.onFailure(OneSignalClientError(code: code, message: "failure", responseHeaders: headers, response: nil, underlyingError: nil)) + } +} + +final class OSSessionOperationExecutorTests: XCTestCase { + private let externalId = "session-external-id" + private let onesignalId = "session-onesignal-id" + private var client: FakeSessionsClient! + private var newRecordsState: MockNewRecordsState! + private var user: OSUserInternal! + private var uptime: TimeInterval = 1000 + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OSSessionService.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + client = FakeSessionsClient() + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + user = OneSignalUserMocks.setUserManagerInternalUser(externalId: externalId, onesignalId: onesignalId) + uptime = 1000 + } + + override func tearDownWithError() throws { + OSSessionService.reset() + OneSignalCoreMocks.clearUserDefaults() + } + + private func makeExecutor() -> OSSessionOperationExecutor { + OSSessionOperationExecutor( + newRecordsState: newRecordsState, + auth: OneSignalUserManagerImpl.sharedInstance.requestAuth, + uptime: { [unowned self] in self.uptime }, + jitter: { 1 } + ) + } + + private func createDelta(session: String = "session-1", directAttributionId: String? = nil, identityModel: OSIdentityModel? = nil) -> OSDelta { + var value: [String: Any] = [ + OSSessionDeltaKey.appId: "test-app-id", + OSSessionDeltaKey.startTime: 1_700_000_000.0 + ] + value[OSSessionDeltaKey.directAttributionId] = directAttributionId + return delta(OS_CREATE_SESSION_DELTA, session: session, value: value, identityModel: identityModel) + } + + private func updateDelta(session: String = "session-1", duration: TimeInterval, endTime: TimeInterval? = nil, serverSessionId: String? = nil) -> OSDelta { + var value: [String: Any] = [ + OSSessionDeltaKey.appId: "test-app-id", + OSSessionDeltaKey.activeDuration: duration + ] + value[OSSessionDeltaKey.endTime] = endTime + value[OSSessionDeltaKey.serverSessionId] = serverSessionId + return delta(OS_UPDATE_SESSION_DELTA, session: session, value: value, identityModel: nil) + } + + private func delta(_ name: String, session: String, value: [String: Any], identityModel: OSIdentityModel?) -> OSDelta { + let identityModel = identityModel ?? user.identityModel + return OSDelta(name: name, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: identityModel, property: session, value: value) + } + + /// Enqueues the Deltas, runs one flush, and waits for it. + @discardableResult + private func flush(_ executor: OSSessionOperationExecutor, _ deltas: OSDelta...) -> [OSSessionRequest] { + deltas.forEach(executor.enqueueDelta) + executor.processDeltaQueue(inBackground: false) + return executor.queuedRequests + } + + private func createSucceeds(_ executor: OSSessionOperationExecutor, serverSessionId: String = "server-1") { + client.succeedLast(["data": ["session_id": serverSessionId]]) + _ = executor.queuedRequests + } + + private func fail(_ executor: OSSessionOperationExecutor, code: Int, headers: [String: String]? = nil) { + client.failLast(code: code, headers: headers) + _ = executor.queuedRequests + } + + // MARK: - Requests + + func testCreateSendsSessionBodyKeyedByLocalSessionId() throws { + let executor = makeExecutor() + + flush(executor, createDelta(directAttributionId: "notification-id")) + + let request = try XCTUnwrap(client.requests.last as? OSRequestCreateSession) + XCTAssertEqual(request.path, "apps/test-app-id/sessions") + XCTAssertEqual(request.method, POST) + XCTAssertEqual(request.parameters?["onesignal_id"] as? String, onesignalId) + XCTAssertEqual(request.parameters?["subscription_id"] as? String, testPushSubId) + XCTAssertEqual(request.parameters?["start_time"] as? String, "2023-11-14T22:13:20.000Z") + XCTAssertEqual(request.parameters?["idempotency_key"] as? String, "session-1") + XCTAssertEqual(request.parameters?["direct_attribution_id"] as? String, "notification-id") + XCTAssertGreaterThan(request.reattemptCount, 0, "OneSignalClient must not retry underneath the executor") + } + + func testCreateWaitsForItsUserToBeCreated() { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + let executor = makeExecutor() + + flush(executor, createDelta()) + XCTAssertTrue(client.requests.isEmpty) + + user.identityModel.addAliases([OS_ONESIGNAL_ID: onesignalId]) + flush(executor) + XCTAssertEqual((client.requests.last as? OSRequestCreateSession)?.parameters?["onesignal_id"] as? String, onesignalId) + } + + func testUpdateWaitsForTheCreateThenUsesItsServerSessionId() throws { + let executor = makeExecutor() + flush(executor, createDelta(), updateDelta(duration: 42, endTime: 1_700_000_100)) + XCTAssertEqual(client.requests.count, 1) + + createSucceeds(executor) + flush(executor) + + let update = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(update.path, "apps/test-app-id/sessions/server-1") + XCTAssertEqual(update.method, PATCH) + XCTAssertEqual(update.parameters?["duration_seconds"] as? Int64, 42) + XCTAssertEqual(update.parameters?["end_time"] as? String, "2023-11-14T22:15:00.000Z") + XCTAssertEqual(update.parameters?["idempotency_key"] as? String, update.idempotencyKey) + } + + func testUpdateWaitsOutThePostCreateDelay() { + newRecordsState.holdWhilePresent = true + let executor = makeExecutor() + flush(executor, createDelta(), updateDelta(duration: 10)) + + createSucceeds(executor) + flush(executor) + + XCTAssertEqual(client.requests.count, 1) + XCTAssertFalse(newRecordsState.canAccess("server-1")) + } + + func testUpdateBuiltAfterItsCreateSucceededGetsTheServerSessionId() { + let executor = makeExecutor() + flush(executor, createDelta()) + createSucceeds(executor) + + flush(executor, updateDelta(duration: 10)) + + XCTAssertEqual((client.requests.last as? OSRequestUpdateSession)?.serverSessionId, "server-1") + } + + // MARK: - Coalescing + + func testUpdatesCoalesceToTheHighestDuration() { + let executor = makeExecutor() + + let queued = flush(executor, createDelta(), updateDelta(duration: 10), updateDelta(duration: 5)) + + let updates = queued.compactMap { $0 as? OSRequestUpdateSession } + XCTAssertEqual(updates.map(\.activeDuration), [10]) + } + + func testEndReplacesQueuedUpdatesAndLaterUpdatesAreDiscarded() { + let executor = makeExecutor() + + let queued = flush(executor, createDelta(), updateDelta(duration: 10), updateDelta(duration: 20, endTime: 1_700_000_100), updateDelta(duration: 30)) + + let updates = queued.compactMap { $0 as? OSRequestUpdateSession } + XCTAssertEqual(updates.count, 1) + XCTAssertEqual(updates.first?.activeDuration, 20) + XCTAssertTrue(updates.first?.isEnd == true) + } + + func testUnchangedUpdateKeepsTheQueuedIdempotencyKey() { + let executor = makeExecutor() + let first = flush(executor, createDelta(), updateDelta(duration: 10)).compactMap { $0 as? OSRequestUpdateSession } + + let second = flush(executor, updateDelta(duration: 10)).compactMap { $0 as? OSRequestUpdateSession } + + XCTAssertEqual(second.map(\.idempotencyKey), first.map(\.idempotencyKey)) + } + + func testUpdateInFlightCountsTowardTheHighestDuration() { + let executor = makeExecutor() + flush(executor, updateDelta(duration: 30, serverSessionId: "server-1")) + XCTAssertEqual(client.requests.count, 1) + + let queued = flush(executor, updateDelta(duration: 20)) + + XCTAssertEqual(queued.compactMap { $0 as? OSRequestUpdateSession }.map(\.activeDuration), [30, 30]) + } + + func testUpdateWithoutACreateOrServerSessionIdIsDiscarded() { + let executor = makeExecutor() + + XCTAssertTrue(flush(executor, updateDelta(duration: 10)).isEmpty) + } + + func testCapDropsTheOldestCreateWithItsUpdates() { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + let executor = makeExecutor() + flush(executor, createDelta(session: "oldest"), updateDelta(session: "oldest", duration: 10)) + + let deltas = (0.. Date: Fri, 9 Oct 2026 17:20:43 -0700 Subject: [PATCH 6/9] fix: [SDK-5379] exempt session requests from Identity Verification The sessions API takes no user JWT, so session Deltas are no longer dropped as anonymous by the operation repo, and the session executor keeps its requests through the anonymous purge. Co-Authored-By: Claude Opus 5.5 --- .../Source/OSOperationRepo.swift | 11 +++++++++- ...erationRepoIdentityVerificationTests.swift | 15 ++++++++++++++ .../OSSessionOperationExecutor.swift | 20 ++----------------- .../OSSessionOperationExecutorTests.swift | 4 ++-- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index c1274ef84..97319f7bd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -232,11 +232,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/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/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index 2eaa27c67..c43426a06 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -140,24 +140,8 @@ class OSSessionOperationExecutor: OSOperationExecutor { } } - func removeOperationsWithoutExternalId() { - self.dispatchQueue.async { - let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } - if remainingDeltas.count != self.deltaQueue.count { - OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") - self.deltaQueue = remainingDeltas - self.cacheDeltas() - } - - let remainingRequests = self.requestQueue.filter { $0.ownerExternalId != nil } - if remainingRequests.count != self.requestQueue.count { - OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(self.requestQueue.count - remainingRequests.count) anonymous Requests, Identity Verification is required") - self.requestQueue = remainingRequests - self.removeOrphanedUpdates() - self.cacheRequests() - } - } - } + /// The sessions API takes no user JWT, so Identity Verification does not apply to session Requests. + func removeOperationsWithoutExternalId() {} func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 50b9dcc10..99a988494 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -375,7 +375,7 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertTrue(flush(executor).isEmpty) } - func testAnonymousRequestsAreRemovedUnderIdentityVerification() { + func testAnonymousRequestsAreKeptUnderIdentityVerification() { user.identityModel.removeAliases([OS_ONESIGNAL_ID]) let anonymous = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(anonymous) @@ -384,7 +384,7 @@ final class OSSessionOperationExecutorTests: XCTestCase { executor.removeOperationsWithoutExternalId() - XCTAssertEqual(executor.queuedRequests.map(\.localSessionId), ["identified"]) + XCTAssertEqual(executor.queuedRequests.map(\.localSessionId), ["identified", "anonymous"]) } // MARK: - Enqueueing From 61ca74c94f441dec27aad9b5fc476c38a36d999d Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 9 Oct 2026 17:29:52 -0700 Subject: [PATCH 7/9] feat: [SDK-5379] retry failed operations on foreground and network return Add OSOperationRepo.retryNow, which ends executors' failure backoff early but still waits out a backend Retry-After, then flushes. OSOperationRetryTrigger calls it when the app comes to the foreground and when a network becomes available while foregrounded, driven by OneSignalTracker's focus events. Executors opt in through OSOperationExecutor.retryNow; only the session executor does so far. Mirrors Android's OperationRetryTrigger. Co-Authored-By: Claude Opus 5.5 --- .../OneSignal.xcodeproj/project.pbxproj | 8 ++ .../Source/OSOperationExecutor.swift | 8 ++ .../Source/OSOperationRepo.swift | 20 ++++ .../Source/OSOperationRetryTrigger.swift | 96 +++++++++++++++++++ .../OSOperationRepoFlushTests.swift | 13 +++ .../OSOperationRepoTestSupport.swift | 5 + .../OSOperationRetryTriggerTests.swift | 89 +++++++++++++++++ .../OSSessionOperationExecutor.swift | 15 ++- .../Source/OneSignalUserManagerImpl.swift | 11 +++ .../Source/Requests/OSSessionRequests.swift | 4 + .../OSSessionOperationExecutorTests.swift | 25 +++++ .../OneSignalSDK/Source/OneSignalTracker.m | 2 + 12 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index b797f6d0c..1cbfc73a9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -94,11 +94,13 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */; }; A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */; }; + A5162F01A1B2C3D4E5F6001C /* OSOperationRetryTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F6001B /* OSOperationRetryTrigger.swift */; }; A5162F01A1B2C3D4E5F60014 /* OSSessionRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60013 /* OSSessionRequests.swift */; }; A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */; }; A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */; }; A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */; }; A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */; }; + A5162F01A1B2C3D4E5F6001E /* OSOperationRetryTriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F6001D /* OSOperationRetryTriggerTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -1914,11 +1916,13 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionService.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F6001B /* OSOperationRetryTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRetryTrigger.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60013 /* OSSessionRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequests.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuard.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuardTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionServiceTests.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F6001D /* OSOperationRetryTriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRetryTriggerTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; @@ -2424,6 +2428,7 @@ 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */, + A5162F01A1B2C3D4E5F6001B /* OSOperationRetryTrigger.swift */, A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, @@ -2756,6 +2761,7 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, + A5162F01A1B2C3D4E5F6001D /* OSOperationRetryTriggerTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -4676,6 +4682,7 @@ A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */, + A5162F01A1B2C3D4E5F6001C /* OSOperationRetryTrigger.swift in Sources */, A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, @@ -4874,6 +4881,7 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */, + A5162F01A1B2C3D4E5F6001E /* OSOperationRetryTriggerTests.swift in Sources */, A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift index 0c0a753c9..7286fe7ae 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift @@ -42,4 +42,12 @@ public protocol OSOperationExecutor { so the policy stays there; only the storage is per-executor. */ func removeOperationsWithoutExternalId() + + /// End any failure backoff early, still waiting out a backend `Retry-After`. Driven by + /// `OSOperationRepo.retryNow`, which flushes right after. + func retryNow() +} + +public extension OSOperationExecutor { + func retryNow() {} } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index 97319f7bd..3f44eca16 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -57,10 +57,17 @@ public class OSOperationRepo: NSObject { var pollIntervalMilliseconds = Int(POLL_INTERVAL_MS) public var paused = false + /// Driven by app focus. Calls `retryNow` on foreground and when the network returns. + public let retryTrigger: OSOperationRetryTrigger + // Uncache in init so an enqueue before start cannot persist over a previous session's queue. public init(identityVerificationService: OSIdentityVerificationService) { self.identityVerificationService = identityVerificationService + self.retryTrigger = OSOperationRetryTrigger() super.init() + retryTrigger.onRetry = { [weak self] in + self?.retryNow() + } uncacheDeltaQueue() } @@ -205,6 +212,19 @@ public class OSOperationRepo: NSObject { } } + /// Ends executors' failure backoff early and flushes. A backend `Retry-After` is still waited out. + public func retryNow() { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { + return + } + dispatchQueue.async { + for executor in self.executors { + executor.retryNow() + } + self.flushDeltaQueue() + } + } + @objc public func addFlushDeltaQueueToDispatchQueue(inBackground: Bool = false) { self.dispatchQueue.async { self.flushDeltaQueue(inBackground: inBackground) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift new file mode 100644 index 000000000..3d58c9e99 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift @@ -0,0 +1,96 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ +import Foundation +import Network + +protocol OSNetworkPathMonitoring: AnyObject { + /// Calls `onAvailable` each time the network becomes available after being unavailable. + func start(onAvailable: @escaping () -> Void) +} + +final class OSNetworkPathMonitor: OSNetworkPathMonitoring { + private let monitor = NWPathMonitor() + + func start(onAvailable: @escaping () -> Void) { + var wasSatisfied: Bool? + monitor.pathUpdateHandler = { path in + let isSatisfied = path.status == .satisfied + defer { wasSatisfied = isSatisfied } + if isSatisfied, wasSatisfied == false { + onAvailable() + } + } + monitor.start(queue: DispatchQueue(label: "OneSignal.OSNetworkPathMonitor")) + } + + deinit { + monitor.cancel() + } +} + +/** + Wakes failed operations early: calls `onRetry` when the app comes to the foreground, and when a + network becomes available while it is in the foreground. Network changes in the background are + ignored, since a suspended app could not finish the attempt. + */ +public final class OSOperationRetryTrigger { + private let pathMonitor: OSNetworkPathMonitoring + /// Set once by the owner, before the trigger is handed out. + var onRetry: () -> Void = {} + private let lock = NSLock() + private var isForeground = false + private var isMonitoring = false + + init(pathMonitor: OSNetworkPathMonitoring = OSNetworkPathMonitor()) { + self.pathMonitor = pathMonitor + } + + public func onFocus() { + let shouldStartMonitoring = lock.withLock { + isForeground = true + defer { isMonitoring = true } + return !isMonitoring + } + if shouldStartMonitoring { + pathMonitor.start { [weak self] in + self?.onNetworkAvailable() + } + } + onRetry() + } + + public func onUnfocus() { + lock.withLock { isForeground = false } + } + + private func onNetworkAvailable() { + guard lock.withLock({ isForeground }) else { + return + } + onRetry() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index 52cc1c79d..9bce161d5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -141,4 +141,17 @@ final class OSOperationRepoFlushTests: XCTestCase { private func makeDelta(name: String, property: String) -> OSDelta { OSOperationRepoTestEnvironment.makeDelta(name: name, externalId: nil, property: property) } + + func testRetryNowEndsExecutorBackoffBeforeFlushing() { + let executor = MockOperationExecutor(supportedDeltas: [knownDelta]) + var retryNowCallsAtFlush: [Int] = [] + executor.onProcessDeltaQueue = { retryNowCallsAtFlush.append(executor.retryNowCallCount) } + repo.addExecutor(executor) + repo.paused = false + + repo.retryNow() + repo.flushAndWait() + + XCTAssertEqual(retryNowCallsAtFlush.first, 1) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift index bb84d4ec3..28907e08c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift @@ -76,6 +76,7 @@ final class MockOperationExecutor: OSOperationExecutor { let supportedDeltas: [String] private(set) var enqueued: [OSDelta] = [] private(set) var removeOperationsWithoutExternalIdCallCount = 0 + private(set) var retryNowCallCount = 0 var onProcessDeltaQueue: (() -> Void)? init(supportedDeltas: [String]) { @@ -95,4 +96,8 @@ final class MockOperationExecutor: OSOperationExecutor { func removeOperationsWithoutExternalId() { removeOperationsWithoutExternalIdCallCount += 1 } + + func retryNow() { + retryNowCallCount += 1 + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift new file mode 100644 index 000000000..0f57e928e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift @@ -0,0 +1,89 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ +import XCTest +@testable import OneSignalOSCore + +private final class FakePathMonitor: OSNetworkPathMonitoring { + private(set) var startCount = 0 + private var onAvailable: (() -> Void)? + + func start(onAvailable: @escaping () -> Void) { + startCount += 1 + self.onAvailable = onAvailable + } + + func networkBecomesAvailable() { + onAvailable?() + } +} + +final class OSOperationRetryTriggerTests: XCTestCase { + private var monitor: FakePathMonitor! + private var trigger: OSOperationRetryTrigger! + private var retryCount = 0 + + override func setUp() { + super.setUp() + monitor = FakePathMonitor() + trigger = OSOperationRetryTrigger(pathMonitor: monitor) + retryCount = 0 + trigger.onRetry = { [unowned self] in self.retryCount += 1 } + } + + func testRetriesOnFocus() { + trigger.onFocus() + + XCTAssertEqual(retryCount, 1) + } + + func testRetriesWhenTheNetworkReturnsWhileFocused() { + trigger.onFocus() + + monitor.networkBecomesAvailable() + + XCTAssertEqual(retryCount, 2) + } + + func testIgnoresTheNetworkReturningInTheBackground() { + trigger.onFocus() + trigger.onUnfocus() + + monitor.networkBecomesAvailable() + + XCTAssertEqual(retryCount, 1) + } + + func testStartsMonitoringOnceOnTheFirstFocus() { + XCTAssertEqual(monitor.startCount, 0) + + trigger.onFocus() + trigger.onUnfocus() + trigger.onFocus() + + XCTAssertEqual(monitor.startCount, 1) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index c43426a06..f35b0f154 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -143,6 +143,14 @@ class OSSessionOperationExecutor: OSOperationExecutor { /// The sessions API takes no user JWT, so Identity Verification does not apply to session Requests. func removeOperationsWithoutExternalId() {} + func retryNow() { + self.dispatchQueue.async { + for request in self.requestQueue { + request.retryNotBefore = request.retryAfterNotBefore + } + } + } + func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { if !self.deltaQueue.isEmpty { @@ -399,7 +407,8 @@ private extension OSSessionOperationExecutor { } /// Backs off exponentially, waiting at least `retryAfter`. Only attempts the backend answered count - /// toward the limit, so a Request made offline keeps retrying until the network returns. + /// toward the limit, so a Request made offline keeps retrying until the network returns, when + /// `retryNow` ends the backoff. func retry(_ request: OSSessionRequest, statusCode: Int, retryAfter: TimeInterval?) { request.sentToClient = false if statusCode != 0 { @@ -414,7 +423,9 @@ private extension OSSessionOperationExecutor { request.retryAttempts += 1 let exponential = Self.baseBackoffSeconds * pow(2, Double(request.retryAttempts - 1)) let backoff = min(exponential, Self.maxBackoffSeconds) * jitter() - request.retryNotBefore = uptime() + max(backoff, retryAfter ?? 0) + let now = uptime() + request.retryAfterNotBefore = retryAfter.map { now + $0 } + request.retryNotBefore = max(now + backoff, request.retryAfterNotBefore ?? 0) cacheRequests() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index dcb9ddea9..82c4836d0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -743,6 +743,17 @@ extension OneSignalUserManagerImpl { updatePropertiesDeltas(property: .session_time, value: sessionTime.intValue) } + /// Retries failed operations right away, and again whenever the network returns while focused. + @objc + public func onFocus() { + operationRepo.retryTrigger.onFocus() + } + + @objc + public func onUnfocus() { + operationRepo.retryTrigger.onUnfocus() + } + /** App has been backgrounded. Run background tasks such to flush the operation repo and hydrating models. Need to consider app killed vs app backgrounded and handle gracefully. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift index 75d5d8987..9885a5f50 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift @@ -55,6 +55,8 @@ protocol OSSessionRequest: OSUserRequest { var retryAttempts: Int { get set } /// System uptime before which a retry is not sent. var retryNotBefore: TimeInterval? { get set } + /// The part of `retryNotBefore` a backend `Retry-After` asked for, which `retryNow` keeps. + var retryAfterNotBefore: TimeInterval? { get set } } private extension OneSignalRequest { @@ -92,6 +94,7 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { var failedAttempts = 0 var retryAttempts = 0 var retryNotBefore: TimeInterval? + var retryAfterNotBefore: TimeInterval? /// The sessions API takes no user JWT. var sendsUnsigned: Bool { true } @@ -206,6 +209,7 @@ final class OSRequestUpdateSession: OneSignalRequest, OSSessionRequest { var failedAttempts = 0 var retryAttempts = 0 var retryNotBefore: TimeInterval? + var retryAfterNotBefore: TimeInterval? /// The sessions API takes no user JWT. var sendsUnsigned: Bool { true } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 99a988494..7a4ebb6ef 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -295,6 +295,31 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertEqual(client.requests.count, 2) } + func testRetryNowEndsTheBackoffEarly() { + let executor = makeExecutor() + flush(executor, createDelta()) + fail(executor, code: 500) + + executor.retryNow() + flush(executor) + + XCTAssertEqual(client.requests.count, 2) + } + + func testRetryNowStillWaitsOutRetryAfter() { + let executor = makeExecutor() + flush(executor, createDelta()) + fail(executor, code: 429, headers: ["Retry-After": "120"]) + + executor.retryNow() + flush(executor) + XCTAssertEqual(client.requests.count, 1) + + uptime += 120 + flush(executor) + XCTAssertEqual(client.requests.count, 2) + } + func testNoResponseDoesNotCountTowardTheAttemptLimit() { let executor = makeExecutor() flush(executor, createDelta()) diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m b/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m index 537160b25..7df923743 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m @@ -81,6 +81,7 @@ + (void)applicationBecameActive { [OSSessionManager.sharedSessionManager setLastOpenedTime:[[NSDate date] timeIntervalSince1970]]; [OSSessionService onFocus]; + [OneSignalUserManagerImpl.sharedInstance onFocus]; // on_session tracking when resumming app. if ([OneSignal shouldStartNewSession]) @@ -96,6 +97,7 @@ + (void)applicationBackgrounded { [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:@"Application Backgrounded started"]; [self updateLastClosedTime]; [OSSessionService onUnfocus]; + [OneSignalUserManagerImpl.sharedInstance onUnfocus]; let timeElapsed = [OSSessionManager.sharedSessionManager getTimeFocusedElapsed]; if (timeElapsed < -1) From eca3dd618cf5f23e5ee832ea0622b931ad1458ff Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 9 Oct 2026 17:35:55 -0700 Subject: [PATCH 8/9] fix: [SDK-5379] drop session creates for users that will never be created Under required Identity Verification an anonymous user is never created, so a session create still waiting for its onesignal_id can never be sent. The anonymous purge now drops those creates and their updates, matching Android. Anonymous users that already have a onesignal_id keep their requests. Co-Authored-By: Claude Opus 5.5 --- .../OSSessionOperationExecutor.swift | 23 +++++++++++++++++-- .../OSSessionOperationExecutorTests.swift | 20 ++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index f35b0f154..ae3f29c5a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -140,8 +140,27 @@ class OSSessionOperationExecutor: OSOperationExecutor { } } - /// The sessions API takes no user JWT, so Identity Verification does not apply to session Requests. - func removeOperationsWithoutExternalId() {} + /** + The sessions API takes no user JWT, so Identity Verification does not apply to session Requests. + But an anonymous user is never created while it is required, so a create still waiting for that + user's `onesignal_id` can never be sent. Drop those, with their updates. + */ + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let countBefore = self.requestQueue.count + self.requestQueue.removeAll { request in + request is OSRequestCreateSession + && request.identityModel.externalId == nil + && request.identityModel.onesignalId == nil + } + guard self.requestQueue.count != countBefore else { + return + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped \(countBefore - self.requestQueue.count) creates for anonymous users that will never be created") + self.removeOrphanedUpdates() + self.cacheRequests() + } + } func retryNow() { self.dispatchQueue.async { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 7a4ebb6ef..e1441a058 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -400,16 +400,26 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertTrue(flush(executor).isEmpty) } - func testAnonymousRequestsAreKeptUnderIdentityVerification() { + func testAnonymousRequestsThatCanStillBeSentAreKeptUnderIdentityVerification() { user.identityModel.removeAliases([OS_ONESIGNAL_ID]) - let anonymous = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) - OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(anonymous) + let createdAnonymous = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: "anonymous-onesignal-id"], changeNotifier: OSEventProducer()) + let neverCreated = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(createdAnonymous) + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(neverCreated) + newRecordsState.holdWhilePresent = true + newRecordsState.add("anonymous-onesignal-id") let executor = makeExecutor() - flush(executor, createDelta(session: "identified"), createDelta(session: "anonymous", identityModel: anonymous)) + flush( + executor, + createDelta(session: "identified"), + createDelta(session: "created-anonymous", identityModel: createdAnonymous), + createDelta(session: "never-created", identityModel: neverCreated), + updateDelta(session: "never-created", duration: 10) + ) executor.removeOperationsWithoutExternalId() - XCTAssertEqual(executor.queuedRequests.map(\.localSessionId), ["identified", "anonymous"]) + XCTAssertEqual(executor.queuedRequests.map(\.localSessionId), ["identified", "created-anonymous"]) } // MARK: - Enqueueing From 5132a8f1174b2f5e9c09a4f473ff665b69238984 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 9 Oct 2026 17:44:23 -0700 Subject: [PATCH 9/9] feat: [SDK-5383] enqueue a session create when a new session starts OSSessionService.startNewSession enqueues the create through the session user provider when the session uses the sessions API. The User module turns it into a session Delta for the operation repo. Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionService.swift | 10 ++++ .../OSSessionServiceTests.swift | 50 +++++++++++++++++++ .../OneSignalUserManagerImpl+Session.swift | 10 ++-- .../OSSessionOperationExecutorTests.swift | 4 +- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 0da8c68af..e3ab505ef 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -48,6 +48,11 @@ public protocol OSSessionUserProvider: AnyObject { /// Taken from one read of the current user, so a concurrent login cannot mix two users. var sessionCurrentUser: OSSessionUser { get } func sessionOnesignalId(identityModelId: String) -> String? + /// Queues the sessions API create for a session. + func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String?) + /// Queues a session's cumulative duration, ending the session when `endTime` (seconds since + /// 1970) is set. + func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: TimeInterval?) } /// New fields must be Optional. A record stored by an earlier version lacks them, and synthesized @@ -159,6 +164,8 @@ public final class OSSessionService: NSObject { /// Leaves any open foreground interval running: the tracker reports focus before the session /// starts asynchronously, and that visit belongs to the new session. A launch in the /// background has no interval open, so nothing counts until the app becomes active. + /// With the sessions API, the create is enqueued here. The executor holds it until the + /// session's IDs are assigned. public func startNewSession(userProvider: OSSessionUserProvider) { let user = userProvider.sessionCurrentUser let newRecord = OSSessionRecord( @@ -177,6 +184,9 @@ public final class OSSessionService: NSObject { record = newRecord persist() } + if newRecord.usesSessionsApi { + userProvider.enqueueSessionCreate(newRecord, directAttributionId: nil) + } } func onFocus() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 305655133..3aad2f398 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -47,6 +47,14 @@ private final class FakeUserProvider: OSSessionUserProvider { func sessionOnesignalId(identityModelId: String) -> String? { onesignalIds[identityModelId] } + + private(set) var createdSessions: [OSSessionRecord] = [] + + func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String?) { + createdSessions.append(record) + } + + func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: TimeInterval?) {} } final class OSSessionServiceTests: XCTestCase { @@ -338,4 +346,46 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(restarted.currentRecord?.activeDuration, 0) } + + // MARK: - Session start + + func testNewSessionEnqueuesItsCreateWithPinnedIds() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeService() + + service.startNewSession(userProvider: user) + + XCTAssertEqual(user.createdSessions, [service.currentRecord!]) + XCTAssertEqual(user.createdSessions.first?.startTime, 5_000) + XCTAssertEqual(user.createdSessions.first?.onesignalId, "onesignal-a") + XCTAssertEqual(user.createdSessions.first?.subscriptionId, "subscription-a") + } + + func testEachNewSessionEnqueuesItsOwnCreate() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeService() + + service.startNewSession(userProvider: user) + service.startNewSession(userProvider: user) + + XCTAssertEqual(Set(user.createdSessions.map(\.sessionId)).count, 2) + } + + func testResumingASessionDoesNotEnqueueAnotherCreate() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeForegroundSession() + + service.onUnfocus() + service.onFocus() + + XCTAssertEqual(user.createdSessions.count, 1) + } + + func testNewSessionEnqueuesNothingWhenFlagIsOff() { + let service = makeService() + + service.startNewSession(userProvider: user) + + XCTAssertTrue(user.createdSessions.isEmpty) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index 409d061c6..2009c6f9a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -42,17 +42,17 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { 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) { + /// Does nothing unless the session uses the sessions API. + public func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String?) { var value: [String: Any] = [OSSessionDeltaKey.startTime: record.startTime] value[OSSessionDeltaKey.directAttributionId] = directAttributionId enqueueSessionDelta(OS_CREATE_SESSION_DELTA, record: record, value: value) } - /// Queues the session's cumulative foreground time, ending the session when `endTime` is set. - func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: Date? = nil) { + /// Does nothing unless the session uses the sessions API. + public func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: TimeInterval?) { var value: [String: Any] = [OSSessionDeltaKey.activeDuration: record.activeDuration] - value[OSSessionDeltaKey.endTime] = endTime?.timeIntervalSince1970 + value[OSSessionDeltaKey.endTime] = endTime value[OSSessionDeltaKey.serverSessionId] = record.serverSessionId enqueueSessionDelta(OS_UPDATE_SESSION_DELTA, record: record, value: value) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index e1441a058..65419b8f3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -439,8 +439,8 @@ final class OSSessionOperationExecutorTests: XCTestCase { ) } - manager.enqueueSessionCreate(record(false)) - manager.enqueueSessionCreate(record(true)) + manager.enqueueSessionCreate(record(false), directAttributionId: nil) + manager.enqueueSessionCreate(record(true), directAttributionId: nil) manager.operationRepo.dispatchQueue.sync {} let sessionDeltas = manager.operationRepo.deltaQueue.filter { $0.name == OS_CREATE_SESSION_DELTA }