From d589e3484529b7077fec70318567e7137ab5007e Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 02:55:08 -0700 Subject: [PATCH 01/10] wip --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../OneSignalReceiveReceiptsController.m | 10 +- .../OSRequestLiveActivityClicked.swift | 2 +- ...OSRequestLiveActivityReceiveReceipts.swift | 2 +- .../Requests/OSRequestRemoveStartToken.swift | 2 +- .../Requests/OSRequestRemoveUpdateToken.swift | 2 +- .../Requests/OSRequestSetStartToken.swift | 2 +- .../Requests/OSRequestSetUpdateToken.swift | 2 +- .../Source/OSResilientStorage.swift | 160 ++++++++++++++++++ .../Source/OneSignalConfig.swift | 15 +- .../Source/OneSignalIdentifiers.swift | 19 ++- .../Source/OSIdentityModel.swift | 8 + .../Source/OSSubscriptionModel.swift | 4 +- .../Source/OneSignalUserManagerImpl.swift | 36 +++- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 35 +++- 15 files changed, 282 insertions(+), 21 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 9adfba76f..8b764ead6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -180,6 +180,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 */; }; @@ -1416,6 +1417,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 +2233,7 @@ 3C115163289A259500565C41 /* OneSignalOSCore.h */, 3C115188289ADEA300565C41 /* OSModelStore.swift */, 3C5C6FFB2FCB8DED00102E2C /* OneSignalIdentifiers.swift */, + 3CCC48032FCD619400D77E94 /* OSResilientStorage.swift */, 3C115186289ADE7700565C41 /* OSModelStoreListener.swift */, 3C115184289ADE4F00565C41 /* OSModel.swift */, 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */, @@ -4381,6 +4384,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 */, 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/OSResilientStorage.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift new file mode 100644 index 000000000..9889ba523 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift @@ -0,0 +1,160 @@ +/* + 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" + @objc public static let keyOneSignalId = "onesignal_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" + + // 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..7fd0e72f4 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: + /// Optional readability check for device-protected storage. The main app sets this to + /// `{ UIApplication.shared.isProtectedDataAvailable }` at initialize. 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 +54,9 @@ public final class OneSignalConfig: NSObject { if OSPrivacyConsentController.shouldLogMissingPrivacyConsentError(withMethodName: methodName) { shouldAwait = true } + if let provider = isProtectedDataAvailableProvider, !provider() { + 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/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index 6e70b5057..df6bf8fb2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -137,6 +137,14 @@ class OSIdentityModel: OSModel { OneSignalUserDefaults.initShared().saveString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, withValue: newOnesignalId) OneSignalUserDefaults.initShared().saveString(forKey: OS_SNAPSHOT_EXTERNAL_ID, withValue: newExternalId) + // Mirror onesignal_id to the OSResilientStorage cache so it survives prewarm-locked + // UserDefaults. Only update when we have a value — some hydrate responses (e.g. + // alias-add) only carry external_id and must not clobber the stored onesignal_id. + // external_id is intentionally NOT mirrored (potential PII). + if let newOnesignalId, !newOnesignalId.isEmpty { + OSResilientStorage.setString(newOnesignalId, forKey: OSResilientStorage.keyOneSignalId) + } + let curUserState = OSUserState(onesignalId: newOnesignalId, externalId: newExternalId) let changedState = OSUserChangedState(current: curUserState) 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..6692b3f14 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -228,6 +228,14 @@ 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) + } + if let osId = identityModel.onesignalId, !osId.isEmpty { + OSResilientStorage.setString(osId, forKey: OSResilientStorage.keyOneSignalId) + } } // TODO: Update the push sub model with any new state from NotificationsManager @@ -258,6 +266,25 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: legacyPlayerId) OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_LEGACY_PLAYER_ID) OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_LEGACY_PLAYER_ID) + } else if !hasCachedUser, _user == nil, + let cachedSubId = OSResilientStorage.string(forKey: OSResilientStorage.keySubscriptionId), !cachedSubId.isEmpty { + // Path 2.5. Model stores looked empty, but the resilient cache shows we've + // initialized before — the prewarm-before-first-unlock case where + // OSModelStore's in-memory dict was loaded empty during locked storage and + // never re-read. Recover the existing identity locally; missing properties / + // aliases will rehydrate on the next fetchUser. Do NOT call setNewInternalUser + // (which would write stub models through modelStore.add) — that would + // overwrite the real cached models on disk. + let cachedOnesignalId = OSResilientStorage.string(forKey: OSResilientStorage.keyOneSignalId) + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager: recovering user from resilient cache subscription_id=\(cachedSubId) onesignal_id=\(cachedOnesignalId ?? "(nil)")") + let identityModel = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + if let onesignalId = cachedOnesignalId, !onesignalId.isEmpty { + identityModel.hydrate([OS_ONESIGNAL_ID: onesignalId]) + } + addIdentityModelToRepo(identityModel) + let propertiesModel = OSPropertiesModel(changeNotifier: OSEventProducer()) + let pushSubscriptionModel = createDefaultPushSubscription(subscriptionId: cachedSubId) + _user = OSUserInternalImpl(identityModel: identityModel, propertiesModel: propertiesModel, pushSubscriptionModel: pushSubscriptionModel) } else { // Path 3. Creates an anonymous user if there isn't one in the cache nor a legacy player if _user == nil { @@ -872,7 +899,10 @@ extension OneSignalUserManagerImpl { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.id") else { return nil } + // Fall back to the live `_user.pushSubscriptionModel` so the prewarm-recovery + // path (where the model isn't added to the store) still returns the right value. return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.subscriptionId + ?? OneSignalUserManagerImpl.sharedInstance._user?.pushSubscriptionModel.subscriptionId } public var token: String? { @@ -880,13 +910,17 @@ extension OneSignalUserManagerImpl { return nil } return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.address + ?? OneSignalUserManagerImpl.sharedInstance._user?.pushSubscriptionModel.address } public var optedIn: Bool { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optedIn") else { return false } - return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.optedIn ?? false + if let storeModel = pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY) { + return storeModel.optedIn + } + return OneSignalUserManagerImpl.sharedInstance._user?.pushSubscriptionModel.optedIn ?? false } /** diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index a4e5bd653..ed1bbb579 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -456,7 +456,15 @@ + (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. + OneSignalConfig.isProtectedDataAvailableProvider = ^BOOL { + return UIApplication.sharedApplication.isProtectedDataAvailable; + }; + // TODO: We moved this check to the top of this method, we should test this. if (initDone) { return; @@ -527,9 +535,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; @@ -542,11 +553,18 @@ + (void)handleAppIdChange:(NSString*)appId { [standardUserDefaults removeValueForKey:OSUD_LEGACY_PLAYER_ID]; [sharedUserDefaults removeValueForKey:OSUD_LEGACY_PLAYER_ID]; + // Drop cached identifiers — a real app-id change invalidates them. + [OSResilientStorage setStrings:@{ + OSResilientStorage.keySubscriptionId: @"", + OSResilientStorage.keyOneSignalId: @"" + }]; + // 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 +614,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]) { From 309da62fb672511e14d2b56262164ca4f1dae840 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 10:25:07 -0700 Subject: [PATCH 02/10] asdf --- .../OneSignalOSCore/Source/OSModelStore.swift | 23 +++++++++++ .../Source/OneSignalUserManagerImpl.swift | 38 ++++++------------- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 14 +++++++ 3 files changed, 48 insertions(+), 27 deletions(-) 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/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 6692b3f14..def69668a 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 @@ -266,25 +276,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: legacyPlayerId) OneSignalUserDefaults.initStandard().removeValue(forKey: OSUD_LEGACY_PLAYER_ID) OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_LEGACY_PLAYER_ID) - } else if !hasCachedUser, _user == nil, - let cachedSubId = OSResilientStorage.string(forKey: OSResilientStorage.keySubscriptionId), !cachedSubId.isEmpty { - // Path 2.5. Model stores looked empty, but the resilient cache shows we've - // initialized before — the prewarm-before-first-unlock case where - // OSModelStore's in-memory dict was loaded empty during locked storage and - // never re-read. Recover the existing identity locally; missing properties / - // aliases will rehydrate on the next fetchUser. Do NOT call setNewInternalUser - // (which would write stub models through modelStore.add) — that would - // overwrite the real cached models on disk. - let cachedOnesignalId = OSResilientStorage.string(forKey: OSResilientStorage.keyOneSignalId) - OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager: recovering user from resilient cache subscription_id=\(cachedSubId) onesignal_id=\(cachedOnesignalId ?? "(nil)")") - let identityModel = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) - if let onesignalId = cachedOnesignalId, !onesignalId.isEmpty { - identityModel.hydrate([OS_ONESIGNAL_ID: onesignalId]) - } - addIdentityModelToRepo(identityModel) - let propertiesModel = OSPropertiesModel(changeNotifier: OSEventProducer()) - let pushSubscriptionModel = createDefaultPushSubscription(subscriptionId: cachedSubId) - _user = OSUserInternalImpl(identityModel: identityModel, propertiesModel: propertiesModel, pushSubscriptionModel: pushSubscriptionModel) } else { // Path 3. Creates an anonymous user if there isn't one in the cache nor a legacy player if _user == nil { @@ -899,10 +890,7 @@ extension OneSignalUserManagerImpl { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.id") else { return nil } - // Fall back to the live `_user.pushSubscriptionModel` so the prewarm-recovery - // path (where the model isn't added to the store) still returns the right value. return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.subscriptionId - ?? OneSignalUserManagerImpl.sharedInstance._user?.pushSubscriptionModel.subscriptionId } public var token: String? { @@ -910,17 +898,13 @@ extension OneSignalUserManagerImpl { return nil } return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.address - ?? OneSignalUserManagerImpl.sharedInstance._user?.pushSubscriptionModel.address } public var optedIn: Bool { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optedIn") else { return false } - if let storeModel = pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY) { - return storeModel.optedIn - } - return OneSignalUserManagerImpl.sharedInstance._user?.pushSubscriptionModel.optedIn ?? false + return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.optedIn ?? false } /** diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index ed1bbb579..897a079a8 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -465,6 +465,20 @@ + (void)init { return UIApplication.sharedApplication.isProtectedDataAvailable; }; + // When the device unlocks after a locked-storage launch (iOS app prewarm), drive `start()` + // again. `start()` 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. + static dispatch_once_t protectedDataObserverOnce; + dispatch_once(&protectedDataObserverOnce, ^{ + [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationProtectedDataDidBecomeAvailable + object:nil + queue:nil + usingBlock:^(NSNotification * _Nonnull note) { + [OneSignalUserManagerImpl.sharedInstance start]; + }]; + }); + // TODO: We moved this check to the top of this method, we should test this. if (initDone) { return; From ae07b4e7528b728c7c439ea48725b0ce53398176 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 11:28:39 -0700 Subject: [PATCH 03/10] fddsgdgsdg --- .../Source/OneSignalConfig.swift | 13 +++-- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 51 +++++++++++++------ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift index 7fd0e72f4..39d186339 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift @@ -32,9 +32,16 @@ import OneSignalCore @objc(OneSignalConfig) public final class OneSignalConfig: NSObject { - /// Optional readability check for device-protected storage. The main app sets this to - /// `{ UIApplication.shared.isProtectedDataAvailable }` at initialize. Left nil in - /// app-extension contexts (NSE) since `UIApplication` is unavailable there. + /// 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 fed by `UIApplicationProtectedData{Did,Will}BecomeAvailable` so we + /// never touch the main-thread-only `UIApplication` from arbitrary callers. + /// + /// 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)? diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 897a079a8..7e04e2e71 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" @@ -461,22 +462,41 @@ + (void)init { // 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. - OneSignalConfig.isProtectedDataAvailableProvider = ^BOOL { - return UIApplication.sharedApplication.isProtectedDataAvailable; - }; - - // When the device unlocks after a locked-storage launch (iOS app prewarm), drive `start()` - // again. `start()` 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. - static dispatch_once_t protectedDataObserverOnce; - dispatch_once(&protectedDataObserverOnce, ^{ - [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationProtectedDataDidBecomeAvailable - object:nil - queue:nil - usingBlock:^(NSNotification * _Nonnull note) { + // + // The flag is cached in an atomic BOOL and updated from + // UIApplicationProtectedData{DidBecomeAvailable,WillBecomeUnavailable} so we never touch + // `UIApplication` synchronously from the arbitrary threads that hit the predicate. + static _Atomic(BOOL) gProtectedDataAvailable = NO; + static dispatch_once_t protectedDataOnce; + dispatch_once(&protectedDataOnce, ^{ + // Seed the cache on the main thread once. UIApplication APIs are main-thread-only. + // On a normal launch this resolves to YES near-immediately; on a prewarm launch + // before first unlock it stays NO and the notification will flip it later. + dispatch_async(dispatch_get_main_queue(), ^{ + atomic_store(&gProtectedDataAvailable, UIApplication.sharedApplication.isProtectedDataAvailable); + }); + + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + [center addObserverForName:UIApplicationProtectedDataDidBecomeAvailable + object:nil + queue:nil + usingBlock:^(NSNotification * _Nonnull note) { + atomic_store(&gProtectedDataAvailable, YES); + // 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]; }]; + [center addObserverForName:UIApplicationProtectedDataWillBecomeUnavailable + object:nil + queue:nil + usingBlock:^(NSNotification * _Nonnull note) { + atomic_store(&gProtectedDataAvailable, NO); + }]; + + OneSignalConfig.isProtectedDataAvailableProvider = ^BOOL { + return atomic_load(&gProtectedDataAvailable); + }; }); // TODO: We moved this check to the top of this method, we should test this. @@ -570,7 +590,8 @@ + (void)handleAppIdChange:(NSString*)appId { // Drop cached identifiers — a real app-id change invalidates them. [OSResilientStorage setStrings:@{ OSResilientStorage.keySubscriptionId: @"", - OSResilientStorage.keyOneSignalId: @"" + OSResilientStorage.keyOneSignalId: @"", + OSResilientStorage.keyReceiveReceiptsEnabled: @"" }]; // Clear all cached data, does not start User Module nor call logout. From 2c9b98049f12460e7d1d5f9f367e4efbd523deee Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 12:11:32 -0700 Subject: [PATCH 04/10] wip --- .../Source/OneSignalUserManagerImpl.swift | 4 +++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 26 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index def69668a..23c1d8c46 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -274,6 +274,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 { diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 7e04e2e71..335eb315c 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -469,13 +469,6 @@ + (void)init { static _Atomic(BOOL) gProtectedDataAvailable = NO; static dispatch_once_t protectedDataOnce; dispatch_once(&protectedDataOnce, ^{ - // Seed the cache on the main thread once. UIApplication APIs are main-thread-only. - // On a normal launch this resolves to YES near-immediately; on a prewarm launch - // before first unlock it stays NO and the notification will flip it later. - dispatch_async(dispatch_get_main_queue(), ^{ - atomic_store(&gProtectedDataAvailable, UIApplication.sharedApplication.isProtectedDataAvailable); - }); - NSNotificationCenter *center = NSNotificationCenter.defaultCenter; [center addObserverForName:UIApplicationProtectedDataDidBecomeAvailable object:nil @@ -497,6 +490,25 @@ + (void)init { OneSignalConfig.isProtectedDataAvailableProvider = ^BOOL { return atomic_load(&gProtectedDataAvailable); }; + + // Seed the cached flag. UIApplication APIs are main-thread-only. `initialize` is normally + // called from `application:didFinishLaunchingWithOptions:` on the main thread, so seed + // synchronously here — that way the flag is correct before `startUserManager` runs later + // in this same `init`. If we're off the main thread, hop to main to read it; once it + // resolves to available, re-drive `start()`, because the synchronous `start()` below will + // have been gated out and a normal (already-unlocked) launch never posts + // UIApplicationProtectedDataDidBecomeAvailable to re-drive it. + if ([NSThread isMainThread]) { + atomic_store(&gProtectedDataAvailable, UIApplication.sharedApplication.isProtectedDataAvailable); + } else { + dispatch_async(dispatch_get_main_queue(), ^{ + BOOL available = UIApplication.sharedApplication.isProtectedDataAvailable; + atomic_store(&gProtectedDataAvailable, available); + if (available) { + [OneSignalUserManagerImpl.sharedInstance start]; + } + }); + } }); // TODO: We moved this check to the top of this method, we should test this. From fa929fdc866d92e6f4adc5a79438716fd1685663 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 11:49:25 -0700 Subject: [PATCH 05/10] add tests --- .../OneSignal.xcodeproj/project.pbxproj | 12 ++ .../OSModelStoreRefreshTests.swift | 135 ++++++++++++++++++ .../OSResilientStorageTests.swift | 124 ++++++++++++++++ .../OneSignalIdentifiersFallbackTests.swift | 105 ++++++++++++++ .../OneSignalUserTests.swift | 37 +++++ 5 files changed, 413 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/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 8b764ead6..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 */; }; @@ -1333,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 = ""; }; @@ -2516,6 +2522,9 @@ isa = PBXGroup; children = ( 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, + 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, + 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, + 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -4531,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/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/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. From ff7567b6d0397dea911a368f64cf72ca1e6e696d Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 13:53:35 -0700 Subject: [PATCH 06/10] checking --- .../Source/OSResilientStorage.swift | 1 - .../Source/OSIdentityModel.swift | 8 ----- .../Source/OneSignalUserManagerImpl.swift | 3 -- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 32 ++++++++++++------- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift index 9889ba523..de7aab58c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift @@ -40,7 +40,6 @@ public final class OSResilientStorage: NSObject { @objc public static let keyAppId = "app_id" @objc public static let keySubscriptionId = "subscription_id" - @objc public static let keyOneSignalId = "onesignal_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" diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index df6bf8fb2..6e70b5057 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -137,14 +137,6 @@ class OSIdentityModel: OSModel { OneSignalUserDefaults.initShared().saveString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, withValue: newOnesignalId) OneSignalUserDefaults.initShared().saveString(forKey: OS_SNAPSHOT_EXTERNAL_ID, withValue: newExternalId) - // Mirror onesignal_id to the OSResilientStorage cache so it survives prewarm-locked - // UserDefaults. Only update when we have a value — some hydrate responses (e.g. - // alias-add) only carry external_id and must not clobber the stored onesignal_id. - // external_id is intentionally NOT mirrored (potential PII). - if let newOnesignalId, !newOnesignalId.isEmpty { - OSResilientStorage.setString(newOnesignalId, forKey: OSResilientStorage.keyOneSignalId) - } - let curUserState = OSUserState(onesignalId: newOnesignalId, externalId: newExternalId) let changedState = OSUserChangedState(current: curUserState) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 23c1d8c46..10107bcda 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -243,9 +243,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { if let subId = pushSubscription.subscriptionId, !subId.isEmpty { OSResilientStorage.setString(subId, forKey: OSResilientStorage.keySubscriptionId) } - if let osId = identityModel.onesignalId, !osId.isEmpty { - OSResilientStorage.setString(osId, forKey: OSResilientStorage.keyOneSignalId) - } } // TODO: Update the push sub model with any new state from NotificationsManager diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 335eb315c..7fe9441c8 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -463,14 +463,19 @@ + (void)init { // 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 cached in an atomic BOOL and updated from - // UIApplicationProtectedData{DidBecomeAvailable,WillBecomeUnavailable} so we never touch - // `UIApplication` synchronously from the arbitrary threads that hit the predicate. + // The flag is a one-way latch: it starts NO, flips to YES on first unlock, and stays YES + // for the lifetime of the process. The SDK's shared App Group UserDefaults use + // `NSFileProtectionCompleteUntilFirstUserAuthentication`, which becomes readable at first + // unlock and stays readable across every subsequent lock/unlock cycle — so we deliberately + // do NOT observe `UIApplicationProtectedDataWillBecomeUnavailable`. That notification + // tracks the stricter `NSFileProtectionComplete` class (~10s after every lock) and would + // re-engage the gate on routine device locks, silently dropping API calls in + // background-mode / silent-push / BGTaskScheduler contexts where the storage is in fact + // still readable. static _Atomic(BOOL) gProtectedDataAvailable = NO; static dispatch_once_t protectedDataOnce; dispatch_once(&protectedDataOnce, ^{ - NSNotificationCenter *center = NSNotificationCenter.defaultCenter; - [center addObserverForName:UIApplicationProtectedDataDidBecomeAvailable + [NSNotificationCenter.defaultCenter addObserverForName:UIApplicationProtectedDataDidBecomeAvailable object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) { @@ -479,12 +484,11 @@ + (void)init { // 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]; - }]; - [center addObserverForName:UIApplicationProtectedDataWillBecomeUnavailable - object:nil - queue:nil - usingBlock:^(NSNotification * _Nonnull note) { - atomic_store(&gProtectedDataAvailable, NO); + // Also drive `startNewSession` — it was gated during `+init`'s synchronous + // `[self startNewSession:YES]` so the session_count delta and `fetchUser` never + // ran. Use `:NO` so `+shouldStartNewSession`'s 30s threshold still applies and + // we don't trigger spurious new-session calls. + [OneSignal startNewSession:NO]; }]; OneSignalConfig.isProtectedDataAvailableProvider = ^BOOL { @@ -506,6 +510,7 @@ + (void)init { atomic_store(&gProtectedDataAvailable, available); if (available) { [OneSignalUserManagerImpl.sharedInstance start]; + [OneSignal startNewSession:NO]; } }); } @@ -598,11 +603,14 @@ + (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 cached identifiers — a real app-id change invalidates them. [OSResilientStorage setStrings:@{ OSResilientStorage.keySubscriptionId: @"", - OSResilientStorage.keyOneSignalId: @"", OSResilientStorage.keyReceiveReceiptsEnabled: @"" }]; From b4c2d8f6ad88b9ce549147111ab9f134ce230493 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 15:35:27 -0700 Subject: [PATCH 07/10] asfdafasfdasfd --- .../Source/OSResilientStorage.swift | 5 ++ .../Source/OneSignalConfig.swift | 3 + .../Source/OneSignalUserManagerImpl.swift | 5 ++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 68 +++++++++---------- 4 files changed, 47 insertions(+), 34 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift index de7aab58c..15ec4af5d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift @@ -43,6 +43,11 @@ public final class OSResilientStorage: NSObject { /// 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 diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift index 39d186339..3a642d9f4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift @@ -62,6 +62,9 @@ public final class OneSignalConfig: NSObject { 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/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 10107bcda..93783acc1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -294,6 +294,11 @@ 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) } } diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 7fe9441c8..08443ea46 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -463,15 +463,24 @@ + (void)init { // 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 on first unlock, and stays YES - // for the lifetime of the process. The SDK's shared App Group UserDefaults use - // `NSFileProtectionCompleteUntilFirstUserAuthentication`, which becomes readable at first - // unlock and stays readable across every subsequent lock/unlock cycle — so we deliberately - // do NOT observe `UIApplicationProtectedDataWillBecomeUnavailable`. That notification - // tracks the stricter `NSFileProtectionComplete` class (~10s after every lock) and would - // re-engage the gate on routine device locks, silently dropping API calls in - // background-mode / silent-push / BGTaskScheduler contexts where the storage is in fact - // still readable. + // 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` (Class B), which becomes + // readable at first unlock and stays readable across every subsequent lock/unlock cycle. + // + // We seed via a UD probe of the push-subscription model store rather than + // `UIApplication.isProtectedDataAvailable`. The UIApplication property tracks the + // stricter `NSFileProtectionComplete` class (Class A) which flips back to NO ~10s after + // every device lock — using it would silently gate APIs in locked-after-first-unlock + // contexts (silent push, BGTaskScheduler) where our Class B storage is actually readable. + // The probe directly tests Class B readability: + // * Prior SDK session + UD readable → pushModels has entries → flag = YES + // * Prior SDK session + UD locked (genuine prewarm) → pushModels empty, `keyDidStart` sentinel set → flag = NO + // * No prior SDK session (fresh install) → both empty → flag = YES (no orphan risk; Path 3 creates the first user) + // `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` + // (same Class A mismatch reason). static _Atomic(BOOL) gProtectedDataAvailable = NO; static dispatch_once_t protectedDataOnce; dispatch_once(&protectedDataOnce, ^{ @@ -484,36 +493,26 @@ + (void)init { // 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]; - // Also drive `startNewSession` — it was gated during `+init`'s synchronous - // `[self startNewSession:YES]` so the session_count delta and `fetchUser` never - // ran. Use `:NO` so `+shouldStartNewSession`'s 30s threshold still applies and - // we don't trigger spurious new-session calls. - [OneSignal startNewSession:NO]; + // 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]; + // 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. UIApplication APIs are main-thread-only. `initialize` is normally - // called from `application:didFinishLaunchingWithOptions:` on the main thread, so seed - // synchronously here — that way the flag is correct before `startUserManager` runs later - // in this same `init`. If we're off the main thread, hop to main to read it; once it - // resolves to available, re-drive `start()`, because the synchronous `start()` below will - // have been gated out and a normal (already-unlocked) launch never posts - // UIApplicationProtectedDataDidBecomeAvailable to re-drive it. - if ([NSThread isMainThread]) { - atomic_store(&gProtectedDataAvailable, UIApplication.sharedApplication.isProtectedDataAvailable); - } else { - dispatch_async(dispatch_get_main_queue(), ^{ - BOOL available = UIApplication.sharedApplication.isProtectedDataAvailable; - atomic_store(&gProtectedDataAvailable, available); - if (available) { - [OneSignalUserManagerImpl.sharedInstance start]; - [OneSignal startNewSession:NO]; - } - }); - } + // Seed the cached flag via a Class B probe — see the block comment above for the + // case table. UserDefaults reads are thread-safe so this works from any thread. + NSDictionary *pushModels = [OneSignalUserDefaults.initShared getSavedCodeableDataForKey:OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY defaultValue:@{}]; + BOOL hasPriorSession = [OSResilientStorage stringForKey:OSResilientStorage.keyDidStart] != nil; + BOOL classBReadable = pushModels.count > 0 || !hasPriorSession; + atomic_store(&gProtectedDataAvailable, classBReadable); }); // TODO: We moved this check to the top of this method, we should test this. @@ -611,7 +610,8 @@ + (void)handleAppIdChange:(NSString*)appId { // Drop cached identifiers — a real app-id change invalidates them. [OSResilientStorage setStrings:@{ OSResilientStorage.keySubscriptionId: @"", - OSResilientStorage.keyReceiveReceiptsEnabled: @"" + OSResilientStorage.keyReceiveReceiptsEnabled: @"", + OSResilientStorage.keyDidStart: @"" }]; // Clear all cached data, does not start User Module nor call logout. From 2e86852129b5b1628e1e45f246d7bf778222b1ad Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 16:10:31 -0700 Subject: [PATCH 08/10] asdfsdf --- .../OneSignalOSCore/Source/OneSignalConfig.swift | 9 +++++++-- .../Source/OneSignalUserManagerImpl.swift | 6 ++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift index 3a642d9f4..10894233d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift @@ -38,8 +38,13 @@ public final class OneSignalConfig: NSObject { /// 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 fed by `UIApplicationProtectedData{Did,Will}BecomeAvailable` so we - /// never touch the main-thread-only `UIApplication` from arbitrary callers. + /// atomic-BOOL load. The flag is a one-way latch: seeded from a Class B UserDefaults + /// probe at init, then flipped to YES by `UIApplicationProtectedDataDidBecomeAvailable` + /// if the seed put us in the deferred state. We deliberately do NOT observe + /// `UIApplicationProtectedDataWillBecomeUnavailable` — that notification tracks the + /// stricter `NSFileProtectionComplete` class and would re-gate APIs on routine locks + /// even though the SDK's `NSFileProtectionCompleteUntilFirstUserAuthentication` + /// 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, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 93783acc1..22e5bac95 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -299,6 +299,12 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // 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/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 08443ea46..6746bd299 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -482,6 +482,14 @@ + (void)init { // We deliberately do NOT observe `UIApplicationProtectedDataWillBecomeUnavailable` // (same Class A mismatch reason). 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` (Class A 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 @@ -489,6 +497,13 @@ + (void)init { 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. @@ -513,6 +528,7 @@ + (void)init { BOOL hasPriorSession = [OSResilientStorage stringForKey:OSResilientStorage.keyDidStart] != nil; BOOL classBReadable = pushModels.count > 0 || !hasPriorSession; atomic_store(&gProtectedDataAvailable, classBReadable); + atomic_store(&gObserverShouldRecover, !classBReadable); }); // TODO: We moved this check to the top of this method, we should test this. From fbf6e276f1a437783bea77cdea8a6cd799f2f6f0 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 17:07:53 -0700 Subject: [PATCH 09/10] fasdafdsf --- .../Source/OneSignalConfig.swift | 12 +-- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 79 +++++++++++++------ 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift index 10894233d..824e40ab3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OneSignalConfig.swift @@ -38,13 +38,13 @@ public final class OneSignalConfig: NSObject { /// 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 Class B UserDefaults - /// probe at init, then flipped to YES by `UIApplicationProtectedDataDidBecomeAvailable` + /// 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 the - /// stricter `NSFileProtectionComplete` class and would re-gate APIs on routine locks - /// even though the SDK's `NSFileProtectionCompleteUntilFirstUserAuthentication` - /// storage stays readable. + /// `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, diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 6746bd299..46046b961 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -354,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]; @@ -465,30 +467,38 @@ + (void)init { // // 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` (Class B), which becomes - // readable at first unlock and stays readable across every subsequent lock/unlock cycle. + // 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 rather than - // `UIApplication.isProtectedDataAvailable`. The UIApplication property tracks the - // stricter `NSFileProtectionComplete` class (Class A) which flips back to NO ~10s after - // every device lock — using it would silently gate APIs in locked-after-first-unlock - // contexts (silent push, BGTaskScheduler) where our Class B storage is actually readable. - // The probe directly tests Class B readability: - // * Prior SDK session + UD readable → pushModels has entries → flag = YES - // * Prior SDK session + UD locked (genuine prewarm) → pushModels empty, `keyDidStart` sentinel set → flag = NO - // * No prior SDK session (fresh install) → both empty → flag = YES (no orphan risk; Path 3 creates the first user) - // `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` - // (same Class A mismatch reason). + // 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` (Class A transitions on every lock), - // re-run `startNewSession:YES`, and bypass the 30s threshold — spuriously incrementing - // `session_count` and firing duplicate `fetchUser` requests. + // `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, ^{ @@ -522,13 +532,23 @@ + (void)init { return atomic_load(&gProtectedDataAvailable); }; - // Seed the cached flag via a Class B probe — see the block comment above for the - // case table. UserDefaults reads are thread-safe so this works from any thread. + // 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 classBReadable = pushModels.count > 0 || !hasPriorSession; - atomic_store(&gProtectedDataAvailable, classBReadable); - atomic_store(&gObserverShouldRecover, !classBReadable); + 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. @@ -622,6 +642,13 @@ + (void)handleAppIdChange:(NSString*)appId { // 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:@{ From 50e82b25db28bab9438c76c93a42e4cabad0c8c1 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 1 Jun 2026 17:50:23 -0700 Subject: [PATCH 10/10] init --- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 46046b961..41ad90cbd 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -522,6 +522,12 @@ + (void)init { // `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. @@ -599,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];