Skip to content
Closed
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
4 changes: 4 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */; };
A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */; };
A5162F01A1B2C3D4E5F6001A /* OSSessionRequestQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */; };
A5162F01A1B2C3D4E5F6002B /* OSSessionHeartbeatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F6002A /* OSSessionHeartbeatTests.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 */; };
Expand Down Expand Up @@ -1925,6 +1926,7 @@
A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionServiceTests.swift; sourceTree = "<group>"; };
A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsBackendServiceTests.swift; sourceTree = "<group>"; };
A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequestQueueTests.swift; sourceTree = "<group>"; };
A5162F01A1B2C3D4E5F6002A /* OSSessionHeartbeatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionHeartbeatTests.swift; sourceTree = "<group>"; };
3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = "<group>"; };
9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = "<group>"; };
8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2765,6 +2767,7 @@
A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */,
A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */,
A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */,
A5162F01A1B2C3D4E5F6002A /* OSSessionHeartbeatTests.swift */,
A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */,
3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */,
9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */,
Expand Down Expand Up @@ -4888,6 +4891,7 @@
A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */,
A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */,
A5162F01A1B2C3D4E5F6001A /* OSSessionRequestQueueTests.swift in Sources */,
A5162F01A1B2C3D4E5F6002B /* OSSessionHeartbeatTests.swift in Sources */,
A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */,
C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */,
32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */,
Expand Down
106 changes: 103 additions & 3 deletions iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,24 @@ public struct OSSessionRecord: Codable, Equatable {
public internal(set) var subscriptionId: String?
/// Nil until the sessions API creates this session.
public internal(set) var serverSessionId: String?
/// Seconds since 1970 when the app last left the foreground, which becomes the session's end
/// time. Nil until then.
public internal(set) var lastUnfocusTime: TimeInterval?
/// The duration the last heartbeat reported. An app killed in the foreground never adds its
/// last visit to `activeDuration`, so the end must not report less than this.
public internal(set) var lastHeartbeatDuration: TimeInterval?
/// Seconds since 1970 when the last heartbeat was enqueued.
public internal(set) var lastHeartbeatTime: TimeInterval?
}

/// 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.
/// While in the foreground, a sessions API session reports its duration every `heartbeatInterval`
/// of foreground time, so the backend keeps it open and a crash loses little of it.
@objc(OSSessionService)
public final class OSSessionService: NSObject {
static let heartbeatInterval: TimeInterval = 30 * 60

private static let lock = NSLock()
private static var _shared: OSSessionService?

Expand All @@ -94,26 +106,36 @@ public final class OSSessionService: NSObject {
private let featureManager: () -> OSFeatureManager
private let monotonicNow: () -> TimeInterval
private let wallNow: () -> TimeInterval
private let requestQueue: () -> OSSessionRequestQueue?
private let schedule: (TimeInterval, @escaping () -> Void) -> Void

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?
/// Bumped whenever the heartbeat restarts or stops, so a check scheduled before then does nothing.
private var heartbeatGeneration = 0

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 }
wallNow: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 },
requestQueue: @escaping () -> OSSessionRequestQueue? = { .shared },
schedule: @escaping (TimeInterval, @escaping () -> Void) -> Void = { delay, block in
DispatchQueue.global().asyncAfter(deadline: .now() + delay, execute: block)
}
) {
self.storage = storage
self.featureManager = featureManager
self.monotonicNow = monotonicNow
self.wallNow = wallNow
self.requestQueue = requestQueue
self.schedule = schedule
super.init()
}

Expand Down Expand Up @@ -165,7 +187,10 @@ 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.
/// With the sessions API, the previous session's end and the new session's create are enqueued
/// here. The queue holds them until their IDs are assigned.
public func startNewSession(userProvider: OSSessionUserProvider) {
let previousIds = pinnableIds()
let user = userProvider.sessionCurrentUser
let newRecord = OSSessionRecord(
sessionId: UUID().uuidString,
Expand All @@ -177,32 +202,50 @@ public final class OSSessionService: NSObject {
subscriptionId: user.pushSubscriptionId,
serverSessionId: nil
)
stateLock.withLock {
let previous: OSSessionRecord? = stateLock.withLock {
loadIfNeeded()
fillPinnedIds(previousIds)
let previous = record
self.userProvider = userProvider
loaded = true
record = newRecord
persist()
return previous
}
if var previous, previous.usesSessionsApi {
// A session killed in the foreground never added its last visit to the duration or
// recorded an unfocus. It ends with its last heartbeat, or else at its start.
let endTime = max(previous.lastUnfocusTime ?? previous.startTime, previous.lastHeartbeatTime ?? 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catch-up on the next onFocus can set lastHeartbeatTime to the return wall time when activeDuration is already past due (a visit that crossed 30 minutes was folded in onUnfocus and the late asyncAfter was generation-invalidated). max then prefers that return time over lastUnfocusTime, so end_time includes the whole background gap.

Only let lastHeartbeatTime win when lastHeartbeatDuration > activeDuration (killed in the foreground), or write the heartbeat in onUnfocus when the visit crosses the interval.

previous.activeDuration = max(previous.activeDuration, previous.lastHeartbeatDuration ?? 0)
requestQueue()?.enqueueUpdate(record: previous, endTime: endTime)
}
if newRecord.usesSessionsApi {
requestQueue()?.enqueueCreate(record: newRecord)
}
restartHeartbeat()
}

func onFocus() {
let now = monotonicNow()
stateLock.withLock { focusedAt = now }
restartHeartbeat()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracker calls [OSSessionService onFocus] before async startNewSession. This restartHeartbeat → checkHeartbeat therefore runs against the outgoing record. If that record is already past due, this is what stamps lastHeartbeatTime to now — the value the endTime max above then prefers.

}

func onUnfocus() {
let now = monotonicNow()
let wallNow = wallNow()
let ids = pinnableIds()
stateLock.withLock {
guard let start = focusedAt else {
return
}
focusedAt = nil
heartbeatGeneration += 1
loadIfNeeded()
guard record != nil else {
return
}
record?.activeDuration += max(0, now - start)
record?.lastUnfocusTime = wallNow
fillPinnedIds(ids)
persist()
}
Expand Down Expand Up @@ -314,3 +357,60 @@ public final class OSSessionService: NSObject {
storage.saveObject(forKey: OSUD_SESSION_RECORD, withValue: data)
}
}

// MARK: - Heartbeat

extension OSSessionService {
/// Counts only foreground time: the interval resumes from the last heartbeat's duration.
private func restartHeartbeat() {
let generation: Int? = stateLock.withLock {
heartbeatGeneration += 1
loadIfNeeded()
guard focusedAt != nil, record?.usesSessionsApi == true else {
return nil
}
return heartbeatGeneration
}
if let generation {
checkHeartbeat(generation: generation)
}
}

/// Rechecks the duration after every wait, since the scheduler's clock can drift from `monotonicNow`.
private func checkHeartbeat(generation: Int) {
let now = monotonicNow()
let wallNow = wallNow()
let ids = pinnableIds()
let next: (heartbeat: OSSessionRecord?, wait: TimeInterval)? = stateLock.withLock {
guard generation == heartbeatGeneration, let start = focusedAt else {
return nil
}
loadIfNeeded()
guard let current = record, current.usesSessionsApi else {
return nil
}
let duration = current.activeDuration + max(0, now - start)
let remaining = (current.lastHeartbeatDuration ?? 0) + Self.heartbeatInterval - duration
guard remaining <= 0 else {
return (nil, remaining)
}
fillPinnedIds(ids)
record?.lastHeartbeatDuration = duration
record?.lastHeartbeatTime = wallNow
persist()
var heartbeat = record
heartbeat?.activeDuration = duration
return (heartbeat, Self.heartbeatInterval)
}
guard let next else {
return
}
if let heartbeat = next.heartbeat {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionService heartbeat with duration \(heartbeat.activeDuration)")
requestQueue()?.enqueueUpdate(record: heartbeat)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still enqueues through OSSessionRequestQueue. Current #1772 deleted that queue and sends updates through enqueueSessionUpdate. Rebase onto that branch and call enqueueSessionUpdate here.

}
schedule(next.wait) { [weak self] in
self?.checkHeartbeat(generation: generation)
}
}
}
Loading
Loading