Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]];
Expand All @@ -738,6 +739,9 @@ + (void)handleNotificationOpened:(NSDictionary*)messageDict
if (!isActive) {
OSSessionManager.sharedSessionManager.appEntryState = NOTIFICATION_CLICK;
[[OSSessionManager sharedSessionManager] onDirectInfluenceFromNotificationOpen:NOTIFICATION_CLICK withNotificationId:messageId];
// A background action runs with the app in the background and never brings it forward.
if (messageId.length > 0 && applicationState == UIApplicationStateInactive)
[OSSessionService onNotificationOpened:messageId];
}

[self handleNotificationActionWithUrl:notification.launchURL actionID:actionID];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ public class OSOperationRepo: NSObject {
private static let deltasExemptFromIdentityVerification: Set<String> = [
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) {
Expand Down
49 changes: 46 additions & 3 deletions iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -227,21 +238,23 @@ 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
recordStartedInThisProcess = true
// 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
Expand All @@ -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()
Expand All @@ -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
}
Expand All @@ -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
Comment thread
fadi-george marked this conversation as resolved.
Resolved
}

private struct PinnableIds {
let identityModelId: String
let onesignalId: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
112 changes: 112 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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 }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading