From 4ce6caf53cbaf7fcd81a1cf18347399800998c8a Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 7 Oct 2026 15:27:14 -0700 Subject: [PATCH 1/7] feat: [SDK-5371] session model and sessions v2 feature flag Persist a per-session record that captures the sdk_sessions_v2_api_cutover flag at session start, pins the OneSignal and push subscription IDs, and measures foreground time with a monotonic clock. Nothing reads the record while the flag is off. Co-Authored-By: Claude Opus 5.5 (1M context) --- OneSignal-KMP-SDK | 2 +- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../Source/OneSignalCommonDefines.h | 1 + .../Source/OSSessionService.swift | 233 ++++++++++++++++++ .../OSSessionServiceTests.swift | 222 +++++++++++++++++ .../Source/OneSignalUserManagerImpl.swift | 10 + iOS_SDK/OneSignalSDK/Source/OneSignal.m | 1 + .../OneSignalSDK/Source/OneSignalTracker.m | 2 + 8 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index ee61d4673..3a4ce0415 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit ee61d4673265876e5cab6328b7a0745f79b7a5aa +Subproject commit 3a4ce041547a5a68134cc75f70a4813dec32737e diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index a641076e0..e480a248d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -92,9 +92,11 @@ A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; }; 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 */; }; 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 */; }; 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 */; }; @@ -1902,9 +1904,11 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; @@ -2409,6 +2413,7 @@ 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */, 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, + A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */, A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, @@ -2736,6 +2741,7 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, + A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -4654,6 +4660,7 @@ A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */, A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, + A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */, A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, @@ -4849,6 +4856,7 @@ A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, + A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.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 e54cead69..73c45b635 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -112,6 +112,7 @@ #define OSUD_APP_LAST_CLOSED_TIME @"GT_LAST_CLOSED_TIME" // * OSUD_APP_LAST_CLOSED_TIME #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" // 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/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift new file mode 100644 index 000000000..97393eb64 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -0,0 +1,233 @@ +/* + 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 +@_implementationOnly import OneSignalKMP + +/// The user a session belongs to, supplied by the User module. +public protocol OSSessionUserProvider: AnyObject { + /// Local ID of the current user's identity model. It survives that user being created on + /// the backend, and every login gets a new one. + var sessionIdentityModelId: String? { get } + func sessionOnesignalId(identityModelId: String) -> String? + var sessionPushSubscriptionId: String? { get } +} + +public struct OSSessionRecord: Codable, Equatable { + public let sessionId: String + /// Seconds since 1970. + public let startTime: TimeInterval + /// Foreground time, measured with a monotonic clock so a wall-clock change cannot skew it. + public internal(set) var activeDuration: TimeInterval + /// 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? + /// Pinned at session start, so it stays the same after a login or user switch. + public internal(set) var onesignalId: String? + public internal(set) var subscriptionId: String? + /// Nil until the sessions API creates this session. + public internal(set) var serverSessionId: String? +} + +/// Owns the persisted record of the current session. Nothing reads the record while +/// `usesSessionsApi` is false, so with the flag off session behavior is unchanged. +@objc(OSSessionService) +public final class OSSessionService: NSObject { + private static let lock = NSLock() + private static var _shared: OSSessionService? + + public static var shared: OSSessionService { + lock.withLock { + if let existing = _shared { + return existing + } + let created = OSSessionService() + _shared = created + return created + } + } + + private let storage: OneSignalUserDefaults + private let featureManager: () -> OSFeatureManager + private let monotonicNow: () -> TimeInterval + private let wallNow: () -> TimeInterval + + private let stateLock = NSLock() + private weak var userProvider: OSSessionUserProvider? + private var loaded = false + private var record: OSSessionRecord? + /// In memory only: a stamp from another process may predate a reboot. + private var focusedAt: TimeInterval? + + init( + storage: OneSignalUserDefaults = .initStandard(), + featureManager: @escaping () -> OSFeatureManager = { .shared }, + monotonicNow: @escaping () -> TimeInterval = { + TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)) / TimeInterval(NSEC_PER_SEC) + }, + wallNow: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 } + ) { + self.storage = storage + self.featureManager = featureManager + self.monotonicNow = monotonicNow + self.wallNow = wallNow + super.init() + } + + @objc public static func onFocus() { + shared.onFocus() + } + + @objc public static func onUnfocus() { + shared.onUnfocus() + } + + @objc public static func reset() { + lock.withLock { _shared = nil } + } + + public var currentRecord: OSSessionRecord? { + let ids = pinnableIds() + return stateLock.withLock { + loadIfNeeded() + fillPinnedIds(ids) + return record + } + } + + public func startNewSession(userProvider: OSSessionUserProvider) { + let identityModelId = userProvider.sessionIdentityModelId + let newRecord = OSSessionRecord( + sessionId: UUID().uuidString, + startTime: wallNow(), + activeDuration: 0, + usesSessionsApi: featureManager().isEnabled(featureKey: FeatureFlag.sdkSessionsV2ApiCutover.key), + identityModelId: identityModelId, + onesignalId: identityModelId.flatMap { userProvider.sessionOnesignalId(identityModelId: $0) }, + subscriptionId: userProvider.sessionPushSubscriptionId, + serverSessionId: nil + ) + let now = monotonicNow() + stateLock.withLock { + self.userProvider = userProvider + loaded = true + record = newRecord + focusedAt = now + persist() + } + } + + func onFocus() { + let now = monotonicNow() + stateLock.withLock { focusedAt = now } + } + + func onUnfocus() { + let now = monotonicNow() + let ids = pinnableIds() + stateLock.withLock { + guard let start = focusedAt else { + return + } + focusedAt = nil + loadIfNeeded() + guard record != nil else { + return + } + record?.activeDuration += max(0, now - start) + fillPinnedIds(ids) + persist() + } + } + + private struct PinnableIds { + let identityModelId: String + let onesignalId: String? + let currentIdentityModelId: String? + let pushSubscriptionId: String? + } + + /// Read outside `stateLock` because the provider takes the User module's locks. + private func pinnableIds() -> PinnableIds? { + let pinned: (OSSessionUserProvider, String)? = stateLock.withLock { + loadIfNeeded() + guard let userProvider, let identityModelId = record?.identityModelId else { + return nil + } + return (userProvider, identityModelId) + } + guard let (provider, identityModelId) = pinned else { + return nil + } + return PinnableIds( + identityModelId: identityModelId, + onesignalId: provider.sessionOnesignalId(identityModelId: identityModelId), + currentIdentityModelId: provider.sessionIdentityModelId, + pushSubscriptionId: provider.sessionPushSubscriptionId + ) + } + + /// A session that starts before its user is created has no IDs to pin yet. Fill them in once + /// the backend assigns them, but only for the session's own user: after a login, the push + /// subscription is created for the new user, not the one pinned here. + private func fillPinnedIds(_ ids: PinnableIds?) { + guard let ids, var current = record, current.identityModelId == ids.identityModelId else { + return + } + if current.onesignalId == nil { + current.onesignalId = ids.onesignalId + } + if current.subscriptionId == nil, ids.currentIdentityModelId == ids.identityModelId { + current.subscriptionId = ids.pushSubscriptionId + } + guard current != record else { + return + } + record = current + persist() + } + + private func loadIfNeeded() { + guard !loaded else { + return + } + loaded = true + guard let data = storage.getSavedObject(forKey: OSUD_SESSION_RECORD, defaultValue: nil) as? Data else { + return + } + record = try? JSONDecoder().decode(OSSessionRecord.self, from: data) + } + + private func persist() { + guard let record, let data = try? JSONEncoder().encode(record) else { + return + } + storage.saveObject(forKey: OSUD_SESSION_RECORD, withValue: data) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift new file mode 100644 index 000000000..7c6a08595 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -0,0 +1,222 @@ +/* + 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 sessionIdentityModelId: String? + var onesignalIds: [String: String] = [:] + var sessionPushSubscriptionId: String? + + func sessionOnesignalId(identityModelId: String) -> String? { + onesignalIds[identityModelId] + } +} + +final class OSSessionServiceTests: XCTestCase { + private let flagKey = FeatureFlag.sdkSessionsV2ApiCutover.key + private var flagsStore: OSFeatureFlagsStore! + private var monotonicNow: TimeInterval = 1_000 + private var user: FakeUserProvider! + + override func setUp() { + super.setUp() + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + flagsStore = OSFeatureFlagsStore() + monotonicNow = 1_000 + user = FakeUserProvider() + user.sessionIdentityModelId = "model-a" + user.onesignalIds = ["model-a": "onesignal-a"] + user.sessionPushSubscriptionId = "subscription-a" + } + + override func tearDown() { + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + super.tearDown() + } + + private func makeService() -> OSSessionService { + let featureManager = OSFeatureManager(store: flagsStore) + return OSSessionService( + featureManager: { featureManager }, + monotonicNow: { [unowned self] in self.monotonicNow }, + wallNow: { 5_000 } + ) + } + + func testNewSessionUsesSessionsApiWhenFlagIsOn() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeService() + + service.startNewSession(userProvider: user) + + XCTAssertEqual(service.currentRecord?.usesSessionsApi, true) + } + + func testNewSessionUsesLegacyPathWhenFlagIsOff() { + let service = makeService() + + service.startNewSession(userProvider: user) + + XCTAssertEqual(service.currentRecord?.usesSessionsApi, false) + } + + func testSessionsApiChoiceDoesNotChangeMidSession() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeService() + service.startNewSession(userProvider: user) + + flagsStore.applyRemoteFlags([], metadata: nil) + service.onUnfocus() + service.onFocus() + + XCTAssertEqual(service.currentRecord?.usesSessionsApi, true) + } + + func testNewSessionPinsIdsAndHasNoServerSessionId() { + let service = makeService() + + service.startNewSession(userProvider: user) + + let record = service.currentRecord + XCTAssertEqual(record?.onesignalId, "onesignal-a") + XCTAssertEqual(record?.subscriptionId, "subscription-a") + XCTAssertEqual(record?.startTime, 5_000) + XCTAssertEqual(record?.activeDuration, 0) + XCTAssertNil(record?.serverSessionId) + } + + func testEachNewSessionGetsANewSessionId() { + let service = makeService() + service.startNewSession(userProvider: user) + let first = service.currentRecord?.sessionId + + service.startNewSession(userProvider: user) + + XCTAssertNotNil(first) + XCTAssertNotEqual(service.currentRecord?.sessionId, first) + } + + func testPinnedIdsStayTheSameAfterLogin() { + let service = makeService() + service.startNewSession(userProvider: user) + + user.sessionIdentityModelId = "model-b" + user.onesignalIds["model-b"] = "onesignal-b" + user.sessionPushSubscriptionId = "subscription-b" + service.onUnfocus() + + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") + } + + func testIdsAreFilledInOnceTheSessionUserIsCreated() { + user.onesignalIds = [:] + user.sessionPushSubscriptionId = nil + let service = makeService() + service.startNewSession(userProvider: user) + XCTAssertNil(service.currentRecord?.onesignalId) + XCTAssertNil(service.currentRecord?.subscriptionId) + + user.onesignalIds["model-a"] = "onesignal-a" + user.sessionPushSubscriptionId = "subscription-a" + + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") + } + + func testSubscriptionIdIsNotFilledInAfterLoginToAnotherUser() { + user.onesignalIds = [:] + user.sessionPushSubscriptionId = nil + let service = makeService() + service.startNewSession(userProvider: user) + + user.sessionIdentityModelId = "model-b" + user.onesignalIds = ["model-a": "onesignal-a", "model-b": "onesignal-b"] + user.sessionPushSubscriptionId = "subscription-b" + + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + XCTAssertNil(service.currentRecord?.subscriptionId) + } + + func testActiveDurationAddsEachForegroundInterval() { + let service = makeService() + service.startNewSession(userProvider: user) + + monotonicNow += 10 + service.onUnfocus() + monotonicNow += 100 + service.onFocus() + monotonicNow += 5 + service.onUnfocus() + + XCTAssertEqual(service.currentRecord?.activeDuration, 15) + } + + func testUnfocusWithoutFocusAddsNothing() { + let service = makeService() + service.startNewSession(userProvider: user) + monotonicNow += 10 + service.onUnfocus() + + monotonicNow += 50 + service.onUnfocus() + + XCTAssertEqual(service.currentRecord?.activeDuration, 10) + } + + func testRecordSurvivesARestart() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeService() + service.startNewSession(userProvider: user) + monotonicNow += 10 + service.onUnfocus() + let saved = service.currentRecord + + let restarted = makeService() + + XCTAssertNotNil(saved) + XCTAssertEqual(restarted.currentRecord, saved) + } + + func testUnfocusInANewProcessDoesNotCountTimeFromThePreviousOne() { + let service = makeService() + service.startNewSession(userProvider: user) + + let restarted = makeService() + monotonicNow += 10 + restarted.onUnfocus() + + XCTAssertEqual(restarted.currentRecord?.activeDuration, 0) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 1e5dc6a11..7506c744d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -686,6 +686,7 @@ extension OneSignalUserManagerImpl { userExecutor!.executePendingRequests() operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1, flush: true) + OSSessionService.shared.startNewSession(userProvider: self) // Fetch the user's data if there is a onesignal_id if let onesignalId = onesignalId { @@ -744,6 +745,15 @@ extension OneSignalUserManagerImpl { operationRepo.addFlushDeltaQueueToDispatchQueue(inBackground: true) } } +extension OneSignalUserManagerImpl: OSSessionUserProvider { + public var sessionIdentityModelId: String? { _user?.identityModel.modelId } + public var sessionPushSubscriptionId: String? { pushSubscriptionId } + + public func sessionOnesignalId(identityModelId: String) -> String? { + identityModelRepo.get(modelId: identityModelId)?.onesignalId + } +} + extension OneSignalUserManagerImpl: OSUser { public var User: OSUser { start() diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 195723348..43685d8be 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -152,6 +152,7 @@ + (void)clearStatics { [OSRemoteLoggingController reset]; [OSFeatureFlagsRefreshService reset]; [OSDeviceGestureDetector reset]; + [OSSessionService reset]; [OSFeatureManager reset]; [OneSignalIdentifiers setCurrentAppId:nil]; launchOptions = false; diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m b/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m index 19ae24fcf..537160b25 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m @@ -80,6 +80,7 @@ + (void)applicationBecameActive { OSSessionManager.sharedSessionManager.appEntryState = APP_OPEN; [OSSessionManager.sharedSessionManager setLastOpenedTime:[[NSDate date] timeIntervalSince1970]]; + [OSSessionService onFocus]; // on_session tracking when resumming app. if ([OneSignal shouldStartNewSession]) @@ -94,6 +95,7 @@ + (void)applicationBecameActive { + (void)applicationBackgrounded { [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:@"Application Backgrounded started"]; [self updateLastClosedTime]; + [OSSessionService onUnfocus]; let timeElapsed = [OSSessionManager.sharedSessionManager getTimeFocusedElapsed]; if (timeElapsed < -1) From 13c6ed64819f0a137d4fddf03eab062722e15954 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 7 Oct 2026 15:53:33 -0700 Subject: [PATCH 2/7] test: [SDK-5371] cover session start ordering and user module wiring Co-Authored-By: Claude Opus 5.5 (1M context) --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../OSSessionServiceTests.swift | 34 +++++++ .../SessionRecordUserTests.swift | 97 +++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index e480a248d..23fcebd86 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -226,6 +226,7 @@ 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */; }; 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */; }; A5426001A1B2C3D4E5F60006 /* InputGuardUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */; }; + A5172F01A1B2C3D4E5F60006 /* SessionRecordUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5172F01A1B2C3D4E5F60005 /* SessionRecordUserTests.swift */; }; 3CC063EF2B6D7FE8002BB07F /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; }; 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */; }; 3CC9A6342AFA1FDE008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */; }; @@ -1517,6 +1518,7 @@ 3CC063EB2B6D7FE8002BB07F /* OneSignalUserTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalUserTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserTests.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputGuardUserTests.swift; sourceTree = ""; }; + A5172F01A1B2C3D4E5F60005 /* SessionRecordUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRecordUserTests.swift; sourceTree = ""; }; 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConcurrencyTests.swift; sourceTree = ""; }; 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -2553,6 +2555,7 @@ 3CF11E3E2C6D61AC002856F5 /* Executors */, 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */, A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */, + A5172F01A1B2C3D4E5F60005 /* SessionRecordUserTests.swift */, 3CA93BC6300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift */, 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */, 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, @@ -4757,6 +4760,7 @@ 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, A5426001A1B2C3D4E5F60006 /* InputGuardUserTests.swift in Sources */, + A5172F01A1B2C3D4E5F60006 /* SessionRecordUserTests.swift in Sources */, 3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 7c6a08595..1cd22c21c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -183,6 +183,40 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(service.currentRecord?.activeDuration, 15) } + /// Resuming past the new-session threshold: the tracker reports focus, then the session + /// starts asynchronously on the main queue. + func testNewSessionStartedAfterFocusCountsFromSessionStart() { + let service = makeService() + service.startNewSession(userProvider: user) + monotonicNow += 10 + service.onUnfocus() + let previousSessionId = service.currentRecord?.sessionId + + monotonicNow += 60 + service.onFocus() + monotonicNow += 1 + service.startNewSession(userProvider: user) + monotonicNow += 20 + service.onUnfocus() + + XCTAssertNotEqual(service.currentRecord?.sessionId, previousSessionId) + XCTAssertEqual(service.currentRecord?.activeDuration, 20) + } + + /// A launch in the background starts the session before the app becomes active, so the time + /// spent in the background must not count. + func testFocusAfterBackgroundSessionStartCountsFromFocus() { + let service = makeService() + service.startNewSession(userProvider: user) + + monotonicNow += 100 + service.onFocus() + monotonicNow += 5 + service.onUnfocus() + + XCTAssertEqual(service.currentRecord?.activeDuration, 5) + } + func testUnfocusWithoutFocusAddsNothing() { let service = makeService() service.startNewSession(userProvider: user) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift new file mode 100644 index 000000000..e7f2df6c7 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -0,0 +1,97 @@ +/* + 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 OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +final class SessionRecordUserTests: XCTestCase { + private var client = MockOneSignalClient() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OSSessionService.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userA_EUID) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userB_EUID) + OneSignalCoreImpl.setSharedClient(client) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle } + OSSessionService.reset() + OneSignalCoreMocks.clearUserDefaults() + } + + func testSessionStartedBeforeUserIsCreatedPinsItsIdsOnceCreated() { + OneSignalUserManagerImpl.sharedInstance.startNewSession() + + OneSignalCoreMocks.waitUntil("Session did not pin the created user's IDs") { + let record = OSSessionService.shared.currentRecord + return record?.onesignalId == anonUserOSID && record?.subscriptionId == testPushSubId + } + } + + func testPinnedIdsStayTheSameAfterLoginsWithinASession() { + OneSignalUserManagerImpl.sharedInstance.startNewSession() + OneSignalCoreMocks.waitUntil("Anonymous user was not created") { + OSSessionService.shared.currentRecord?.onesignalId == anonUserOSID + } + let pinned = OSSessionService.shared.currentRecord + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + OneSignalCoreMocks.waitUntil("User B was not created") { + OneSignalUserManagerImpl.sharedInstance.onesignalId == userB_OSID + } + + let record = OSSessionService.shared.currentRecord + XCTAssertEqual(record?.sessionId, pinned?.sessionId) + XCTAssertEqual(record?.onesignalId, anonUserOSID) + XCTAssertEqual(record?.subscriptionId, pinned?.subscriptionId) + } + + func testNewSessionAfterLoginPinsTheNewUser() { + OneSignalUserManagerImpl.sharedInstance.startNewSession() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + OneSignalCoreMocks.waitUntil("User B was not created") { + OneSignalUserManagerImpl.sharedInstance.onesignalId == userB_OSID + } + + OneSignalUserManagerImpl.sharedInstance.startNewSession() + + XCTAssertEqual(OSSessionService.shared.currentRecord?.onesignalId, userB_OSID) + } +} From f9e63767c59b9f89677a87db9c657f598aee7904 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 7 Oct 2026 16:56:04 -0700 Subject: [PATCH 3/7] fix: [SDK-5371] address session record review feedback - Keep an open foreground interval when a session starts, so the visit that started it counts from focus and a background launch counts nothing. - Read the current user's IDs from one snapshot. - Fill in the subscription ID after a login that identified the same user. - Clear the stored record on an app-id change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Source/OSSessionService.swift | 57 +++++-- .../OSSessionServiceTests.swift | 154 +++++++++++------- .../OneSignalUserManagerImpl+Session.swift | 44 +++++ .../Source/OneSignalUserManagerImpl.swift | 9 - iOS_SDK/OneSignalSDK/Source/OneSignal.m | 1 + 6 files changed, 182 insertions(+), 87 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 23fcebd86..16e6803cf 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ D9F1B3E5A7C2495FC1E6B8D3 /* ExecutorRequestAgingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */; }; 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */; }; 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; + A5182F01A1B2C3D4E5F60002 /* OneSignalUserManagerImpl+Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5182F01A1B2C3D4E5F60001 /* OneSignalUserManagerImpl+Session.swift */; }; 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */; }; 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */; }; @@ -1604,6 +1605,7 @@ 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + A5182F01A1B2C3D4E5F60001 /* OneSignalUserManagerImpl+Session.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Session.swift"; sourceTree = ""; }; 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoTestSupport.swift; sourceTree = ""; }; 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSDeltaTests.swift; sourceTree = ""; }; 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; @@ -2977,6 +2979,7 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, + A5182F01A1B2C3D4E5F60001 /* OneSignalUserManagerImpl+Session.swift */, E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */, 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, A1C4E7F2B9D3468CA5E1F0B2 /* OSRequestAging.swift */, @@ -4999,6 +5002,7 @@ DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, + A5182F01A1B2C3D4E5F60002 /* OneSignalUserManagerImpl+Session.swift in Sources */, DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */, 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, B7D2A9C4E6F1483DA2C5E9F1 /* OSRequestAging.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 97393eb64..5a372bf9b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -29,13 +29,25 @@ import Foundation import OneSignalCore @_implementationOnly import OneSignalKMP +public struct OSSessionUser { + /// Local ID of the user's identity model. It survives that user being created on the + /// backend, and every login gets a new one. + public let identityModelId: String? + public let onesignalId: String? + public let pushSubscriptionId: String? + + public init(identityModelId: String?, onesignalId: String?, pushSubscriptionId: String?) { + self.identityModelId = identityModelId + self.onesignalId = onesignalId + self.pushSubscriptionId = pushSubscriptionId + } +} + /// The user a session belongs to, supplied by the User module. public protocol OSSessionUserProvider: AnyObject { - /// Local ID of the current user's identity model. It survives that user being created on - /// the backend, and every login gets a new one. - var sessionIdentityModelId: String? { get } + /// 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? - var sessionPushSubscriptionId: String? { get } } public struct OSSessionRecord: Codable, Equatable { @@ -112,6 +124,12 @@ public final class OSSessionService: NSObject { lock.withLock { _shared = nil } } + /// The stored record belongs to the previous app, so it must not outlive an app-id change. + @objc public static func resetAndClearStoredRecord() { + reset() + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) + } + public var currentRecord: OSSessionRecord? { let ids = pinnableIds() return stateLock.withLock { @@ -121,24 +139,25 @@ 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) { - let identityModelId = userProvider.sessionIdentityModelId + let user = userProvider.sessionCurrentUser let newRecord = OSSessionRecord( sessionId: UUID().uuidString, startTime: wallNow(), activeDuration: 0, usesSessionsApi: featureManager().isEnabled(featureKey: FeatureFlag.sdkSessionsV2ApiCutover.key), - identityModelId: identityModelId, - onesignalId: identityModelId.flatMap { userProvider.sessionOnesignalId(identityModelId: $0) }, - subscriptionId: userProvider.sessionPushSubscriptionId, + identityModelId: user.identityModelId, + onesignalId: user.onesignalId, + subscriptionId: user.pushSubscriptionId, serverSessionId: nil ) - let now = monotonicNow() stateLock.withLock { self.userProvider = userProvider loaded = true record = newRecord - focusedAt = now persist() } } @@ -169,8 +188,7 @@ public final class OSSessionService: NSObject { private struct PinnableIds { let identityModelId: String let onesignalId: String? - let currentIdentityModelId: String? - let pushSubscriptionId: String? + let currentUser: OSSessionUser } /// Read outside `stateLock` because the provider takes the User module's locks. @@ -188,14 +206,14 @@ public final class OSSessionService: NSObject { return PinnableIds( identityModelId: identityModelId, onesignalId: provider.sessionOnesignalId(identityModelId: identityModelId), - currentIdentityModelId: provider.sessionIdentityModelId, - pushSubscriptionId: provider.sessionPushSubscriptionId + currentUser: provider.sessionCurrentUser ) } /// A session that starts before its user is created has no IDs to pin yet. Fill them in once - /// the backend assigns them, but only for the session's own user: after a login, the push - /// subscription is created for the new user, not the one pinned here. + /// the backend assigns them, but only for the session's own user. The push subscription is + /// carried across logins, but one first created after a login belongs to the new user, unless + /// that login identified the same backend user. private func fillPinnedIds(_ ids: PinnableIds?) { guard let ids, var current = record, current.identityModelId == ids.identityModelId else { return @@ -203,8 +221,11 @@ public final class OSSessionService: NSObject { if current.onesignalId == nil { current.onesignalId = ids.onesignalId } - if current.subscriptionId == nil, ids.currentIdentityModelId == ids.identityModelId { - current.subscriptionId = ids.pushSubscriptionId + let currentUser = ids.currentUser + let isSameUser = currentUser.identityModelId == ids.identityModelId + || (currentUser.onesignalId != nil && currentUser.onesignalId == current.onesignalId) + if current.subscriptionId == nil, isSameUser { + current.subscriptionId = currentUser.pushSubscriptionId } guard current != record else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 1cd22c21c..9e5ab3e6e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -32,9 +32,17 @@ import OneSignalKMP import XCTest private final class FakeUserProvider: OSSessionUserProvider { - var sessionIdentityModelId: String? + var identityModelId: String? var onesignalIds: [String: String] = [:] - var sessionPushSubscriptionId: String? + var pushSubscriptionId: String? + + var sessionCurrentUser: OSSessionUser { + OSSessionUser( + identityModelId: identityModelId, + onesignalId: identityModelId.flatMap { onesignalIds[$0] }, + pushSubscriptionId: pushSubscriptionId + ) + } func sessionOnesignalId(identityModelId: String) -> String? { onesignalIds[identityModelId] @@ -44,6 +52,7 @@ private final class FakeUserProvider: OSSessionUserProvider { final class OSSessionServiceTests: XCTestCase { private let flagKey = FeatureFlag.sdkSessionsV2ApiCutover.key private var flagsStore: OSFeatureFlagsStore! + private var featureManager: OSFeatureManager! private var monotonicNow: TimeInterval = 1_000 private var user: FakeUserProvider! @@ -52,61 +61,71 @@ final class OSSessionServiceTests: XCTestCase { OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) flagsStore = OSFeatureFlagsStore() + featureManager = nil monotonicNow = 1_000 user = FakeUserProvider() - user.sessionIdentityModelId = "model-a" + user.identityModelId = "model-a" user.onesignalIds = ["model-a": "onesignal-a"] - user.sessionPushSubscriptionId = "subscription-a" + user.pushSubscriptionId = "subscription-a" } override func tearDown() { + OSSessionService.reset() OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) super.tearDown() } private func makeService() -> OSSessionService { - let featureManager = OSFeatureManager(store: flagsStore) + let manager = featureManager ?? OSFeatureManager(store: flagsStore) + featureManager = manager return OSSessionService( - featureManager: { featureManager }, + featureManager: { manager }, monotonicNow: { [unowned self] in self.monotonicNow }, wallNow: { 5_000 } ) } + /// The tracker reports focus, then the session starts asynchronously on the main queue. + private func makeForegroundSession() -> OSSessionService { + let service = makeService() + service.onFocus() + service.startNewSession(userProvider: user) + return service + } + + // MARK: - Feature flag + func testNewSessionUsesSessionsApiWhenFlagIsOn() { flagsStore.applyRemoteFlags([flagKey], metadata: nil) - let service = makeService() - service.startNewSession(userProvider: user) + let service = makeForegroundSession() XCTAssertEqual(service.currentRecord?.usesSessionsApi, true) } func testNewSessionUsesLegacyPathWhenFlagIsOff() { - let service = makeService() - - service.startNewSession(userProvider: user) + let service = makeForegroundSession() XCTAssertEqual(service.currentRecord?.usesSessionsApi, false) } func testSessionsApiChoiceDoesNotChangeMidSession() { flagsStore.applyRemoteFlags([flagKey], metadata: nil) - let service = makeService() - service.startNewSession(userProvider: user) + let service = makeForegroundSession() flagsStore.applyRemoteFlags([], metadata: nil) service.onUnfocus() service.onFocus() + XCTAssertFalse(featureManager.isEnabled(featureKey: flagKey)) XCTAssertEqual(service.currentRecord?.usesSessionsApi, true) } - func testNewSessionPinsIdsAndHasNoServerSessionId() { - let service = makeService() + // MARK: - Record - service.startNewSession(userProvider: user) + func testNewSessionPinsIdsAndHasNoServerSessionId() { + let service = makeForegroundSession() let record = service.currentRecord XCTAssertEqual(record?.onesignalId, "onesignal-a") @@ -117,8 +136,7 @@ final class OSSessionServiceTests: XCTestCase { } func testEachNewSessionGetsANewSessionId() { - let service = makeService() - service.startNewSession(userProvider: user) + let service = makeForegroundSession() let first = service.currentRecord?.sessionId service.startNewSession(userProvider: user) @@ -127,13 +145,35 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertNotEqual(service.currentRecord?.sessionId, first) } + func testRecordSurvivesARestart() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeForegroundSession() + monotonicNow += 10 + service.onUnfocus() + let saved = service.currentRecord + + let restarted = makeService() + + XCTAssertNotNil(saved) + XCTAssertEqual(restarted.currentRecord, saved) + } + + func testClearingTheStoredRecordDropsIt() { + _ = makeForegroundSession() + + OSSessionService.resetAndClearStoredRecord() + + XCTAssertNil(makeService().currentRecord) + } + + // MARK: - Pinned IDs + func testPinnedIdsStayTheSameAfterLogin() { - let service = makeService() - service.startNewSession(userProvider: user) + let service = makeForegroundSession() - user.sessionIdentityModelId = "model-b" + user.identityModelId = "model-b" user.onesignalIds["model-b"] = "onesignal-b" - user.sessionPushSubscriptionId = "subscription-b" + user.pushSubscriptionId = "subscription-b" service.onUnfocus() XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") @@ -142,14 +182,13 @@ final class OSSessionServiceTests: XCTestCase { func testIdsAreFilledInOnceTheSessionUserIsCreated() { user.onesignalIds = [:] - user.sessionPushSubscriptionId = nil - let service = makeService() - service.startNewSession(userProvider: user) + user.pushSubscriptionId = nil + let service = makeForegroundSession() XCTAssertNil(service.currentRecord?.onesignalId) XCTAssertNil(service.currentRecord?.subscriptionId) user.onesignalIds["model-a"] = "onesignal-a" - user.sessionPushSubscriptionId = "subscription-a" + user.pushSubscriptionId = "subscription-a" XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") @@ -157,21 +196,35 @@ final class OSSessionServiceTests: XCTestCase { func testSubscriptionIdIsNotFilledInAfterLoginToAnotherUser() { user.onesignalIds = [:] - user.sessionPushSubscriptionId = nil - let service = makeService() - service.startNewSession(userProvider: user) + user.pushSubscriptionId = nil + let service = makeForegroundSession() - user.sessionIdentityModelId = "model-b" + user.identityModelId = "model-b" user.onesignalIds = ["model-a": "onesignal-a", "model-b": "onesignal-b"] - user.sessionPushSubscriptionId = "subscription-b" + user.pushSubscriptionId = "subscription-b" XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") XCTAssertNil(service.currentRecord?.subscriptionId) } + /// Identifying the anonymous user gives the new identity model the same backend user. + func testSubscriptionIdIsFilledInAfterLoginIdentifiesTheSameUser() { + user.onesignalIds = [:] + user.pushSubscriptionId = nil + let service = makeForegroundSession() + + user.identityModelId = "model-b" + user.onesignalIds = ["model-a": "onesignal-a", "model-b": "onesignal-a"] + user.pushSubscriptionId = "subscription-a" + + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") + } + + // MARK: - Active duration + func testActiveDurationAddsEachForegroundInterval() { - let service = makeService() - service.startNewSession(userProvider: user) + let service = makeForegroundSession() monotonicNow += 10 service.onUnfocus() @@ -183,11 +236,10 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(service.currentRecord?.activeDuration, 15) } - /// Resuming past the new-session threshold: the tracker reports focus, then the session - /// starts asynchronously on the main queue. - func testNewSessionStartedAfterFocusCountsFromSessionStart() { - let service = makeService() - service.startNewSession(userProvider: user) + /// 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() { + let service = makeForegroundSession() monotonicNow += 10 service.onUnfocus() let previousSessionId = service.currentRecord?.sessionId @@ -200,12 +252,10 @@ final class OSSessionServiceTests: XCTestCase { service.onUnfocus() XCTAssertNotEqual(service.currentRecord?.sessionId, previousSessionId) - XCTAssertEqual(service.currentRecord?.activeDuration, 20) + XCTAssertEqual(service.currentRecord?.activeDuration, 21) } - /// A launch in the background starts the session before the app becomes active, so the time - /// spent in the background must not count. - func testFocusAfterBackgroundSessionStartCountsFromFocus() { + func testSessionStartedInTheBackgroundCountsFromFocus() { let service = makeService() service.startNewSession(userProvider: user) @@ -218,8 +268,7 @@ final class OSSessionServiceTests: XCTestCase { } func testUnfocusWithoutFocusAddsNothing() { - let service = makeService() - service.startNewSession(userProvider: user) + let service = makeForegroundSession() monotonicNow += 10 service.onUnfocus() @@ -229,23 +278,8 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(service.currentRecord?.activeDuration, 10) } - func testRecordSurvivesARestart() { - flagsStore.applyRemoteFlags([flagKey], metadata: nil) - let service = makeService() - service.startNewSession(userProvider: user) - monotonicNow += 10 - service.onUnfocus() - let saved = service.currentRecord - - let restarted = makeService() - - XCTAssertNotNil(saved) - XCTAssertEqual(restarted.currentRecord, saved) - } - func testUnfocusInANewProcessDoesNotCountTimeFromThePreviousOne() { - let service = makeService() - service.startNewSession(userProvider: user) + _ = makeForegroundSession() let restarted = makeService() monotonicNow += 10 diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift new file mode 100644 index 000000000..fca1f94d0 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -0,0 +1,44 @@ +/* + 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 OneSignalCore +import OneSignalOSCore + +extension OneSignalUserManagerImpl: OSSessionUserProvider { + public var sessionCurrentUser: OSSessionUser { + let user = _user + return OSSessionUser( + identityModelId: user?.identityModel.modelId, + onesignalId: user?.identityModel.onesignalId, + pushSubscriptionId: user?.pushSubscriptionModel.subscriptionId + ) + } + + public func sessionOnesignalId(identityModelId: String) -> String? { + identityModelRepo.get(modelId: identityModelId)?.onesignalId + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 7506c744d..6de1f1c8e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -745,15 +745,6 @@ extension OneSignalUserManagerImpl { operationRepo.addFlushDeltaQueueToDispatchQueue(inBackground: true) } } -extension OneSignalUserManagerImpl: OSSessionUserProvider { - public var sessionIdentityModelId: String? { _user?.identityModel.modelId } - public var sessionPushSubscriptionId: String? { pushSubscriptionId } - - public func sessionOnesignalId(identityModelId: String) -> String? { - identityModelRepo.get(modelId: identityModelId)?.onesignalId - } -} - extension OneSignalUserManagerImpl: OSUser { public var User: OSUser { start() diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 43685d8be..3e10b908a 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -756,6 +756,7 @@ + (void)handleAppIdChange:(NSString*)appId { // Flags are app-scoped but stored unscoped, and APP_STARTUP flags never unlatch // within a process, so both the cache and the latch have to go. [OSFeatureManager resetAndClearCachedFlags]; + [OSSessionService resetAndClearStoredRecord]; let sharedUserDefaults = OneSignalUserDefaults.initShared; From 0a8876fe86e05f0d8a1d1ecc0c67c2d62b58503c Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 8 Oct 2026 13:05:04 -0700 Subject: [PATCH 4/7] fix: [SDK-5371] pin session IDs as soon as the backend assigns them Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionService.swift | 8 +++++++ .../OSSessionServiceTests.swift | 15 ++++++++++++ .../Source/OSIdentityModel.swift | 1 + .../Source/OSSubscriptionModel.swift | 3 +++ .../SessionRecordUserTests.swift | 23 ++++++++++++++++++- 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 5a372bf9b..0a76be9ee 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -139,6 +139,14 @@ public final class OSSessionService: NSObject { } } + /// The User module calls this as soon as the backend assigns a user or subscription ID. Filling + /// only on the next read could be too late: a login in between switches the current user, and + /// the subscription can then no longer be attributed to this session's user. + public func refreshPinnedIds() { + let ids = pinnableIds() + stateLock.withLock { fillPinnedIds(ids) } + } + /// 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. diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 9e5ab3e6e..5d8bb437c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -207,6 +207,21 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertNil(service.currentRecord?.subscriptionId) } + func testIdsRefreshedBeforeLoginToAnotherUserStayPinned() { + user.onesignalIds = [:] + user.pushSubscriptionId = nil + let service = makeForegroundSession() + + user.onesignalIds["model-a"] = "onesignal-a" + user.pushSubscriptionId = "subscription-a" + service.refreshPinnedIds() + user.identityModelId = "model-b" + user.onesignalIds["model-b"] = "onesignal-b" + + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") + } + /// Identifying the anonymous user gives the new identity model the same backend user. func testSubscriptionIdIsFilledInAfterLoginIdentifiesTheSameUser() { user.onesignalIds = [:] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index 06a362887..55cfd64be 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -167,6 +167,7 @@ class OSIdentityModel: OSModel { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSIdentityModel hydrateModel with aliases: \(remoteAliases)") // Reporting the user to the app is the executor's call, since only a current user may be reported. internalAddAliases(remoteAliases) + OSSessionService.shared.refreshPinnedIds() } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift index 26f829c11..e18650349 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -226,6 +226,9 @@ class OSSubscriptionModel: OSModel { .subscriptionId(oldValue, remoteDisabledReason: previousRemoteDisabledReason), generateEnabledDelta: false ) + if newValue != nil { + OSSessionService.shared.refreshPinnedIds() + } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift index e7f2df6c7..6b242296f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -66,7 +66,8 @@ final class SessionRecordUserTests: XCTestCase { func testPinnedIdsStayTheSameAfterLoginsWithinASession() { OneSignalUserManagerImpl.sharedInstance.startNewSession() OneSignalCoreMocks.waitUntil("Anonymous user was not created") { - OSSessionService.shared.currentRecord?.onesignalId == anonUserOSID + let record = OSSessionService.shared.currentRecord + return record?.onesignalId == anonUserOSID && record?.subscriptionId == testPushSubId } let pinned = OSSessionService.shared.currentRecord @@ -82,6 +83,26 @@ final class SessionRecordUserTests: XCTestCase { XCTAssertEqual(record?.subscriptionId, pinned?.subscriptionId) } + /// Reading the record fills it, so this waits on the User module instead and reads only at the end. + func testIdsAssignedBeforeALoginArePinnedWithoutTheRecordBeingRead() { + OneSignalUserManagerImpl.sharedInstance.startNewSession() + OneSignalCoreMocks.waitUntil("Anonymous user was not created") { + OneSignalUserManagerImpl.sharedInstance.onesignalId == anonUserOSID + && OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId == testPushSubId + } + + // An existing external ID: Identify User conflicts, and a new user is created for it. + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userB_EUID, conflicted: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + OneSignalCoreMocks.waitUntil("User B was not created") { + OneSignalUserManagerImpl.sharedInstance.onesignalId == userB_OSID + } + + let record = OSSessionService.shared.currentRecord + XCTAssertEqual(record?.onesignalId, anonUserOSID) + XCTAssertEqual(record?.subscriptionId, testPushSubId) + } + func testNewSessionAfterLoginPinsTheNewUser() { OneSignalUserManagerImpl.sharedInstance.startNewSession() OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) From cd293edceac1ade78e8c04f91a1486a0a180078d Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 8 Oct 2026 13:15:05 -0700 Subject: [PATCH 5/7] fix: [SDK-5371] fill a session record left by the previous process Co-Authored-By: Claude Opus 5.5 --- .../OneSignalOSCore/Source/OSSessionService.swift | 7 +++++++ .../OSSessionServiceTests.swift | 15 +++++++++++++++ .../Source/OneSignalUserManagerImpl.swift | 3 +++ .../SessionRecordUserTests.swift | 15 +++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 0a76be9ee..97c69d498 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -139,6 +139,13 @@ public final class OSSessionService: NSObject { } } + /// Set at launch, not only when a session starts: a record loaded from disk still needs its IDs + /// filled in if the previous process exited before the backend assigned them. + public func setUserProvider(_ userProvider: OSSessionUserProvider) { + stateLock.withLock { self.userProvider = userProvider } + refreshPinnedIds() + } + /// The User module calls this as soon as the backend assigns a user or subscription ID. Filling /// only on the next read could be too late: a login in between switches the current user, and /// the subscription can then no longer be attributed to this session's user. diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 5d8bb437c..9a5bd4ba6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -158,6 +158,21 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(restarted.currentRecord, saved) } + func testIdsAreFilledInAfterARestartBeforeTheUserIsCreated() { + user.onesignalIds = [:] + user.pushSubscriptionId = nil + _ = makeForegroundSession() + + let restarted = makeService() + restarted.setUserProvider(user) + user.onesignalIds["model-a"] = "onesignal-a" + user.pushSubscriptionId = "subscription-a" + restarted.refreshPinnedIds() + + XCTAssertEqual(restarted.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(restarted.currentRecord?.subscriptionId, "subscription-a") + } + func testClearingTheStoredRecordDropsIt() { _ = makeForegroundSession() diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 6de1f1c8e..7d9745679 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -308,6 +308,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self?.pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabledInternally = false } + // Before the executors, so a hydrate they cause can fill a session record left by the last process. + OSSessionService.shared.setUserProvider(self) + // Setup the executors // The OSUserExecutor has to run first, before other executors self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: identityVerificationService, auth: requestAuth) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift index 6b242296f..ec9a3a868 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -103,6 +103,21 @@ final class SessionRecordUserTests: XCTestCase { XCTAssertEqual(record?.subscriptionId, testPushSubId) } + /// The last process started a session and was killed before its anonymous user was created. + func testRecordFromThePreviousProcessIsFilledInOnceTheUserIsCreated() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.setNewInternalUser(externalId: nil, pushSubscriptionModel: nil) + OSSessionService.shared.startNewSession(userProvider: manager) + OSSessionService.reset() + + manager.start() + + OneSignalCoreMocks.waitUntil("Session record was not filled in") { + let record = OSSessionService.shared.currentRecord + return record?.onesignalId == anonUserOSID && record?.subscriptionId == testPushSubId + } + } + func testNewSessionAfterLoginPinsTheNewUser() { OneSignalUserManagerImpl.sharedInstance.startNewSession() OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) From 64eb8098c427046090992ae919b938ff5ba69dd7 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 8 Oct 2026 13:27:28 -0700 Subject: [PATCH 6/7] fix: [SDK-5371] guard stored session records against non-optional fields Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionService.swift | 8 +++++++- .../OSSessionServiceTests.swift | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 97c69d498..a14c82543 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -50,6 +50,8 @@ public protocol OSSessionUserProvider: AnyObject { func sessionOnesignalId(identityModelId: String) -> String? } +/// New fields must be Optional. A record stored by an earlier version lacks them, and synthesized +/// decoding fails on a missing non-Optional field even when it has a default value. public struct OSSessionRecord: Codable, Equatable { public let sessionId: String /// Seconds since 1970. @@ -257,7 +259,11 @@ public final class OSSessionService: NSObject { guard let data = storage.getSavedObject(forKey: OSUD_SESSION_RECORD, defaultValue: nil) as? Data else { return } - record = try? JSONDecoder().decode(OSSessionRecord.self, from: data) + do { + record = try JSONDecoder().decode(OSSessionRecord.self, from: data) + } catch { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionService dropping a stored session record it cannot decode: \(error)") + } } private func persist() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 9a5bd4ba6..f89f936c0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -173,6 +173,26 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(restarted.currentRecord?.subscriptionId, "subscription-a") } + /// A record as stored by the first version. If this fails, a new field was not made Optional, and + /// upgrading mid-session would drop the session. + func testRecordStoredByTheFirstVersionStillDecodes() { + let stored = """ + {"sessionId":"session-1","startTime":5000,"activeDuration":12,"usesSessionsApi":true,\ + "identityModelId":"model-a","onesignalId":"onesignal-a","subscriptionId":"subscription-a"} + """ + OneSignalUserDefaults.initStandard().saveObject(forKey: OSUD_SESSION_RECORD, withValue: Data(stored.utf8)) + + let record = makeService().currentRecord + + XCTAssertEqual(record?.sessionId, "session-1") + XCTAssertEqual(record?.startTime, 5_000) + XCTAssertEqual(record?.activeDuration, 12) + XCTAssertEqual(record?.usesSessionsApi, true) + XCTAssertEqual(record?.onesignalId, "onesignal-a") + XCTAssertEqual(record?.subscriptionId, "subscription-a") + XCTAssertNil(record?.serverSessionId) + } + func testClearingTheStoredRecordDropsIt() { _ = makeForegroundSession() From dcd6bd57fbd086500a80870a4c4d21376a77da98 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 8 Oct 2026 14:49:12 -0700 Subject: [PATCH 7/7] fix: [SDK-5371] fill the session subscription ID after a login conflict The push subscription is carried across logins, so its ID is always this device's. A login that conflicted before the anonymous user was created left the session with no subscription ID, so it could never be sent. Co-Authored-By: Claude Opus 5.5 --- .../Source/OSSessionService.swift | 17 +++++++--------- .../OSSessionServiceTests.swift | 7 ++++--- .../SessionRecordUserTests.swift | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index a14c82543..9c6ef0a07 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -205,7 +205,7 @@ public final class OSSessionService: NSObject { private struct PinnableIds { let identityModelId: String let onesignalId: String? - let currentUser: OSSessionUser + let pushSubscriptionId: String? } /// Read outside `stateLock` because the provider takes the User module's locks. @@ -223,14 +223,14 @@ public final class OSSessionService: NSObject { return PinnableIds( identityModelId: identityModelId, onesignalId: provider.sessionOnesignalId(identityModelId: identityModelId), - currentUser: provider.sessionCurrentUser + pushSubscriptionId: provider.sessionCurrentUser.pushSubscriptionId ) } /// A session that starts before its user is created has no IDs to pin yet. Fill them in once - /// the backend assigns them, but only for the session's own user. The push subscription is - /// carried across logins, but one first created after a login belongs to the new user, unless - /// that login identified the same backend user. + /// the backend assigns them. The onesignal_id is the session's own user's. The push + /// subscription is the same device subscription across logins, even when a login conflict + /// moves it to another user, so it is filled from the current one. private func fillPinnedIds(_ ids: PinnableIds?) { guard let ids, var current = record, current.identityModelId == ids.identityModelId else { return @@ -238,11 +238,8 @@ public final class OSSessionService: NSObject { if current.onesignalId == nil { current.onesignalId = ids.onesignalId } - let currentUser = ids.currentUser - let isSameUser = currentUser.identityModelId == ids.identityModelId - || (currentUser.onesignalId != nil && currentUser.onesignalId == current.onesignalId) - if current.subscriptionId == nil, isSameUser { - current.subscriptionId = currentUser.pushSubscriptionId + if current.subscriptionId == nil { + current.subscriptionId = ids.pushSubscriptionId } guard current != record else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index f89f936c0..305655133 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -229,17 +229,18 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") } - func testSubscriptionIdIsNotFilledInAfterLoginToAnotherUser() { + /// The push subscription is carried to the new user, so its ID is still this device's. + func testSubscriptionIdIsFilledInAfterLoginToAnotherUser() { user.onesignalIds = [:] user.pushSubscriptionId = nil let service = makeForegroundSession() user.identityModelId = "model-b" user.onesignalIds = ["model-a": "onesignal-a", "model-b": "onesignal-b"] - user.pushSubscriptionId = "subscription-b" + user.pushSubscriptionId = "subscription-a" XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") - XCTAssertNil(service.currentRecord?.subscriptionId) + XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") } func testIdsRefreshedBeforeLoginToAnotherUserStayPinned() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift index ec9a3a868..36de2fb86 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -103,6 +103,26 @@ final class SessionRecordUserTests: XCTestCase { XCTAssertEqual(record?.subscriptionId, testPushSubId) } + /// A new install that logs in to an existing user before its anonymous user is created. + func testLoginConflictBeforeTheAnonymousUserIsCreatedStillFillsTheSession() { + client.holdResponses = true + OneSignalUserManagerImpl.sharedInstance.startNewSession() + OneSignalCoreMocks.waitUntil("Create User was not sent") { + self.client.startedRequestCount(ofType: OSRequestCreateUser.self) == 1 + } + + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userB_EUID, conflicted: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + client.releaseHeldResponses() + OneSignalCoreMocks.waitUntil("User B was not created") { + OneSignalUserManagerImpl.sharedInstance.onesignalId == userB_OSID + } + + let record = OSSessionService.shared.currentRecord + XCTAssertEqual(record?.onesignalId, anonUserOSID) + XCTAssertEqual(record?.subscriptionId, testPushSubId) + } + /// The last process started a session and was killed before its anonymous user was created. func testRecordFromThePreviousProcessIsFilledInOnceTheUserIsCreated() { let manager = OneSignalUserManagerImpl.sharedInstance