From 5469163b905e43ce63714180d04c531d863cfe16 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 19 Oct 2026 11:45:48 -0700 Subject: [PATCH 1/3] feat: [SDK-5383] enqueue a session create when a new session starts Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionService.swift | 4 +- .../OneSignalUserManagerImpl+Session.swift | 2 +- .../SessionRecordUserTests.swift | 54 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index eb565d4df..abebe5a06 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -157,7 +157,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. - public func startNewSession(userProvider: OSSessionUserProvider) { + @discardableResult + public func startNewSession(userProvider: OSSessionUserProvider) -> OSSessionRecord { let user = userProvider.sessionCurrentUser let newRecord = OSSessionRecord( sessionId: UUID().uuidString, @@ -174,6 +175,7 @@ public final class OSSessionService: NSObject { record = newRecord persist() } + return newRecord } func onFocus() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index 5947e527f..da805d334 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -40,7 +40,7 @@ extension OneSignalUserManagerImpl { userExecutor!.executePendingRequestsOnNewSession() operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1, flush: true) - OSSessionService.shared.startNewSession(userProvider: self) + enqueueSessionCreate(OSSessionService.shared.startNewSession(userProvider: self)) // Fetch the user's data if there is a onesignal_id if let onesignalId = onesignalId { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift index 36de2fb86..325ddedd1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -52,6 +52,8 @@ final class SessionRecordUserTests: XCTestCase { OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle } OSSessionService.reset() OneSignalCoreMocks.clearUserDefaults() + OSFeatureFlagsStore.shared.clear() + OSFeatureManager.reset() } func testSessionStartedBeforeUserIsCreatedPinsItsIdsOnceCreated() { @@ -151,3 +153,55 @@ final class SessionRecordUserTests: XCTestCase { XCTAssertEqual(OSSessionService.shared.currentRecord?.onesignalId, userB_OSID) } } + +// MARK: - Session create + +extension SessionRecordUserTests { + private func enableSessionsApi() { + OSFeatureFlagsStore.shared.applyRemoteFlags(["sdk_sessions_v2_api_cutover"], metadata: nil) + } + + /// With responses held the user is never created, so its session creates stay queued. + private func queuedSessionCreates() -> [OSRequestCreateSession] { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.operationRepo.flushAndWait() + return manager.sessionExecutor?.queuedRequests.compactMap { $0 as? OSRequestCreateSession } ?? [] + } + + func testNewSessionQueuesACreateWithItsStartTime() { + enableSessionsApi() + client.holdResponses = true + + OneSignalUserManagerImpl.sharedInstance.startNewSession() + + let record = OSSessionService.shared.currentRecord + let creates = queuedSessionCreates() + XCTAssertEqual(creates.count, 1) + XCTAssertEqual(creates.first?.localSessionId, record?.sessionId) + XCTAssertEqual(creates.first?.startTime.timeIntervalSince1970, record?.startTime) + client.releaseHeldResponses() + } + + func testEachNewSessionQueuesItsOwnCreate() { + enableSessionsApi() + client.holdResponses = true + + OneSignalUserManagerImpl.sharedInstance.startNewSession() + let first = OSSessionService.shared.currentRecord?.sessionId + OneSignalUserManagerImpl.sharedInstance.startNewSession() + let second = OSSessionService.shared.currentRecord?.sessionId + + XCTAssertNotEqual(first, second) + XCTAssertEqual(queuedSessionCreates().map(\.localSessionId), [first, second].compactMap { $0 }) + client.releaseHeldResponses() + } + + func testNewSessionQueuesNoCreateWhenTheFlagIsOff() { + client.holdResponses = true + + OneSignalUserManagerImpl.sharedInstance.startNewSession() + + XCTAssertTrue(queuedSessionCreates().isEmpty) + client.releaseHeldResponses() + } +} From e4847b27ead16732b522a9ae1c3a30331c881f82 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 20 Oct 2026 10:56:27 -0700 Subject: [PATCH 2/3] fix: [SDK-5383] send subscription_type instead of device_type on session create Co-Authored-By: Claude Opus 5.5 --- .../OneSignalUser/Source/Requests/OSSessionRequests.swift | 2 +- .../Executors/OSSessionOperationExecutorTests.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift index d05c8d4ca..6ac87eb47 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift @@ -129,7 +129,7 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { var parameters: [String: Any] = [ "onesignal_id": onesignalId, "subscription_id": subscriptionId, - "device_type": DEVICE_TYPE_PUSH, + "subscription_type": OSSubscriptionType.push.rawValue, "start_time": OSSessionTimestamp.string(from: startTime), // The local session ID, so a create replayed from any process returns the same session. "idempotency_key": localSessionId diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 43f9d75d7..70242bf92 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -168,6 +168,8 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertEqual(request.method, POST) XCTAssertEqual(request.parameters?["onesignal_id"] as? String, onesignalId) XCTAssertEqual(request.parameters?["subscription_id"] as? String, testPushSubId) + XCTAssertEqual(request.parameters?["subscription_type"] as? String, "iOSPush") + XCTAssertNil(request.parameters?["device_type"]) 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") From d666a8dee86f05e697178f40fa95f09b533f2cce Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 20 Oct 2026 12:04:48 -0700 Subject: [PATCH 3/3] fix: [SDK-5383] start one session when a prewarmed app is foregrounded Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionService.swift | 51 ++++++++++++++++ .../OSSessionServiceTests.swift | 60 +++++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 35 +++++------ 3 files changed, 127 insertions(+), 19 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index abebe5a06..4087df2fb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -269,3 +269,54 @@ public final class OSSessionService: NSObject { storage.saveObject(forKey: OSUD_SESSION_RECORD, withValue: data) } } + +/// Decides whether a foreground starts a new session: the app must have been closed, and the SDK +/// initialized, at least `threshold` seconds ago. +@objc(OSSessionStartGate) +public final class OSSessionStartGate: NSObject { + @objc public static let shared = OSSessionStartGate() + static let threshold: TimeInterval = 30 + + private let storage: OneSignalUserDefaults + private let now: () -> TimeInterval + private let lock = NSLock() + private var initializationTime: TimeInterval = 0 + private var lastStartTime: TimeInterval? + + init( + storage: OneSignalUserDefaults = .initStandard(), + now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 } + ) { + self.storage = storage + self.now = now + super.init() + } + + @objc public func recordInitialization() { + let now = now() + lock.withLock { initializationTime = now } + } + + @objc public func shouldStartNewSession() -> Bool { + let now = now() + let lastClosedTime = storage.getSavedDouble(forKey: OSUD_APP_LAST_CLOSED_TIME, defaultValue: 0) + let initializationTime = lock.withLock { self.initializationTime } + let elapsed = min(now - lastClosedTime, now - initializationTime) + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionStartGate seconds since last closed or initialized: \(elapsed)") + return elapsed >= Self.threshold + } + + /// Records a session start, unless `deduplicate` is set and a session started within the threshold. + /// Recovery from a prewarm and the foreground that triggered it each start a session, in either + /// order, so both pass `deduplicate` and the later one is skipped. Returns whether to start. + @objc public func beginSessionStart(deduplicate: Bool) -> Bool { + let now = now() + return lock.withLock { + if deduplicate, let lastStartTime, now - lastStartTime < Self.threshold { + return false + } + lastStartTime = now + return true + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index f8e414968..57df25295 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -337,3 +337,63 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(restarted.currentRecord?.activeDuration, 0) } } + +final class OSSessionStartGateTests: XCTestCase { + private var now: TimeInterval = 10_000 + private var gate: OSSessionStartGate! + + override func setUp() { + super.setUp() + now = 10_000 + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_APP_LAST_CLOSED_TIME) + gate = OSSessionStartGate(now: { [unowned self] in self.now }) + } + + override func tearDown() { + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_APP_LAST_CLOSED_TIME) + super.tearDown() + } + + private func closeApp() { + OneSignalUserDefaults.initStandard().saveDouble(forKey: OSUD_APP_LAST_CLOSED_TIME, withValue: now) + } + + func testResumingWithinTheThresholdDoesNotStartASession() { + gate.recordInitialization() + now += 60 + closeApp() + + now += 29 + XCTAssertFalse(gate.shouldStartNewSession()) + + now += 1 + XCTAssertTrue(gate.shouldStartNewSession()) + } + + func testForegroundWithinTheThresholdOfInitializationDoesNotStartASession() { + closeApp() + now += 60 + gate.recordInitialization() + + now += 29 + XCTAssertFalse(gate.shouldStartNewSession()) + + now += 1 + XCTAssertTrue(gate.shouldStartNewSession()) + } + + /// A prewarm's init start does nothing, so recovery and the foreground's gated start can both run. + func testOnlyOneOfTwoDeduplicatedStartsWithinTheThresholdRuns() { + XCTAssertTrue(gate.beginSessionStart(deduplicate: true)) + XCTAssertFalse(gate.beginSessionStart(deduplicate: true)) + + now += 30 + XCTAssertTrue(gate.beginSessionStart(deduplicate: true)) + } + + func testStartFromInitIsNeverDeduplicated() { + XCTAssertTrue(gate.beginSessionStart(deduplicate: false)) + XCTAssertTrue(gate.beginSessionStart(deduplicate: false)) + XCTAssertFalse(gate.beginSessionStart(deduplicate: true)) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 3e10b908a..6d056a7f6 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -105,9 +105,6 @@ + (DelayedConsentInitializationParameters *)delayedInitParameters { // Called after successfully calling setAppId and setLaunchOptions static BOOL initDone = false; -// Used to track last time SDK was initialized, for whether or not to start a new session -static NSTimeInterval initializationTime; - //// Set when the app is launched //static NSDate *sessionLaunchTime; @@ -366,18 +363,7 @@ + (BOOL)shouldStartNewSession { return false; } - NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; - NSTimeInterval lastTimeClosed = [OneSignalUserDefaults.initStandard getSavedDoubleForKey:OSUD_APP_LAST_CLOSED_TIME defaultValue:0]; - - // Make sure last time we closed app was more than 30 secs ago - const int minTimeThreshold = 30; - NSTimeInterval timeSinceLastClosed = now - lastTimeClosed; - NSTimeInterval timeSinceInitialization = now - initializationTime; - - [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:[NSString stringWithFormat:@"shouldStartNewSession:timeSinceLastClosed: %f", timeSinceLastClosed]]; - [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:[NSString stringWithFormat:@"shouldStartNewSession:timeSinceInitialization: %f", timeSinceInitialization]]; - - return MIN(timeSinceLastClosed, timeSinceInitialization) >= minTimeThreshold; + return [OSSessionStartGate.shared shouldStartNewSession]; } + (void)startNewSession:(BOOL)fromInit { @@ -391,17 +377,28 @@ + (void)startNewSession:(BOOL)fromInit { // Run on the main queue as it is possible for this to be called from multiple queues. // Also some of the code in the method is not thread safe such as _outcomeEventsController. [OneSignalHelper dispatch_async_on_main_queue:^{ - [self startNewSessionInternal]; + [self startNewSessionInternal:!fromInit]; + }]; +} + +/// Starts the session init deferred during a prewarm, without the 30 second gate: the init start +/// it replaces never ran. ++ (void)startDeferredSession { + [OneSignalHelper dispatch_async_on_main_queue:^{ + [self startNewSessionInternal:YES]; }]; } -+ (void)startNewSessionInternal { ++ (void)startNewSessionInternal:(BOOL)deduplicate { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OneSignal.startNewSessionInternal"]; // return if the user has not granted consent, or device-protected storage isn't readable yet if ([OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil]) return; + if (![OSSessionStartGate.shared beginSessionStartWithDeduplicate:deduplicate]) + return; + // A new session is a fresh chance at params after the launch attempt exhausted its retries if (!_downloadedParameters && !_didCallDownloadParameters && OneSignalIdentifiers.currentAppId) { _downloadParametersAttempts = 0; @@ -621,7 +618,7 @@ + (void)setupProtectedDataObserverOnce { [OneSignal startInAppMessages]; StartFeatureFlagsRefresh(); [OSDeviceGestureDetector start]; - [OneSignal startNewSession:YES]; + [OneSignal startDeferredSession]; }; // Authoritative signal: the OS says protected data just became available. @@ -726,7 +723,7 @@ + (void)init { } [self startNewSession:YES]; - initializationTime = [[NSDate date] timeIntervalSince1970]; + [OSSessionStartGate.shared recordInitialization]; initDone = true; }