Skip to content
Merged
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 @@ -157,7 +157,8 @@ 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) {
@discardableResult
public func startNewSession(userProvider: OSSessionUserProvider) -> OSSessionRecord {
let user = userProvider.sessionCurrentUser
let newRecord = OSSessionRecord(
sessionId: UUID().uuidString,
Expand All @@ -174,6 +175,7 @@ public final class OSSessionService: NSObject {
record = newRecord
persist()
}
return newRecord
}

func onFocus() {
Expand Down Expand Up @@ -267,3 +269,54 @@ public final class OSSessionService: NSObject {
storage.saveObject(forKey: OSUD_SESSION_RECORD, withValue: data)
}
}

/// Decides whether a foreground starts a new session: the app must have been closed, and the SDK
/// initialized, at least `threshold` seconds ago.
@objc(OSSessionStartGate)
public final class OSSessionStartGate: NSObject {
@objc public static let shared = OSSessionStartGate()
static let threshold: TimeInterval = 30

private let storage: OneSignalUserDefaults
private let now: () -> TimeInterval
private let lock = NSLock()
private var initializationTime: TimeInterval = 0
private var lastStartTime: TimeInterval?

init(
storage: OneSignalUserDefaults = .initStandard(),
now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }
) {
self.storage = storage
self.now = now
super.init()
}

@objc public func recordInitialization() {
let now = now()
lock.withLock { initializationTime = now }
}

@objc public func shouldStartNewSession() -> Bool {
let now = now()
let lastClosedTime = storage.getSavedDouble(forKey: OSUD_APP_LAST_CLOSED_TIME, defaultValue: 0)
let initializationTime = lock.withLock { self.initializationTime }
let elapsed = min(now - lastClosedTime, now - initializationTime)
OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionStartGate seconds since last closed or initialized: \(elapsed)")
return elapsed >= Self.threshold
}

/// Records a session start, unless `deduplicate` is set and a session started within the threshold.
/// Recovery from a prewarm and the foreground that triggered it each start a session, in either
/// order, so both pass `deduplicate` and the later one is skipped. Returns whether to start.
@objc public func beginSessionStart(deduplicate: Bool) -> Bool {
let now = now()
return lock.withLock {
if deduplicate, let lastStartTime, now - lastStartTime < Self.threshold {
return false
}
lastStartTime = now
return true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,63 @@ final class OSSessionServiceTests: XCTestCase {
XCTAssertEqual(restarted.currentRecord?.activeDuration, 0)
}
}

final class OSSessionStartGateTests: XCTestCase {
private var now: TimeInterval = 10_000
private var gate: OSSessionStartGate!

override func setUp() {
super.setUp()
now = 10_000
OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_APP_LAST_CLOSED_TIME)
gate = OSSessionStartGate(now: { [unowned self] in self.now })
}

override func tearDown() {
OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_APP_LAST_CLOSED_TIME)
super.tearDown()
}

private func closeApp() {
OneSignalUserDefaults.initStandard().saveDouble(forKey: OSUD_APP_LAST_CLOSED_TIME, withValue: now)
}

func testResumingWithinTheThresholdDoesNotStartASession() {
gate.recordInitialization()
now += 60
closeApp()

now += 29
XCTAssertFalse(gate.shouldStartNewSession())

now += 1
XCTAssertTrue(gate.shouldStartNewSession())
}

func testForegroundWithinTheThresholdOfInitializationDoesNotStartASession() {
closeApp()
now += 60
gate.recordInitialization()

now += 29
XCTAssertFalse(gate.shouldStartNewSession())

now += 1
XCTAssertTrue(gate.shouldStartNewSession())
}

/// A prewarm's init start does nothing, so recovery and the foreground's gated start can both run.
func testOnlyOneOfTwoDeduplicatedStartsWithinTheThresholdRuns() {
XCTAssertTrue(gate.beginSessionStart(deduplicate: true))
XCTAssertFalse(gate.beginSessionStart(deduplicate: true))

now += 30
XCTAssertTrue(gate.beginSessionStart(deduplicate: true))
}

func testStartFromInitIsNeverDeduplicated() {
XCTAssertTrue(gate.beginSessionStart(deduplicate: false))
XCTAssertTrue(gate.beginSessionStart(deduplicate: false))
XCTAssertFalse(gate.beginSessionStart(deduplicate: true))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ extension OneSignalUserManagerImpl {
userExecutor!.executePendingRequestsOnNewSession()
operationRepo.paused = false
updatePropertiesDeltas(property: .session_count, value: 1, flush: true)
OSSessionService.shared.startNewSession(userProvider: self)
enqueueSessionCreate(OSSessionService.shared.startNewSession(userProvider: self))
Comment thread
fadi-george marked this conversation as resolved.

// Fetch the user's data if there is a onesignal_id
if let onesignalId = onesignalId {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ final class OSRequestCreateSession: OneSignalRequest, OSSessionRequest {
var parameters: [String: Any] = [
"onesignal_id": onesignalId,
"subscription_id": subscriptionId,
"device_type": DEVICE_TYPE_PUSH,
"subscription_type": OSSubscriptionType.push.rawValue,
Comment thread
fadi-george marked this conversation as resolved.
"start_time": OSSessionTimestamp.string(from: startTime),
// The local session ID, so a create replayed from any process returns the same session.
"idempotency_key": localSessionId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ final class OSSessionOperationExecutorTests: XCTestCase {
XCTAssertEqual(request.method, POST)
XCTAssertEqual(request.parameters?["onesignal_id"] as? String, onesignalId)
XCTAssertEqual(request.parameters?["subscription_id"] as? String, testPushSubId)
XCTAssertEqual(request.parameters?["subscription_type"] as? String, "iOSPush")
XCTAssertNil(request.parameters?["device_type"])
XCTAssertEqual(request.parameters?["start_time"] as? String, "2023-11-14T22:13:20.000Z")
XCTAssertEqual(request.parameters?["idempotency_key"] as? String, "session-1")
XCTAssertEqual(request.parameters?["direct_attribution_id"] as? String, "notification-id")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ final class SessionRecordUserTests: XCTestCase {
OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle }
OSSessionService.reset()
OneSignalCoreMocks.clearUserDefaults()
OSFeatureFlagsStore.shared.clear()
OSFeatureManager.reset()
}

func testSessionStartedBeforeUserIsCreatedPinsItsIdsOnceCreated() {
Expand Down Expand Up @@ -151,3 +153,55 @@ final class SessionRecordUserTests: XCTestCase {
XCTAssertEqual(OSSessionService.shared.currentRecord?.onesignalId, userB_OSID)
}
}

// MARK: - Session create

extension SessionRecordUserTests {
private func enableSessionsApi() {
OSFeatureFlagsStore.shared.applyRemoteFlags(["sdk_sessions_v2_api_cutover"], metadata: nil)
}

/// With responses held the user is never created, so its session creates stay queued.
private func queuedSessionCreates() -> [OSRequestCreateSession] {
let manager = OneSignalUserManagerImpl.sharedInstance
manager.operationRepo.flushAndWait()
return manager.sessionExecutor?.queuedRequests.compactMap { $0 as? OSRequestCreateSession } ?? []
}

func testNewSessionQueuesACreateWithItsStartTime() {
enableSessionsApi()
client.holdResponses = true

OneSignalUserManagerImpl.sharedInstance.startNewSession()
Comment thread
fadi-george marked this conversation as resolved.

let record = OSSessionService.shared.currentRecord
let creates = queuedSessionCreates()
XCTAssertEqual(creates.count, 1)
XCTAssertEqual(creates.first?.localSessionId, record?.sessionId)
XCTAssertEqual(creates.first?.startTime.timeIntervalSince1970, record?.startTime)
client.releaseHeldResponses()
}

func testEachNewSessionQueuesItsOwnCreate() {
enableSessionsApi()
client.holdResponses = true

OneSignalUserManagerImpl.sharedInstance.startNewSession()
let first = OSSessionService.shared.currentRecord?.sessionId
OneSignalUserManagerImpl.sharedInstance.startNewSession()
let second = OSSessionService.shared.currentRecord?.sessionId

XCTAssertNotEqual(first, second)
XCTAssertEqual(queuedSessionCreates().map(\.localSessionId), [first, second].compactMap { $0 })
client.releaseHeldResponses()
}

func testNewSessionQueuesNoCreateWhenTheFlagIsOff() {
client.holdResponses = true

OneSignalUserManagerImpl.sharedInstance.startNewSession()

XCTAssertTrue(queuedSessionCreates().isEmpty)
client.releaseHeldResponses()
}
}
35 changes: 16 additions & 19 deletions iOS_SDK/OneSignalSDK/Source/OneSignal.m
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,6 @@ + (DelayedConsentInitializationParameters *)delayedInitParameters {
// Called after successfully calling setAppId and setLaunchOptions
static BOOL initDone = false;

// Used to track last time SDK was initialized, for whether or not to start a new session
static NSTimeInterval initializationTime;

//// Set when the app is launched
//static NSDate *sessionLaunchTime;

Expand Down Expand Up @@ -366,18 +363,7 @@ + (BOOL)shouldStartNewSession {
return false;
}

NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
NSTimeInterval lastTimeClosed = [OneSignalUserDefaults.initStandard getSavedDoubleForKey:OSUD_APP_LAST_CLOSED_TIME defaultValue:0];

// Make sure last time we closed app was more than 30 secs ago
const int minTimeThreshold = 30;
NSTimeInterval timeSinceLastClosed = now - lastTimeClosed;
NSTimeInterval timeSinceInitialization = now - initializationTime;

[OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:[NSString stringWithFormat:@"shouldStartNewSession:timeSinceLastClosed: %f", timeSinceLastClosed]];
[OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:[NSString stringWithFormat:@"shouldStartNewSession:timeSinceInitialization: %f", timeSinceInitialization]];

return MIN(timeSinceLastClosed, timeSinceInitialization) >= minTimeThreshold;
return [OSSessionStartGate.shared shouldStartNewSession];
}

+ (void)startNewSession:(BOOL)fromInit {
Expand All @@ -391,17 +377,28 @@ + (void)startNewSession:(BOOL)fromInit {
// Run on the main queue as it is possible for this to be called from multiple queues.
// Also some of the code in the method is not thread safe such as _outcomeEventsController.
[OneSignalHelper dispatch_async_on_main_queue:^{
[self startNewSessionInternal];
[self startNewSessionInternal:!fromInit];
}];
}

/// Starts the session init deferred during a prewarm, without the 30 second gate: the init start
/// it replaces never ran.
+ (void)startDeferredSession {
[OneSignalHelper dispatch_async_on_main_queue:^{
[self startNewSessionInternal:YES];
}];
}

+ (void)startNewSessionInternal {
+ (void)startNewSessionInternal:(BOOL)deduplicate {
[OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OneSignal.startNewSessionInternal"];

// return if the user has not granted consent, or device-protected storage isn't readable yet
if ([OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil])
return;

if (![OSSessionStartGate.shared beginSessionStartWithDeduplicate:deduplicate])
return;

// A new session is a fresh chance at params after the launch attempt exhausted its retries
if (!_downloadedParameters && !_didCallDownloadParameters && OneSignalIdentifiers.currentAppId) {
_downloadParametersAttempts = 0;
Expand Down Expand Up @@ -621,7 +618,7 @@ + (void)setupProtectedDataObserverOnce {
[OneSignal startInAppMessages];
StartFeatureFlagsRefresh();
[OSDeviceGestureDetector start];
[OneSignal startNewSession:YES];
[OneSignal startDeferredSession];
};

// Authoritative signal: the OS says protected data just became available.
Expand Down Expand Up @@ -726,7 +723,7 @@ + (void)init {
}
[self startNewSession:YES];

initializationTime = [[NSDate date] timeIntervalSince1970];
[OSSessionStartGate.shared recordInitialization];
initDone = true;
}

Expand Down
Loading