diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 373a5df89..c0e575ed9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -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 */; }; @@ -1925,6 +1926,7 @@ A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionServiceTests.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsBackendServiceTests.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60019 /* OSSessionRequestQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionRequestQueueTests.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 = ""; }; @@ -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 */, @@ -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 */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index fe0755549..4d27e62b1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -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? @@ -94,6 +106,8 @@ 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? @@ -101,6 +115,8 @@ public final class OSSessionService: NSObject { 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(), @@ -108,12 +124,18 @@ 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 }, + 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() } @@ -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, @@ -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) + 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() } 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() } @@ -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) + } + schedule(next.wait) { [weak self] in + self?.checkHeartbeat(generation: generation) + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionHeartbeatTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionHeartbeatTests.swift new file mode 100644 index 000000000..ceaa121e8 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionHeartbeatTests.swift @@ -0,0 +1,213 @@ +/* + 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 FakeUserProvider: OSSessionUserProvider { + var sessionCurrentUser: OSSessionUser { + OSSessionUser(identityModelId: "model-a", onesignalId: "onesignal-a", pushSubscriptionId: "subscription-a") + } + + func sessionOnesignalId(identityModelId: String) -> String? { + "onesignal-a" + } + + func sessionUserCanBeCreated(identityModelId: String) -> Bool { + true + } +} + +/// Never answers, so every request stays queued where the test can read it. +private final class SilentBackend: OSSessionsBackend { + func createSession(appId: String, body: OSCreateSessionRequestBody, completion: @escaping (OSSessionsApiResult) -> Void) {} + + func updateSession( + appId: String, + sessionId: String, + body: OSUpdateSessionRequestBody, + completion: @escaping (OSSessionsApiResult) -> Void + ) {} +} + +private final class FakeNetworkMonitor: OSSessionNetworkMonitoring { + func start(onAvailable: @escaping () -> Void) {} + func stop() {} +} + +final class OSSessionHeartbeatTests: XCTestCase { + private let interval = OSSessionService.heartbeatInterval + private let wallStart: TimeInterval = 1_700_000_000 + private let user = FakeUserProvider() + private var flagsStore: OSFeatureFlagsStore! + private var monotonicNow: TimeInterval = 0 + private var pending: [(fireAt: TimeInterval, block: () -> Void)] = [] + private var service: OSSessionService! + private var queue: OSSessionRequestQueue! + + override func setUp() { + super.setUp() + clearStorage() + flagsStore = OSFeatureFlagsStore() + flagsStore.applyRemoteFlags([FeatureFlag.sdkSessionsV2ApiCutover.key], metadata: nil) + monotonicNow = 0 + pending = [] + service = makeService() + queue = OSSessionRequestQueue( + backend: SilentBackend(), + sessionService: { [unowned self] in self.service }, + appId: { "app-id" }, + networkMonitor: FakeNetworkMonitor(), + schedule: { _, _ in }, + postCreateDelay: 0 + ) + } + + override func tearDown() { + queue?.retireForTesting() + clearStorage() + super.tearDown() + } + + private func clearStorage() { + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_REQUEST_QUEUE) + 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 }, + requestQueue: { [unowned self] in self.queue }, + schedule: { [unowned self] delay, block in self.pending.append((self.monotonicNow + delay, block)) } + ) + } + + /// The tracker reports focus, then the session starts. + private func startForegroundSession() -> OSSessionRecord { + service.onFocus() + service.startNewSession(userProvider: user) + return service.currentRecord! + } + + /// 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 + } + + /// Unsent heartbeats for a session are coalesced, so each session has at most one. + private func heartbeats(for record: OSSessionRecord) -> [OSSessionRequest] { + queue.waitUntilIdle() + return queue.queuedRequests.filter { $0.localSessionId == record.sessionId && !$0.isCreate && !$0.isEnd } + } + + func testHeartbeatReportsDurationAfterThirtyMinutesInTheForeground() { + let record = startForegroundSession() + + advance(by: interval - 1) + XCTAssertTrue(heartbeats(for: record).isEmpty) + + advance(by: 1) + XCTAssertEqual(heartbeats(for: record).map(\.kind), [.update(activeDuration: interval, endTime: nil)]) + XCTAssertEqual(service.currentRecord?.lastHeartbeatDuration, interval) + XCTAssertEqual(service.currentRecord?.lastHeartbeatTime, wallStart + interval) + } + + func testHeartbeatRepeatsEveryThirtyMinutesInTheForeground() { + let record = startForegroundSession() + + advance(by: 2 * interval) + + XCTAssertEqual(heartbeats(for: record).map(\.activeDuration), [2 * interval]) + XCTAssertEqual(service.currentRecord?.lastHeartbeatDuration, 2 * interval) + } + + func testBackgroundPausesHeartbeatAndOnlyForegroundTimeCounts() { + let record = startForegroundSession() + advance(by: 20 * 60) + service.onUnfocus() + + advance(by: 2 * interval) + XCTAssertTrue(heartbeats(for: record).isEmpty) + + service.onFocus() + advance(by: 10 * 60) + XCTAssertEqual(heartbeats(for: record).map(\.activeDuration), [interval]) + } + + func testNewSessionRestartsTheInterval() { + let first = startForegroundSession() + advance(by: interval) + service.onUnfocus() + + let second = startForegroundSession() + advance(by: interval - 1) + XCTAssertTrue(heartbeats(for: second).isEmpty) + XCTAssertNil(service.currentRecord?.lastHeartbeatDuration) + + advance(by: 1) + XCTAssertNotEqual(first.sessionId, second.sessionId) + XCTAssertEqual(heartbeats(for: second).map(\.activeDuration), [interval]) + } + + func testNoHeartbeatWhenFlagIsOff() { + flagsStore.applyRemoteFlags([], metadata: nil) + service = makeService() + _ = startForegroundSession() + + advance(by: 2 * interval) + + XCTAssertTrue(pending.isEmpty) + queue.waitUntilIdle() + XCTAssertTrue(queue.queuedRequests.isEmpty) + } + + func testSessionKilledAfterAHeartbeatEndsWithItsDurationAndTime() { + let first = startForegroundSession() + advance(by: interval + 60) + + service = makeService() + _ = startForegroundSession() + queue.waitUntilIdle() + + let end = queue.queuedRequests.first { $0.localSessionId == first.sessionId && $0.isEnd } + XCTAssertEqual(end?.kind, .update(activeDuration: interval, endTime: wallStart + interval)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift index c19d5e47d..775407c41 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionRequestQueueTests.swift @@ -136,6 +136,8 @@ final class OSSessionRequestQueueTests: XCTestCase { private var service: OSSessionService! private var flagsStore: OSFeatureFlagsStore! private var now: TimeInterval = 10_000 + private var monotonicNow: TimeInterval = 0 + private var wallNow: TimeInterval = 1_700_000_000 private var scheduled: [(delay: TimeInterval, block: () -> Void)] = [] private var queue: OSSessionRequestQueue! @@ -147,8 +149,9 @@ final class OSSessionRequestQueueTests: XCTestCase { monitor = FakeNetworkMonitor() flagsStore = OSFeatureFlagsStore() flagsStore.applyRemoteFlags([FeatureFlag.sdkSessionsV2ApiCutover.key], metadata: nil) - let manager = OSFeatureManager(store: flagsStore) - service = OSSessionService(featureManager: { manager }, monotonicNow: { 0 }, wallNow: { 1_700_000_000 }) + monotonicNow = 0 + wallNow = 1_700_000_000 + service = makeService() now = 10_000 scheduled = [] queue = makeQueue() @@ -166,6 +169,17 @@ final class OSSessionRequestQueueTests: XCTestCase { OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) } + /// Most tests enqueue requests themselves, so by default starting a session does not. + private func makeService(enqueuesRequests: Bool = false) -> OSSessionService { + let manager = OSFeatureManager(store: flagsStore) + return OSSessionService( + featureManager: { manager }, + monotonicNow: { [unowned self] in self.monotonicNow }, + wallNow: { [unowned self] in self.wallNow }, + requestQueue: { [unowned self] in enqueuesRequests ? self.queue : nil } + ) + } + private func makeQueue(postCreateDelay: TimeInterval = 0) -> OSSessionRequestQueue { OSSessionRequestQueue( backend: backend, @@ -715,3 +729,197 @@ extension OSSessionRequestQueueTests { XCTAssertNil(service.currentRecord?.serverSessionId) } } + +// MARK: - Session start + +extension OSSessionRequestQueueTests { + func testNewSessionEnqueuesCreateWithStartTimeAndPinnedIds() { + service = makeService(enqueuesRequests: true) + + let record = startSession() + settle() + + guard case .create(let appId, let body, _) = backend.calls.first else { + return XCTFail("expected a create") + } + XCTAssertEqual(backend.calls.count, 1) + XCTAssertEqual(appId, "app-id") + XCTAssertEqual(body.idempotencyKey, record.sessionId) + XCTAssertEqual(body.onesignalId, "onesignal-a") + XCTAssertEqual(body.subscriptionId, "subscription-a") + XCTAssertEqual(body.startTime, Date(timeIntervalSince1970: 1_700_000_000)) + XCTAssertNil(body.directAttributionId) + } + + func testEachNewSessionEnqueuesItsOwnCreate() { + service = makeService(enqueuesRequests: true) + + let first = startSession() + let second = startSession() + settle() + + let creates = queue.queuedRequests.filter { $0.isCreate } + XCTAssertEqual(creates.map { $0.localSessionId }, [first.sessionId, second.sessionId]) + XCTAssertNotEqual(creates.first?.idempotencyKey, creates.last?.idempotencyKey) + } + + func testResumingASessionDoesNotEnqueueAnotherCreate() { + service = makeService(enqueuesRequests: true) + service.onFocus() + _ = startSession() + + service.onUnfocus() + service.onFocus() + settle() + + XCTAssertEqual(queue.queuedRequests.filter { $0.isCreate }.count, 1) + } + + func testNewSessionEnqueuesNothingWhenFlagIsOff() { + flagsStore.applyRemoteFlags([], metadata: nil) + service = makeService(enqueuesRequests: true) + + _ = startSession() + settle() + + XCTAssertTrue(queue.queuedRequests.isEmpty) + XCTAssertTrue(backend.calls.isEmpty) + } +} + +// MARK: - Session end + +extension OSSessionRequestQueueTests { + /// A foreground visit of `duration` seconds that leaves the foreground at `unfocusAt`. + private func visit(duration: TimeInterval, unfocusAt: TimeInterval) { + service.onFocus() + monotonicNow += duration + wallNow = unfocusAt + service.onUnfocus() + } + + func testNewSessionEndsThePreviousOneAtItsLastUnfocusWithItsFinalDuration() { + service = makeService(enqueuesRequests: true) + let first = startSession() + settle() + backend.respondToLast(createdSessionId: "server-1") + visit(duration: 30, unfocusAt: 1_700_000_100) + visit(duration: 12, unfocusAt: 1_700_000_200) + + wallNow = 1_700_000_500 + let second = startSession() + settle() + + guard case .update(let appId, let sessionId, let body, _) = backend.calls.last else { + return XCTFail("expected an end") + } + XCTAssertEqual(appId, "app-id") + XCTAssertEqual(sessionId, "server-1") + XCTAssertEqual(body.durationSeconds, 42) + XCTAssertEqual(body.endTime, Date(timeIntervalSince1970: 1_700_000_200)) + XCTAssertEqual(body.onesignalId, "onesignal-a") + XCTAssertEqual(body.subscriptionId, "subscription-a") + + backend.respondToLastUpdate() + settle() + + guard case .create(_, let createBody, _) = backend.calls.last else { + return XCTFail("expected the next session's create") + } + XCTAssertNotEqual(first.sessionId, second.sessionId) + XCTAssertEqual(createBody.idempotencyKey, second.sessionId) + } + + /// The previous process ended before the user was created, and nothing set the user provider + /// in this one before the next session started. + func testSessionWithIncompleteIdsFromAnEarlierProcessIsStillEnded() { + user.onesignalIds = [:] + service = makeService(enqueuesRequests: true) + let first = startSession() + visit(duration: 20, unfocusAt: 1_700_000_100) + settle() + XCTAssertTrue(backend.calls.isEmpty) + + service = makeService(enqueuesRequests: true) + user.onesignalIds["model-a"] = "onesignal-a" + _ = startSession() + settle() + + guard case .create(_, let createBody, _) = backend.calls.last else { + return XCTFail("expected the previous session's create") + } + XCTAssertEqual(createBody.idempotencyKey, first.sessionId) + XCTAssertEqual(createBody.onesignalId, "onesignal-a") + backend.respondToLast(createdSessionId: "server-1") + settle() + + guard case .update(_, let sessionId, let body, _) = backend.calls.last else { + return XCTFail("expected an end") + } + XCTAssertEqual(sessionId, "server-1") + XCTAssertEqual(body.onesignalId, "onesignal-a") + XCTAssertEqual(body.durationSeconds, 20) + XCTAssertEqual(body.endTime, Date(timeIntervalSince1970: 1_700_000_100)) + } + + func testEndReplacesUnsentUpdateAndBlocksLaterOnes() { + user.onesignalIds = [:] + service = makeService(enqueuesRequests: true) + let first = startSession() + visit(duration: 10, unfocusAt: 1_700_000_100) + queue.enqueueUpdate(record: service.currentRecord!) + visit(duration: 5, unfocusAt: 1_700_000_200) + + _ = startSession() + queue.enqueueUpdate(record: record(duration: 99, from: first)) + settle() + + let requests = queue.queuedRequests.filter { $0.localSessionId == first.sessionId } + XCTAssertEqual(requests.count, 2) + XCTAssertTrue(requests.first?.isCreate == true) + XCTAssertEqual(requests.last?.kind, .update(activeDuration: 15, endTime: 1_700_000_200)) + } + + func testEndDurationIsNotLowerThanAQueuedUpdate() { + user.onesignalIds = [:] + service = makeService(enqueuesRequests: true) + let first = startSession() + queue.enqueueUpdate(record: record(duration: 50, from: first)) + visit(duration: 40, unfocusAt: 1_700_000_100) + + _ = startSession() + settle() + + let end = queue.queuedRequests.first { $0.localSessionId == first.sessionId && $0.isEnd } + XCTAssertEqual(end?.activeDuration, 50) + } + + func testSessionKilledInTheForegroundEndsAtItsStartTime() { + service = makeService(enqueuesRequests: true) + let first = startSession() + service.onFocus() + monotonicNow += 30 + + wallNow = 1_700_000_500 + service = makeService(enqueuesRequests: true) + _ = startSession() + settle() + + let end = queue.queuedRequests.first { $0.localSessionId == first.sessionId && $0.isEnd } + XCTAssertEqual(end?.kind, .update(activeDuration: 0, endTime: first.startTime)) + } + + func testLegacySessionIsNotEndedThroughTheSessionsApi() { + flagsStore.applyRemoteFlags([], metadata: nil) + service = makeService(enqueuesRequests: true) + let legacy = startSession() + visit(duration: 10, unfocusAt: 1_700_000_100) + + flagsStore.applyRemoteFlags([FeatureFlag.sdkSessionsV2ApiCutover.key], metadata: nil) + _ = startSession() + settle() + + XCTAssertFalse(queue.queuedRequests.contains { $0.localSessionId == legacy.sessionId }) + XCTAssertEqual(queue.queuedRequests.map(\.isCreate), [true]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 8a3d22b45..482704b6e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -88,7 +88,8 @@ final class OSSessionServiceTests: XCTestCase { return OSSessionService( featureManager: { manager }, monotonicNow: { [unowned self] in self.monotonicNow }, - wallNow: { 5_000 } + wallNow: { 5_000 }, + requestQueue: { nil } ) } @@ -197,6 +198,7 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(record?.onesignalId, "onesignal-a") XCTAssertEqual(record?.subscriptionId, "subscription-a") XCTAssertNil(record?.serverSessionId) + XCTAssertNil(record?.lastUnfocusTime) } func testClearingTheStoredRecordDropsIt() { @@ -291,6 +293,7 @@ final class OSSessionServiceTests: XCTestCase { service.onUnfocus() XCTAssertEqual(service.currentRecord?.activeDuration, 15) + XCTAssertEqual(service.currentRecord?.lastUnfocusTime, 5_000) } /// Resuming past the new-session threshold: the visit that started the session counts from @@ -343,5 +346,6 @@ final class OSSessionServiceTests: XCTestCase { restarted.onUnfocus() XCTAssertEqual(restarted.currentRecord?.activeDuration, 0) + XCTAssertNil(restarted.currentRecord?.lastUnfocusTime) } }