From c762aac0465c0416eeb701b08bbd4824b1798334 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 22 Oct 2026 11:54:35 -0700 Subject: [PATCH 1/7] feat: [SDK-5391] end the previous sessions API session when a new one starts Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionService.swift | 30 +++++++- .../OSSessionServiceTests.swift | 74 ++++++++++++++++++- .../OneSignalUserManagerImpl+Session.swift | 26 +++++-- .../SessionRecordUserTests.swift | 63 ++++++++++++++++ 4 files changed, 183 insertions(+), 10 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 4087df2fb..a53d93b4f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -65,6 +65,20 @@ public struct OSSessionRecord: Codable, Equatable { /// 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? + /// Seconds since 1970 when the app last left the foreground. Nil until then. + public internal(set) var lastUnfocusTime: TimeInterval? + + /// Seconds since 1970. A session killed before its first unfocus ends at its start, since its + /// foreground time was never added to the duration either. + public var endTime: TimeInterval { + lastUnfocusTime ?? startTime + } +} + +public struct OSSessionStart { + public let started: OSSessionRecord + /// The session the new one replaced, which has now ended. + public let ended: OSSessionRecord? } /// Owns the persisted record of the current session. Nothing reads the record while @@ -157,8 +171,10 @@ 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. + /// The replaced record is returned rather than lost, so its session can be ended. @discardableResult - public func startNewSession(userProvider: OSSessionUserProvider) -> OSSessionRecord { + public func startNewSession(userProvider: OSSessionUserProvider) -> OSSessionStart { + let previousIds = pinnableIds() let user = userProvider.sessionCurrentUser let newRecord = OSSessionRecord( sessionId: UUID().uuidString, @@ -169,13 +185,17 @@ public final class OSSessionService: NSObject { onesignalId: user.onesignalId, subscriptionId: user.pushSubscriptionId ) - stateLock.withLock { + let ended: OSSessionRecord? = stateLock.withLock { + loadIfNeeded() + // Last chance to fill the replaced record's IDs while it is still the current one. + fillPinnedIds(previousIds) + let ended = record self.userProvider = userProvider - loaded = true record = newRecord persist() + return ended } - return newRecord + return OSSessionStart(started: newRecord, ended: ended) } func onFocus() { @@ -185,6 +205,7 @@ public final class OSSessionService: NSObject { func onUnfocus() { let now = monotonicNow() + let wallNow = wallNow() let ids = pinnableIds() stateLock.withLock { guard let start = focusedAt else { @@ -196,6 +217,7 @@ public final class OSSessionService: NSObject { return } record?.activeDuration += max(0, now - start) + record?.lastUnfocusTime = wallNow fillPinnedIds(ids) persist() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 57df25295..e9fefafce 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -54,6 +54,7 @@ final class OSSessionServiceTests: XCTestCase { private var flagsStore: OSFeatureFlagsStore! private var featureManager: OSFeatureManager! private var monotonicNow: TimeInterval = 1_000 + private var wallNow: TimeInterval = 5_000 private var user: FakeUserProvider! override func setUp() { @@ -63,6 +64,7 @@ final class OSSessionServiceTests: XCTestCase { flagsStore = OSFeatureFlagsStore() featureManager = nil monotonicNow = 1_000 + wallNow = 5_000 user = FakeUserProvider() user.identityModelId = "model-a" user.onesignalIds = ["model-a": "onesignal-a"] @@ -82,7 +84,7 @@ final class OSSessionServiceTests: XCTestCase { return OSSessionService( featureManager: { manager }, monotonicNow: { [unowned self] in self.monotonicNow }, - wallNow: { 5_000 } + wallNow: { [unowned self] in self.wallNow } ) } @@ -189,6 +191,7 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(record?.usesSessionsApi, true) XCTAssertEqual(record?.onesignalId, "onesignal-a") XCTAssertEqual(record?.subscriptionId, "subscription-a") + XCTAssertNil(record?.lastUnfocusTime) } func testClearingTheStoredRecordDropsIt() { @@ -285,6 +288,16 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(service.currentRecord?.activeDuration, 15) } + func testUnfocusRecordsTheWallClockTime() { + let service = makeForegroundSession() + wallNow = 6_000 + service.onUnfocus() + wallNow = 7_000 + + XCTAssertEqual(service.currentRecord?.lastUnfocusTime, 6_000) + XCTAssertEqual(service.currentRecord?.endTime, 6_000) + } + /// Resuming past the new-session threshold: the visit that started the session counts from /// focus, not from when the session start ran on the main queue. func testNewSessionStartedAfterFocusCountsFromFocus() { @@ -335,6 +348,65 @@ final class OSSessionServiceTests: XCTestCase { restarted.onUnfocus() XCTAssertEqual(restarted.currentRecord?.activeDuration, 0) + XCTAssertNil(restarted.currentRecord?.lastUnfocusTime) + } +} + +// MARK: - Session end + +extension OSSessionServiceTests { + + /// A foreground visit of `duration` seconds that leaves the foreground at `unfocusAt`. + private func visit(_ service: OSSessionService, duration: TimeInterval, unfocusAt: TimeInterval) { + service.onFocus() + monotonicNow += duration + wallNow = unfocusAt + service.onUnfocus() + } + + func testFirstSessionEndsNothing() { + XCTAssertNil(makeService().startNewSession(userProvider: user).ended) + } + + func testNewSessionReturnsThePreviousOneWithItsFinalDurationAndLastUnfocus() { + let service = makeService() + let first = service.startNewSession(userProvider: user).started + visit(service, duration: 30, unfocusAt: 6_000) + visit(service, duration: 12, unfocusAt: 7_000) + + wallNow = 9_000 + let start = service.startNewSession(userProvider: user) + + XCTAssertEqual(start.ended?.sessionId, first.sessionId) + XCTAssertEqual(start.ended?.activeDuration, 42) + XCTAssertEqual(start.ended?.endTime, 7_000) + XCTAssertEqual(start.started.startTime, 9_000) + XCTAssertEqual(service.currentRecord, start.started) + } + + func testSessionKilledInTheForegroundEndsAtItsStartTime() { + let first = makeForegroundSession().currentRecord + monotonicNow += 30 + + wallNow = 9_000 + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.sessionId, first?.sessionId) + XCTAssertEqual(ended?.activeDuration, 0) + XCTAssertEqual(ended?.endTime, first?.startTime) + } + + /// The previous process ended before the user was created. + func testSessionFromAnEarlierProcessEndsWithTheIdsAssignedSince() { + user.onesignalIds = [:] + makeService().startNewSession(userProvider: user) + + user.onesignalIds = ["model-a": "onesignal-a"] + let restarted = makeService() + restarted.setUserProvider(user) + let ended = restarted.startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.onesignalId, "onesignal-a") } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index da805d334..f02e9f48a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -40,7 +40,11 @@ extension OneSignalUserManagerImpl { userExecutor!.executePendingRequestsOnNewSession() operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1, flush: true) - enqueueSessionCreate(OSSessionService.shared.startNewSession(userProvider: self)) + let start = OSSessionService.shared.startNewSession(userProvider: self) + if let ended = start.ended { + enqueueSessionUpdate(ended, endTime: Date(timeIntervalSince1970: ended.endTime)) + } + enqueueSessionCreate(start.started) // Fetch the user's data if there is a onesignal_id if let onesignalId = onesignalId { @@ -85,9 +89,7 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { private func enqueueSessionDelta(_ name: String, record: OSSessionRecord, value: [String: Any]) { guard record.usesSessionsApi else { return } - guard let identityModelId = record.identityModelId, - let identityModel = identityModelRepo.get(modelId: identityModelId) - else { + guard let identityModel = sessionIdentityModel(for: record) else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManagerImpl dropped \(name) for session \(record.sessionId), no identity model") return } @@ -99,7 +101,7 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { value[OSSessionDeltaKey.appId] = appId let delta = OSDelta( name: name, - identityModelId: identityModelId, + identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: identityModel, property: record.sessionId, @@ -107,4 +109,18 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { ) operationRepo.enqueueDelta(delta) } + + /// Identity models are not cached across launches, so the user of a session that ended before a + /// login in an earlier process is gone. It is restored from the session's pinned `onesignal_id`. + private func sessionIdentityModel(for record: OSSessionRecord) -> OSIdentityModel? { + if let identityModelId = record.identityModelId, let identityModel = identityModelRepo.get(modelId: identityModelId) { + return identityModel + } + guard let onesignalId = record.onesignalId else { + return nil + } + let identityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: onesignalId], changeNotifier: OSEventProducer()) + addIdentityModelToRepo(identityModel) + return identityModel + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift index 325ddedd1..3a3fdec8f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -196,6 +196,69 @@ extension SessionRecordUserTests { client.releaseHeldResponses() } + private func queuedSessionUpdates() -> [OSRequestUpdateSession] { + let queued = OneSignalUserManagerImpl.sharedInstance.sessionExecutor?.queuedRequests ?? [] + return queued.compactMap { $0 as? OSRequestUpdateSession } + } + + func testNewSessionQueuesThePreviousSessionsEndBeforeItsCreate() { + enableSessionsApi() + client.holdResponses = true + let manager = OneSignalUserManagerImpl.sharedInstance + + manager.startNewSession() + let first = OSSessionService.shared.currentRecord + manager.startNewSession() + let second = OSSessionService.shared.currentRecord + + let creates = queuedSessionCreates() + let queued = manager.sessionExecutor?.queuedRequests.map(\.localSessionId) + XCTAssertEqual(queued, [first?.sessionId, first?.sessionId, second?.sessionId].compactMap { $0 }) + XCTAssertEqual(creates.count, 2) + let end = queuedSessionUpdates().first + XCTAssertEqual(end?.localSessionId, first?.sessionId) + XCTAssertEqual(end?.endTime?.timeIntervalSince1970, first?.startTime) + client.releaseHeldResponses() + } + + func testLegacySessionQueuesNoEnd() { + client.holdResponses = true + let manager = OneSignalUserManagerImpl.sharedInstance + + manager.startNewSession() + enableSessionsApi() + manager.startNewSession() + + XCTAssertEqual(queuedSessionCreates().count, 1) + XCTAssertTrue(queuedSessionUpdates().isEmpty) + client.releaseHeldResponses() + } + + /// The session's user was replaced by a login in an earlier process, so its model is not loaded. + func testEndOfASessionWhoseUserIsNoLongerLoadedIsQueuedForItsPinnedUser() throws { + let manager = OneSignalUserManagerImpl.sharedInstance + let record = { (onesignalId: String?) in + OSSessionRecord( + sessionId: onesignalId ?? "never-created", + startTime: 1_700_000_000, + activeDuration: 12, + usesSessionsApi: true, + identityModelId: "unloaded-model", + onesignalId: onesignalId, + subscriptionId: nil + ) + } + + manager.enqueueSessionUpdate(record("pinned-onesignal-id"), endTime: Date(timeIntervalSince1970: 1_700_000_100)) + manager.enqueueSessionUpdate(record(nil), endTime: Date(timeIntervalSince1970: 1_700_000_100)) + manager.operationRepo.dispatchQueue.sync {} + + let sessionDeltas = manager.operationRepo.deltaQueue.filter { $0.name == OS_UPDATE_SESSION_DELTA } + XCTAssertEqual(sessionDeltas.map(\.property), ["pinned-onesignal-id"]) + let identityModel = try XCTUnwrap(manager.getIdentityModel(sessionDeltas[0].identityModelId)) + XCTAssertEqual(identityModel.onesignalId, "pinned-onesignal-id") + } + func testNewSessionQueuesNoCreateWhenTheFlagIsOff() { client.holdResponses = true From b55742e2733341718cdd9ceff3663e23c4548081 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 22 Oct 2026 12:12:47 -0700 Subject: [PATCH 2/7] fix: [SDK-5391] end a session killed in the foreground at its last focus Matches Android, which ends it at max(unfocus, focus) time. The focus that starts a new session is reported before the session starts, so that visit does not move the previous session's end. Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionService.swift | 35 ++++++++++-- .../OSSessionServiceTests.swift | 56 +++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index a53d93b4f..681df0f58 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -67,11 +67,13 @@ public struct OSSessionRecord: Codable, Equatable { public internal(set) var subscriptionId: String? /// Seconds since 1970 when the app last left the foreground. Nil until then. public internal(set) var lastUnfocusTime: TimeInterval? + /// Seconds since 1970 when the app last entered the foreground during this session. + public internal(set) var lastFocusTime: TimeInterval? - /// Seconds since 1970. A session killed before its first unfocus ends at its start, since its - /// foreground time was never added to the duration either. + /// Seconds since 1970. A session killed in the foreground never unfocused, so it ends at its + /// last focus. The visit the kill cut short is not in the duration either. public var endTime: TimeInterval { - lastUnfocusTime ?? startTime + max(startTime, lastUnfocusTime ?? startTime, lastFocusTime ?? startTime) } } @@ -110,6 +112,9 @@ public final class OSSessionService: NSObject { private var record: OSSessionRecord? /// In memory only: a stamp from another process may predate a reboot. private var focusedAt: TimeInterval? + /// The open visit's wall-clock focus time, and the record's focus time it replaced. A visit open + /// when a session starts belongs to the new session, so the ended one gets its earlier focus back. + private var openVisit: (focusTime: TimeInterval, replacedFocusTime: TimeInterval?)? init( storage: OneSignalUserDefaults = .initStandard(), @@ -176,7 +181,7 @@ public final class OSSessionService: NSObject { public func startNewSession(userProvider: OSSessionUserProvider) -> OSSessionStart { let previousIds = pinnableIds() let user = userProvider.sessionCurrentUser - let newRecord = OSSessionRecord( + var newRecord = OSSessionRecord( sessionId: UUID().uuidString, startTime: wallNow(), activeDuration: 0, @@ -189,7 +194,12 @@ public final class OSSessionService: NSObject { loadIfNeeded() // Last chance to fill the replaced record's IDs while it is still the current one. fillPinnedIds(previousIds) - let ended = record + var ended = record + if let visit = openVisit { + ended?.lastFocusTime = visit.replacedFocusTime + newRecord.lastFocusTime = visit.focusTime + openVisit = (visit.focusTime, nil) + } self.userProvider = userProvider record = newRecord persist() @@ -200,7 +210,19 @@ public final class OSSessionService: NSObject { func onFocus() { let now = monotonicNow() - stateLock.withLock { focusedAt = now } + let wallNow = wallNow() + stateLock.withLock { + focusedAt = now + loadIfNeeded() + let replacedFocusTime = openVisit.map(\.replacedFocusTime) ?? record?.lastFocusTime + openVisit = (wallNow, replacedFocusTime) + guard record != nil else { + return + } + // Saved now, since a kill leaves no unfocus to record when the session was last active. + record?.lastFocusTime = wallNow + persist() + } } func onUnfocus() { @@ -212,6 +234,7 @@ public final class OSSessionService: NSObject { return } focusedAt = nil + openVisit = nil loadIfNeeded() guard record != nil else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index e9fefafce..07ba563da 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -192,6 +192,7 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(record?.onesignalId, "onesignal-a") XCTAssertEqual(record?.subscriptionId, "subscription-a") XCTAssertNil(record?.lastUnfocusTime) + XCTAssertNil(record?.lastFocusTime) } func testClearingTheStoredRecordDropsIt() { @@ -396,6 +397,61 @@ extension OSSessionServiceTests { XCTAssertEqual(ended?.endTime, first?.startTime) } + func testSessionKilledAfterReturningToTheForegroundEndsAtItsLastFocus() { + let service = makeService() + let first = service.startNewSession(userProvider: user).started + visit(service, duration: 30, unfocusAt: 6_000) + wallNow = 7_000 + service.onFocus() + monotonicNow += 60 + + wallNow = 9_000 + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.sessionId, first.sessionId) + XCTAssertEqual(ended?.activeDuration, 30) + XCTAssertEqual(ended?.endTime, 7_000) + } + + /// The tracker reports the focus that starts a new session before the session starts. + func testResumePastTheThresholdDoesNotMoveThePreviousSessionsEnd() { + let service = makeService() + service.startNewSession(userProvider: user) + visit(service, duration: 30, unfocusAt: 6_000) + + wallNow = 9_000 + service.onFocus() + let start = service.startNewSession(userProvider: user) + + XCTAssertEqual(start.ended?.endTime, 6_000) + XCTAssertEqual(start.started.lastFocusTime, 9_000) + XCTAssertEqual(service.currentRecord?.lastFocusTime, 9_000) + } + + func testColdStartDoesNotMoveThePreviousSessionsEnd() { + let service = makeService() + service.startNewSession(userProvider: user) + visit(service, duration: 30, unfocusAt: 6_000) + + wallNow = 9_000 + let restarted = makeService() + restarted.onFocus() + let start = restarted.startNewSession(userProvider: user) + + XCTAssertEqual(start.ended?.endTime, 6_000) + XCTAssertEqual(start.started.lastFocusTime, 9_000) + } + + func testSessionStartedInTheBackgroundRecordsItsFirstFocus() { + let service = makeService() + service.startNewSession(userProvider: user) + + wallNow = 6_000 + service.onFocus() + + XCTAssertEqual(service.currentRecord?.lastFocusTime, 6_000) + } + /// The previous process ended before the user was created. func testSessionFromAnEarlierProcessEndsWithTheIdsAssignedSince() { user.onesignalIds = [:] From 8f463b54a3870b4b6de7c8c8740afba2e9ba7d78 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 22 Oct 2026 12:47:00 -0700 Subject: [PATCH 3/7] test: [SDK-5391] stop session requests leaking between session record tests Co-Authored-By: Claude Opus 5.5 --- .../OneSignalUserTests/SessionRecordUserTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift index 3a3fdec8f..b1f572287 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -50,6 +50,10 @@ final class SessionRecordUserTests: XCTestCase { override func tearDownWithError() throws { OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle } + // A polled session executor re-caches its queue, which the next test's executor would uncache. + let manager = OneSignalUserManagerImpl.sharedInstance + manager.operationRepo.reset() + _ = manager.sessionExecutor?.queuedRequests OSSessionService.reset() OneSignalCoreMocks.clearUserDefaults() OSFeatureFlagsStore.shared.clear() From 64fd0a8d637b4625682a6fccbcb80287d151f3f6 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 22 Oct 2026 13:40:44 -0700 Subject: [PATCH 4/7] fix: [SDK-5391] keep a session end queued for a user no longer loaded across a kill Co-Authored-By: Claude Opus 5.5 --- .../OSSessionOperationExecutor.swift | 17 +++++++++++-- .../OSSessionOperationExecutorTests.swift | 24 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index ae7c83d37..68e4c9c68 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -78,7 +78,7 @@ class OSSessionOperationExecutor: OSOperationExecutor { return } deltaQueue.removeAll { delta in - guard OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil else { + guard identityModel(for: delta) == nil else { return false } OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor.init dropped: \(delta)") @@ -184,10 +184,23 @@ class OSSessionOperationExecutor: OSOperationExecutor { // MARK: - Queueing private extension OSSessionOperationExecutor { + /// A session can end for a user that is no longer loaded, whose model only the Delta still holds. + func identityModel(for delta: OSDelta) -> OSIdentityModel? { + let manager = OneSignalUserManagerImpl.sharedInstance + if let identityModel = manager.getIdentityModel(delta.identityModelId) { + return identityModel + } + guard let identityModel = delta.model as? OSIdentityModel, identityModel.onesignalId != nil else { + return nil + } + manager.addIdentityModelToRepo(identityModel) + return identityModel + } + 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) + let identityModel = identityModel(for: delta) else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor.processDeltaQueue dropped: \(delta)") return nil diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 70242bf92..a0cab5c84 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -546,6 +546,30 @@ extension OSSessionOperationExecutorTests { XCTAssertEqual(update.path, "apps/test-app-id/sessions/server-1") } + func testEndForAUserNoLongerLoadedSurvivesAKill() throws { + let executor = makeExecutor() + createSession(executor) + executor.enqueueDelta(updateDelta(duration: 10, endTime: 1_700_000_100)) + executor.cacheDeltaQueue() + _ = executor.queuedRequests + OneSignalUserManagerImpl.sharedInstance.identityModelRepo.models = [:] + + flush(makeExecutor()) + + let end = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(end.path, "apps/test-app-id/sessions/server-1") + } + + func testEndForAUserNoLongerLoadedIsSentAfterARestart() throws { + createSession(makeExecutor()) + OneSignalUserManagerImpl.sharedInstance.identityModelRepo.models = [:] + + flush(makeExecutor(), updateDelta(duration: 10, endTime: 1_700_000_100)) + + let end = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(end.path, "apps/test-app-id/sessions/server-1") + } + func testUpdateAfterARestartUsesTheCreateFromBefore() throws { createSession(makeExecutor()) From 917e521b4feef2fb41024b83a00b9a21615655ac Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 22 Oct 2026 13:49:27 -0700 Subject: [PATCH 5/7] test: [SDK-5391] assert a queued end carries the ended session's last unfocus and duration Co-Authored-By: Claude Opus 5.5 --- .../OneSignalUserTests/SessionRecordUserTests.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift index b1f572287..b1a6719bd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -211,6 +211,8 @@ extension SessionRecordUserTests { let manager = OneSignalUserManagerImpl.sharedInstance manager.startNewSession() + OSSessionService.onFocus() + OSSessionService.onUnfocus() let first = OSSessionService.shared.currentRecord manager.startNewSession() let second = OSSessionService.shared.currentRecord @@ -221,7 +223,10 @@ extension SessionRecordUserTests { XCTAssertEqual(creates.count, 2) let end = queuedSessionUpdates().first XCTAssertEqual(end?.localSessionId, first?.sessionId) - XCTAssertEqual(end?.endTime?.timeIntervalSince1970, first?.startTime) + XCTAssertNotNil(first?.lastUnfocusTime) + XCTAssertEqual(end?.endTime?.timeIntervalSince1970, first?.lastUnfocusTime) + XCTAssertGreaterThan(first?.activeDuration ?? 0, 0) + XCTAssertEqual(end?.activeDuration, first?.activeDuration) client.releaseHeldResponses() } From 615c23504f2c45923c943985cbda5883e39307d4 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 23 Oct 2026 10:41:00 -0700 Subject: [PATCH 6/7] fix: [SDK-5391] keep a focus that starts a new session off the current record Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionService.swift | 32 +++++++++++-------- .../OSSessionServiceTests.swift | 28 ++++++++++++++-- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 681df0f58..14d2aaeb6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -112,9 +112,10 @@ public final class OSSessionService: NSObject { private var record: OSSessionRecord? /// In memory only: a stamp from another process may predate a reboot. private var focusedAt: TimeInterval? - /// The open visit's wall-clock focus time, and the record's focus time it replaced. A visit open - /// when a session starts belongs to the new session, so the ended one gets its earlier focus back. - private var openVisit: (focusTime: TimeInterval, replacedFocusTime: TimeInterval?)? + /// Wall-clock time of the open visit's focus. A visit open when a session starts belongs to it. + private var openFocusTime: TimeInterval? + /// A record loaded from disk belongs to an earlier process, so the next focus always replaces it. + private var recordStartedInThisProcess = false init( storage: OneSignalUserDefaults = .initStandard(), @@ -194,14 +195,11 @@ public final class OSSessionService: NSObject { loadIfNeeded() // Last chance to fill the replaced record's IDs while it is still the current one. fillPinnedIds(previousIds) - var ended = record - if let visit = openVisit { - ended?.lastFocusTime = visit.replacedFocusTime - newRecord.lastFocusTime = visit.focusTime - openVisit = (visit.focusTime, nil) - } + let ended = record + newRecord.lastFocusTime = openFocusTime self.userProvider = userProvider record = newRecord + recordStartedInThisProcess = true persist() return ended } @@ -214,9 +212,8 @@ public final class OSSessionService: NSObject { stateLock.withLock { focusedAt = now loadIfNeeded() - let replacedFocusTime = openVisit.map(\.replacedFocusTime) ?? record?.lastFocusTime - openVisit = (wallNow, replacedFocusTime) - guard record != nil else { + openFocusTime = wallNow + guard record != nil, focusContinuesRecord(at: wallNow) else { return } // Saved now, since a kill leaves no unfocus to record when the session was last active. @@ -225,6 +222,15 @@ public final class OSSessionService: NSObject { } } + /// A focus that will start a new session is not saved on the current record, or a kill before + /// that session starts would move the current session's end past its last unfocus. + private func focusContinuesRecord(at time: TimeInterval) -> Bool { + guard recordStartedInThisProcess, let lastUnfocusTime = record?.lastUnfocusTime else { + return recordStartedInThisProcess + } + return time - lastUnfocusTime < OSSessionStartGate.threshold + } + func onUnfocus() { let now = monotonicNow() let wallNow = wallNow() @@ -234,7 +240,7 @@ public final class OSSessionService: NSObject { return } focusedAt = nil - openVisit = nil + openFocusTime = nil loadIfNeeded() guard record != nil else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 07ba563da..fc8ba00c4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -401,7 +401,7 @@ extension OSSessionServiceTests { let service = makeService() let first = service.startNewSession(userProvider: user).started visit(service, duration: 30, unfocusAt: 6_000) - wallNow = 7_000 + wallNow = 6_020 service.onFocus() monotonicNow += 60 @@ -410,7 +410,31 @@ extension OSSessionServiceTests { XCTAssertEqual(ended?.sessionId, first.sessionId) XCTAssertEqual(ended?.activeDuration, 30) - XCTAssertEqual(ended?.endTime, 7_000) + XCTAssertEqual(ended?.endTime, 6_020) + } + + func testKillBetweenAResumePastTheThresholdAndItsSessionDoesNotMoveTheEnd() { + let service = makeService() + service.startNewSession(userProvider: user) + visit(service, duration: 30, unfocusAt: 6_000) + wallNow = 9_000 + service.onFocus() + + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.endTime, 6_000) + } + + func testKillBetweenAColdStartFocusAndItsSessionDoesNotMoveTheEnd() { + let service = makeService() + service.startNewSession(userProvider: user) + visit(service, duration: 30, unfocusAt: 6_000) + wallNow = 9_000 + makeService().onFocus() + + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.endTime, 6_000) } /// The tracker reports the focus that starts a new session before the session starts. From 84f5da0bb4686b7195aa88a3405039c0d773d8d9 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 26 Oct 2026 13:42:04 -0700 Subject: [PATCH 7/7] fix: [SDK-5391] measure a background session's focus threshold from its start Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionService.swift | 6 +++--- .../OSSessionServiceTests.swift | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 14d2aaeb6..6d5d8b838 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -225,10 +225,10 @@ public final class OSSessionService: NSObject { /// A focus that will start a new session is not saved on the current record, or a kill before /// that session starts would move the current session's end past its last unfocus. private func focusContinuesRecord(at time: TimeInterval) -> Bool { - guard recordStartedInThisProcess, let lastUnfocusTime = record?.lastUnfocusTime else { - return recordStartedInThisProcess + guard recordStartedInThisProcess, let record else { + return false } - return time - lastUnfocusTime < OSSessionStartGate.threshold + return time - (record.lastUnfocusTime ?? record.startTime) < OSSessionStartGate.threshold } func onUnfocus() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index fc8ba00c4..586b0449a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -466,14 +466,26 @@ extension OSSessionServiceTests { XCTAssertEqual(start.started.lastFocusTime, 9_000) } - func testSessionStartedInTheBackgroundRecordsItsFirstFocus() { + func testSessionStartedInTheBackgroundRecordsAFocusWithinTheThreshold() { let service = makeService() service.startNewSession(userProvider: user) + wallNow = 5_020 + service.onFocus() + + XCTAssertEqual(service.currentRecord?.lastFocusTime, 5_020) + } + + func testFirstForegroundPastTheThresholdDoesNotMoveABackgroundSessionsEnd() { + let service = makeService() + let background = service.startNewSession(userProvider: user).started + wallNow = 6_000 service.onFocus() + let start = service.startNewSession(userProvider: user) - XCTAssertEqual(service.currentRecord?.lastFocusTime, 6_000) + XCTAssertEqual(start.ended?.endTime, background.startTime) + XCTAssertEqual(start.started.lastFocusTime, 6_000) } /// The previous process ended before the user was created.