From b4f0d510e9ad9773d2eae60d9142aefc428310aa Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 30 Oct 2026 10:44:41 -0700 Subject: [PATCH 1/3] feat: [SDK-5400] attribute a session started by a notification open Send direct_attribution_id on the session create when a notification open from the background starts the session. On a cold start the launch's session usually starts before iOS delivers the open, so the ID is folded into that session's still-queued create. Co-Authored-By: Claude Opus 5.5 --- .../Source/OneSignalCommonDefines.h | 1 + .../OSNotificationsManager.m | 2 + .../Source/OSOperationRepo.swift | 3 +- .../Source/OSSessionService.swift | 49 +++++++- .../OSSessionHeartbeatTests.swift | 2 + .../OSSessionServiceTests.swift | 112 ++++++++++++++++++ .../OSSessionOperationExecutor.swift | 17 ++- .../OneSignalUserManagerImpl+Session.swift | 7 +- .../Source/Requests/OSSessionRequests.swift | 17 ++- .../OSSessionOperationExecutorTests.swift | 60 ++++++++++ .../SessionRecordUserTests.swift | 34 ++++++ 11 files changed, 295 insertions(+), 9 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 331f30ea7..adaf89529 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -360,6 +360,7 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CREATE_SESSION_DELTA @"OS_CREATE_SESSION_DELTA" #define OS_UPDATE_SESSION_DELTA @"OS_UPDATE_SESSION_DELTA" +#define OS_ATTRIBUTE_SESSION_DELTA @"OS_ATTRIBUTE_SESSION_DELTA" // Operation Repo #define OS_OPERATION_REPO_DELTA_QUEUE_KEY @"OS_OPERATION_REPO_DELTA_QUEUE_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m b/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m index d855d0c7a..2981ac0f4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m +++ b/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m @@ -738,6 +738,8 @@ + (void)handleNotificationOpened:(NSDictionary*)messageDict if (!isActive) { OSSessionManager.sharedSessionManager.appEntryState = NOTIFICATION_CLICK; [[OSSessionManager sharedSessionManager] onDirectInfluenceFromNotificationOpen:NOTIFICATION_CLICK withNotificationId:messageId]; + if (messageId.length > 0) + [OSSessionService onNotificationOpened:messageId]; } [self handleNotificationActionWithUrl:notification.launchURL actionID:actionID]; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index 0862bd6dc..b3c0c4faf 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -252,7 +252,8 @@ public class OSOperationRepo: NSObject { private static let deltasExemptFromIdentityVerification: Set = [ OS_UPDATE_SUBSCRIPTION_DELTA, OS_CREATE_SESSION_DELTA, - OS_UPDATE_SESSION_DELTA + OS_UPDATE_SESSION_DELTA, + OS_ATTRIBUTE_SESSION_DELTA ] private func flushDeltaQueue(inBackground: Bool = false) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index ce7696ac5..3597626e2 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? /// Called with the record whose `reportedDuration` is due to be sent, on any thread. func sessionHeartbeat(_ record: OSSessionRecord) + /// Called when a notification open brings a session to the foreground after its create was queued. + func sessionAttributed(_ record: OSSessionRecord, directAttributionId: String) } /// New fields must be Optional. A record stored by an earlier version lacks them, and synthesized @@ -94,6 +96,8 @@ public struct OSSessionRecord: Codable, Equatable { public struct OSSessionStart { public let started: OSSessionRecord + /// The notification whose open started the session. + public let directAttributionId: String? /// The session the new one replaced, which has now ended. public let ended: OSSessionRecord? } @@ -138,6 +142,9 @@ public final class OSSessionService: NSObject { private var recordStartedInThisProcess = false /// Bumped whenever the heartbeat restarts or stops, so a check scheduled before then does nothing. private var heartbeatGeneration = 0 + /// A notification opened from the background, for the session its focus starts. A focus that + /// continues the current session, or a later unfocus, discards it. + private var pendingAttributionId: String? init( storage: OneSignalUserDefaults = .initStandard(), @@ -166,6 +173,10 @@ public final class OSSessionService: NSObject { shared.onUnfocus() } + @objc public static func onNotificationOpened(_ notificationId: String) { + shared.onNotificationOpened(notificationId) + } + @objc public static func reset() { let retired = lock.withLock { let existing = _shared @@ -227,11 +238,13 @@ public final class OSSessionService: NSObject { onesignalId: user.onesignalId, subscriptionId: user.pushSubscriptionId ) - let ended: OSSessionRecord? = stateLock.withLock { + let (ended, directAttributionId): (OSSessionRecord?, String?) = stateLock.withLock { loadIfNeeded() // Last chance to fill the replaced record's IDs while it is still the current one. fillPinnedIds(previousIds) let ended = record + let directAttributionId = pendingAttributionId + pendingAttributionId = nil newRecord.lastFocusTime = openFocusTime self.userProvider = userProvider record = newRecord @@ -239,9 +252,9 @@ public final class OSSessionService: NSObject { // The replaced session's heartbeat stops here, before its record is gone. heartbeatGeneration += 1 persist() - return ended + return (ended, directAttributionId) } - let start = OSSessionStart(started: newRecord, ended: ended) + let start = OSSessionStart(started: newRecord, directAttributionId: directAttributionId, ended: ended) enqueue(start) restartHeartbeat(continuesRecord: true) return start @@ -257,6 +270,7 @@ public final class OSSessionService: NSObject { guard record != nil, focusContinuesRecord(at: wallNow) else { return false } + pendingAttributionId = nil // Saved now, since a kill leaves no unfocus to record when the session was last active. record?.lastFocusTime = wallNow persist() @@ -280,6 +294,7 @@ public final class OSSessionService: NSObject { let wallNow = wallNow() let ids = pinnableIds() stateLock.withLock { + pendingAttributionId = nil guard let start = focusedAt else { return } @@ -298,6 +313,34 @@ public final class OSSessionService: NSObject { } } + /// Called for an open from the background, before the app's focus. The session that focus starts + /// is attributed to it. On a cold start the launch's session usually starts first, before any + /// focus, and is the one the open brings to the foreground, so it is attributed instead. + func onNotificationOpened(_ notificationId: String) { + let attributed: (record: OSSessionRecord, provider: OSSessionUserProvider)? = stateLock.withLock { + loadIfNeeded() + guard let record, let userProvider, opensUnfocusedRecord(at: wallNow()) else { + pendingAttributionId = notificationId + return nil + } + return (record, userProvider) + } + guard let attributed else { + return + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionService attributing session \(attributed.record.sessionId) to \(notificationId)") + attributed.provider.sessionAttributed(attributed.record, directAttributionId: notificationId) + } + + /// A session started in the background and never focused continues on the next focus only within + /// the threshold, as in `focusContinuesRecord`. + private func opensUnfocusedRecord(at time: TimeInterval) -> Bool { + guard recordStartedInThisProcess, focusedAt == nil, let record, record.lastFocusTime == nil else { + return false + } + return time - record.startTime < OSSessionStartGate.threshold + } + private struct PinnableIds { let identityModelId: String let onesignalId: String? diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionHeartbeatTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionHeartbeatTests.swift index 974ea7709..db256ceb2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionHeartbeatTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionHeartbeatTests.swift @@ -45,6 +45,8 @@ private final class HeartbeatRecorder: OSSessionUserProvider { func sessionHeartbeat(_ record: OSSessionRecord) { heartbeats.append(record) } + + func sessionAttributed(_ record: OSSessionRecord, directAttributionId: String) {} } final class OSSessionHeartbeatTests: XCTestCase { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 89a25e8e7..3051c6086 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -48,6 +48,11 @@ private final class FakeUserProvider: OSSessionUserProvider { onesignalIds[identityModelId] } func sessionHeartbeat(_ record: OSSessionRecord) {} + + private(set) var attributions: [(sessionId: String, notificationId: String)] = [] + func sessionAttributed(_ record: OSSessionRecord, directAttributionId: String) { + attributions.append((record.sessionId, directAttributionId)) + } } final class OSSessionServiceTests: XCTestCase { @@ -354,6 +359,113 @@ final class OSSessionServiceTests: XCTestCase { } } +// MARK: - Notification attribution + +extension OSSessionServiceTests { + /// The app returns from the background past the threshold: the open comes before the focus, and + /// the session starts after both. + func testOpenFromTheBackgroundAttributesTheSessionItStarts() { + let service = makeForegroundSession() + service.onUnfocus() + wallNow += 60 + + service.onNotificationOpened("notification-1") + service.onFocus() + let start = service.startNewSession(userProvider: user) + + XCTAssertEqual(start.directAttributionId, "notification-1") + XCTAssertTrue(user.attributions.isEmpty) + } + + func testNormalOpenIsNotAttributed() { + let service = makeForegroundSession() + service.onUnfocus() + wallNow += 60 + + service.onFocus() + + XCTAssertNil(service.startNewSession(userProvider: user).directAttributionId) + } + + func testOpenWithinTheThresholdDoesNotChangeTheSessionOrCarryOver() { + let service = makeForegroundSession() + let sessionId = service.currentRecord?.sessionId + service.onUnfocus() + wallNow += 10 + + service.onNotificationOpened("notification-1") + service.onFocus() + + XCTAssertEqual(service.currentRecord?.sessionId, sessionId) + XCTAssertTrue(user.attributions.isEmpty) + + service.onUnfocus() + wallNow += 60 + service.onFocus() + XCTAssertNil(service.startNewSession(userProvider: user).directAttributionId) + } + + func testOpenThatNeverFocusesIsNotAttributedToALaterSession() { + let service = makeForegroundSession() + service.onUnfocus() + wallNow += 60 + + service.onNotificationOpened("notification-1") + service.onUnfocus() + service.onFocus() + + XCTAssertNil(service.startNewSession(userProvider: user).directAttributionId) + } + + /// Init starts the launch's session on the main queue before iOS delivers the open. + func testColdStartOpenAfterTheSessionStartsAttributesIt() { + let service = makeService() + let start = service.startNewSession(userProvider: user) + + wallNow += 1 + service.onNotificationOpened("notification-1") + service.onFocus() + + XCTAssertEqual(user.attributions.map(\.sessionId), [start.started.sessionId]) + XCTAssertEqual(user.attributions.map(\.notificationId), ["notification-1"]) + XCTAssertNil(service.startNewSession(userProvider: user).directAttributionId) + } + + func testColdStartOpenBeforeTheSessionStartsAttributesIt() { + let service = makeService() + + service.onNotificationOpened("notification-1") + let start = service.startNewSession(userProvider: user) + + XCTAssertEqual(start.directAttributionId, "notification-1") + XCTAssertTrue(user.attributions.isEmpty) + } + + /// The focus past the threshold starts a new session, which the open belongs to. + func testOpenPastTheThresholdOfABackgroundSessionAttributesTheNextSession() { + let service = makeService() + service.startNewSession(userProvider: user) + wallNow += 60 + + service.onNotificationOpened("notification-1") + service.onFocus() + + XCTAssertTrue(user.attributions.isEmpty) + XCTAssertEqual(service.startNewSession(userProvider: user).directAttributionId, "notification-1") + } + + func testOpenForASessionFromAnEarlierProcessAttributesTheNextSession() { + _ = makeService().startNewSession(userProvider: user) + let restarted = makeService() + restarted.setUserProvider(user) + + restarted.onNotificationOpened("notification-1") + + XCTAssertTrue(user.attributions.isEmpty) + XCTAssertEqual(restarted.startNewSession(userProvider: user).directAttributionId, "notification-1") + } +} + // MARK: - Session end extension OSSessionServiceTests { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index 68e4c9c68..2f882ae65 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -38,7 +38,7 @@ class OSSessionOperationExecutor: OSOperationExecutor { /// A create answered without a `session_id` is retried this many times, then dropped. static let maxMissingSessionIdAttempts = 3 - var supportedDeltas: [String] = [OS_CREATE_SESSION_DELTA, OS_UPDATE_SESSION_DELTA] + var supportedDeltas: [String] = [OS_CREATE_SESSION_DELTA, OS_UPDATE_SESSION_DELTA, OS_ATTRIBUTE_SESSION_DELTA] private var deltaQueue: [OSDelta] = [] private var requestQueue: [OSSessionRequest] = [] /// The one place that knows whether a session was created and whether it ended, oldest first and @@ -151,7 +151,9 @@ class OSSessionOperationExecutor: OSOperationExecutor { if !self.deltaQueue.isEmpty { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSSessionOperationExecutor processDeltaQueue with queue: \(self.deltaQueue)") for delta in self.deltaQueue { - if let request = self.makeRequest(from: delta), !self.dropIfStale(request) { + if delta.name == OS_ATTRIBUTE_SESSION_DELTA { + self.attribute(delta) + } else if let request = self.makeRequest(from: delta), !self.dropIfStale(request) { self.admit(request) } } @@ -283,6 +285,17 @@ private extension OSSessionOperationExecutor { return true } + /// Deltas arrive in order, so the session's create is already queued unless it was dropped. + func attribute(_ delta: OSDelta) { + guard let notificationId = (delta.value as? [String: Any])?[OSSessionDeltaKey.directAttributionId] as? String, + let create = requestQueue.lazy.compactMap({ $0 as? OSRequestCreateSession }).first(where: { $0.localSessionId == delta.property }), + create.attribute(to: notificationId) + else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionOperationExecutor dropped attribution for \(delta.property)") + return + } + } + func hasCreate(forSession sessionId: String) -> Bool { requestQueue.contains { $0 is OSRequestCreateSession && $0.localSessionId == sessionId } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index f9a1e2b40..b47e88a5c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -44,7 +44,7 @@ extension OneSignalUserManagerImpl { if let ended = start.ended { self.enqueueSessionUpdate(ended, endTime: Date(timeIntervalSince1970: ended.endTime)) } - self.enqueueSessionCreate(start.started) + self.enqueueSessionCreate(start.started, directAttributionId: start.directAttributionId) } // Fetch the user's data if there is a onesignal_id @@ -76,6 +76,11 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { enqueueSessionUpdate(record) } + /// The executor folds this into the session's queued create, or drops it if the create was sent. + public func sessionAttributed(_ record: OSSessionRecord, directAttributionId: String) { + enqueueSessionDelta(OS_ATTRIBUTE_SESSION_DELTA, record: record, value: [OSSessionDeltaKey.directAttributionId: directAttributionId]) + } + /// Queues the session's create. Does nothing unless the session uses the sessions API. func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String? = nil) { var value: [String: Any] = [OSSessionDeltaKey.startTime: record.startTime] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift index 6ac87eb47..5296dfa0f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift @@ -28,7 +28,7 @@ import Foundation import OneSignalCore import OneSignalOSCore -/// Keys of the value an `OS_CREATE_SESSION_DELTA` or `OS_UPDATE_SESSION_DELTA` carries. The Delta's +/// Keys of the value a session Delta carries. The Delta's /// `property` is the local session ID. enum OSSessionDeltaKey { static let appId = "app_id" @@ -75,7 +75,8 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { let appId: String let localSessionId: String let startTime: Date - let directAttributionId: String? + /// Fixed by the first send, like the IDs. + private(set) var directAttributionId: String? var identityModel: OSIdentityModel /// See the ownership convention in `OSUserRequest.swift`. let ownerExternalId: String? @@ -83,6 +84,8 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { private(set) var onesignalId: String? private(set) var subscriptionId: String? var missingSessionIdAttempts = 0 + /// In memory only. Only a session started in this process is attributed after its create is queued. + private var prepared = false /// The sessions API takes no user JWT. var sendsUnsigned: Bool { true } @@ -126,6 +129,7 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { } self.onesignalId = onesignalId self.subscriptionId = subscriptionId + prepared = true var parameters: [String: Any] = [ "onesignal_id": onesignalId, "subscription_id": subscriptionId, @@ -140,6 +144,15 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { return true } + /// Returns false once the create was sent, since a replay must send the same body. + func attribute(to notificationId: String) -> Bool { + guard !prepared else { + return false + } + directAttributionId = notificationId + return true + } + func encode(with coder: NSCoder) { coder.encode(appId, forKey: "appId") coder.encode(localSessionId, forKey: "localSessionId") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index a0cab5c84..90aa64d65 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -119,6 +119,14 @@ final class OSSessionOperationExecutorTests: XCTestCase { return delta(OS_UPDATE_SESSION_DELTA, session: session, value: value, identityModel: nil) } + private func attributeDelta(session: String = "session-1", notificationId: String) -> OSDelta { + let value: [String: Any] = [ + OSSessionDeltaKey.appId: "test-app-id", + OSSessionDeltaKey.directAttributionId: notificationId + ] + return delta(OS_ATTRIBUTE_SESSION_DELTA, session: session, value: value, identityModel: nil) + } + private func delta(_ name: String, session: String, value: [String: Any], identityModel: OSIdentityModel?, timestamp: Date = Date()) -> OSDelta { let identityModel = identityModel ?? user.identityModel return OSDelta( @@ -187,6 +195,48 @@ final class OSSessionOperationExecutorTests: XCTestCase { XCTAssertEqual((client.requests.last as? OSRequestCreateSession)?.parameters?["onesignal_id"] as? String, onesignalId) } + func testAttributionIsFoldedIntoTheQueuedCreate() throws { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + let executor = makeExecutor() + flush(executor, createDelta()) + + flush(executor, attributeDelta(notificationId: "notification-id")) + user.identityModel.addAliases([OS_ONESIGNAL_ID: onesignalId]) + flush(executor) + + let request = try XCTUnwrap(client.requests.last as? OSRequestCreateSession) + XCTAssertEqual(request.parameters?["direct_attribution_id"] as? String, "notification-id") + XCTAssertEqual(executor.queuedRequests.count, 1) + } + + func testAttributionInTheSameFlushAsTheCreateIsSent() { + let executor = makeExecutor() + + flush(executor, createDelta(), attributeDelta(notificationId: "notification-id")) + + XCTAssertEqual((client.requests.last as? OSRequestCreateSession)?.parameters?["direct_attribution_id"] as? String, "notification-id") + } + + /// The create's retry must send the same body as the attempt that may have reached the backend. + func testAttributionAfterTheCreateWasSentIsDropped() { + let executor = makeExecutor() + flush(executor, createDelta()) + fail(executor, code: 500) + + uptime += OSRequestRetryBackoff.baseDelaySeconds + flush(executor, attributeDelta(notificationId: "notification-id")) + + XCTAssertEqual(client.requests.count, 2) + XCTAssertNil((client.requests.last as? OSRequestCreateSession)?.parameters?["direct_attribution_id"]) + } + + func testAttributionWithoutACreateIsDropped() { + let executor = makeExecutor() + + XCTAssertTrue(flush(executor, attributeDelta(notificationId: "notification-id")).isEmpty) + XCTAssertTrue(client.requests.isEmpty) + } + func testUpdateWaitsForTheCreateThenUsesItsServerSessionId() throws { let executor = makeExecutor() flush(executor, createDelta(), updateDelta(duration: 42, endTime: 1_700_000_100)) @@ -521,6 +571,16 @@ extension OSSessionOperationExecutorTests { XCTAssertEqual((restored.last as? OSRequestUpdateSession)?.idempotencyKey, key) } + func testAttributionFoldedIntoAWaitingCreateSurvivesAKill() throws { + user.identityModel.removeAliases([OS_ONESIGNAL_ID]) + let executor = makeExecutor() + flush(executor, createDelta(), attributeDelta(notificationId: "notification-id")) + + let restored = try XCTUnwrap(makeExecutor().queuedRequests.first as? OSRequestCreateSession) + + XCTAssertEqual(restored.directAttributionId, "notification-id") + } + func testACreateKeepsItsSentIdsAcrossAKillBeforeTheResponse() throws { let executor = makeExecutor() flush(executor, createDelta()) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift index 717bfe628..888934c28 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -186,6 +186,40 @@ extension SessionRecordUserTests { client.releaseHeldResponses() } + func testOpenBeforeTheSessionStartsIsOnItsCreate() { + enableSessionsApi() + client.holdResponses = true + + OSSessionService.onNotificationOpened("notification-id") + OneSignalUserManagerImpl.sharedInstance.startNewSession() + + XCTAssertEqual(queuedSessionCreates().first?.directAttributionId, "notification-id") + client.releaseHeldResponses() + } + + func testOpenAfterTheLaunchSessionStartsIsFoldedIntoItsCreate() { + enableSessionsApi() + client.holdResponses = true + + OneSignalUserManagerImpl.sharedInstance.startNewSession() + OSSessionService.onNotificationOpened("notification-id") + + let creates = queuedSessionCreates() + XCTAssertEqual(creates.count, 1) + XCTAssertEqual(creates.first?.directAttributionId, "notification-id") + client.releaseHeldResponses() + } + + func testNormalStartHasNoAttribution() { + enableSessionsApi() + client.holdResponses = true + + OneSignalUserManagerImpl.sharedInstance.startNewSession() + + XCTAssertNil(queuedSessionCreates().first?.directAttributionId) + client.releaseHeldResponses() + } + func testEachNewSessionQueuesItsOwnCreate() { enableSessionsApi() client.holdResponses = true From 41a92fc003a0c43d7a439a29175d0b176edff1a1 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 30 Oct 2026 11:07:10 -0700 Subject: [PATCH 2/3] feat: [SDK-5400] attribute only notification opens that bring the app forward A background action button runs with the app in the background, so it never starts the session it would otherwise be credited with. Co-Authored-By: Claude Opus 5.5 --- .../OneSignalNotifications/OSNotificationsManager.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m b/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m index 2981ac0f4..725d3e0b4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m +++ b/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m @@ -714,7 +714,8 @@ + (void)handleNotificationOpened:(NSDictionary*)messageDict NSString* messageId = [customDict objectForKey:@"i"]; [self submitNotificationOpened:messageId]; - let isActive = [UIApplication sharedApplication].applicationState == UIApplicationStateActive; + let applicationState = [UIApplication sharedApplication].applicationState; + let isActive = applicationState == UIApplicationStateActive; [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"handleNotificationOpened called! isActive: %@ notificationId: %@", isActive ? @"YES" : @"NO", messageId]]; @@ -738,7 +739,8 @@ + (void)handleNotificationOpened:(NSDictionary*)messageDict if (!isActive) { OSSessionManager.sharedSessionManager.appEntryState = NOTIFICATION_CLICK; [[OSSessionManager sharedSessionManager] onDirectInfluenceFromNotificationOpen:NOTIFICATION_CLICK withNotificationId:messageId]; - if (messageId.length > 0) + // A background action runs with the app in the background and never brings it forward. + if (messageId.length > 0 && applicationState == UIApplicationStateInactive) [OSSessionService onNotificationOpened:messageId]; } From 5857de697d2410fdc0a1d858fe2a316cc4dca9bf Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 30 Oct 2026 12:30:12 -0700 Subject: [PATCH 3/3] fix: [SDK-5400] keep a sent session create unchanged across a kill Co-Authored-By: Claude Opus 5.5 --- .../Source/Requests/OSSessionRequests.swift | 4 +++- .../Executors/OSSessionOperationExecutorTests.swift | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift index 5296dfa0f..69ef0f422 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSSessionRequests.swift @@ -84,7 +84,7 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { private(set) var onesignalId: String? private(set) var subscriptionId: String? var missingSessionIdAttempts = 0 - /// In memory only. Only a session started in this process is attributed after its create is queued. + /// Persisted, since an attribution queued before a kill can arrive after the create was sent. private var prepared = false /// The sessions API takes no user JWT. @@ -163,6 +163,7 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { coder.encode(onesignalId, forKey: "onesignalId") coder.encode(subscriptionId, forKey: "subscriptionId") coder.encode(missingSessionIdAttempts, forKey: "missingSessionIdAttempts") + coder.encode(prepared, forKey: "prepared") coder.encode(timestamp, forKey: "timestamp") } @@ -184,6 +185,7 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest { self.onesignalId = coder.decodeObject(forKey: "onesignalId") as? String self.subscriptionId = coder.decodeObject(forKey: "subscriptionId") as? String self.missingSessionIdAttempts = coder.decodeInteger(forKey: "missingSessionIdAttempts") + self.prepared = coder.decodeBool(forKey: "prepared") super.init() self.method = POST self.timestamp = timestamp diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 90aa64d65..eafb7c9dc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -593,6 +593,18 @@ extension OSSessionOperationExecutorTests { XCTAssertEqual(restored.subscriptionId, testPushSubId) } + func testAttributionAfterAKillDoesNotChangeASentCreate() throws { + let executor = makeExecutor() + flush(executor, createDelta()) + + let restored = makeExecutor() + flush(restored, attributeDelta(notificationId: "notification-id")) + + XCTAssertEqual(client.requests.count, 2) + let replay = try XCTUnwrap(client.requests.last as? OSRequestCreateSession) + XCTAssertNil(replay.parameters?["direct_attribution_id"]) + } + func testUpdateBuiltBeforeItsCreateSucceededSurvivesAKill() throws { let executor = makeExecutor() flush(executor, createDelta())