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 @@ -103,6 +103,10 @@ NS_SWIFT_NAME(onClick(event:));

+ (void)clearStatics; // Used by Unit Tests

/// A tap on the notification, or one of its OneSignal buttons, which always open the app. An action
/// from an app-defined category may run in the background without bringing the app forward.
+ (BOOL)openBringsAppForward:(OSNotification *_Nonnull)notification actionID:(NSString *_Nullable)actionID;

// Indicates if the app provides its own custom Notification customization settings UI
// To enable this, set kOSSettingsKeyProvidesAppNotificationSettings to true in init.
+ (BOOL)providesAppNotificationSettings;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ of this software and associated documentation files (the "Software"), to deal
#import "UNUserNotificationCenter+OneSignalNotifications.h"
#import "UIApplicationDelegate+OneSignalNotifications.h"
#import <OneSignalOutcomes/OSSessionManager.h>
#import <OneSignalOutcomes/OSInfluenceDataRepository.h>

@implementation OSNotificationClickEvent
@synthesize notification = _notification, result = _result;
Expand Down Expand Up @@ -738,11 +739,28 @@ + (void)handleNotificationOpened:(NSDictionary*)messageDict
if (!isActive) {
OSSessionManager.sharedSessionManager.appEntryState = NOTIFICATION_CLICK;
[[OSSessionManager sharedSessionManager] onDirectInfluenceFromNotificationOpen:NOTIFICATION_CLICK withNotificationId:messageId];
// Only an open that brings the app forward starts or continues a session because of it.
if (messageId.length > 0 && [self openBringsAppForward:notification actionID:actionID]) {
if (OSInfluenceDataRepository.sharedInfluenceDataRepository.isDirectInfluenceEnabled)
[OSSessionService onNotificationOpened:messageId];
else
[OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:@"Notification open not attributed to a session, direct influence is disabled"];
}
}

[self handleNotificationActionWithUrl:notification.launchURL actionID:actionID];
}

+ (BOOL)openBringsAppForward:(OSNotification *)notification actionID:(NSString *)actionID {
if (!actionID)
return true;
for (NSDictionary *button in notification.actionButtons) {
if ([actionID isEqualToString:button[@"id"]])
return true;
}
return false;
}

+ (void)submitNotificationOpened:(NSString*)messageId {
// return if the user has not granted privacy permissions
if ([OSPrivacyConsentController shouldLogMissingPrivacyConsentErrorWithMethodName:nil])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,25 @@ extension OneSignalNotificationsTests: OneSignalNotificationsDelegate {
self.token = pushToken
}
}

// MARK: - Session attribution

extension OneSignalNotificationsTests {
private func notification(buttonIds: [String]) throws -> OSNotification {
let buttons = buttonIds.map { ["i": $0, "n": $0] }
return try XCTUnwrap(OSNotification.parse(withApns: ["aps": ["alert": "body"], "custom": ["i": "notification-id"], "buttons": buttons]))
}

func testTapOnTheNotificationBringsTheAppForward() throws {
XCTAssertTrue(OSNotificationsManager.openBringsAppForward(try notification(buttonIds: []), actionID: nil))
}

func testOneSignalButtonBringsTheAppForward() throws {
XCTAssertTrue(OSNotificationsManager.openBringsAppForward(try notification(buttonIds: ["reply"]), actionID: "reply"))
}

/// An action from an app-defined category may run in the background.
func testAppDefinedActionDoesNotBringTheAppForward() throws {
XCTAssertFalse(OSNotificationsManager.openBringsAppForward(try notification(buttonIds: ["reply"]), actionID: "archive"))
}
}
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
54 changes: 51 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,10 @@ 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. A background action from
/// Notification Center arrives after the unfocus and brings no focus, so it expires after the threshold.
private var pendingAttribution: (notificationId: String, openedAt: TimeInterval)?

init(
storage: OneSignalUserDefaults = .initStandard(),
Expand Down Expand Up @@ -166,6 +174,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 @@ -217,6 +229,7 @@ public final class OSSessionService: NSObject {
enqueue: (OSSessionStart) -> Void = { _ in }
) -> OSSessionStart {
let previousIds = pinnableIds()
let now = monotonicNow()
let user = userProvider.sessionCurrentUser
var newRecord = OSSessionRecord(
sessionId: UUID().uuidString,
Expand All @@ -227,21 +240,25 @@ 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 = pendingAttribution.flatMap { pending in
now - pending.openedAt < OSSessionStartGate.threshold ? pending.notificationId : nil
}
pendingAttribution = 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 +274,7 @@ public final class OSSessionService: NSObject {
guard record != nil, focusContinuesRecord(at: wallNow) else {
return false
}
pendingAttribution = 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 +298,7 @@ public final class OSSessionService: NSObject {
let wallNow = wallNow()
let ids = pinnableIds()
stateLock.withLock {
pendingAttribution = nil
guard let start = focusedAt else {
return
}
Expand All @@ -298,6 +317,35 @@ 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 now = monotonicNow()
let attributed: (record: OSSessionRecord, provider: OSSessionUserProvider)? = stateLock.withLock {
loadIfNeeded()
guard let record, let userProvider, opensUnfocusedRecord(at: wallNow()) else {
pendingAttribution = (notificationId, now)
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
125 changes: 125 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,126 @@ 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)
}

/// A background action from Notification Center arrives after the unfocus, with no focus after it.
func testOpenWithNoFocusAfterItExpires() {
let service = makeForegroundSession()
service.onUnfocus()

service.onNotificationOpened("notification-1")
wallNow += 60
monotonicNow += 60
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
Loading
Loading