diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index f30211396..8cf7311c3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -100,6 +100,7 @@ A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */; }; A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */; }; A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.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 */; }; @@ -1931,6 +1932,7 @@ A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuardTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionServiceTests.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F6002A /* OSSessionHeartbeatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionHeartbeatTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; @@ -2773,6 +2775,7 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, + A5162F01A1B2C3D4E5F6002A /* OSSessionHeartbeatTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -4897,6 +4900,7 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */, + A5162F01A1B2C3D4E5F6002B /* OSSessionHeartbeatTests.swift in Sources */, A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 6d5d8b838..ce7696ac5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -43,11 +43,13 @@ public struct OSSessionUser { } } -/// The user a session belongs to, supplied by the User module. +/// The user a session belongs to, supplied by the User module, which also sends its heartbeats. public protocol OSSessionUserProvider: AnyObject { /// Taken from one read of the current user, so a concurrent login cannot mix two users. var sessionCurrentUser: OSSessionUser { get } func sessionOnesignalId(identityModelId: String) -> String? + /// Called with the record whose `reportedDuration` is due to be sent, on any thread. + func sessionHeartbeat(_ record: OSSessionRecord) } /// New fields must be Optional. A record stored by an earlier version lacks them, and synthesized @@ -69,11 +71,24 @@ public struct OSSessionRecord: Codable, Equatable { public internal(set) var lastUnfocusTime: TimeInterval? /// Seconds since 1970 when the app last entered the foreground during this session. public internal(set) var lastFocusTime: TimeInterval? + /// The foreground time the last heartbeat reported, which can exceed `activeDuration` until the + /// visit it was sent in ends. + public internal(set) var lastHeartbeatDuration: TimeInterval? + /// Seconds since 1970 when the last heartbeat was sent, measured from the visit's focus on the + /// monotonic clock so a wall-clock jump mid-visit cannot move a killed session's end. Cleared on + /// unfocus, which then marks the end. + public internal(set) var lastHeartbeatTime: TimeInterval? + + /// Cumulative foreground time to report. A session killed in the foreground never added its last + /// visit to `activeDuration`, so this never reports less than its last heartbeat did. + public var reportedDuration: TimeInterval { + max(activeDuration, lastHeartbeatDuration ?? 0) + } /// Seconds since 1970. A session killed in the foreground never unfocused, so it ends at its - /// last focus. The visit the kill cut short is not in the duration either. + /// last focus or heartbeat, whichever is later. public var endTime: TimeInterval { - max(startTime, lastUnfocusTime ?? startTime, lastFocusTime ?? startTime) + max(startTime, lastUnfocusTime ?? startTime, lastFocusTime ?? startTime, lastHeartbeatTime ?? startTime) } } @@ -85,8 +100,12 @@ public struct OSSessionStart { /// 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? @@ -105,6 +124,7 @@ public final class OSSessionService: NSObject { private let featureManager: () -> OSFeatureManager private let monotonicNow: () -> TimeInterval private let wallNow: () -> TimeInterval + private let schedule: (TimeInterval, @escaping () -> Void) -> Void private let stateLock = NSLock() private weak var userProvider: OSSessionUserProvider? @@ -116,6 +136,8 @@ public final class OSSessionService: NSObject { private var openFocusTime: TimeInterval? /// A record loaded from disk belongs to an earlier process, so the next focus always replaces it. private var recordStartedInThisProcess = false + /// Bumped whenever the heartbeat restarts or stops, so a check scheduled before then does nothing. + private var heartbeatGeneration = 0 init( storage: OneSignalUserDefaults = .initStandard(), @@ -123,12 +145,16 @@ public final class OSSessionService: NSObject { 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 }, + 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.schedule = schedule super.init() } @@ -141,7 +167,12 @@ public final class OSSessionService: NSObject { } @objc public static func reset() { - lock.withLock { _shared = nil } + let retired = lock.withLock { + let existing = _shared + _shared = nil + return existing + } + retired?.stopHeartbeat() } /// The stored record belongs to the previous app, so it must not outlive an app-id change. @@ -178,8 +209,13 @@ public final class OSSessionService: NSObject { /// 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. /// The replaced record is returned rather than lost, so its session can be ended. + /// `enqueue` queues the sessions' end and create. The heartbeat starts after it returns, since an + /// update queued before its session's create is discarded. @discardableResult - public func startNewSession(userProvider: OSSessionUserProvider) -> OSSessionStart { + public func startNewSession( + userProvider: OSSessionUserProvider, + enqueue: (OSSessionStart) -> Void = { _ in } + ) -> OSSessionStart { let previousIds = pinnableIds() let user = userProvider.sessionCurrentUser var newRecord = OSSessionRecord( @@ -200,26 +236,34 @@ public final class OSSessionService: NSObject { 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 OSSessionStart(started: newRecord, ended: ended) + let start = OSSessionStart(started: newRecord, ended: ended) + enqueue(start) + restartHeartbeat(continuesRecord: true) + return start } func onFocus() { let now = monotonicNow() let wallNow = wallNow() - stateLock.withLock { + let continuesRecord: Bool = stateLock.withLock { focusedAt = now loadIfNeeded() openFocusTime = wallNow guard record != nil, focusContinuesRecord(at: wallNow) else { - return + return false } // Saved now, since a kill leaves no unfocus to record when the session was last active. record?.lastFocusTime = wallNow persist() + return true } + // A focus that starts a new session leaves the heartbeat to that session's start. + restartHeartbeat(continuesRecord: continuesRecord) } /// A focus that will start a new session is not saved on the current record, or a kill before @@ -241,12 +285,14 @@ public final class OSSessionService: NSObject { } focusedAt = nil openFocusTime = nil + heartbeatGeneration += 1 loadIfNeeded() guard record != nil else { return } record?.activeDuration += max(0, now - start) record?.lastUnfocusTime = wallNow + record?.lastHeartbeatTime = nil fillPinnedIds(ids) persist() } @@ -321,6 +367,67 @@ public final class OSSessionService: NSObject { } } +// MARK: - Heartbeat + +extension OSSessionService { + private func stopHeartbeat() { + stateLock.withLock { heartbeatGeneration += 1 } + } + + /// Counts only foreground time: the interval resumes from the last heartbeat's duration. + private func restartHeartbeat(continuesRecord: Bool) { + let armed: (generation: Int, sessionId: String)? = stateLock.withLock { + heartbeatGeneration += 1 + guard continuesRecord, focusedAt != nil, let record, record.usesSessionsApi else { + return nil + } + return (heartbeatGeneration, record.sessionId) + } + guard let armed else { + return + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionService starting heartbeat for session \(armed.sessionId)") + checkHeartbeat(generation: armed.generation) + } + + /// Rechecks the duration after every wait. The scheduler's clock pauses through device sleep and + /// `monotonicNow` doesn't, so a check can fire late but never early. + private func checkHeartbeat(generation: Int) { + let now = monotonicNow() + let ids = pinnableIds() + let next: (due: (record: OSSessionRecord, provider: OSSessionUserProvider?)?, wait: TimeInterval)? = stateLock.withLock { + guard generation == heartbeatGeneration, let start = focusedAt, let focusTime = openFocusTime, + let current = record, current.usesSessionsApi else { + return nil + } + let focusDuration = max(0, now - start) + let duration = current.activeDuration + focusDuration + let remaining = (current.lastHeartbeatDuration ?? 0) + Self.heartbeatInterval - duration + guard remaining <= 0 else { + return (nil, remaining) + } + fillPinnedIds(ids) + record?.lastHeartbeatDuration = duration + record?.lastHeartbeatTime = focusTime + focusDuration + persist() + return (record.map { ($0, userProvider) }, Self.heartbeatInterval) + } + guard let next else { + return + } + if let (record, provider) = next.due { + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OSSessionService heartbeat for session \(record.sessionId) with duration \(record.reportedDuration)" + ) + provider?.sessionHeartbeat(record) + } + schedule(next.wait) { [weak self] in + self?.checkHeartbeat(generation: generation) + } + } +} + /// 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) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionHeartbeatTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionHeartbeatTests.swift new file mode 100644 index 000000000..974ea7709 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionHeartbeatTests.swift @@ -0,0 +1,269 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +import OneSignalKMP +@testable import OneSignalOSCore +import XCTest + +private final class HeartbeatRecorder: OSSessionUserProvider { + private(set) var heartbeats: [OSSessionRecord] = [] + + var sessionCurrentUser: OSSessionUser { + OSSessionUser(identityModelId: "model-a", onesignalId: "onesignal-a", pushSubscriptionId: "subscription-a") + } + + func sessionOnesignalId(identityModelId: String) -> String? { + "onesignal-a" + } + + func sessionHeartbeat(_ record: OSSessionRecord) { + heartbeats.append(record) + } +} + +final class OSSessionHeartbeatTests: XCTestCase { + private let interval = OSSessionService.heartbeatInterval + private let wallStart: TimeInterval = 1_700_000_000 + private var user: HeartbeatRecorder! + private var flagsStore: OSFeatureFlagsStore! + private var monotonicNow: TimeInterval = 0 + /// Moves the wall clock without moving the monotonic one. + private var wallClockJump: TimeInterval = 0 + private var pending: [(fireAt: TimeInterval, block: () -> Void)] = [] + private var service: OSSessionService! + + override func setUp() { + super.setUp() + clearStorage() + user = HeartbeatRecorder() + flagsStore = OSFeatureFlagsStore() + flagsStore.applyRemoteFlags([FeatureFlag.sdkSessionsV2ApiCutover.key], metadata: nil) + monotonicNow = 0 + wallClockJump = 0 + pending = [] + service = makeService() + } + + override func tearDown() { + clearStorage() + super.tearDown() + } + + private func clearStorage() { + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + } + + /// Wall time moves with the monotonic clock. + private func makeService() -> OSSessionService { + let manager = OSFeatureManager(store: flagsStore) + return OSSessionService( + featureManager: { manager }, + monotonicNow: { [unowned self] in self.monotonicNow }, + wallNow: { [unowned self] in self.wallStart + self.monotonicNow + self.wallClockJump }, + schedule: { [unowned self] delay, block in self.pending.append((self.monotonicNow + delay, block)) } + ) + } + + /// The tracker reports focus, then the session starts. + @discardableResult + private func startForegroundSession() -> OSSessionRecord { + service.onFocus() + return service.startNewSession(userProvider: user).started + } + + /// Advances both clocks, running each scheduled check when it comes due. + private func advance(by seconds: TimeInterval) { + let target = monotonicNow + seconds + while let next = pending.enumerated().filter({ $0.element.fireAt <= target }).min(by: { $0.element.fireAt < $1.element.fireAt }) { + pending.remove(at: next.offset) + monotonicNow = next.element.fireAt + next.element.block() + } + monotonicNow = target + } + + private func heartbeatDurations(for record: OSSessionRecord) -> [TimeInterval] { + user.heartbeats.filter { $0.sessionId == record.sessionId }.map(\.reportedDuration) + } + + func testHeartbeatReportsDurationAfterThirtyMinutesInTheForeground() { + let record = startForegroundSession() + + advance(by: interval - 1) + XCTAssertTrue(user.heartbeats.isEmpty) + + advance(by: 1) + XCTAssertEqual(heartbeatDurations(for: record), [interval]) + XCTAssertEqual(service.currentRecord?.lastHeartbeatDuration, interval) + XCTAssertEqual(service.currentRecord?.lastHeartbeatTime, wallStart + interval) + } + + func testHeartbeatRepeatsEveryThirtyMinutesInTheForeground() { + let record = startForegroundSession() + + advance(by: 2 * interval) + + XCTAssertEqual(heartbeatDurations(for: record), [interval, 2 * interval]) + } + + /// A return within the new-session threshold continues the session. + func testBackgroundPausesHeartbeatAndOnlyForegroundTimeCounts() { + let record = startForegroundSession() + advance(by: 20 * 60) + service.onUnfocus() + advance(by: 20) + + service.onFocus() + advance(by: 10 * 60 - 1) + XCTAssertTrue(user.heartbeats.isEmpty) + + advance(by: 1) + XCTAssertEqual(heartbeatDurations(for: record), [interval]) + } + + /// Each stale check is left pending and runs after its heartbeat stopped. + func testUnfocusStopsAPendingCheck() { + startForegroundSession() + advance(by: 20 * 60) + service.onUnfocus() + XCTAssertFalse(pending.isEmpty) + + advance(by: 2 * interval) + + XCTAssertTrue(user.heartbeats.isEmpty) + XCTAssertTrue(pending.isEmpty) + } + + /// The scheduler's clock pauses through device sleep, so a check can fire after more foreground + /// time than it waited for. + func testCheckThatFiresLateReportsTheFullDurationAndWaitsAFullInterval() { + let record = startForegroundSession() + + let check = pending.removeFirst() + monotonicNow = check.fireAt + 600 + check.block() + + XCTAssertEqual(heartbeatDurations(for: record), [interval + 600]) + XCTAssertEqual(pending.map(\.fireAt), [monotonicNow + interval]) + } + + func testNewSessionRestartsTheInterval() { + let first = startForegroundSession() + advance(by: interval) + service.onUnfocus() + advance(by: 60) + + let second = startForegroundSession() + advance(by: interval - 1) + XCTAssertTrue(heartbeatDurations(for: second).isEmpty) + + advance(by: 1) + XCTAssertEqual(heartbeatDurations(for: first), [interval]) + XCTAssertEqual(heartbeatDurations(for: second), [interval]) + } + + /// The focus that starts a new session must not send a heartbeat for the session it replaces. + func testFocusThatStartsANewSessionDoesNotStartTheHeartbeat() { + startForegroundSession() + advance(by: 20 * 60) + service.onUnfocus() + advance(by: 60) + + service.onFocus() + advance(by: 2 * interval) + + XCTAssertTrue(user.heartbeats.isEmpty) + } + + /// An update queued before its session's create is discarded. The visit open when the new + /// session starts carries over to it, so its first heartbeat counts that whole visit. + func testNewSessionStopsTheOldHeartbeatAndStartsItsOwnAfterEnqueueing() { + let first = startForegroundSession() + advance(by: interval - 60) + + let second = service.startNewSession(userProvider: user) { _ in + advance(by: 2 * interval) + XCTAssertTrue(user.heartbeats.isEmpty) + }.started + + XCTAssertEqual(heartbeatDurations(for: first), []) + XCTAssertEqual(heartbeatDurations(for: second), [3 * interval - 60]) + } + + func testSessionThatLeftTheForegroundAfterAHeartbeatEndsAtItsUnfocus() { + let first = startForegroundSession() + wallClockJump = 3_600 + advance(by: interval) + wallClockJump = 0 + advance(by: 60) + service.onUnfocus() + + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.sessionId, first.sessionId) + XCTAssertNil(ended?.lastHeartbeatTime) + XCTAssertEqual(ended?.endTime, wallStart + interval + 60) + } + + func testSessionKilledAfterAClockJumpedHeartbeatEndsAtItsForegroundTime() { + let first = startForegroundSession() + wallClockJump = 3_600 + advance(by: interval) + + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.sessionId, first.sessionId) + XCTAssertEqual(ended?.lastHeartbeatTime, wallStart + interval) + XCTAssertEqual(ended?.endTime, wallStart + interval) + } + + func testNoHeartbeatWhenFlagIsOff() { + flagsStore.applyRemoteFlags([], metadata: nil) + service = makeService() + startForegroundSession() + + advance(by: 2 * interval) + + XCTAssertTrue(pending.isEmpty) + XCTAssertTrue(user.heartbeats.isEmpty) + } + + func testSessionKilledAfterAHeartbeatEndsWithItsDurationAndTime() { + let first = startForegroundSession() + advance(by: interval + 60) + + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.sessionId, first.sessionId) + XCTAssertEqual(ended?.activeDuration, 0) + XCTAssertEqual(ended?.reportedDuration, interval) + XCTAssertEqual(ended?.endTime, wallStart + interval) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 586b0449a..89a25e8e7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -47,6 +47,7 @@ private final class FakeUserProvider: OSSessionUserProvider { func sessionOnesignalId(identityModelId: String) -> String? { onesignalIds[identityModelId] } + func sessionHeartbeat(_ record: OSSessionRecord) {} } final class OSSessionServiceTests: XCTestCase { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index f02e9f48a..f9a1e2b40 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -40,11 +40,12 @@ extension OneSignalUserManagerImpl { userExecutor!.executePendingRequestsOnNewSession() operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1, flush: true) - let start = OSSessionService.shared.startNewSession(userProvider: self) - if let ended = start.ended { - enqueueSessionUpdate(ended, endTime: Date(timeIntervalSince1970: ended.endTime)) + OSSessionService.shared.startNewSession(userProvider: self) { start in + if let ended = start.ended { + self.enqueueSessionUpdate(ended, endTime: Date(timeIntervalSince1970: ended.endTime)) + } + self.enqueueSessionCreate(start.started) } - enqueueSessionCreate(start.started) // Fetch the user's data if there is a onesignal_id if let onesignalId = onesignalId { @@ -71,6 +72,10 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { identityModelRepo.get(modelId: identityModelId)?.onesignalId } + public func sessionHeartbeat(_ record: OSSessionRecord) { + enqueueSessionUpdate(record) + } + /// 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] @@ -82,7 +87,7 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { /// Queues the session's cumulative foreground time, ending the session when `endTime` is set. func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: Date? = nil) { - var value: [String: Any] = [OSSessionDeltaKey.activeDuration: record.activeDuration] + var value: [String: Any] = [OSSessionDeltaKey.activeDuration: record.reportedDuration] value[OSSessionDeltaKey.endTime] = endTime?.timeIntervalSince1970 enqueueSessionDelta(OS_UPDATE_SESSION_DELTA, record: record, value: value) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift index b1a6719bd..717bfe628 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -268,6 +268,31 @@ extension SessionRecordUserTests { XCTAssertEqual(identityModel.onesignalId, "pinned-onesignal-id") } + func testHeartbeatAndEndReportAtLeastTheLastHeartbeatsDuration() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.setNewInternalUser(externalId: nil, pushSubscriptionModel: nil) + var record = OSSessionRecord( + sessionId: "session-1", + startTime: 1_700_000_000, + activeDuration: 12, + usesSessionsApi: true, + identityModelId: manager.user.identityModel.modelId, + onesignalId: nil, + subscriptionId: nil + ) + record.lastHeartbeatDuration = 1_800 + + manager.sessionHeartbeat(record) + manager.enqueueSessionUpdate(record, endTime: Date(timeIntervalSince1970: record.endTime)) + manager.operationRepo.dispatchQueue.sync {} + + let updates = manager.operationRepo.deltaQueue.filter { $0.name == OS_UPDATE_SESSION_DELTA } + let values = updates.compactMap { $0.value as? [String: Any] } + XCTAssertEqual(values.map { $0[OSSessionDeltaKey.activeDuration] as? TimeInterval }, [1_800, 1_800]) + XCTAssertNil(values.first?[OSSessionDeltaKey.endTime]) + XCTAssertNotNil(values.last?[OSSessionDeltaKey.endTime]) + } + func testNewSessionQueuesNoCreateWhenTheFlagIsOff() { client.holdResponses = true