diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift index 4087df2fb..6d5d8b838 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -65,6 +65,22 @@ public struct OSSessionRecord: Codable, Equatable { /// Pinned at session start, so it stays the same after a login or user switch. public internal(set) var onesignalId: String? public internal(set) var subscriptionId: String? + /// Seconds since 1970 when the app last left the foreground. Nil until then. + 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? + + /// 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. + public var endTime: TimeInterval { + max(startTime, lastUnfocusTime ?? startTime, lastFocusTime ?? startTime) + } +} + +public struct OSSessionStart { + public let started: OSSessionRecord + /// The session the new one replaced, which has now ended. + public let ended: OSSessionRecord? } /// Owns the persisted record of the current session. Nothing reads the record while @@ -96,6 +112,10 @@ 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? + /// Wall-clock time of the open visit's focus. A visit open when a session starts belongs to it. + 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 init( storage: OneSignalUserDefaults = .initStandard(), @@ -157,10 +177,12 @@ 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. + /// The replaced record is returned rather than lost, so its session can be ended. @discardableResult - public func startNewSession(userProvider: OSSessionUserProvider) -> OSSessionRecord { + public func startNewSession(userProvider: OSSessionUserProvider) -> OSSessionStart { + let previousIds = pinnableIds() let user = userProvider.sessionCurrentUser - let newRecord = OSSessionRecord( + var newRecord = OSSessionRecord( sessionId: UUID().uuidString, startTime: wallNow(), activeDuration: 0, @@ -169,33 +191,62 @@ public final class OSSessionService: NSObject { onesignalId: user.onesignalId, subscriptionId: user.pushSubscriptionId ) - stateLock.withLock { + let ended: OSSessionRecord? = stateLock.withLock { + loadIfNeeded() + // Last chance to fill the replaced record's IDs while it is still the current one. + fillPinnedIds(previousIds) + let ended = record + newRecord.lastFocusTime = openFocusTime self.userProvider = userProvider - loaded = true record = newRecord + recordStartedInThisProcess = true persist() + return ended } - return newRecord + return OSSessionStart(started: newRecord, ended: ended) } func onFocus() { let now = monotonicNow() - stateLock.withLock { focusedAt = now } + let wallNow = wallNow() + stateLock.withLock { + focusedAt = now + loadIfNeeded() + openFocusTime = wallNow + guard record != nil, focusContinuesRecord(at: wallNow) else { + return + } + // Saved now, since a kill leaves no unfocus to record when the session was last active. + record?.lastFocusTime = wallNow + persist() + } + } + + /// A focus that will start a new session is not saved on the current record, or a kill before + /// that session starts would move the current session's end past its last unfocus. + private func focusContinuesRecord(at time: TimeInterval) -> Bool { + guard recordStartedInThisProcess, let record else { + return false + } + return time - (record.lastUnfocusTime ?? record.startTime) < OSSessionStartGate.threshold } func onUnfocus() { let now = monotonicNow() + let wallNow = wallNow() let ids = pinnableIds() stateLock.withLock { guard let start = focusedAt else { return } focusedAt = nil + openFocusTime = nil loadIfNeeded() guard record != nil else { return } record?.activeDuration += max(0, now - start) + record?.lastUnfocusTime = wallNow fillPinnedIds(ids) persist() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift index 57df25295..586b0449a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -54,6 +54,7 @@ final class OSSessionServiceTests: XCTestCase { private var flagsStore: OSFeatureFlagsStore! private var featureManager: OSFeatureManager! private var monotonicNow: TimeInterval = 1_000 + private var wallNow: TimeInterval = 5_000 private var user: FakeUserProvider! override func setUp() { @@ -63,6 +64,7 @@ final class OSSessionServiceTests: XCTestCase { flagsStore = OSFeatureFlagsStore() featureManager = nil monotonicNow = 1_000 + wallNow = 5_000 user = FakeUserProvider() user.identityModelId = "model-a" user.onesignalIds = ["model-a": "onesignal-a"] @@ -82,7 +84,7 @@ final class OSSessionServiceTests: XCTestCase { return OSSessionService( featureManager: { manager }, monotonicNow: { [unowned self] in self.monotonicNow }, - wallNow: { 5_000 } + wallNow: { [unowned self] in self.wallNow } ) } @@ -189,6 +191,8 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(record?.usesSessionsApi, true) XCTAssertEqual(record?.onesignalId, "onesignal-a") XCTAssertEqual(record?.subscriptionId, "subscription-a") + XCTAssertNil(record?.lastUnfocusTime) + XCTAssertNil(record?.lastFocusTime) } func testClearingTheStoredRecordDropsIt() { @@ -285,6 +289,16 @@ final class OSSessionServiceTests: XCTestCase { XCTAssertEqual(service.currentRecord?.activeDuration, 15) } + func testUnfocusRecordsTheWallClockTime() { + let service = makeForegroundSession() + wallNow = 6_000 + service.onUnfocus() + wallNow = 7_000 + + XCTAssertEqual(service.currentRecord?.lastUnfocusTime, 6_000) + XCTAssertEqual(service.currentRecord?.endTime, 6_000) + } + /// Resuming past the new-session threshold: the visit that started the session counts from /// focus, not from when the session start ran on the main queue. func testNewSessionStartedAfterFocusCountsFromFocus() { @@ -335,6 +349,156 @@ final class OSSessionServiceTests: XCTestCase { restarted.onUnfocus() XCTAssertEqual(restarted.currentRecord?.activeDuration, 0) + XCTAssertNil(restarted.currentRecord?.lastUnfocusTime) + } +} + +// MARK: - Session end + +extension OSSessionServiceTests { + + /// A foreground visit of `duration` seconds that leaves the foreground at `unfocusAt`. + private func visit(_ service: OSSessionService, duration: TimeInterval, unfocusAt: TimeInterval) { + service.onFocus() + monotonicNow += duration + wallNow = unfocusAt + service.onUnfocus() + } + + func testFirstSessionEndsNothing() { + XCTAssertNil(makeService().startNewSession(userProvider: user).ended) + } + + func testNewSessionReturnsThePreviousOneWithItsFinalDurationAndLastUnfocus() { + let service = makeService() + let first = service.startNewSession(userProvider: user).started + visit(service, duration: 30, unfocusAt: 6_000) + visit(service, duration: 12, unfocusAt: 7_000) + + wallNow = 9_000 + let start = service.startNewSession(userProvider: user) + + XCTAssertEqual(start.ended?.sessionId, first.sessionId) + XCTAssertEqual(start.ended?.activeDuration, 42) + XCTAssertEqual(start.ended?.endTime, 7_000) + XCTAssertEqual(start.started.startTime, 9_000) + XCTAssertEqual(service.currentRecord, start.started) + } + + func testSessionKilledInTheForegroundEndsAtItsStartTime() { + let first = makeForegroundSession().currentRecord + monotonicNow += 30 + + wallNow = 9_000 + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.sessionId, first?.sessionId) + XCTAssertEqual(ended?.activeDuration, 0) + XCTAssertEqual(ended?.endTime, first?.startTime) + } + + func testSessionKilledAfterReturningToTheForegroundEndsAtItsLastFocus() { + let service = makeService() + let first = service.startNewSession(userProvider: user).started + visit(service, duration: 30, unfocusAt: 6_000) + wallNow = 6_020 + service.onFocus() + monotonicNow += 60 + + wallNow = 9_000 + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.sessionId, first.sessionId) + XCTAssertEqual(ended?.activeDuration, 30) + XCTAssertEqual(ended?.endTime, 6_020) + } + + func testKillBetweenAResumePastTheThresholdAndItsSessionDoesNotMoveTheEnd() { + let service = makeService() + service.startNewSession(userProvider: user) + visit(service, duration: 30, unfocusAt: 6_000) + wallNow = 9_000 + service.onFocus() + + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.endTime, 6_000) + } + + func testKillBetweenAColdStartFocusAndItsSessionDoesNotMoveTheEnd() { + let service = makeService() + service.startNewSession(userProvider: user) + visit(service, duration: 30, unfocusAt: 6_000) + wallNow = 9_000 + makeService().onFocus() + + let ended = makeService().startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.endTime, 6_000) + } + + /// The tracker reports the focus that starts a new session before the session starts. + func testResumePastTheThresholdDoesNotMoveThePreviousSessionsEnd() { + let service = makeService() + service.startNewSession(userProvider: user) + visit(service, duration: 30, unfocusAt: 6_000) + + wallNow = 9_000 + service.onFocus() + let start = service.startNewSession(userProvider: user) + + XCTAssertEqual(start.ended?.endTime, 6_000) + XCTAssertEqual(start.started.lastFocusTime, 9_000) + XCTAssertEqual(service.currentRecord?.lastFocusTime, 9_000) + } + + func testColdStartDoesNotMoveThePreviousSessionsEnd() { + let service = makeService() + service.startNewSession(userProvider: user) + visit(service, duration: 30, unfocusAt: 6_000) + + wallNow = 9_000 + let restarted = makeService() + restarted.onFocus() + let start = restarted.startNewSession(userProvider: user) + + XCTAssertEqual(start.ended?.endTime, 6_000) + XCTAssertEqual(start.started.lastFocusTime, 9_000) + } + + func testSessionStartedInTheBackgroundRecordsAFocusWithinTheThreshold() { + let service = makeService() + service.startNewSession(userProvider: user) + + wallNow = 5_020 + service.onFocus() + + XCTAssertEqual(service.currentRecord?.lastFocusTime, 5_020) + } + + func testFirstForegroundPastTheThresholdDoesNotMoveABackgroundSessionsEnd() { + let service = makeService() + let background = service.startNewSession(userProvider: user).started + + wallNow = 6_000 + service.onFocus() + let start = service.startNewSession(userProvider: user) + + XCTAssertEqual(start.ended?.endTime, background.startTime) + XCTAssertEqual(start.started.lastFocusTime, 6_000) + } + + /// The previous process ended before the user was created. + func testSessionFromAnEarlierProcessEndsWithTheIdsAssignedSince() { + user.onesignalIds = [:] + makeService().startNewSession(userProvider: user) + + user.onesignalIds = ["model-a": "onesignal-a"] + let restarted = makeService() + restarted.setUserProvider(user) + let ended = restarted.startNewSession(userProvider: user).ended + + XCTAssertEqual(ended?.onesignalId, "onesignal-a") } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift index ae7c83d37..68e4c9c68 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSessionOperationExecutor.swift @@ -78,7 +78,7 @@ class OSSessionOperationExecutor: OSOperationExecutor { return } deltaQueue.removeAll { delta in - guard OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil else { + guard identityModel(for: delta) == nil else { return false } OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionOperationExecutor.init dropped: \(delta)") @@ -184,10 +184,23 @@ class OSSessionOperationExecutor: OSOperationExecutor { // MARK: - Queueing private extension OSSessionOperationExecutor { + /// A session can end for a user that is no longer loaded, whose model only the Delta still holds. + func identityModel(for delta: OSDelta) -> OSIdentityModel? { + let manager = OneSignalUserManagerImpl.sharedInstance + if let identityModel = manager.getIdentityModel(delta.identityModelId) { + return identityModel + } + guard let identityModel = delta.model as? OSIdentityModel, identityModel.onesignalId != nil else { + return nil + } + manager.addIdentityModelToRepo(identityModel) + return identityModel + } + func makeRequest(from delta: OSDelta) -> OSSessionRequest? { guard let value = delta.value as? [String: Any], let appId = value[OSSessionDeltaKey.appId] as? String, - let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) + let identityModel = identityModel(for: delta) else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSessionOperationExecutor.processDeltaQueue dropped: \(delta)") return nil diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift index da805d334..f02e9f48a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -40,7 +40,11 @@ extension OneSignalUserManagerImpl { userExecutor!.executePendingRequestsOnNewSession() operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1, flush: true) - enqueueSessionCreate(OSSessionService.shared.startNewSession(userProvider: self)) + let start = OSSessionService.shared.startNewSession(userProvider: self) + if let ended = start.ended { + enqueueSessionUpdate(ended, endTime: Date(timeIntervalSince1970: ended.endTime)) + } + enqueueSessionCreate(start.started) // Fetch the user's data if there is a onesignal_id if let onesignalId = onesignalId { @@ -85,9 +89,7 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { private func enqueueSessionDelta(_ name: String, record: OSSessionRecord, value: [String: Any]) { guard record.usesSessionsApi else { return } - guard let identityModelId = record.identityModelId, - let identityModel = identityModelRepo.get(modelId: identityModelId) - else { + guard let identityModel = sessionIdentityModel(for: record) else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManagerImpl dropped \(name) for session \(record.sessionId), no identity model") return } @@ -99,7 +101,7 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { value[OSSessionDeltaKey.appId] = appId let delta = OSDelta( name: name, - identityModelId: identityModelId, + identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: identityModel, property: record.sessionId, @@ -107,4 +109,18 @@ extension OneSignalUserManagerImpl: OSSessionUserProvider { ) operationRepo.enqueueDelta(delta) } + + /// Identity models are not cached across launches, so the user of a session that ended before a + /// login in an earlier process is gone. It is restored from the session's pinned `onesignal_id`. + private func sessionIdentityModel(for record: OSSessionRecord) -> OSIdentityModel? { + if let identityModelId = record.identityModelId, let identityModel = identityModelRepo.get(modelId: identityModelId) { + return identityModel + } + guard let onesignalId = record.onesignalId else { + return nil + } + let identityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: onesignalId], changeNotifier: OSEventProducer()) + addIdentityModelToRepo(identityModel) + return identityModel + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift index 70242bf92..a0cab5c84 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSSessionOperationExecutorTests.swift @@ -546,6 +546,30 @@ extension OSSessionOperationExecutorTests { XCTAssertEqual(update.path, "apps/test-app-id/sessions/server-1") } + func testEndForAUserNoLongerLoadedSurvivesAKill() throws { + let executor = makeExecutor() + createSession(executor) + executor.enqueueDelta(updateDelta(duration: 10, endTime: 1_700_000_100)) + executor.cacheDeltaQueue() + _ = executor.queuedRequests + OneSignalUserManagerImpl.sharedInstance.identityModelRepo.models = [:] + + flush(makeExecutor()) + + let end = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(end.path, "apps/test-app-id/sessions/server-1") + } + + func testEndForAUserNoLongerLoadedIsSentAfterARestart() throws { + createSession(makeExecutor()) + OneSignalUserManagerImpl.sharedInstance.identityModelRepo.models = [:] + + flush(makeExecutor(), updateDelta(duration: 10, endTime: 1_700_000_100)) + + let end = try XCTUnwrap(client.requests.last as? OSRequestUpdateSession) + XCTAssertEqual(end.path, "apps/test-app-id/sessions/server-1") + } + func testUpdateAfterARestartUsesTheCreateFromBefore() throws { createSession(makeExecutor()) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift index 325ddedd1..b1a6719bd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -50,6 +50,10 @@ final class SessionRecordUserTests: XCTestCase { override func tearDownWithError() throws { OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle } + // A polled session executor re-caches its queue, which the next test's executor would uncache. + let manager = OneSignalUserManagerImpl.sharedInstance + manager.operationRepo.reset() + _ = manager.sessionExecutor?.queuedRequests OSSessionService.reset() OneSignalCoreMocks.clearUserDefaults() OSFeatureFlagsStore.shared.clear() @@ -196,6 +200,74 @@ extension SessionRecordUserTests { client.releaseHeldResponses() } + private func queuedSessionUpdates() -> [OSRequestUpdateSession] { + let queued = OneSignalUserManagerImpl.sharedInstance.sessionExecutor?.queuedRequests ?? [] + return queued.compactMap { $0 as? OSRequestUpdateSession } + } + + func testNewSessionQueuesThePreviousSessionsEndBeforeItsCreate() { + enableSessionsApi() + client.holdResponses = true + let manager = OneSignalUserManagerImpl.sharedInstance + + manager.startNewSession() + OSSessionService.onFocus() + OSSessionService.onUnfocus() + let first = OSSessionService.shared.currentRecord + manager.startNewSession() + let second = OSSessionService.shared.currentRecord + + let creates = queuedSessionCreates() + let queued = manager.sessionExecutor?.queuedRequests.map(\.localSessionId) + XCTAssertEqual(queued, [first?.sessionId, first?.sessionId, second?.sessionId].compactMap { $0 }) + XCTAssertEqual(creates.count, 2) + let end = queuedSessionUpdates().first + XCTAssertEqual(end?.localSessionId, first?.sessionId) + XCTAssertNotNil(first?.lastUnfocusTime) + XCTAssertEqual(end?.endTime?.timeIntervalSince1970, first?.lastUnfocusTime) + XCTAssertGreaterThan(first?.activeDuration ?? 0, 0) + XCTAssertEqual(end?.activeDuration, first?.activeDuration) + client.releaseHeldResponses() + } + + func testLegacySessionQueuesNoEnd() { + client.holdResponses = true + let manager = OneSignalUserManagerImpl.sharedInstance + + manager.startNewSession() + enableSessionsApi() + manager.startNewSession() + + XCTAssertEqual(queuedSessionCreates().count, 1) + XCTAssertTrue(queuedSessionUpdates().isEmpty) + client.releaseHeldResponses() + } + + /// The session's user was replaced by a login in an earlier process, so its model is not loaded. + func testEndOfASessionWhoseUserIsNoLongerLoadedIsQueuedForItsPinnedUser() throws { + let manager = OneSignalUserManagerImpl.sharedInstance + let record = { (onesignalId: String?) in + OSSessionRecord( + sessionId: onesignalId ?? "never-created", + startTime: 1_700_000_000, + activeDuration: 12, + usesSessionsApi: true, + identityModelId: "unloaded-model", + onesignalId: onesignalId, + subscriptionId: nil + ) + } + + manager.enqueueSessionUpdate(record("pinned-onesignal-id"), endTime: Date(timeIntervalSince1970: 1_700_000_100)) + manager.enqueueSessionUpdate(record(nil), endTime: Date(timeIntervalSince1970: 1_700_000_100)) + manager.operationRepo.dispatchQueue.sync {} + + let sessionDeltas = manager.operationRepo.deltaQueue.filter { $0.name == OS_UPDATE_SESSION_DELTA } + XCTAssertEqual(sessionDeltas.map(\.property), ["pinned-onesignal-id"]) + let identityModel = try XCTUnwrap(manager.getIdentityModel(sessionDeltas[0].identityModelId)) + XCTAssertEqual(identityModel.onesignalId, "pinned-onesignal-id") + } + func testNewSessionQueuesNoCreateWhenTheFlagIsOff() { client.holdResponses = true