diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index ee61d4673..3a4ce0415 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit ee61d4673265876e5cab6328b7a0745f79b7a5aa +Subproject commit 3a4ce041547a5a68134cc75f70a4813dec32737e diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index a641076e0..16e6803cf 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ D9F1B3E5A7C2495FC1E6B8D3 /* ExecutorRequestAgingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */; }; 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */; }; 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; + A5182F01A1B2C3D4E5F60002 /* OneSignalUserManagerImpl+Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5182F01A1B2C3D4E5F60001 /* OneSignalUserManagerImpl+Session.swift */; }; 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */; }; 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */; }; @@ -92,9 +93,11 @@ A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; }; A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */; }; + A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */; }; A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */; }; 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 */; }; 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 */; }; @@ -224,6 +227,7 @@ 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */; }; 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */; }; A5426001A1B2C3D4E5F60006 /* InputGuardUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */; }; + A5172F01A1B2C3D4E5F60006 /* SessionRecordUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5172F01A1B2C3D4E5F60005 /* SessionRecordUserTests.swift */; }; 3CC063EF2B6D7FE8002BB07F /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; }; 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */; }; 3CC9A6342AFA1FDE008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */; }; @@ -1515,6 +1519,7 @@ 3CC063EB2B6D7FE8002BB07F /* OneSignalUserTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalUserTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserTests.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputGuardUserTests.swift; sourceTree = ""; }; + A5172F01A1B2C3D4E5F60005 /* SessionRecordUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRecordUserTests.swift; sourceTree = ""; }; 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConcurrencyTests.swift; sourceTree = ""; }; 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -1600,6 +1605,7 @@ 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + A5182F01A1B2C3D4E5F60001 /* OneSignalUserManagerImpl+Session.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Session.swift"; sourceTree = ""; }; 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoTestSupport.swift; sourceTree = ""; }; 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSDeltaTests.swift; sourceTree = ""; }; 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; @@ -1902,9 +1908,11 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionService.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuard.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -2409,6 +2417,7 @@ 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */, 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, + A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */, A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, @@ -2548,6 +2557,7 @@ 3CF11E3E2C6D61AC002856F5 /* Executors */, 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */, A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */, + A5172F01A1B2C3D4E5F60005 /* SessionRecordUserTests.swift */, 3CA93BC6300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift */, 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */, 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, @@ -2736,6 +2746,7 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, + A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -2968,6 +2979,7 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, + A5182F01A1B2C3D4E5F60001 /* OneSignalUserManagerImpl+Session.swift */, E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */, 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, A1C4E7F2B9D3468CA5E1F0B2 /* OSRequestAging.swift */, @@ -4654,6 +4666,7 @@ A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */, A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, + A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */, A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, @@ -4750,6 +4763,7 @@ 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, A5426001A1B2C3D4E5F60006 /* InputGuardUserTests.swift in Sources */, + A5172F01A1B2C3D4E5F60006 /* SessionRecordUserTests.swift in Sources */, 3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, @@ -4849,6 +4863,7 @@ A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, + A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */, A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, @@ -4987,6 +5002,7 @@ DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, + A5182F01A1B2C3D4E5F60002 /* OneSignalUserManagerImpl+Session.swift in Sources */, DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */, 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, B7D2A9C4E6F1483DA2C5E9F1 /* OSRequestAging.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index e54cead69..73c45b635 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -112,6 +112,7 @@ #define OSUD_APP_LAST_CLOSED_TIME @"GT_LAST_CLOSED_TIME" // * OSUD_APP_LAST_CLOSED_TIME #define OSUD_UNSENT_ACTIVE_TIME @"GT_UNSENT_ACTIVE_TIME" // * OSUD_UNSENT_ACTIVE_TIME #define OSUD_UNSENT_ACTIVE_TIME_ATTRIBUTED @"GT_UNSENT_ACTIVE_TIME_ATTRIBUTED" // * OSUD_UNSENT_ACTIVE_TIME_ATTRIBUTED +#define OSUD_SESSION_RECORD @"OSUD_SESSION_RECORD" // To avoid undefined symbol compiler errors on older versions of Xcode, // instead of using UNAuthorizationOptionProvisional directly, we will use diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift new file mode 100644 index 000000000..9c6ef0a07 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift @@ -0,0 +1,272 @@ +/* + 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 +@_implementationOnly import OneSignalKMP + +public struct OSSessionUser { + /// Local ID of the user's identity model. It survives that user being created on the + /// backend, and every login gets a new one. + public let identityModelId: String? + public let onesignalId: String? + public let pushSubscriptionId: String? + + public init(identityModelId: String?, onesignalId: String?, pushSubscriptionId: String?) { + self.identityModelId = identityModelId + self.onesignalId = onesignalId + self.pushSubscriptionId = pushSubscriptionId + } +} + +/// The user a session belongs to, supplied by the User module. +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? +} + +/// New fields must be Optional. A record stored by an earlier version lacks them, and synthesized +/// decoding fails on a missing non-Optional field even when it has a default value. +public struct OSSessionRecord: Codable, Equatable { + public let sessionId: String + /// Seconds since 1970. + public let startTime: TimeInterval + /// Foreground time, measured with a monotonic clock so a wall-clock change cannot skew it. + public internal(set) var activeDuration: TimeInterval + /// Fixed for the whole session so it never mixes paths. Read this rather than the feature + /// manager, whose value can change mid-session. + public let usesSessionsApi: Bool + let identityModelId: String? + /// 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? + /// Nil until the sessions API creates this session. + public internal(set) var serverSessionId: String? +} + +/// 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. +@objc(OSSessionService) +public final class OSSessionService: NSObject { + private static let lock = NSLock() + private static var _shared: OSSessionService? + + public static var shared: OSSessionService { + lock.withLock { + if let existing = _shared { + return existing + } + let created = OSSessionService() + _shared = created + return created + } + } + + private let storage: OneSignalUserDefaults + private let featureManager: () -> OSFeatureManager + private let monotonicNow: () -> TimeInterval + private let wallNow: () -> TimeInterval + + 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? + + 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 } + ) { + self.storage = storage + self.featureManager = featureManager + self.monotonicNow = monotonicNow + self.wallNow = wallNow + super.init() + } + + @objc public static func onFocus() { + shared.onFocus() + } + + @objc public static func onUnfocus() { + shared.onUnfocus() + } + + @objc public static func reset() { + lock.withLock { _shared = nil } + } + + /// The stored record belongs to the previous app, so it must not outlive an app-id change. + @objc public static func resetAndClearStoredRecord() { + reset() + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) + } + + public var currentRecord: OSSessionRecord? { + let ids = pinnableIds() + return stateLock.withLock { + loadIfNeeded() + fillPinnedIds(ids) + return record + } + } + + /// Set at launch, not only when a session starts: a record loaded from disk still needs its IDs + /// filled in if the previous process exited before the backend assigned them. + public func setUserProvider(_ userProvider: OSSessionUserProvider) { + stateLock.withLock { self.userProvider = userProvider } + refreshPinnedIds() + } + + /// The User module calls this as soon as the backend assigns a user or subscription ID. Filling + /// only on the next read could be too late: a login in between switches the current user, and + /// the subscription can then no longer be attributed to this session's user. + public func refreshPinnedIds() { + let ids = pinnableIds() + stateLock.withLock { fillPinnedIds(ids) } + } + + /// 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. + public func startNewSession(userProvider: OSSessionUserProvider) { + let user = userProvider.sessionCurrentUser + let newRecord = OSSessionRecord( + sessionId: UUID().uuidString, + startTime: wallNow(), + activeDuration: 0, + usesSessionsApi: featureManager().isEnabled(featureKey: FeatureFlag.sdkSessionsV2ApiCutover.key), + identityModelId: user.identityModelId, + onesignalId: user.onesignalId, + subscriptionId: user.pushSubscriptionId, + serverSessionId: nil + ) + stateLock.withLock { + self.userProvider = userProvider + loaded = true + record = newRecord + persist() + } + } + + func onFocus() { + let now = monotonicNow() + stateLock.withLock { focusedAt = now } + } + + func onUnfocus() { + let now = monotonicNow() + let ids = pinnableIds() + stateLock.withLock { + guard let start = focusedAt else { + return + } + focusedAt = nil + loadIfNeeded() + guard record != nil else { + return + } + record?.activeDuration += max(0, now - start) + fillPinnedIds(ids) + persist() + } + } + + private struct PinnableIds { + let identityModelId: String + let onesignalId: String? + let pushSubscriptionId: String? + } + + /// Read outside `stateLock` because the provider takes the User module's locks. + private func pinnableIds() -> PinnableIds? { + let pinned: (OSSessionUserProvider, String)? = stateLock.withLock { + loadIfNeeded() + guard let userProvider, let identityModelId = record?.identityModelId else { + return nil + } + return (userProvider, identityModelId) + } + guard let (provider, identityModelId) = pinned else { + return nil + } + return PinnableIds( + identityModelId: identityModelId, + onesignalId: provider.sessionOnesignalId(identityModelId: identityModelId), + pushSubscriptionId: provider.sessionCurrentUser.pushSubscriptionId + ) + } + + /// A session that starts before its user is created has no IDs to pin yet. Fill them in once + /// the backend assigns them. The onesignal_id is the session's own user's. The push + /// subscription is the same device subscription across logins, even when a login conflict + /// moves it to another user, so it is filled from the current one. + private func fillPinnedIds(_ ids: PinnableIds?) { + guard let ids, var current = record, current.identityModelId == ids.identityModelId else { + return + } + if current.onesignalId == nil { + current.onesignalId = ids.onesignalId + } + if current.subscriptionId == nil { + current.subscriptionId = ids.pushSubscriptionId + } + guard current != record else { + return + } + record = current + persist() + } + + private func loadIfNeeded() { + guard !loaded else { + return + } + loaded = true + guard let data = storage.getSavedObject(forKey: OSUD_SESSION_RECORD, defaultValue: nil) as? Data else { + return + } + do { + record = try JSONDecoder().decode(OSSessionRecord.self, from: data) + } catch { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionService dropping a stored session record it cannot decode: \(error)") + } + } + + private func persist() { + guard let record, let data = try? JSONEncoder().encode(record) else { + return + } + storage.saveObject(forKey: OSUD_SESSION_RECORD, withValue: data) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift new file mode 100644 index 000000000..305655133 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionServiceTests.swift @@ -0,0 +1,341 @@ +/* + 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 identityModelId: String? + var onesignalIds: [String: String] = [:] + var pushSubscriptionId: String? + + var sessionCurrentUser: OSSessionUser { + OSSessionUser( + identityModelId: identityModelId, + onesignalId: identityModelId.flatMap { onesignalIds[$0] }, + pushSubscriptionId: pushSubscriptionId + ) + } + + func sessionOnesignalId(identityModelId: String) -> String? { + onesignalIds[identityModelId] + } +} + +final class OSSessionServiceTests: XCTestCase { + private let flagKey = FeatureFlag.sdkSessionsV2ApiCutover.key + private var flagsStore: OSFeatureFlagsStore! + private var featureManager: OSFeatureManager! + private var monotonicNow: TimeInterval = 1_000 + private var user: FakeUserProvider! + + override func setUp() { + super.setUp() + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + flagsStore = OSFeatureFlagsStore() + featureManager = nil + monotonicNow = 1_000 + user = FakeUserProvider() + user.identityModelId = "model-a" + user.onesignalIds = ["model-a": "onesignal-a"] + user.pushSubscriptionId = "subscription-a" + } + + override func tearDown() { + OSSessionService.reset() + OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_SESSION_RECORD) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + super.tearDown() + } + + private func makeService() -> OSSessionService { + let manager = featureManager ?? OSFeatureManager(store: flagsStore) + featureManager = manager + return OSSessionService( + featureManager: { manager }, + monotonicNow: { [unowned self] in self.monotonicNow }, + wallNow: { 5_000 } + ) + } + + /// The tracker reports focus, then the session starts asynchronously on the main queue. + private func makeForegroundSession() -> OSSessionService { + let service = makeService() + service.onFocus() + service.startNewSession(userProvider: user) + return service + } + + // MARK: - Feature flag + + func testNewSessionUsesSessionsApiWhenFlagIsOn() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + + let service = makeForegroundSession() + + XCTAssertEqual(service.currentRecord?.usesSessionsApi, true) + } + + func testNewSessionUsesLegacyPathWhenFlagIsOff() { + let service = makeForegroundSession() + + XCTAssertEqual(service.currentRecord?.usesSessionsApi, false) + } + + func testSessionsApiChoiceDoesNotChangeMidSession() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeForegroundSession() + + flagsStore.applyRemoteFlags([], metadata: nil) + service.onUnfocus() + service.onFocus() + + XCTAssertFalse(featureManager.isEnabled(featureKey: flagKey)) + XCTAssertEqual(service.currentRecord?.usesSessionsApi, true) + } + + // MARK: - Record + + func testNewSessionPinsIdsAndHasNoServerSessionId() { + let service = makeForegroundSession() + + let record = service.currentRecord + XCTAssertEqual(record?.onesignalId, "onesignal-a") + XCTAssertEqual(record?.subscriptionId, "subscription-a") + XCTAssertEqual(record?.startTime, 5_000) + XCTAssertEqual(record?.activeDuration, 0) + XCTAssertNil(record?.serverSessionId) + } + + func testEachNewSessionGetsANewSessionId() { + let service = makeForegroundSession() + let first = service.currentRecord?.sessionId + + service.startNewSession(userProvider: user) + + XCTAssertNotNil(first) + XCTAssertNotEqual(service.currentRecord?.sessionId, first) + } + + func testRecordSurvivesARestart() { + flagsStore.applyRemoteFlags([flagKey], metadata: nil) + let service = makeForegroundSession() + monotonicNow += 10 + service.onUnfocus() + let saved = service.currentRecord + + let restarted = makeService() + + XCTAssertNotNil(saved) + XCTAssertEqual(restarted.currentRecord, saved) + } + + func testIdsAreFilledInAfterARestartBeforeTheUserIsCreated() { + user.onesignalIds = [:] + user.pushSubscriptionId = nil + _ = makeForegroundSession() + + let restarted = makeService() + restarted.setUserProvider(user) + user.onesignalIds["model-a"] = "onesignal-a" + user.pushSubscriptionId = "subscription-a" + restarted.refreshPinnedIds() + + XCTAssertEqual(restarted.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(restarted.currentRecord?.subscriptionId, "subscription-a") + } + + /// A record as stored by the first version. If this fails, a new field was not made Optional, and + /// upgrading mid-session would drop the session. + func testRecordStoredByTheFirstVersionStillDecodes() { + let stored = """ + {"sessionId":"session-1","startTime":5000,"activeDuration":12,"usesSessionsApi":true,\ + "identityModelId":"model-a","onesignalId":"onesignal-a","subscriptionId":"subscription-a"} + """ + OneSignalUserDefaults.initStandard().saveObject(forKey: OSUD_SESSION_RECORD, withValue: Data(stored.utf8)) + + let record = makeService().currentRecord + + XCTAssertEqual(record?.sessionId, "session-1") + XCTAssertEqual(record?.startTime, 5_000) + XCTAssertEqual(record?.activeDuration, 12) + XCTAssertEqual(record?.usesSessionsApi, true) + XCTAssertEqual(record?.onesignalId, "onesignal-a") + XCTAssertEqual(record?.subscriptionId, "subscription-a") + XCTAssertNil(record?.serverSessionId) + } + + func testClearingTheStoredRecordDropsIt() { + _ = makeForegroundSession() + + OSSessionService.resetAndClearStoredRecord() + + XCTAssertNil(makeService().currentRecord) + } + + // MARK: - Pinned IDs + + func testPinnedIdsStayTheSameAfterLogin() { + let service = makeForegroundSession() + + user.identityModelId = "model-b" + user.onesignalIds["model-b"] = "onesignal-b" + user.pushSubscriptionId = "subscription-b" + service.onUnfocus() + + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") + } + + func testIdsAreFilledInOnceTheSessionUserIsCreated() { + user.onesignalIds = [:] + user.pushSubscriptionId = nil + let service = makeForegroundSession() + XCTAssertNil(service.currentRecord?.onesignalId) + XCTAssertNil(service.currentRecord?.subscriptionId) + + user.onesignalIds["model-a"] = "onesignal-a" + user.pushSubscriptionId = "subscription-a" + + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") + } + + /// The push subscription is carried to the new user, so its ID is still this device's. + func testSubscriptionIdIsFilledInAfterLoginToAnotherUser() { + user.onesignalIds = [:] + user.pushSubscriptionId = nil + let service = makeForegroundSession() + + user.identityModelId = "model-b" + user.onesignalIds = ["model-a": "onesignal-a", "model-b": "onesignal-b"] + user.pushSubscriptionId = "subscription-a" + + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") + } + + func testIdsRefreshedBeforeLoginToAnotherUserStayPinned() { + user.onesignalIds = [:] + user.pushSubscriptionId = nil + let service = makeForegroundSession() + + user.onesignalIds["model-a"] = "onesignal-a" + user.pushSubscriptionId = "subscription-a" + service.refreshPinnedIds() + user.identityModelId = "model-b" + user.onesignalIds["model-b"] = "onesignal-b" + + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") + } + + /// Identifying the anonymous user gives the new identity model the same backend user. + func testSubscriptionIdIsFilledInAfterLoginIdentifiesTheSameUser() { + user.onesignalIds = [:] + user.pushSubscriptionId = nil + let service = makeForegroundSession() + + user.identityModelId = "model-b" + user.onesignalIds = ["model-a": "onesignal-a", "model-b": "onesignal-a"] + user.pushSubscriptionId = "subscription-a" + + XCTAssertEqual(service.currentRecord?.onesignalId, "onesignal-a") + XCTAssertEqual(service.currentRecord?.subscriptionId, "subscription-a") + } + + // MARK: - Active duration + + func testActiveDurationAddsEachForegroundInterval() { + let service = makeForegroundSession() + + monotonicNow += 10 + service.onUnfocus() + monotonicNow += 100 + service.onFocus() + monotonicNow += 5 + service.onUnfocus() + + XCTAssertEqual(service.currentRecord?.activeDuration, 15) + } + + /// 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() { + let service = makeForegroundSession() + monotonicNow += 10 + service.onUnfocus() + let previousSessionId = service.currentRecord?.sessionId + + monotonicNow += 60 + service.onFocus() + monotonicNow += 1 + service.startNewSession(userProvider: user) + monotonicNow += 20 + service.onUnfocus() + + XCTAssertNotEqual(service.currentRecord?.sessionId, previousSessionId) + XCTAssertEqual(service.currentRecord?.activeDuration, 21) + } + + func testSessionStartedInTheBackgroundCountsFromFocus() { + let service = makeService() + service.startNewSession(userProvider: user) + + monotonicNow += 100 + service.onFocus() + monotonicNow += 5 + service.onUnfocus() + + XCTAssertEqual(service.currentRecord?.activeDuration, 5) + } + + func testUnfocusWithoutFocusAddsNothing() { + let service = makeForegroundSession() + monotonicNow += 10 + service.onUnfocus() + + monotonicNow += 50 + service.onUnfocus() + + XCTAssertEqual(service.currentRecord?.activeDuration, 10) + } + + func testUnfocusInANewProcessDoesNotCountTimeFromThePreviousOne() { + _ = makeForegroundSession() + + let restarted = makeService() + monotonicNow += 10 + restarted.onUnfocus() + + XCTAssertEqual(restarted.currentRecord?.activeDuration, 0) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index 06a362887..55cfd64be 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -167,6 +167,7 @@ class OSIdentityModel: OSModel { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSIdentityModel hydrateModel with aliases: \(remoteAliases)") // Reporting the user to the app is the executor's call, since only a current user may be reported. internalAddAliases(remoteAliases) + OSSessionService.shared.refreshPinnedIds() } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift index 26f829c11..e18650349 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -226,6 +226,9 @@ class OSSubscriptionModel: OSModel { .subscriptionId(oldValue, remoteDisabledReason: previousRemoteDisabledReason), generateEnabledDelta: false ) + if newValue != nil { + OSSessionService.shared.refreshPinnedIds() + } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift new file mode 100644 index 000000000..fca1f94d0 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Session.swift @@ -0,0 +1,44 @@ +/* + 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 OneSignalCore +import OneSignalOSCore + +extension OneSignalUserManagerImpl: OSSessionUserProvider { + public var sessionCurrentUser: OSSessionUser { + let user = _user + return OSSessionUser( + identityModelId: user?.identityModel.modelId, + onesignalId: user?.identityModel.onesignalId, + pushSubscriptionId: user?.pushSubscriptionModel.subscriptionId + ) + } + + public func sessionOnesignalId(identityModelId: String) -> String? { + identityModelRepo.get(modelId: identityModelId)?.onesignalId + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 1e5dc6a11..7d9745679 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -308,6 +308,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self?.pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabledInternally = false } + // Before the executors, so a hydrate they cause can fill a session record left by the last process. + OSSessionService.shared.setUserProvider(self) + // Setup the executors // The OSUserExecutor has to run first, before other executors self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: identityVerificationService, auth: requestAuth) @@ -686,6 +689,7 @@ extension OneSignalUserManagerImpl { userExecutor!.executePendingRequests() operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1, flush: true) + OSSessionService.shared.startNewSession(userProvider: self) // Fetch the user's data if there is a onesignal_id if let onesignalId = onesignalId { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift new file mode 100644 index 000000000..36de2fb86 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SessionRecordUserTests.swift @@ -0,0 +1,153 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +final class SessionRecordUserTests: XCTestCase { + private var client = MockOneSignalClient() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OSSessionService.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userA_EUID) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userB_EUID) + OneSignalCoreImpl.setSharedClient(client) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle } + OSSessionService.reset() + OneSignalCoreMocks.clearUserDefaults() + } + + func testSessionStartedBeforeUserIsCreatedPinsItsIdsOnceCreated() { + OneSignalUserManagerImpl.sharedInstance.startNewSession() + + OneSignalCoreMocks.waitUntil("Session did not pin the created user's IDs") { + let record = OSSessionService.shared.currentRecord + return record?.onesignalId == anonUserOSID && record?.subscriptionId == testPushSubId + } + } + + func testPinnedIdsStayTheSameAfterLoginsWithinASession() { + OneSignalUserManagerImpl.sharedInstance.startNewSession() + OneSignalCoreMocks.waitUntil("Anonymous user was not created") { + let record = OSSessionService.shared.currentRecord + return record?.onesignalId == anonUserOSID && record?.subscriptionId == testPushSubId + } + let pinned = OSSessionService.shared.currentRecord + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + OneSignalCoreMocks.waitUntil("User B was not created") { + OneSignalUserManagerImpl.sharedInstance.onesignalId == userB_OSID + } + + let record = OSSessionService.shared.currentRecord + XCTAssertEqual(record?.sessionId, pinned?.sessionId) + XCTAssertEqual(record?.onesignalId, anonUserOSID) + XCTAssertEqual(record?.subscriptionId, pinned?.subscriptionId) + } + + /// Reading the record fills it, so this waits on the User module instead and reads only at the end. + func testIdsAssignedBeforeALoginArePinnedWithoutTheRecordBeingRead() { + OneSignalUserManagerImpl.sharedInstance.startNewSession() + OneSignalCoreMocks.waitUntil("Anonymous user was not created") { + OneSignalUserManagerImpl.sharedInstance.onesignalId == anonUserOSID + && OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId == testPushSubId + } + + // An existing external ID: Identify User conflicts, and a new user is created for it. + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userB_EUID, conflicted: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + OneSignalCoreMocks.waitUntil("User B was not created") { + OneSignalUserManagerImpl.sharedInstance.onesignalId == userB_OSID + } + + let record = OSSessionService.shared.currentRecord + XCTAssertEqual(record?.onesignalId, anonUserOSID) + XCTAssertEqual(record?.subscriptionId, testPushSubId) + } + + /// A new install that logs in to an existing user before its anonymous user is created. + func testLoginConflictBeforeTheAnonymousUserIsCreatedStillFillsTheSession() { + client.holdResponses = true + OneSignalUserManagerImpl.sharedInstance.startNewSession() + OneSignalCoreMocks.waitUntil("Create User was not sent") { + self.client.startedRequestCount(ofType: OSRequestCreateUser.self) == 1 + } + + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userB_EUID, conflicted: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + client.releaseHeldResponses() + OneSignalCoreMocks.waitUntil("User B was not created") { + OneSignalUserManagerImpl.sharedInstance.onesignalId == userB_OSID + } + + let record = OSSessionService.shared.currentRecord + XCTAssertEqual(record?.onesignalId, anonUserOSID) + XCTAssertEqual(record?.subscriptionId, testPushSubId) + } + + /// The last process started a session and was killed before its anonymous user was created. + func testRecordFromThePreviousProcessIsFilledInOnceTheUserIsCreated() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.setNewInternalUser(externalId: nil, pushSubscriptionModel: nil) + OSSessionService.shared.startNewSession(userProvider: manager) + OSSessionService.reset() + + manager.start() + + OneSignalCoreMocks.waitUntil("Session record was not filled in") { + let record = OSSessionService.shared.currentRecord + return record?.onesignalId == anonUserOSID && record?.subscriptionId == testPushSubId + } + } + + func testNewSessionAfterLoginPinsTheNewUser() { + OneSignalUserManagerImpl.sharedInstance.startNewSession() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + OneSignalCoreMocks.waitUntil("User B was not created") { + OneSignalUserManagerImpl.sharedInstance.onesignalId == userB_OSID + } + + OneSignalUserManagerImpl.sharedInstance.startNewSession() + + XCTAssertEqual(OSSessionService.shared.currentRecord?.onesignalId, userB_OSID) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 195723348..3e10b908a 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -152,6 +152,7 @@ + (void)clearStatics { [OSRemoteLoggingController reset]; [OSFeatureFlagsRefreshService reset]; [OSDeviceGestureDetector reset]; + [OSSessionService reset]; [OSFeatureManager reset]; [OneSignalIdentifiers setCurrentAppId:nil]; launchOptions = false; @@ -755,6 +756,7 @@ + (void)handleAppIdChange:(NSString*)appId { // Flags are app-scoped but stored unscoped, and APP_STARTUP flags never unlatch // within a process, so both the cache and the latch have to go. [OSFeatureManager resetAndClearCachedFlags]; + [OSSessionService resetAndClearStoredRecord]; let sharedUserDefaults = OneSignalUserDefaults.initShared; diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m b/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m index 19ae24fcf..537160b25 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m @@ -80,6 +80,7 @@ + (void)applicationBecameActive { OSSessionManager.sharedSessionManager.appEntryState = APP_OPEN; [OSSessionManager.sharedSessionManager setLastOpenedTime:[[NSDate date] timeIntervalSince1970]]; + [OSSessionService onFocus]; // on_session tracking when resumming app. if ([OneSignal shouldStartNewSession]) @@ -94,6 +95,7 @@ + (void)applicationBecameActive { + (void)applicationBackgrounded { [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:@"Application Backgrounded started"]; [self updateLastClosedTime]; + [OSSessionService onUnfocus]; let timeElapsed = [OSSessionManager.sharedSessionManager getTimeFocusedElapsed]; if (timeElapsed < -1)