From 79001c20e042801eb67af3c9a08e189a86ed5cbf Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 18:11:29 -0700 Subject: [PATCH 01/11] update OneSignalIdentifiers to read from resilient storage --- .../Source/OneSignalIdentifiers.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalIdentifiers.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalIdentifiers.swift index 129a274ab..e6cb52b13 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalIdentifiers.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalIdentifiers.swift @@ -47,13 +47,22 @@ 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` from shared UserDefaults first, then the unencrypted `OSResilientStorage` mirror, + /// covering the prewarm-before-first-unlock window where UserDefaults is locked and 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` from 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) } } From c9dc1343c4197f645586ba7c71f0ab2f1a652421 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 20:32:34 -0700 Subject: [PATCH 02/11] add OSResilientStorage file-backed identifier mirror NSFileProtectionNone JSON file in the App Group container that mirrors opaque identifiers so they're readable before first unlock, when cfprefsd-backed UserDefaults returns nil. --- .../Source/OSResilientStorage.swift | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift new file mode 100644 index 000000000..21f3b07ae --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift @@ -0,0 +1,163 @@ +/* + 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 at least once on this app + /// install; cleared on app-id change. Read at startup to distinguish a true fresh install from + /// a prior session whose UserDefaults isn't readable yet (ie: during iOS prewarm before first unlock). + @objc public static let keyHasPriorSession = "has_prior_session" + + // 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) + } + } +} From 1b44eed0bfa38e66380d79799d2294e6c97beca1 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 20:40:48 -0700 Subject: [PATCH 03/11] add protected-data gate to readiness predicate Adds `isProtectedDataAvailableProvider` injection point and a third branch in `shouldAwaitAppIdAndLogMissingPrivacyConsent` so SDK ops defer when device-protected storage isn't readable (iOS prewarm before first unlock). NSE callers (provider nil) keep prior behavior. --- .../Source/OneSignalConfig.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift index 67e44e54e..edab30547 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift @@ -32,9 +32,17 @@ import OneSignalCore @objc(OneSignalConfig) public final class OneSignalConfig: NSObject { - /// Returns true when the SDK shouldn't perform an operation yet because either: + /// Returns whether device-protected storage is currently readable. The main app target sets + /// this once at `+OneSignal.init`; the predicate calls it on every gate check from + /// arbitrary threads, so the closure must be cheap and thread-safe. Left nil in app + /// extensions (NSE) where `UIApplication` is unavailable — nil is treated as available, + /// which is correct for NSE since it reads identifiers through `OSResilientStorage`. + @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 +54,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") + } + shouldAwait = true + } return shouldAwait } } From e05f600deca9c2f0157bd31e4222a2514821ea2c Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 20:52:05 -0700 Subject: [PATCH 04/11] add OSModelStore.refresh() to re-hydrate from UserDefaults Lets callers re-read from disk after protected-data becomes available (iOS prewarm fix). No-op when models already populated; doesn't fire listeners. Extract UD read + change-notifier subscribe into helpers shared with init(). --- .../OneSignalOSCore/Source/OSModelStore.swift | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift index 1a2d6226e..b35978a84 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift @@ -36,17 +36,20 @@ open class OSModelStore: NSObject { public init(changeSubscription: OSEventProducer, storeKey: String) { self.storeKey = storeKey self.changeSubscription = changeSubscription - - // read models from cache, if any - if let models = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: self.storeKey, defaultValue: [:]) as? [String: TModel] { - self.models = models - } else { - // log error - self.models = [:] - } + self.models = OSModelStore.loadModelsFromUserDefaults(storeKey: storeKey) super.init() + subscribeToOwnedModels() + } + + /// Reads the archived `[String: TModel]` dict for `storeKey` from shared UserDefaults. + /// Returns an empty dict if the entry is missing or the unarchive fails. + private static func loadModelsFromUserDefaults(storeKey: String) -> [String: TModel] { + return (OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: storeKey, defaultValue: [:]) as? [String: TModel]) ?? [:] + } - // listen for changes to the models + /// Subscribes this store as a change observer on every model currently in `models`. + /// Callers must hold `lock` if invoking after init. + private func subscribeToOwnedModels() { for model in self.models.values { model.changeNotifier.subscribe(self) } @@ -93,6 +96,25 @@ 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 } + let stored = OSModelStore.loadModelsFromUserDefaults(storeKey: self.storeKey) + guard !stored.isEmpty else { return } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSModelStore[\(self.storeKey)] refresh hydrated \(stored.count) model(s) from UserDefaults") + self.models = stored + subscribeToOwnedModels() + } + } + 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 From 33bbd80357378d24fbbee67e008789dac4367fd6 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 20:54:08 -0700 Subject: [PATCH 05/11] mirror subscriptionId to OSResilientStorage on change Push subscription id writes now hit both shared UserDefaults and the file-backed mirror so the NSE / prewarm callers can read it when UserDefaults is locked. nil resets clear the mirror. --- .../OneSignalUser/Source/OSSubscriptionModel.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift index 6e4799cfb..80fee10c6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -135,8 +135,9 @@ class OSSubscriptionModel: OSModel { return } - // Cache the subscriptionId as it persists across users on the device?? + // Cache the subscriptionId to UserDefaults for routine reads, and the OSResilientStorage mirror OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: subscriptionId) + OSResilientStorage.setString(subscriptionId ?? "", forKey: OSResilientStorage.keySubscriptionId) firePushSubscriptionChanged(.subscriptionId(oldValue)) } From 9bc16f408a0fe4c20ad23384432d49908958d95b Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 21:01:36 -0700 Subject: [PATCH 06/11] refresh model stores + backfill mirror in start() Refresh all four model stores at the top of start() so a singleton that was first touched during iOS prewarm (UD locked, dicts loaded empty) sees what's actually on disk before Path 1's cache check. Backfill OSResilientStorage.keySubscriptionId from Path 1 and the v3 legacy migration so SDK upgraders and migrated users populate the mirror on their first post-upgrade launch. Write OSResilientStorage.keyHasPriorSession at the end of start(); drain via snapshot() so an OS kill can't lose the sentinel. --- .../Source/OneSignalUserManagerImpl.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index efc02663f..5b9e0ce35 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 mirror so SDK upgraders to v5.5.2 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,7 @@ 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) + 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 +291,13 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { _user?.update() } hasCalledStart = true + // Mark this app install as having completed `start()` at least once — see + // `OSResilientStorage.keyHasPriorSession`. The `snapshot()` below forces the + // async write queue to drain before returning, so an OS kill in the + // microsecond window can't lose the sentinel and cause the next prewarm + // launch to misclassify us as a fresh install. + OSResilientStorage.setString("1", forKey: OSResilientStorage.keyHasPriorSession) + _ = OSResilientStorage.snapshot() } } From 0aae83d3daeb7acccf9e39a1054f1a7963f1c323 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 21:04:24 -0700 Subject: [PATCH 07/11] fall back to OSResilientStorage in NSE receive-receipts gate NSE reads OSUD_RECEIVE_RECEIPTS_ENABLED from shared UD, but that returns NO when the device is locked under NSFileProtectionCompleteUntilFirstUserAuthentication. Short-circuit on YES, then consult the file-backed mirror so receive receipts still fire for NSE wakes during the prewarm/locked window. --- .../OneSignalReceiveReceiptsController.m | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 { From d5786ea748478dca6a6af84020c3a1b9c3fb002b Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 21:05:31 -0700 Subject: [PATCH 08/11] route LA requests through OneSignalIdentifiers.subscriptionId LA request prepareForExecution was reading _user?.pushSubscriptionModel, which is nil during prewarm (predicate gates start()). Switch all 6 LA request types to OneSignalIdentifiers.subscriptionId so they read from persisted UD (with OSResilientStorage fallback for locked-storage cases). --- .../Source/Requests/OSRequestLiveActivityClicked.swift | 2 +- .../Source/Requests/OSRequestLiveActivityReceiveReceipts.swift | 2 +- .../Source/Requests/OSRequestRemoveStartToken.swift | 2 +- .../Source/Requests/OSRequestRemoveUpdateToken.swift | 2 +- .../Source/Requests/OSRequestSetStartToken.swift | 2 +- .../Source/Requests/OSRequestSetUpdateToken.swift | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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 } From d6395bcb6cfa87a67d2ded3746d229e42df5559d Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 2 Jun 2026 01:02:58 -0700 Subject: [PATCH 09/11] wire protected-data gate + observer recovery + mirror writes Extract protected-data setup into +setupProtectedDataObserverOnce and ComputeInitialStorageReadable, called from +init. Owns the atomic-BOOL latch, DidBecomeAvailable observer (CAS-gated so it runs at most once per process), provider closure, and seed. Gate startLiveActivitiesManager / startInAppMessages on the predicate so their singletons don't load empty UD state during prewarm; observer re-drives them post-unlock. Switch startNewSessionInternal to the full predicate so it defers during prewarm. handleAppIdChange: nil-guard prevAppId; extend clear set with OSUD_RECEIVE_RECEIPTS_ENABLED, the push-sub model store UD entry, and three OSResilientStorage keys; mirror app_id on every setAppId. downloadIOSParamsWithAppId mirrors receive_receipts to OSResilientStorage for the NSE fallback. --- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 109 +++++++++++++++++++++--- 1 file changed, 99 insertions(+), 10 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index a4e5bd653..6442b63b2 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,9 @@ + (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 consent, or device-protected storage isn't readable yet + if ([OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil]) return; [OSOutcomes.sharedController clearOutcomes]; @@ -451,12 +452,83 @@ + (void)delayInitializationForPrivacyConsent { [OneSignalIdentifiers setCurrentAppId:nil]; } +/// Computes the initial value for `gProtectedDataAvailable` (see the case table in +/// `+setupProtectedDataObserverOnce`). +static BOOL ComputeInitialStorageReadable(void) { + NSDictionary *pushModels = [OneSignalUserDefaults.initShared getSavedCodeableDataForKey:OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY defaultValue:@{}]; + BOOL hasPriorSession = [OSResilientStorage stringForKey:OSResilientStorage.keyHasPriorSession] != nil; + if (pushModels.count > 0) { // UD is readable + return YES; + } + if (hasPriorSession) { // returning user during prewarm; defer + return NO; + } + if ([NSThread isMainThread]) { + return UIApplication.sharedApplication.isProtectedDataAvailable; + } + return YES; // off-main: can't safely read UIApplication +} + +/// One-time setup of the protected-data readiness check that matters during iOS app +/// prewarm, when reads from shared App Group UserDefaults silently return nil +/// +/// The cached flag is a one-way latch (NO → YES, never reverses). It is: +/// * Seeded by `ComputeInitialStorageReadable` (case table below). +/// * Flipped to YES on `UIApplicationProtectedDataDidBecomeAvailable`. +/// * Read via `OneSignalConfig.isProtectedDataAvailableProvider` +/// +/// Seed case table: +/// 1. pushModels populated → YES (UD is readable) +/// 2. `keyHasPriorSession` set, no UD → NO (returning user during prewarm; defer) +/// 3. neither + main thread → fall back to `UIApplication.isProtectedDataAvailable` +/// 4. neither + off-main thread → YES (can't safely read UIApplication) +/// +/// `keyHasPriorSession` is the only reliable "SDK previously ran here" sentinel, and case 3's +/// `isProtectedDataAvailable` tiebreaker also protects SDK upgraders who never wrote `keyHasPriorSession`. +/// +/// `gObserverShouldRecover` is set when init defers (storage isn't yet readable) and cleared by the +/// observer's first fire (tracked since `DidBecomeAvailable` posts on every device unlock). ++ (void)setupProtectedDataObserverOnce { + static _Atomic(BOOL) gProtectedDataAvailable = NO; + 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 init deferred. If `gObserverShouldRecover == YES`, + // atomically swap to NO and proceed; otherwise bail (already consumed, or never set). + BOOL shouldRecover = YES; + if (!atomic_compare_exchange_strong(&gObserverShouldRecover, &shouldRecover, NO)) { + return; + } + [OneSignalUserManagerImpl.sharedInstance start]; + [OSNotificationsManager sendPushTokenToDelegate]; + [OneSignal startLiveActivitiesManager]; + [OneSignal startInAppMessages]; + [OneSignal startNewSession:YES]; + }]; + + OneSignalConfig.isProtectedDataAvailableProvider = ^BOOL { + return atomic_load(&gProtectedDataAvailable); + }; + + BOOL initialState = ComputeInitialStorageReadable(); + atomic_store(&gProtectedDataAvailable, initialState); + atomic_store(&gObserverShouldRecover, !initialState); + }); +} + /* Called after setAppId and setLaunchOptions, depending on which one is called last (order does not matter) */ + (void)init { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"launchOptions is set and appId of %@ is set, initializing OneSignal...", OneSignalIdentifiers.currentAppId]]; - + + [self setupProtectedDataObserverOnce]; + // TODO: We moved this check to the top of this method, we should test this. if (initDone) { return; @@ -505,8 +577,12 @@ + (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]; + // Defer LA and IAM init during prewarm: both eagerly read UserDefaults at first access and would + // overwrite the on-disk state with empty caches on the next save. The observer re-drives them post-unlock. + if (![OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil]) { + [self startLiveActivitiesManager]; + [self startInAppMessages]; + } [self startNewSession:YES]; initializationTime = [[NSDate date] timeIntervalSince1970]; @@ -528,8 +604,7 @@ + (void)handleAppIdChange:(NSString*)appId { 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]) { + if (appId && prevAppId && ![appId isEqualToString:prevAppId]) { initDone = false; _downloadedParameters = false; _didCallDownloadParameters = false; @@ -541,12 +616,22 @@ + (void)handleAppIdChange:(NSString*)appId { [sharedUserDefaults removeValueForKey:OSUD_PUSH_SUBSCRIPTION_ID]; [standardUserDefaults removeValueForKey:OSUD_LEGACY_PLAYER_ID]; [sharedUserDefaults removeValueForKey:OSUD_LEGACY_PLAYER_ID]; + [sharedUserDefaults removeValueForKey:OSUD_RECEIVE_RECEIPTS_ENABLED]; + [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.keyHasPriorSession: @"" + }]; // 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 +681,12 @@ + (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 + [OSResilientStorage setString:enabled ? @"1" : @"0" forKey:OSResilientStorage.keyReceiveReceiptsEnabled]; + } [[OSRemoteParamController sharedController] saveRemoteParams:result]; if ([[OSRemoteParamController sharedController] hasLocationKey]) { From 4442d65dc57c21c5350a3c789eb0587e6a6285fc Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 2 Jun 2026 01:09:06 -0700 Subject: [PATCH 10/11] add OSResilientStorage + 3 OSCore tests to project targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSResilientStorage.swift → OneSignalOSCore framework. OSResilientStorageTests / OSModelStoreRefreshTests / OneSignalIdentifiersFallbackTests → OneSignalOSCoreTests. --- .../OneSignal.xcodeproj/project.pbxproj | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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; }; From d47f412bcde3fc199dc818f4ff80d7d8765c3fc3 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 2 Jun 2026 01:14:42 -0700 Subject: [PATCH 11/11] add tests for OSResilientStorage, refresh(), identifier fallback, start gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OSResilientStorageTests: 8 tests covering the public API - OSModelStoreRefreshTests: 4 tests for the post-unlock refresh path - OneSignalIdentifiersFallbackTests: 6 tests for the UD→mirror fallback - OneSignalUserTests: regression test for start() defer-then-proceed contract, captures the async-seed bug surfaced during review --- .../OSModelStoreRefreshTests.swift | 135 ++++++++++++++++++ .../OSResilientStorageTests.swift | 117 +++++++++++++++ .../OneSignalIdentifiersFallbackTests.swift | 105 ++++++++++++++ .../OneSignalUserTests.swift | 37 +++++ 4 files changed, 394 insertions(+) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSModelStoreRefreshTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSResilientStorageTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OneSignalIdentifiersFallbackTests.swift 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..af6f527d6 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSResilientStorageTests.swift @@ -0,0 +1,117 @@ +/* + 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)) + } + + // 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/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.