Skip to content
63 changes: 57 additions & 6 deletions iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
fadi-george marked this conversation as resolved.
}
}

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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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
Comment thread
fadi-george marked this conversation as resolved.
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
Comment thread
fadi-george marked this conversation as resolved.
}

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()
}
Expand Down
166 changes: 165 additions & 1 deletion iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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"]
Expand All @@ -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 }
)
}

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading