diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 9adfba76f..01d0071f8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -70,6 +70,9 @@ 3C14E3A12AFAE461006ED053 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C14E3A02AFAE461006ED053 /* PrivacyInfo.xcprivacy */; }; 3C14E3A42AFAE54C006ED053 /* OneSignalSwiftInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC08AFF2947D4E900C81DA3 /* OneSignalSwiftInterface.swift */; }; 3C19C6322E919F0C00D6731E /* OSRequestLiveActivityClicked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */; }; + 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */; }; + 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */; }; + 3C23A21F2FCE0AA1001D32E3 /* OSResilientStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */; }; 3C24B0EC2BD09D7A0052E771 /* OneSignalCoreObjCTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C24B0EB2BD09D7A0052E771 /* OneSignalCoreObjCTests.m */; }; 3C277D7E2BD76E0000857606 /* OSIdentityModelRepo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C277D7D2BD76E0000857606 /* OSIdentityModelRepo.swift */; }; 3C2C7DC8288F3C020020F9AE /* OSSubscriptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2C7DC7288F3C020020F9AE /* OSSubscriptionModel.swift */; }; @@ -180,6 +183,7 @@ 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */; }; 3CC9A6342AFA1FDE008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */; }; 3CC9A6362AFA26E7008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */; }; + 3CCC48042FCD619400D77E94 /* OSResilientStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCC48032FCD619400D77E94 /* OSResilientStorage.swift */; }; 3CCF44BE299B17290021964D /* OneSignalWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CCF44BC299B17290021964D /* OneSignalWrapper.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3CCF44BF299B17290021964D /* OneSignalWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CCF44BD299B17290021964D /* OneSignalWrapper.m */; }; 3CDE664C2BFC2A56006DA114 /* OneSignalUserObjcTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CDE664B2BFC2A56006DA114 /* OneSignalUserObjcTests.m */; }; @@ -1332,6 +1336,9 @@ 3C14E39E2AFAE39B006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3C14E3A02AFAE461006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestLiveActivityClicked.swift; sourceTree = ""; }; + 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalIdentifiersFallbackTests.swift; sourceTree = ""; }; + 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSModelStoreRefreshTests.swift; sourceTree = ""; }; + 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSResilientStorageTests.swift; sourceTree = ""; }; 3C24B0EA2BD09D790052E771 /* OneSignalCoreTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OneSignalCoreTests-Bridging-Header.h"; sourceTree = ""; }; 3C24B0EB2BD09D7A0052E771 /* OneSignalCoreObjCTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalCoreObjCTests.m; sourceTree = ""; }; 3C277D7D2BD76E0000857606 /* OSIdentityModelRepo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModelRepo.swift; sourceTree = ""; }; @@ -1416,6 +1423,7 @@ 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 = ""; }; + 3CCC48032FCD619400D77E94 /* OSResilientStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSResilientStorage.swift; sourceTree = ""; }; 3CCF44BC299B17290021964D /* OneSignalWrapper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalWrapper.h; sourceTree = ""; }; 3CCF44BD299B17290021964D /* OneSignalWrapper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalWrapper.m; sourceTree = ""; }; 3CDE664A2BFC2A55006DA114 /* OneSignalUserTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OneSignalUserTests-Bridging-Header.h"; sourceTree = ""; }; @@ -2231,6 +2239,7 @@ 3C115163289A259500565C41 /* OneSignalOSCore.h */, 3C115188289ADEA300565C41 /* OSModelStore.swift */, 3C5C6FFB2FCB8DED00102E2C /* OneSignalIdentifiers.swift */, + 3CCC48032FCD619400D77E94 /* OSResilientStorage.swift */, 3C115186289ADE7700565C41 /* OSModelStoreListener.swift */, 3C115184289ADE4F00565C41 /* OSModel.swift */, 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */, @@ -2513,6 +2522,9 @@ isa = PBXGroup; children = ( 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, + 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, + 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, + 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -4381,6 +4393,7 @@ 5BC1DE5C2C90B7E600CA8807 /* OSConsistencyManager.swift in Sources */, 3C5C6FFC2FCB8DED00102E2C /* OneSignalIdentifiers.swift in Sources */, 3C115189289ADEA300565C41 /* OSModelStore.swift in Sources */, + 3CCC48042FCD619400D77E94 /* OSResilientStorage.swift in Sources */, 3C115185289ADE4F00565C41 /* OSModel.swift in Sources */, 3CF1A5632C669EA40056B3AA /* OSNewRecordsState.swift in Sources */, 3C448BA22936B474002F96BC /* OSBackgroundTaskManager.swift in Sources */, @@ -4527,6 +4540,9 @@ buildActionMask = 2147483647; files = ( 5B053FC32CAE0843002F30C4 /* OSConsistencyManagerTests.swift in Sources */, + 3C23A21F2FCE0AA1001D32E3 /* OSResilientStorageTests.swift in Sources */, + 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */, + 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalReceiveReceiptsController.m b/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalReceiveReceiptsController.m index 560ae245b..7a1d6c6d0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalReceiveReceiptsController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalReceiveReceiptsController.m @@ -35,7 +35,15 @@ @implementation OneSignalReceiveReceiptsController - (BOOL)isReceiveReceiptsEnabled { - return [OneSignalUserDefaults.initShared getSavedBoolForKey:OSUD_RECEIVE_RECEIPTS_ENABLED defaultValue:NO]; + BOOL enabled = [OneSignalUserDefaults.initShared getSavedBoolForKey:OSUD_RECEIVE_RECEIPTS_ENABLED defaultValue:NO]; + if (enabled) { + return YES; + } + // UserDefaults can return NO for two reasons: the flag is genuinely off, or the shared + // UserDefaults file isn't readable right now (NSE running while device is locked under + // NSFileProtectionCompleteUntilFirstUserAuthentication). Fall back to the unencrypted cache. + NSString *cached = [OSResilientStorage stringForKey:OSResilientStorage.keyReceiveReceiptsEnabled]; + return [cached isEqualToString:@"1"]; } - (void)sendReceiveReceiptWithNotificationId:(NSString *)notificationId { diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityClicked.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityClicked.swift index 15a6c2c0a..2900bc596 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityClicked.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityClicked.swift @@ -45,7 +45,7 @@ class OSRequestLiveActivityClicked: OneSignalRequest, OSLiveActivityRequest { return false } - guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else { + guard let subscriptionId = OneSignalIdentifiers.subscriptionId else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the OSRequestLiveActivityClicked due to null subscription ID.") return false } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift index 657e87c7d..f5ef0561b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift @@ -44,7 +44,7 @@ class OSRequestLiveActivityReceiveReceipts: OneSignalRequest, OSLiveActivityRequ return false } - guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else { + guard let subscriptionId = OneSignalIdentifiers.subscriptionId else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the OSRequestLiveActivityReceiveReceipts due to null subscription ID.") return false } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestRemoveStartToken.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestRemoveStartToken.swift index cf6fe9e0e..b3fd515f1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestRemoveStartToken.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestRemoveStartToken.swift @@ -42,7 +42,7 @@ class OSRequestRemoveStartToken: OneSignalRequest, OSLiveActivityRequest, OSLive return false } - guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else { + guard let subscriptionId = OneSignalIdentifiers.subscriptionId else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the remove start token request due to null subscription ID.") return false } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestRemoveUpdateToken.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestRemoveUpdateToken.swift index cfc2f71ce..d48630afb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestRemoveUpdateToken.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestRemoveUpdateToken.swift @@ -42,7 +42,7 @@ class OSRequestRemoveUpdateToken: OneSignalRequest, OSLiveActivityRequest, OSLiv return false } - guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else { + guard let subscriptionId = OneSignalIdentifiers.subscriptionId else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the remove update token request due to null subscription ID.") return false } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetStartToken.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetStartToken.swift index a517fabd8..46007fa63 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetStartToken.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetStartToken.swift @@ -43,7 +43,7 @@ class OSRequestSetStartToken: OneSignalRequest, OSLiveActivityRequest, OSLiveAct return false } - guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else { + guard let subscriptionId = OneSignalIdentifiers.subscriptionId else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the set start token request due to null subscription ID.") return false } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetUpdateToken.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetUpdateToken.swift index 38a0d3e12..5cbaa3cae 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetUpdateToken.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestSetUpdateToken.swift @@ -43,7 +43,7 @@ class OSRequestSetUpdateToken: OneSignalRequest, OSLiveActivityRequest, OSLiveAc return false } - guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else { + guard let subscriptionId = OneSignalIdentifiers.subscriptionId else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the set update token request due to null subscription ID.") return false } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift index 1a2d6226e..12bba9084 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift @@ -93,6 +93,29 @@ open class OSModelStore: NSObject { } } + /// Re-read this store's backing UserDefaults entry and hydrate `models` from disk. + /// No-op when `models` is already non-empty — we never clobber in-memory state. + /// + /// Motivation: model stores load their `models` dict once in `init()` from shared UserDefaults. + /// If `init()` runs while protected data is unavailable (iOS app prewarm, NSE before first + /// unlock), that read returns nil and the dict stays empty for the lifetime of the singleton — + /// it is never re-read. After protected data becomes available, callers can call `refresh()` + /// so the store reflects what's actually on disk. Does not fire listener events. + public func refresh() { + lock.withLock { + guard models.isEmpty else { return } + guard let stored = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: self.storeKey, defaultValue: [:]) as? [String: TModel], + !stored.isEmpty else { + return + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSModelStore[\(self.storeKey)] refresh hydrated \(stored.count) model(s) from UserDefaults") + self.models = stored + for model in stored.values { + model.changeNotifier.subscribe(self) + } + } + } + public func add(id: String, model: TModel, hydrating: Bool) { // TODO: Check if we are adding the same model? Do we replace? // For example, calling addEmail multiple times with the same email diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift new file mode 100644 index 000000000..15ec4af5d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift @@ -0,0 +1,164 @@ +/* + 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 + +/// File-backed mirror of OneSignal SDK identifiers, written with `NSFileProtectionNone` +/// so it's readable before first unlock, when shared `UserDefaults` reads silently return nil. +/// +/// Stored in the App Group container, so it's shared across any targets (main app, NSE, etc.) +/// configured with the same App Group entitlement. Opaque identifiers only: no PII or credentials. +@objc(OSResilientStorage) +public final class OSResilientStorage: NSObject { + + // MARK: - Public key constants + + @objc public static let keyAppId = "app_id" + @objc public static let keySubscriptionId = "subscription_id" + /// Needed because the NSE reads this flag from shared UserDefaults while the device may be locked + /// and the read silently returns the default (NO). Stored as "1" / "0". + @objc public static let keyReceiveReceiptsEnabled = "receive_receipts_enabled" + /// Set to `"1"` once `OneSignalUserManagerImpl.start()` has completed on this device at least once. + /// Used by the main app's protected-data seed to distinguish "fresh install" from + /// "prior session exists but UserDefaults isn't readable yet (iOS prewarm before first + /// unlock)". Cleared on app-id change so a new app's first launch behaves like a fresh install. + @objc public static let keyDidStart = "did_start" + + // MARK: - Internal + + private static let fileName = "onesignal_identity.json" + + /// Serial queue used to serialize all file reads/writes. + private static let queue = DispatchQueue(label: "com.onesignal.resilient-storage") + + /// Resolve a writable container URL. App Group container is preferred so + /// the NSE can read the same file. Falls back to the app's private + /// Application Support directory when no App Group is entitled. + private static func fileURL() -> URL? { + let fm = FileManager.default + + let groupName = OneSignalUserDefaults.appGroupName() + if let container = fm.containerURL(forSecurityApplicationGroupIdentifier: groupName) { + return container.appendingPathComponent(fileName) + } + + do { + let support = try fm.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + return support.appendingPathComponent(fileName) + } catch { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSResilientStorage could not resolve a container URL: \(error)") + return nil + } + } + + /// Reads the cache file. Caller is responsible for queue-serialization. + /// Returns an empty dict if the file is missing or unreadable. + private static func loadUnsafe() -> [String: String] { + guard let url = fileURL() else { return [:] } + guard FileManager.default.fileExists(atPath: url.path) else { return [:] } + + do { + let data = try Data(contentsOf: url) + if data.isEmpty { return [:] } + let object = try JSONSerialization.jsonObject(with: data, options: []) + return (object as? [String: String]) ?? [:] + } catch { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSResilientStorage could not read file: \(error)") + return [:] + } + } + + /// Writes the cache file atomically with `.none` file protection. + /// Caller is responsible for queue-serialization. + private static func writeUnsafe(_ contents: [String: String]) { + guard let url = fileURL() else { return } + + do { + let data = try JSONSerialization.data(withJSONObject: contents, options: []) + try data.write(to: url, options: [.atomic, .noFileProtection]) + + // Explicitly re-apply protection class. The atomic write performs a rename which + // has been observed to reset attributes on some iOS versions. + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.none], + ofItemAtPath: url.path + ) + } catch { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSResilientStorage write failed: \(error)") + } + } + + // MARK: - Public API + + /// Returns the full current contents of the cache. Empty dict if absent. + @objc public static func snapshot() -> [String: String] { + return queue.sync { loadUnsafe() } + } + + /// Reads a single value. Returns nil when missing or unreadable. + @objc public static func string(forKey key: String) -> String? { + let dict = snapshot() + guard let value = dict[key], !value.isEmpty else { return nil } + return value + } + + /// Atomically updates a single value. Passing nil or an empty string removes the key. + @objc public static func setString(_ value: String?, forKey key: String) { + queue.async { + var current = loadUnsafe() + if let value = value, !value.isEmpty { + current[key] = value + } else { + current.removeValue(forKey: key) + } + writeUnsafe(current) + } + } + + /// Atomically updates multiple values, preserving keys not in `values`. + /// An empty-string value removes the corresponding key. + @objc public static func setStrings(_ values: [String: String]) { + guard !values.isEmpty else { return } + queue.async { + var current = loadUnsafe() + for (key, value) in values { + if value.isEmpty { + current.removeValue(forKey: key) + } else { + current[key] = value + } + } + writeUnsafe(current) + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift index 67e44e54e..824e40ab3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift @@ -32,9 +32,29 @@ import OneSignalCore @objc(OneSignalConfig) public final class OneSignalConfig: NSObject { - /// Returns true when the SDK shouldn't perform an operation yet because either: + /// Optional readability check for device-protected storage. + /// + /// Set-once invariant: the main app assigns this exactly once during `OneSignal.init`, + /// inside a `dispatch_once`. The assignment is the publishing side and `dispatch_once` + /// is a memory barrier, so concurrent readers via `shouldAwait…` observe a stable closure. + /// The closure itself must be cheap and thread-safe — the main app implements it as an + /// atomic-BOOL load. The flag is a one-way latch: seeded from a UserDefaults probe at + /// init (testing whether `NSFileProtectionCompleteUntilFirstUserAuthentication`-protected + /// reads succeed), then flipped to YES by `UIApplicationProtectedDataDidBecomeAvailable` + /// if the seed put us in the deferred state. We deliberately do NOT observe + /// `UIApplicationProtectedDataWillBecomeUnavailable` — that notification tracks + /// `NSFileProtectionComplete` and would re-gate APIs on routine locks even though the + /// SDK's `…UntilFirstUserAuthentication` storage stays readable. + /// + /// Left nil in app-extension contexts (NSE) since `UIApplication` is unavailable there. + /// When nil the predicate treats protected data as available — the right default for NSE, + /// which reads identifiers through `OSResilientStorage` (file-backed, bypasses cfprefsd). + @objc public static var isProtectedDataAvailableProvider: (() -> Bool)? + + /// Returns true when the SDK shouldn't perform an operation yet because: /// * `app_id` hasn't been set via `OneSignal.initialize`, or - /// * the host app hasn't granted privacy consent (per `OSPrivacyConsentController`). + /// * the host app hasn't granted privacy consent, or + /// * device storage isn't readable yet (iOS prewarm before first unlock). @objc public static func shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod methodName: String?) -> Bool { var shouldAwait = false if OneSignalIdentifiers.currentAppId == nil { @@ -46,6 +66,12 @@ public final class OneSignalConfig: NSObject { if OSPrivacyConsentController.shouldLogMissingPrivacyConsentError(withMethodName: methodName) { shouldAwait = true } + if let provider = isProtectedDataAvailableProvider, !provider() { + if let methodName { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "\(methodName) deferred: device-protected storage is not yet available (iOS prewarm before first unlock).") + } + shouldAwait = true + } return shouldAwait } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalIdentifiers.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalIdentifiers.swift index 129a274ab..76b563335 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalIdentifiers.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalIdentifiers.swift @@ -47,13 +47,24 @@ public final class OneSignalIdentifiers: NSObject { set { lock.withLock { _currentAppId = newValue } } } - /// Last-known persisted `app_id` from shared UserDefaults. Returns nil if absent. + /// Persisted `app_id` — shared UserDefaults first, then the unencrypted + /// `OSResilientStorage` mirror. The mirror covers the prewarm-before-first-unlock + /// window where UserDefaults is locked and cfprefsd silently returns nil. @objc public static var storedAppId: String? { - return OneSignalUserDefaults.initShared().getSavedString(forKey: OSUD_APP_ID, defaultValue: nil) + if let fromUD = OneSignalUserDefaults.initShared().getSavedString(forKey: OSUD_APP_ID, defaultValue: nil), + !fromUD.isEmpty { + return fromUD + } + return OSResilientStorage.string(forKey: OSResilientStorage.keyAppId) } - /// Persisted push `subscription_id` from shared UserDefaults. Returns nil if absent. + /// Persisted push `subscription_id` — shared UserDefaults first, then the unencrypted + /// `OSResilientStorage` mirror. @objc public static var subscriptionId: String? { - return OneSignalUserDefaults.initShared().getSavedString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, defaultValue: nil) + if let fromUD = OneSignalUserDefaults.initShared().getSavedString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, defaultValue: nil), + !fromUD.isEmpty { + return fromUD + } + return OSResilientStorage.string(forKey: OSResilientStorage.keySubscriptionId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSModelStoreRefreshTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSModelStoreRefreshTests.swift new file mode 100644 index 000000000..b9cd81947 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSModelStoreRefreshTests.swift @@ -0,0 +1,135 @@ +/* + 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 XCTest +import OneSignalCore +@testable import OneSignalOSCore + +/// Validates `OSModelStore.refresh()` — the post-unlock recovery path that the SDK's +/// `start()` relies on so model stores that loaded empty during iOS prewarm re-hydrate +/// from disk before Path 1 runs. +final class OSModelStoreRefreshTests: XCTestCase { + + private let storeKey = "OSModelStoreRefreshTests_storeKey" + + override func setUp() { + super.setUp() + OneSignalUserDefaults.initShared().removeValue(forKey: storeKey) + } + + override func tearDown() { + OneSignalUserDefaults.initShared().removeValue(forKey: storeKey) + super.tearDown() + } + + /// Seed UserDefaults with a serialized models dict, the same way `OSModelStore.add` + /// persists. Mimics "prior session wrote models to disk". + private func seedUserDefaults(with models: [String: OSModel]) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: storeKey, withValue: models) + } + + private func makeStore() -> OSModelStore { + return OSModelStore(changeSubscription: OSEventProducer(), storeKey: storeKey) + } + + /// Simulates the prewarm case: at OSModelStore.init time UserDefaults returned nil, + /// then disk became readable. refresh() must pick up what's on disk. + func testRefresh_hydratesFromUserDefaults_whenStoreLoadedEmpty() { + // 1. Store inits empty (UD has no entry for storeKey). + let store = makeStore() + XCTAssertTrue(store.getModels().isEmpty, "Precondition: store should load empty") + + // 2. Disk gets populated after the fact (simulates the prior session's persisted state). + let model = OSModel(changeNotifier: OSEventProducer()) + seedUserDefaults(with: ["key_x": model]) + + // 3. refresh() should hydrate. + store.refresh() + + XCTAssertEqual(store.getModels().count, 1, "refresh should hydrate the in-memory dict") + XCTAssertNotNil(store.getModel(key: "key_x")) + } + + /// refresh() must be a no-op when the store is already populated — never clobber + /// in-memory state that may have diverged from disk via writes that haven't flushed. + func testRefresh_isNoOp_whenStoreAlreadyPopulated() { + // 1. Seed disk with model A; store init() will load it. + let modelA = OSModel(changeNotifier: OSEventProducer()) + seedUserDefaults(with: ["key_x": modelA]) + let store = makeStore() + XCTAssertEqual(store.getModels().count, 1) + let loadedAId = store.getModel(key: "key_x")?.modelId + + // 2. Disk gets a different model B for the same key. + let modelB = OSModel(changeNotifier: OSEventProducer()) + seedUserDefaults(with: ["key_x": modelB]) + + // 3. refresh() must NOT replace the existing in-memory entry. + store.refresh() + + XCTAssertEqual(store.getModel(key: "key_x")?.modelId, loadedAId, + "refresh must not replace already-loaded model") + } + + /// refresh() should subscribe the store to each hydrated model's change notifier so + /// downstream mutations persist via the normal onModelUpdated path. + func testRefresh_subscribesStoreToHydratedModelNotifiers() { + let store = makeStore() + XCTAssertTrue(store.getModels().isEmpty) + + let model = OSModel(changeNotifier: OSEventProducer()) + seedUserDefaults(with: ["key_x": model]) + + store.refresh() + + guard let loaded = store.getModel(key: "key_x") else { + XCTFail("Expected model to be loaded by refresh") + return + } + + // Mutate via set(property:) — triggers fire() on changeNotifier. If refresh() wired + // the subscription, the store's onModelUpdated will receive it and persist the + // dict back to UserDefaults. + OneSignalUserDefaults.initShared().removeValue(forKey: storeKey) + loaded.set(property: "test_prop", newValue: "test_value") + + // After the mutation, UD should be repopulated by onModelUpdated. + let written = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: storeKey, defaultValue: nil) + XCTAssertNotNil(written, "Mutating a refresh()-hydrated model should persist via the store's onModelUpdated handler") + } + + /// Sanity: when there's nothing on disk, refresh() leaves an empty store empty. + func testRefresh_doesNothing_whenDiskIsEmpty() { + let store = makeStore() + XCTAssertTrue(store.getModels().isEmpty) + + store.refresh() + + XCTAssertTrue(store.getModels().isEmpty) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSResilientStorageTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSResilientStorageTests.swift new file mode 100644 index 000000000..b93500b5b --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSResilientStorageTests.swift @@ -0,0 +1,124 @@ +/* + 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 XCTest +@testable import OneSignalOSCore + +final class OSResilientStorageTests: XCTestCase { + + /// Test keys. We avoid the real key constants so collisions with anything written by + /// other tests / fixtures during the same simulator session can't bleed into assertions. + private let keyA = "test_key_a" + private let keyB = "test_key_b" + + override func setUp() { + super.setUp() + clearAllTestKeys() + } + + override func tearDown() { + clearAllTestKeys() + super.tearDown() + } + + private func clearAllTestKeys() { + OSResilientStorage.setString(nil, forKey: keyA) + OSResilientStorage.setString(nil, forKey: keyB) + // Writes are queue.async; force ordering with a queue.sync read. + _ = OSResilientStorage.snapshot() + } + + // MARK: - setString / string(forKey:) + + func testSetThenGet_returnsTheStoredValue() { + OSResilientStorage.setString("alpha", forKey: keyA) + XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha") + } + + func testGet_returnsNilWhenAbsent() { + XCTAssertNil(OSResilientStorage.string(forKey: "never_written_\(UUID().uuidString)")) + } + + func testSetWithNil_removesKey() { + OSResilientStorage.setString("alpha", forKey: keyA) + XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha") + + OSResilientStorage.setString(nil, forKey: keyA) + XCTAssertNil(OSResilientStorage.string(forKey: keyA)) + } + + func testSetWithEmptyString_removesKey() { + OSResilientStorage.setString("alpha", forKey: keyA) + OSResilientStorage.setString("", forKey: keyA) + XCTAssertNil(OSResilientStorage.string(forKey: keyA)) + } + + func testGet_returnsNilWhenStoredValueIsEmpty() { + // Direct insert via setStrings to verify the read-side empty-guard, not just the write side. + OSResilientStorage.setStrings([keyA: "alpha"]) + OSResilientStorage.setStrings([keyA: ""]) + XCTAssertNil(OSResilientStorage.string(forKey: keyA)) + } + + // MARK: - setStrings (batch) + + func testSetStrings_setsMultipleKeysAtomically() { + OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"]) + XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha") + XCTAssertEqual(OSResilientStorage.string(forKey: keyB), "beta") + } + + func testSetStrings_emptyValueRemovesCorrespondingKey() { + OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"]) + OSResilientStorage.setStrings([keyA: ""]) + XCTAssertNil(OSResilientStorage.string(forKey: keyA)) + XCTAssertEqual(OSResilientStorage.string(forKey: keyB), "beta") + } + + func testSetStrings_preservesKeysNotInTheUpdateDict() { + OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"]) + OSResilientStorage.setStrings([keyA: "alpha_updated"]) + XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha_updated") + XCTAssertEqual(OSResilientStorage.string(forKey: keyB), "beta") + } + + func testSetStrings_emptyDictIsNoOp() { + OSResilientStorage.setStrings([keyA: "alpha"]) + OSResilientStorage.setStrings([:]) + XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha") + } + + // MARK: - snapshot + + func testSnapshot_reflectsCurrentContents() { + OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"]) + let snap = OSResilientStorage.snapshot() + XCTAssertEqual(snap[keyA], "alpha") + XCTAssertEqual(snap[keyB], "beta") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OneSignalIdentifiersFallbackTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OneSignalIdentifiersFallbackTests.swift new file mode 100644 index 000000000..61294126c --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OneSignalIdentifiersFallbackTests.swift @@ -0,0 +1,105 @@ +/* + 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 XCTest +import OneSignalCore +@testable import OneSignalOSCore + +/// Validates the UserDefaults-first → OSResilientStorage-fallback read paths. +/// During iOS prewarm before first unlock the shared UserDefaults file is encrypted and +/// returns nil/empty for these keys, so callers (LA executors, NSE) must transparently +/// recover the value from the unencrypted mirror. +final class OneSignalIdentifiersFallbackTests: XCTestCase { + + override func setUp() { + super.setUp() + clearUserDefaultsKey(OSUD_APP_ID) + clearUserDefaultsKey(OSUD_PUSH_SUBSCRIPTION_ID) + OSResilientStorage.setString(nil, forKey: OSResilientStorage.keyAppId) + OSResilientStorage.setString(nil, forKey: OSResilientStorage.keySubscriptionId) + // Force the async OSResilientStorage write queue to drain before the next test step. + _ = OSResilientStorage.snapshot() + } + + override func tearDown() { + clearUserDefaultsKey(OSUD_APP_ID) + clearUserDefaultsKey(OSUD_PUSH_SUBSCRIPTION_ID) + OSResilientStorage.setString(nil, forKey: OSResilientStorage.keyAppId) + OSResilientStorage.setString(nil, forKey: OSResilientStorage.keySubscriptionId) + _ = OSResilientStorage.snapshot() + super.tearDown() + } + + private func clearUserDefaultsKey(_ key: String) { + OneSignalUserDefaults.initShared().removeValue(forKey: key) + } + + // MARK: - storedAppId + + func testStoredAppId_returnsUserDefaultsValue_whenUDPopulated() { + OneSignalUserDefaults.initShared().saveString(forKey: OSUD_APP_ID, withValue: "app_id_ud") + OSResilientStorage.setString("app_id_mirror", forKey: OSResilientStorage.keyAppId) + _ = OSResilientStorage.snapshot() + + XCTAssertEqual(OneSignalIdentifiers.storedAppId, "app_id_ud", + "UD value should take precedence when present") + } + + func testStoredAppId_fallsBackToMirror_whenUDAbsent() { + OSResilientStorage.setString("app_id_mirror", forKey: OSResilientStorage.keyAppId) + _ = OSResilientStorage.snapshot() + + XCTAssertEqual(OneSignalIdentifiers.storedAppId, "app_id_mirror", + "Mirror should be returned when UD is empty (locked-storage scenario)") + } + + func testStoredAppId_returnsNil_whenBothEmpty() { + XCTAssertNil(OneSignalIdentifiers.storedAppId) + } + + // MARK: - subscriptionId + + func testSubscriptionId_returnsUserDefaultsValue_whenUDPopulated() { + OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: "sub_ud") + OSResilientStorage.setString("sub_mirror", forKey: OSResilientStorage.keySubscriptionId) + _ = OSResilientStorage.snapshot() + + XCTAssertEqual(OneSignalIdentifiers.subscriptionId, "sub_ud") + } + + func testSubscriptionId_fallsBackToMirror_whenUDAbsent() { + OSResilientStorage.setString("sub_mirror", forKey: OSResilientStorage.keySubscriptionId) + _ = OSResilientStorage.snapshot() + + XCTAssertEqual(OneSignalIdentifiers.subscriptionId, "sub_mirror") + } + + func testSubscriptionId_returnsNil_whenBothEmpty() { + XCTAssertNil(OneSignalIdentifiers.subscriptionId) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift index 6e4799cfb..b7f8a429f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -135,8 +135,10 @@ class OSSubscriptionModel: OSModel { return } - // Cache the subscriptionId as it persists across users on the device?? + // Cache the subscriptionId — UserDefaults for routine reads, and the + // OSResilientStorage mirror so it survives prewarm-locked UserDefaults. OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: subscriptionId) + OSResilientStorage.setString(subscriptionId ?? "", forKey: OSResilientStorage.keySubscriptionId) firePushSubscriptionChanged(.subscriptionId(oldValue)) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index efc02663f..22e5bac95 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -215,6 +215,16 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager calling start") + // The model stores load their in-memory `models` dict once in their initializer. + // If the singleton was first touched while protected data was unavailable (iOS app + // prewarm), that read returned nil and the dicts are empty. The gate above ensures + // `start()` only proceeds when protected data is available, but the stores need to + // be refreshed here so Path 1's cache check can see what's on disk. + identityModelStore.refresh() + propertiesModelStore.refresh() + subscriptionModelStore.refresh() + pushSubscriptionModelStore.refresh() + OSNotificationsManager.delegate = self var hasCachedUser = false @@ -228,6 +238,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { _user = OSUserInternalImpl(identityModel: identityModel, propertiesModel: propertiesModel, pushSubscriptionModel: pushSubscription) addIdentityModelToRepo(identityModel) OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager.start called, loaded the user from cache.") + + // Backfill the OSResilientStorage mirror so SDK upgraders populate it on first normal launch. + if let subId = pushSubscription.subscriptionId, !subId.isEmpty { + OSResilientStorage.setString(subId, forKey: OSResilientStorage.keySubscriptionId) + } } // TODO: Update the push sub model with any new state from NotificationsManager @@ -256,6 +271,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager: creating user linked to legacy subscription \(legacyPlayerId)") createUserFromLegacyPlayer(legacyPlayerId) OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: legacyPlayerId) + // Mirror to OSResilientStorage as well — createDefaultPushSubscription sets the id + // via the initializer, so subscriptionId.didSet (which normally writes the mirror) + // does not fire here. Keeps the prewarm fallback populated for migrated v3 players. + OSResilientStorage.setString(legacyPlayerId, forKey: OSResilientStorage.keySubscriptionId) OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_LEGACY_PLAYER_ID) OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_LEGACY_PLAYER_ID) } else { @@ -275,6 +294,17 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { _user?.update() } hasCalledStart = true + // Mark that the SDK has completed start() at least once on this device. + // The main app's protected-data seed reads this on subsequent launches to + // distinguish "fresh install" from "prior session exists but UserDefaults isn't + // readable yet (iOS prewarm before first unlock)". + OSResilientStorage.setString("1", forKey: OSResilientStorage.keyDidStart) + // Force the OSResilientStorage write queue to drain before returning. The setString + // above is `queue.async`; if the OS kills the process within the ms-window before + // the file write lands and the next launch happens under prewarm-before-first-unlock, + // the seed would misclassify as "fresh install" and Path 3 would orphan the real user. + // `snapshot()` is `queue.sync`, FIFO with pending writes — calling it here drains. + _ = OSResilientStorage.snapshot() } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 9cf4652dd..5b4a9d2c7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -64,6 +64,43 @@ final class OneSignalUserTests: XCTestCase { XCTAssertEqual(userInstanceExternalId, "my-external-id") } + /** + Regression test for the iOS prewarm fix (SDK-4725). + + `start()` is gated by `OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent`, which now + also defers while device-protected storage is unreadable (iOS app prewarm before first unlock). + This verifies the contract the fix depends on: a `start()` that is gated out must be a no-op — + `hasCalledStart` stays false and no user is half-initialized — and a later `start()`, once + protected data is available, must proceed. The main app relies on that re-drive after it seeds + the protected-data flag and on `UIApplicationProtectedDataDidBecomeAvailable`; if the re-drive + ever stops taking effect (as it did when the flag was seeded asynchronously after the + synchronous `start()` during init), the user module never starts on a normal launch. + */ + func testStartDefersUntilProtectedDataAvailableThenProceeds() throws { + /* Setup */ + let client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + OneSignalCoreImpl.setSharedClient(client) + + let manager = OneSignalUserManagerImpl.sharedInstance + + // Simulate protected data being unavailable (iOS prewarm before first unlock). + var protectedDataAvailable = false + OneSignalConfig.isProtectedDataAvailableProvider = { protectedDataAvailable } + defer { OneSignalConfig.isProtectedDataAvailableProvider = nil } + + /* When protected data is unavailable, start() must be a no-op */ + manager.start() + XCTAssertFalse(manager.hasCalledStart) + XCTAssertNil(manager._user) + + /* When protected data becomes available, the re-driven start() must proceed */ + protectedDataAvailable = true + manager.start() + XCTAssertTrue(manager.hasCalledStart) + XCTAssertNotNil(manager._user) + } + /** Tests multiple user updates should be combined and sent together. Multiple session times should be added. diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index a4e5bd653..41ad90cbd 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -25,6 +25,7 @@ * THE SOFTWARE. */ +#import #import "OneSignalFramework.h" #import #import "OneSignalInternal.h" @@ -353,9 +354,11 @@ + (void)startNewSession:(BOOL)fromInit { + (void)startNewSessionInternal { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OneSignal.startNewSessionInternal"]; - - // return if the user has not granted privacy permissions - if ([OSPrivacyConsentController shouldLogMissingPrivacyConsentErrorWithMethodName:nil]) + + // return if the user has not granted privacy permissions, or device-protected storage + // isn't readable yet (iOS prewarm before first unlock — the observer recovery will + // re-fire `[OneSignal startNewSession:YES]` once storage becomes available). + if ([OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil]) return; [OSOutcomes.sharedController clearOutcomes]; @@ -456,7 +459,104 @@ + (void)delayInitializationForPrivacyConsent { */ + (void)init { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"launchOptions is set and appId of %@ is set, initializing OneSignal...", OneSignalIdentifiers.currentAppId]]; - + + // Wire the protected-data check so OneSignalConfig's readiness predicate defers SDK + // operations during iOS prewarm (before first unlock) when shared UserDefaults reads + // are unreliable. UIApplication isn't available to OneSignalOSCore (which OneSignalConfig + // lives in), so the main app injects the check here at initialize time. + // + // The flag is a one-way latch: it starts NO, flips to YES once our storage is readable, + // and stays YES for the lifetime of the process. The SDK's shared App Group UserDefaults + // use `NSFileProtectionCompleteUntilFirstUserAuthentication` — readable from first unlock + // onward, and stays readable across every subsequent lock/unlock cycle. + // + // We seed via a UD probe of the push-subscription model store. Primary path tests our + // actual storage class (`…UntilFirstUserAuthentication`) directly, with + // `UIApplication.isProtectedDataAvailable` only as a tiebreaker for the ambiguous + // no-positive-signal case (so pre-PR SDK upgraders — who never wrote `keyDidStart` — + // don't get misclassified as "fresh install" during prewarm and orphaned by Path 3). + // * UD readable → pushModels has entries → flag = YES + // * UD locked (genuine prewarm) + `keyDidStart` set → flag = NO (returning user; defer) + // * No positive signal + main thread → tiebreaker on `isProtectedDataAvailable`: + // - YES → flag = YES (likely fresh install on a normal launch) + // - NO → flag = NO (could be pre-PR upgrader prewarm OR fresh install in a + // locked-after-first-unlock spawn; defer to observer to be safe) + // * No positive signal + off-main thread → flag = YES (can't safely read UIApplication; + // accept the rare pre-PR-upgrader-off-main-init risk in exchange for not bricking + // the common case) + // `keyDidStart` is written at the end of a successful `start()` and cleared on app-id + // change; it's the explicit "SDK previously initialized on this device" sentinel. + // We deliberately do NOT observe `UIApplicationProtectedDataWillBecomeUnavailable` — + // it tracks `NSFileProtectionComplete` (the stricter class) which flips back to NO ~10s + // after every device lock, and using it would silently gate APIs in locked-after-first- + // unlock contexts where our `…UntilFirstUserAuthentication` storage is still readable. + static _Atomic(BOOL) gProtectedDataAvailable = NO; + // Whether `+init`'s synchronous `start()` / `startNewSession:YES` calls were gated out + // and need to be re-driven from the observer. Set inside the dispatch_once below; + // cleared atomically on the first observer fire. Without this guard, every + // device lock-then-unlock cycle while the app is alive would post + // `UIApplicationProtectedDataDidBecomeAvailable` (it tracks `NSFileProtectionComplete`, + // which transitions on every lock), re-run `startNewSession:YES`, and bypass the + // 30s threshold — spuriously incrementing `session_count` and firing duplicate + // `fetchUser` requests. + static _Atomic(BOOL) gObserverShouldRecover = NO; + static dispatch_once_t protectedDataOnce; + dispatch_once(&protectedDataOnce, ^{ + [NSNotificationCenter.defaultCenter addObserverForName:UIApplicationProtectedDataDidBecomeAvailable + object:nil + queue:nil + usingBlock:^(NSNotification * _Nonnull note) { + atomic_store(&gProtectedDataAvailable, YES); + // Only run the recovery if the seed put us in the deferred state. Otherwise + // `+init`'s synchronous calls already ran and re-driving them on routine + // lock/unlock cycles would duplicate work. + BOOL expected = YES; + if (!atomic_compare_exchange_strong(&gObserverShouldRecover, &expected, NO)) { + return; + } + // Drive `start()` again — it was gated by the predicate while protected data was + // unavailable. The re-call now sees the gate clear, refreshes the model stores + // from shared UserDefaults, and takes the normal Path 1 cache load. + [OneSignalUserManagerImpl.sharedInstance start]; + // Replay any APNs token cached in `_pushToken` before the delegate was set. + // `start()` only assigns `OSNotificationsManager.delegate`; it does not pull the + // existing token. Mirrors `+startUserManager`'s pairing. + [OSNotificationsManager sendPushTokenToDelegate]; + // Start the modules `+init` deferred during prewarm — their singletons eagerly + // read UserDefaults at init, so initializing them now (with storage readable) + // gives them the real on-disk state instead of empty caches that would overwrite + // it on first save. + [OneSignal startLiveActivitiesManager]; + [OneSignal startInAppMessages]; + // Complete the deferred `+init` new-session call. We use `:YES` (force) because + // the 30s threshold would otherwise no-op this recovery when unlock happens + // within 30s of init. + [OneSignal startNewSession:YES]; + }]; + + OneSignalConfig.isProtectedDataAvailableProvider = ^BOOL { + return atomic_load(&gProtectedDataAvailable); + }; + + // Seed the cached flag — see the block comment above for the case table. + // UserDefaults reads are thread-safe so this works from any thread; the + // `UIApplication` tiebreaker is only consulted on the main thread. + NSDictionary *pushModels = [OneSignalUserDefaults.initShared getSavedCodeableDataForKey:OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY defaultValue:@{}]; + BOOL hasPriorSession = [OSResilientStorage stringForKey:OSResilientStorage.keyDidStart] != nil; + BOOL storageReadable; + if (pushModels.count > 0) { + storageReadable = YES; + } else if (hasPriorSession) { + storageReadable = NO; + } else if ([NSThread isMainThread]) { + storageReadable = UIApplication.sharedApplication.isProtectedDataAvailable; + } else { + storageReadable = YES; + } + atomic_store(&gProtectedDataAvailable, storageReadable); + atomic_store(&gObserverShouldRecover, !storageReadable); + }); + // TODO: We moved this check to the top of this method, we should test this. if (initDone) { return; @@ -505,8 +605,16 @@ + (void)init { [self startLifecycleObserver]; //TODO: Should these be started in Dependency order? e.g. IAM depends on User Manager shared instance [self startUserManager]; // By here, app_id exists, and consent is granted. - [self startLiveActivitiesManager]; - [self startInAppMessages]; + // `OneSignalLiveActivitiesManagerImpl` and `OSMessagingController` both eagerly read + // shared/standard UserDefaults at their first-access init (LA's `RequestCache.init` + // reads the pending token-update queue; IAM's `OSMessagingController.init` reads + // seen/clicked/impressioned sets). During prewarm-before-first-unlock those reads + // return empty and subsequent saves overwrite the prior on-disk state. Gate both here + // and re-drive from the protected-data observer (above) once storage is readable. + if (![OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil]) { + [self startLiveActivitiesManager]; + [self startInAppMessages]; + } [self startNewSession:YES]; initializationTime = [[NSDate date] timeIntervalSince1970]; @@ -527,9 +635,12 @@ + (void)handleAppIdChange:(NSString*)appId { let standardUserDefaults = OneSignalUserDefaults.initStandard; NSString *prevAppId = OneSignalIdentifiers.storedAppId; - // Handle changes to the app id, this might happen on a developer's device when testing - // Will also run the first time OneSignal is initialized - if (appId && ![appId isEqualToString:prevAppId]) { + // Handle changes to the app id, this might happen on a developer's device when testing. + // Only treat as a change when a non-nil prevAppId was previously stored AND it differs — + // a nil prevAppId means either a true first install OR a degenerate read (which the + // OSResilientStorage fallback inside storedAppId now mostly prevents). Treating nil as + // "changed" would fire the destructive subscription_id clear on first install. + if (appId && prevAppId && ![appId isEqualToString:prevAppId]) { initDone = false; _downloadedParameters = false; _didCallDownloadParameters = false; @@ -541,12 +652,31 @@ + (void)handleAppIdChange:(NSString*)appId { [sharedUserDefaults removeValueForKey:OSUD_PUSH_SUBSCRIPTION_ID]; [standardUserDefaults removeValueForKey:OSUD_LEGACY_PLAYER_ID]; [sharedUserDefaults removeValueForKey:OSUD_LEGACY_PLAYER_ID]; + // Symmetric with the OSResilientStorage clear below. The NSE's `isReceiveReceiptsEnabled` + // short-circuits on UD = YES before consulting the mirror, so leaving stale UD here + // would mask the mirror clear until the new app's `downloadIOSParams` lands. + [sharedUserDefaults removeValueForKey:OSUD_RECEIVE_RECEIPTS_ENABLED]; + // Drop the archived push subscription model dict — `pushSubscriptionModelStore` is the + // only model store NOT registered for `OS_ON_USER_WILL_CHANGE` (its push-sub is + // device-tied), so `clearAllModelsFromStores` below doesn't clear it. Without this + // removal, the next launch reloads the OLD app's server-issued `subscriptionId` via + // NSCoding and the new `_user` operates with a stale id until `downloadIOSParams` + // re-issues one. + [sharedUserDefaults removeValueForKey:OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY]; + + // Drop cached identifiers — a real app-id change invalidates them. + [OSResilientStorage setStrings:@{ + OSResilientStorage.keySubscriptionId: @"", + OSResilientStorage.keyReceiveReceiptsEnabled: @"", + OSResilientStorage.keyDidStart: @"" + }]; // Clear all cached data, does not start User Module nor call logout. [OneSignalUserManagerImpl.sharedInstance clearAllModelsFromStores]; } [OneSignalUserDefaults.initShared saveStringForKey:OSUD_APP_ID withValue:appId]; + [OSResilientStorage setString:appId forKey:OSResilientStorage.keyAppId]; } + (void)registerForAPNsToken { @@ -596,8 +726,13 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { [OSNotificationsManager checkProvisionalAuthorizationStatus]; } - if (result[IOS_RECEIVE_RECEIPTS_ENABLE] != (id)[NSNull null]) - [OneSignalUserDefaults.initShared saveBoolForKey:OSUD_RECEIVE_RECEIPTS_ENABLED withValue:[result[IOS_RECEIVE_RECEIPTS_ENABLE] boolValue]]; + if (result[IOS_RECEIVE_RECEIPTS_ENABLE] != (id)[NSNull null]) { + BOOL enabled = [result[IOS_RECEIVE_RECEIPTS_ENABLE] boolValue]; + [OneSignalUserDefaults.initShared saveBoolForKey:OSUD_RECEIVE_RECEIPTS_ENABLED withValue:enabled]; + // Mirror to the unencrypted cache so the NSE can read this flag while the + // device is booted-but-locked (UserDefaults reads return nil in that state). + [OSResilientStorage setString:enabled ? @"1" : @"0" forKey:OSResilientStorage.keyReceiveReceiptsEnabled]; + } [[OSRemoteParamController sharedController] saveRemoteParams:result]; if ([[OSRemoteParamController sharedController] hasLocationKey]) {