diff --git a/iOS_SDK/OneSignalDevApp/OneSignalExample.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalDevApp/OneSignalExample.xcodeproj/project.pbxproj index 5be2fb614..3bc28031c 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalExample.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalDevApp/OneSignalExample.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 3C448BA429381303002F96BC /* OneSignalNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C448BA329381303002F96BC /* OneSignalNotifications.framework */; }; 3C448BA529381303002F96BC /* OneSignalNotifications.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C448BA329381303002F96BC /* OneSignalNotifications.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 3C5C6FED2FC902C600102E2C /* OneSignalOSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE12F3F6289B2B7F002F63AA /* OneSignalOSCore.framework */; }; 3C79A42C2DB2B3170097BC13 /* click.wav in Resources */ = {isa = PBXBuildFile; fileRef = 3C79A42A2DB2B3170097BC13 /* click.wav */; }; 3C79A42D2DB2B3170097BC13 /* bark.wav in Resources */ = {isa = PBXBuildFile; fileRef = 3C79A4292DB2B3170097BC13 /* bark.wav */; }; 4529DECC1FA7EAB800CEAB1D /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 91B6EA051E83215000B5CF01 /* UserNotifications.framework */; }; @@ -293,6 +294,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3C5C6FED2FC902C600102E2C /* OneSignalOSCore.framework in Frameworks */, CACBAAB7218A713C000ACAA5 /* WebKit.framework in Frameworks */, DE97177A2756E6FF00FC409E /* OneSignalOutcomes.framework in Frameworks */, 91B6EA071E83215800B5CF01 /* UIKit.framework in Frameworks */, diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 026d1027c..b65226d36 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -94,6 +94,10 @@ 3C5501402E09CF0100E77DF7 /* OSCopyOnWriteSet.h in Headers */ = {isa = PBXBuildFile; fileRef = 3C55013E2E09CF0100E77DF7 /* OSCopyOnWriteSet.h */; }; 3C5501412E09CF0100E77DF7 /* OSCopyOnWriteSet.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C55013F2E09CF0100E77DF7 /* OSCopyOnWriteSet.m */; }; 3C5501432E09F3D900E77DF7 /* LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5501422E09F3D900E77DF7 /* LoggingTests.swift */; }; + 3C5C6FE72FC9008A00102E2C /* OSResilientStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5C6FE62FC9008A00102E2C /* OSResilientStorage.swift */; }; + 3C5C6FE82FC902A100102E2C /* OneSignalOSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C115161289A259500565C41 /* OneSignalOSCore.framework */; }; + 3C5C6FE92FC902A100102E2C /* OneSignalOSCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C115161289A259500565C41 /* OneSignalOSCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 3C5C6FF02FC903B900102E2C /* OneSignalOSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C115161289A259500565C41 /* OneSignalOSCore.framework */; }; 3C60BB9B2ECF860600C765F7 /* OneSignalInAppMessages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; }; 3C60BB9C2ECF860600C765F7 /* OneSignalInAppMessages.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3C62999F2BEEA34800649187 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C62999E2BEEA34800649187 /* PrivacyInfo.xcprivacy */; }; @@ -665,6 +669,20 @@ remoteGlobalIDString = 3C115160289A259500565C41; remoteInfo = OneSignalOSCore; }; + 3C5C6FEA2FC902A100102E2C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 37747F8B19147D6400558FAD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3C115160289A259500565C41; + remoteInfo = OneSignalOSCore; + }; + 3C5C6FF22FC903B900102E2C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 37747F8B19147D6400558FAD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3C115160289A259500565C41; + remoteInfo = OneSignalOSCore; + }; 3C60BB9D2ECF860600C765F7 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 37747F8B19147D6400558FAD /* Project object */; @@ -1147,6 +1165,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3C5C6FEC2FC902A100102E2C /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 3C5C6FE92FC902A100102E2C /* OneSignalOSCore.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; 3C60BB9F2ECF860600C765F7 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -1330,6 +1359,7 @@ 3C55013E2E09CF0100E77DF7 /* OSCopyOnWriteSet.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSCopyOnWriteSet.h; sourceTree = ""; }; 3C55013F2E09CF0100E77DF7 /* OSCopyOnWriteSet.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSCopyOnWriteSet.m; sourceTree = ""; }; 3C5501422E09F3D900E77DF7 /* LoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingTests.swift; sourceTree = ""; }; + 3C5C6FE62FC9008A00102E2C /* OSResilientStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSResilientStorage.swift; sourceTree = ""; }; 3C62999E2BEEA34800649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3C6299A02BEEA38100649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3C6299A22BEEA3CC00649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -1964,6 +1994,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3C5C6FE82FC902A100102E2C /* OneSignalOSCore.framework in Frameworks */, DE7D1846270286C6002D3A5D /* OneSignalCore.framework in Frameworks */, DE7D18D22703ADE0002D3A5D /* OneSignalOutcomes.framework in Frameworks */, DE7D1843270283B9002D3A5D /* UserNotifications.framework in Frameworks */, @@ -2048,6 +2079,7 @@ DEF7845F2912EA0D00A1F3A5 /* UserNotifications.framework in Frameworks */, DEF784612912F5E100A1F3A5 /* UIKit.framework in Frameworks */, DEF784422912E16F00A1F3A5 /* OneSignalCore.framework in Frameworks */, + 3C5C6FF02FC903B900102E2C /* OneSignalOSCore.framework in Frameworks */, DE2D8F4A2947D86200844084 /* OneSignalOutcomes.framework in Frameworks */, DE2D8F452947D85800844084 /* OneSignalExtension.framework in Frameworks */, ); @@ -2204,6 +2236,7 @@ 3C115163289A259500565C41 /* OneSignalOSCore.h */, 3C115188289ADEA300565C41 /* OSModelStore.swift */, 3C115186289ADE7700565C41 /* OSModelStoreListener.swift */, + 3C5C6FE62FC9008A00102E2C /* OSResilientStorage.swift */, 3C115184289ADE4F00565C41 /* OSModel.swift */, 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */, 3C11518A289ADEEB00565C41 /* OSEventProducer.swift */, @@ -3750,12 +3783,14 @@ DE7D17F527026BA3002D3A5D /* Sources */, DE7D17F627026BA3002D3A5D /* Frameworks */, DE7D17F727026BA3002D3A5D /* Resources */, + 3C5C6FEC2FC902A100102E2C /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( DE7D181B27026BEC002D3A5D /* PBXTargetDependency */, DE7D18D52703ADE0002D3A5D /* PBXTargetDependency */, + 3C5C6FEB2FC902A100102E2C /* PBXTargetDependency */, ); name = OneSignalExtension; productName = OneSignalExtension; @@ -3888,6 +3923,7 @@ DEF784452912E16F00A1F3A5 /* PBXTargetDependency */, DE2D8F482947D85800844084 /* PBXTargetDependency */, DE2D8F4D2947D86200844084 /* PBXTargetDependency */, + 3C5C6FF32FC903B900102E2C /* PBXTargetDependency */, ); name = OneSignalNotifications; productName = OneSignalNotifications; @@ -4349,6 +4385,7 @@ 3C11518B289ADEEB00565C41 /* OSEventProducer.swift in Sources */, 3C115165289A259500565C41 /* OneSignalOSCore.docc in Sources */, 5BC1DE5E2C90B80E00CA8807 /* OSCondition.swift in Sources */, + 3C5C6FE72FC9008A00102E2C /* OSResilientStorage.swift in Sources */, 5BC1DE5C2C90B7E600CA8807 /* OSConsistencyManager.swift in Sources */, 3C115189289ADEA300565C41 /* OSModelStore.swift in Sources */, 3C115185289ADE4F00565C41 /* OSModel.swift in Sources */, @@ -4795,6 +4832,16 @@ target = 3C115160289A259500565C41 /* OneSignalOSCore */; targetProxy = 3C115199289AF86C00565C41 /* PBXContainerItemProxy */; }; + 3C5C6FEB2FC902A100102E2C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3C115160289A259500565C41 /* OneSignalOSCore */; + targetProxy = 3C5C6FEA2FC902A100102E2C /* PBXContainerItemProxy */; + }; + 3C5C6FF32FC903B900102E2C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3C115160289A259500565C41 /* OneSignalOSCore */; + targetProxy = 3C5C6FF22FC903B900102E2C /* PBXContainerItemProxy */; + }; 3C60BB9E2ECF860600C765F7 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DEBAAE272A4211D900BF2C1C /* OneSignalInAppMessages */; diff --git a/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalNotificationServiceExtensionHandler.m b/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalNotificationServiceExtensionHandler.m index 84273fe38..a4a37eca5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalNotificationServiceExtensionHandler.m +++ b/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalNotificationServiceExtensionHandler.m @@ -26,6 +26,7 @@ */ #import +#import #import "OSMacros.h" #import #import "OneSignalNotificationServiceExtensionHandler.h" @@ -141,8 +142,19 @@ + (void)onNotificationReceived:(NSString *)receivedNotificationId withBlockingTa // Track confirmed delivery let sharedUserDefaults = OneSignalUserDefaults.initShared; - let playerId = [sharedUserDefaults getSavedStringForKey:OSUD_PUSH_SUBSCRIPTION_ID defaultValue:nil]; - let appId = [sharedUserDefaults getSavedStringForKey:OSUD_APP_ID defaultValue:nil]; + NSString *playerId = [sharedUserDefaults getSavedStringForKey:OSUD_PUSH_SUBSCRIPTION_ID defaultValue:nil]; + NSString *appId = [sharedUserDefaults getSavedStringForKey:OSUD_APP_ID defaultValue:nil]; + + // Fall back to the unencrypted cache if UserDefaults reads return nil. + // This handles cases where the NSE runs while the device is locked and the + // UserDefaults file (NSFileProtectionCompleteUntilFirstUserAuthentication) is not readable. + if (!playerId) { + playerId = [OSResilientStorage stringForKey:OSResilientStorage.keySubscriptionId]; + } + if (!appId) { + appId = [OSResilientStorage stringForKey:OSResilientStorage.keyAppId]; + } + // Randomize send of confirmed deliveries to lessen traffic for high recipient notifications int randomDelay = semaphore != nil ? arc4random_uniform(MAX_CONF_DELIVERY_DELAY) : 0; [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"OneSignal onNotificationReceived sendReceiveReceipt with delay: %i", randomDelay]]; diff --git a/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalReceiveReceiptsController.m b/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalReceiveReceiptsController.m index 86e1db286..bc99306a1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalReceiveReceiptsController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalReceiveReceiptsController.m @@ -28,13 +28,22 @@ #import #import "OneSignalReceiveReceiptsController.h" #import +#import #import "OSMacros.h" #import "OneSignalExtensionRequests.h" @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 may 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/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/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index 6e70b5057..e5bc25ad6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -137,6 +137,9 @@ 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 unencrypted cache; external_id is intentionally NOT cached as it can be PII. + 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..9ae75b29e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -135,8 +135,8 @@ class OSSubscriptionModel: OSModel { return } - // Cache the subscriptionId as it persists across users on the device?? - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: subscriptionId) + // Cache the subscriptionId as it persists across users on the device + cacheSubscriptionId(subscriptionId) firePushSubscriptionChanged(.subscriptionId(oldValue)) } @@ -432,10 +432,17 @@ extension OSSubscriptionModel { // sdkType ?? // isRooted ?? if type == .push && !(subscriptionId ?? "").isEmpty { - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: subscriptionId) + cacheSubscriptionId(subscriptionId) } } + /// Persists the subscription ID to shared UserDefaults and mirrors it to + /// OSResilientStorage so it survives prewarm / locked-storage reads. + func cacheSubscriptionId(_ subscriptionId: String?) { + OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: subscriptionId) + OSResilientStorage.setString(subscriptionId ?? "", forKey: OSResilientStorage.keySubscriptionId) + } + enum OSPushPropertyChanged { case subscriptionId(String?) case reachable(Bool) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 28b68d232..a10fc55e2 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,24 @@ 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 cachedSubscriptionId = OSResilientStorage.string(forKey: OSResilientStorage.keySubscriptionId), + !cachedSubscriptionId.isEmpty + { + // TODO: In this state, we don't want to persist anything to UserDefaults and overwrite correct data with half data. + // TODO: However, in this state where we can't read UserDefaults, does writing to it fail automatically? + // Path 2.5. Model stores loaded empty but the resilient cache shows we've + // initialized before: the prewarm-before-first-unlock case. Recover identity + // locally; missing properties/aliases will rehydrate on next fetchUser. + let cachedOnesignalId = OSResilientStorage.string(forKey: OSResilientStorage.keyOneSignalId) + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager: recovering user from cache subscription_id=\(cachedSubscriptionId) onesignal_id=\(cachedOnesignalId ?? "(nil)")") + + let pushSubscriptionModel = createDefaultPushSubscription(subscriptionId: cachedSubscriptionId) + let recoveredUser = setNewInternalUser(externalId: nil, pushSubscriptionModel: pushSubscriptionModel) + if let onesignalId = cachedOnesignalId, !onesignalId.isEmpty { + recoveredUser.identityModel.hydrate([OS_ONESIGNAL_ID: onesignalId]) + } } else { // Path 3. Creates an anonymous user if there isn't one in the cache nor a legacy player if _user == nil { diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index bbdecb547..4989b7975 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -44,6 +44,7 @@ #import #import #import +#import // TODO: ^ if no longer support ios 9 + 10 after user model, need to address all stuffs @@ -533,33 +534,48 @@ + (void)handleAppIdChange:(NSString*)appId { } let standardUserDefaults = OneSignalUserDefaults.initStandard; - let prevAppId = [standardUserDefaults getSavedStringForKey:OSUD_APP_ID defaultValue:nil]; + NSString *prevAppId = [standardUserDefaults getSavedStringForKey:OSUD_APP_ID defaultValue:nil]; + + // If UserDefaults returns nil for the previously-stored app id, the read may be + // unreliable (e.g. iOS prewarm before first unlock). Fall back to the unencrypted + // cache file before treating the absence as new install or "app id changed" + if (!prevAppId) { + prevAppId = [OSResilientStorage stringForKey:OSResilientStorage.keyAppId]; + } // 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]) { + // Only treat as changed if a non-nil previous ID was stored AND it differs + if (appId && prevAppId && ![appId isEqualToString:prevAppId]) { initDone = false; _downloadedParameters = false; _didCallDownloadParameters = false; let sharedUserDefaults = OneSignalUserDefaults.initShared; - - [standardUserDefaults saveStringForKey:OSUD_APP_ID withValue:appId]; - + // Remove player_id from both standard and shared NSUserDefaults [standardUserDefaults removeValueForKey:OSUD_PUSH_SUBSCRIPTION_ID]; [sharedUserDefaults removeValueForKey:OSUD_PUSH_SUBSCRIPTION_ID]; [standardUserDefaults removeValueForKey:OSUD_LEGACY_PLAYER_ID]; [sharedUserDefaults removeValueForKey:OSUD_LEGACY_PLAYER_ID]; - + + // Drop the 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]; } - - // Always save appId and player_id as it will not be present on shared if: + + // Always save appId as it will not be present on shared if: // - Updating from an older SDK // - Updating to an app that didn't have App Groups setup before + // Save to both UserDefaults and to the unencrypted cache + [standardUserDefaults saveStringForKey:OSUD_APP_ID withValue:appId]; [OneSignalUserDefaults.initShared saveStringForKey:OSUD_APP_ID withValue:appId]; + [OSResilientStorage setString:appId forKey:OSResilientStorage.keyAppId]; } + (void)registerForAPNsToken { @@ -609,8 +625,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]) {