From 3c512c98c32ac22bef006173c07e20d8a7682fb3 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 23 Sep 2026 18:19:25 -0700 Subject: [PATCH 01/11] fix: [SDK-5314] drop the Identity Verification beta queues and guard cached queue blobs --- .../Source/OneSignalCommonDefines.h | 12 ++++ .../Source/OneSignalUserDefaults.h | 2 + .../Source/OneSignalUserDefaults.m | 55 +++++++++++++++++-- .../OneSignalCoreTests.swift | 53 ++++++++++++++++++ .../Source/OSOperationRepo.swift | 2 +- .../Executors/OSCustomEventsExecutor.swift | 4 +- .../OSIdentityOperationExecutor.swift | 6 +- .../OSPropertyOperationExecutor.swift | 4 +- .../OSSubscriptionOperationExecutor.swift | 8 +-- .../Source/Executors/OSUserExecutor.swift | 2 +- .../Source/OneSignalUserManagerImpl.swift | 19 +++++++ .../OneSignalUserTests.swift | 33 +++++++++++ 12 files changed, 181 insertions(+), 19 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 0c3b6cd960..a31abb5c61 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -378,6 +378,18 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY" #define OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY" +// A cached queue above this size is dropped instead of decoded; real queues are a few KB. +#define OS_CACHED_QUEUE_MAX_BYTES 1048576 +// CFPreferences rejects a single value at or above this size on iOS. +#define OS_USER_DEFAULTS_MAX_VALUE_BYTES 4194304 + +// Written only by the 5.3.0-beta Identity Verification builds; nothing reads them, so start() removes them. +#define OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY @"OS_USER_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY @"OS_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY @"OS_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY @"OS_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY" + // Live Activies Executor #define OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY" #define OS_LIVE_ACTIVITIES_EXECUTOR_START_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_START_TOKENS_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h index 89a03fe3e1..4a7269a2b9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h @@ -70,6 +70,8 @@ // NSUserDefaults for storing and getting saved codeable data (custom objects) - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value; +/// Same, but a stored blob larger than `maxBytes` is removed and `value` returned instead of being decoded. +- (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value maxBytes:(NSUInteger)maxBytes; - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m index fb48bacf55..605f971182 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m @@ -28,6 +28,7 @@ #import #import "OneSignalUserDefaults.h" #import "OneSignalCommonDefines.h" +#import "OneSignalLog.h" @implementation OneSignalUserDefaults : NSObject @@ -152,15 +153,57 @@ - (void)saveObjectForKey:(NSString * _Nonnull)key withValue:(id _Nullable)object } - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value { - if ([self keyExists:key]) - return [NSKeyedUnarchiver unarchiveObjectWithData:[self.userDefaults objectForKey:key]]; - - return value; + return [self getSavedCodeableDataForKey:key defaultValue:value maxBytes:0]; +} + +- (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value maxBytes:(NSUInteger)maxBytes { + if (![self keyExists:key]) + return value; + + id stored = [self.userDefaults objectForKey:key]; + if (![stored isKindOfClass:[NSData class]]) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults dropping %@: not archived data", key]]; + [self removeValueForKey:key]; + return value; + } + NSData *data = stored; + if (maxBytes > 0 && data.length > maxBytes) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults dropping %@: %lu bytes is over the %lu byte limit", key, (unsigned long)data.length, (unsigned long)maxBytes]]; + [self removeValueForKey:key]; + return value; + } + @try { + // An archive can decode to nothing; hand back the default the same way a missing key does. + return [NSKeyedUnarchiver unarchiveObjectWithData:data] ?: value; + } @catch (NSException *exception) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults dropping %@: %@", key, exception.reason]]; + [self removeValueForKey:key]; + return value; + } } - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value { - [self.userDefaults setObject:[NSKeyedArchiver archivedDataWithRootObject:value] forKey:key]; - [self.userDefaults synchronize]; + NSData *data; + @try { + data = [NSKeyedArchiver archivedDataWithRootObject:value]; + } @catch (NSException *exception) { + // The old blob no longer matches memory, so drop it rather than restore it next launch. + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not archive %@, removing it: %@", key, exception.reason]]; + [self removeValueForKey:key]; + return; + } + // CFPreferences refuses a value this large and may stop persisting the suite afterwards. + if (data.length >= OS_USER_DEFAULTS_MAX_VALUE_BYTES) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults not caching %@: %lu bytes", key, (unsigned long)data.length]]; + [self removeValueForKey:key]; + return; + } + @try { + [self.userDefaults setObject:data forKey:key]; + [self.userDefaults synchronize]; + } @catch (NSException *exception) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not cache %@: %@", key, exception.reason]]; + } } //gets the NSBundle of the primary application - NOT the app extension diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift index 7187b5f0cd..595bdb0257 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift @@ -73,4 +73,57 @@ final class OneSignalCoreTests: XCTestCase { XCTAssertEqual(templateId, "templateId123") XCTAssertEqual(templateName, "Template name") } + + // MARK: - Codeable data guards + + private let guardKey = "OS_CORE_TESTS_CODEABLE_GUARD" + + func testOversizedCodeableDataIsDroppedNotDecoded() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveObject(forKey: guardKey, withValue: Data(count: 1025)) + + let restored = defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"], maxBytes: 1024) as? [String] + + XCTAssertEqual(restored, ["default"]) + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + func testCodeableDataWithinTheLimitIsDecoded() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) + + let restored = defaults.getSavedCodeableData(forKey: guardKey, defaultValue: [], maxBytes: 1024) as? [String] + + XCTAssertEqual(restored, ["cached"]) + defaults.removeValue(forKey: guardKey) + } + + func testUnreadableCodeableDataReturnsTheDefaultWithoutThrowing() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveObject(forKey: guardKey, withValue: Data("not an archive".utf8)) + + let restored = defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"]) as? [String] + + XCTAssertEqual(restored, ["default"]) + defaults.removeValue(forKey: guardKey) + } + + func testUnarchivableValueIsNotCachedAndClearsTheKey() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) + + // NSObject does not adopt NSCoding, so archiving it raises. + defaults.saveCodeableData(forKey: guardKey, withValue: NSObject()) + + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + func testValueAtThePlatformLimitIsNotCached() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) + + defaults.saveCodeableData(forKey: guardKey, withValue: Data(count: Int(OS_USER_DEFAULTS_MAX_VALUE_BYTES))) + + XCTAssertFalse(defaults.keyExists(guardKey)) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index d1b3a36e8d..851a99c8c1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -78,7 +78,7 @@ public class OSOperationRepo: NSObject { } private func uncacheDeltaQueue() { - guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] else { + guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSDelta] else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo is unable to uncache the OSDelta queue.") return } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index b6571ac08a..b65c2b9091 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -63,7 +63,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSDelta] { for (index, delta) in deltaQueue.enumerated().reversed() { if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { // The identity model does not exist, drop this Delta @@ -81,7 +81,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { } private func uncacheRequests() { - if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestCustomEvents] { + if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestCustomEvents] { // Hook each uncached Request to the model in the store for (index, request) in requestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index c3e1825bc0..3435a77a9e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -50,7 +50,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSDelta] { // Hook each uncached Delta to the model in the store for (index, delta) in deltaQueue.enumerated().reversed() { if let modelInStore = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.model.modelId) { @@ -70,7 +70,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheAddAliasRequests() { - if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestAddAliases] { + if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestAddAliases] { // Hook each uncached Request to the model in the store for (index, request) in addRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -93,7 +93,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheRemoveAliasRequests() { - if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestRemoveAlias] { + if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestRemoveAlias] { // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 4dbb9e25c8..d50b13ee51 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -85,7 +85,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSDelta] { for (index, delta) in deltaQueue.enumerated().reversed() { if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { // The identity model does not exist, drop this Delta @@ -101,7 +101,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } private func uncacheUpdateRequests() { - if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestUpdateProperties] { + if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestUpdateProperties] { // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index aeee2c0992..c7d7ae68df 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -53,7 +53,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSDelta] { // Hook each uncached Delta to the model in the store for (index, delta) in deltaQueue.enumerated().reversed() { if let modelInStore = getSubscriptionModelFromStores(modelId: delta.model.modelId) { @@ -75,7 +75,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { private func uncacheCreateSubscriptionRequests() { var requestQueue: [OSRequestCreateSubscription] = [] - if let cachedAddRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestCreateSubscription] { + if let cachedAddRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestCreateSubscription] { // Hook each uncached Request to the model in the store for request in cachedAddRequestQueue { // 1. Hook up the subscription model @@ -111,7 +111,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheDeleteSubscriptionRequests() { - if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestDeleteSubscription] { + if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestDeleteSubscription] { // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { @@ -136,7 +136,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheUpdateSubscriptionRequests() { - if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestUpdateSubscription] { + if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestUpdateSubscription] { // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 392fb34081..6e06cb820a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -167,7 +167,7 @@ class OSUserExecutor { var userRequestQueue: [OSUserRequest] = [] // Read unfinished Create User + Identify User + Get Identity By Subscription requests from cache, if any... - if let cachedRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSUserRequest] { + if let cachedRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSUserRequest] { // Hook each uncached Request to the right model reference for request in cachedRequestQueue { if request.isKind(of: OSRequestFetchIdentityBySubscription.self), let req = request as? OSRequestFetchIdentityBySubscription { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 652c7836ef..a1a4573b6a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -238,6 +238,23 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } + /// The 5.3.0-beta builds parked Requests awaiting a JWT under these keys and never trimmed them, so an + /// upgrading device can carry a large blob nothing reads anymore. + private func removeIdentityVerificationBetaCaches() { + let defaults = OneSignalUserDefaults.initShared() + let keys = [ + OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY + ] + for key in keys where defaults.keyExists(key) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager removing the Identity Verification beta cache \(key)") + defaults.removeValue(forKey: key) + } + } + @objc public func start() { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { @@ -307,6 +324,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self?.pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabledInternally = false } + removeIdentityVerificationBetaCaches() + // Setup the executors // The OSUserExecutor has to run first, before other executors self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: identityVerificationService, auth: requestAuth) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 2a4f8ebb80..fc361a4194 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -850,4 +850,37 @@ private class SpyModelChangedHandler: OSModelChangedHandler { serverUpdates.append(args.property) } } + + func testStartRemovesIdentityVerificationBetaCachesAndAnOversizedQueue() throws { + let client = MockOneSignalClient() + client.executeInstantaneously = true + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + OneSignalCoreImpl.setSharedClient(client) + + let defaults = OneSignalUserDefaults.initShared() + let betaKeys = [ + OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY + ] + for key in betaKeys { + defaults.saveCodeableData(forKey: key, withValue: ["nan01": []]) + } + // A queue blob a defect left behind, too large to be worth decoding at launch. + defaults.saveObject(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: Data(count: Int(OS_CACHED_QUEUE_MAX_BYTES) + 1)) + + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + + for key in betaKeys { + XCTAssertFalse(defaults.keyExists(key), "\(key) survived start()") + } + // The property executor replaced the blob with its empty queue rather than decoding it. + let queue = defaults.getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: nil) as? [Any] + XCTAssertEqual(queue?.count, 0) + } } From f155df67fb9f4e8bd2e0e404be1eec9ad1e360d2 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 24 Sep 2026 08:42:19 -0700 Subject: [PATCH 02/11] chore: [SDK-5314] reword the cache guard comments --- .../OneSignalCore/Source/OneSignalCommonDefines.h | 4 ++-- .../OneSignalCore/Source/OneSignalUserDefaults.h | 2 +- .../OneSignalCore/Source/OneSignalUserDefaults.m | 6 +++--- .../OneSignalUserTests/OneSignalUserTests.swift | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index a31abb5c61..a621afe313 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -378,12 +378,12 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY" #define OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY" -// A cached queue above this size is dropped instead of decoded; real queues are a few KB. +// A cached queue past this size is dropped without decoding it. Real queues are a few KB. #define OS_CACHED_QUEUE_MAX_BYTES 1048576 // CFPreferences rejects a single value at or above this size on iOS. #define OS_USER_DEFAULTS_MAX_VALUE_BYTES 4194304 -// Written only by the 5.3.0-beta Identity Verification builds; nothing reads them, so start() removes them. +// Written only by the 5.3.0-beta Identity Verification builds. Nothing reads them anymore. #define OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY @"OS_USER_EXECUTOR_PENDING_QUEUE_KEY" #define OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY @"OS_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY" #define OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY @"OS_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h index 4a7269a2b9..8f0b54ade8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h @@ -70,7 +70,7 @@ // NSUserDefaults for storing and getting saved codeable data (custom objects) - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value; -/// Same, but a stored blob larger than `maxBytes` is removed and `value` returned instead of being decoded. +/// Removes a stored blob larger than `maxBytes` and returns `value` instead of decoding it. - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value maxBytes:(NSUInteger)maxBytes; - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m index 605f971182..3d4af4b87f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m @@ -168,12 +168,12 @@ - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue } NSData *data = stored; if (maxBytes > 0 && data.length > maxBytes) { - [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults dropping %@: %lu bytes is over the %lu byte limit", key, (unsigned long)data.length, (unsigned long)maxBytes]]; + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults dropping %@: %lu bytes, limit %lu", key, (unsigned long)data.length, (unsigned long)maxBytes]]; [self removeValueForKey:key]; return value; } @try { - // An archive can decode to nothing; hand back the default the same way a missing key does. + // A blob that decodes to nil is treated like a missing key. return [NSKeyedUnarchiver unarchiveObjectWithData:data] ?: value; } @catch (NSException *exception) { [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults dropping %@: %@", key, exception.reason]]; @@ -187,7 +187,7 @@ - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable) @try { data = [NSKeyedArchiver archivedDataWithRootObject:value]; } @catch (NSException *exception) { - // The old blob no longer matches memory, so drop it rather than restore it next launch. + // The old blob no longer matches memory, so drop it rather than restore it on the next launch. [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not archive %@, removing it: %@", key, exception.reason]]; [self removeValueForKey:key]; return; diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index fc361a4194..afa6b2f89a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -868,7 +868,7 @@ private class SpyModelChangedHandler: OSModelChangedHandler { for key in betaKeys { defaults.saveCodeableData(forKey: key, withValue: ["nan01": []]) } - // A queue blob a defect left behind, too large to be worth decoding at launch. + // A queue blob too large to decode at launch. defaults.saveObject(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: Data(count: Int(OS_CACHED_QUEUE_MAX_BYTES) + 1)) OneSignalUserManagerImpl.sharedInstance.start() From e4a210a2cde6b45f005a8c3c32606fdecbbf2680 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 28 Sep 2026 12:31:18 -0700 Subject: [PATCH 03/11] chore: [SDK-5314] run the start() test, prove the cap, keep blobs on archive failure --- .../Source/OneSignalUserDefaults.h | 3 +- .../Source/OneSignalUserDefaults.m | 5 +- .../OneSignalCoreTests.swift | 8 ++- .../Source/Executors/OSUserExecutor.swift | 2 +- .../Source/OneSignalUserManagerImpl.swift | 3 +- .../OneSignalUserTests.swift | 68 ++++++++++--------- 6 files changed, 46 insertions(+), 43 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h index 8f0b54ade8..908e632eb3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h @@ -69,8 +69,9 @@ - (void)saveObjectForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; // NSUserDefaults for storing and getting saved codeable data (custom objects) +/// A blob that is not archived data or cannot be decoded is removed and `value` returned. - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value; -/// Removes a stored blob larger than `maxBytes` and returns `value` instead of decoding it. +/// Same, and a stored blob larger than `maxBytes` is removed and `value` returned instead of decoding it. 0 means no limit. - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value maxBytes:(NSUInteger)maxBytes; - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m index 3d4af4b87f..5602591591 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m @@ -187,9 +187,8 @@ - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable) @try { data = [NSKeyedArchiver archivedDataWithRootObject:value]; } @catch (NSException *exception) { - // The old blob no longer matches memory, so drop it rather than restore it on the next launch. - [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not archive %@, removing it: %@", key, exception.reason]]; - [self removeValueForKey:key]; + // The previous blob stays: for a model store a stale copy beats none. + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not archive %@: %@", key, exception.reason]]; return; } // CFPreferences refuses a value this large and may stop persisting the suite afterwards. diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift index 595bdb0257..0b333c4d45 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift @@ -80,7 +80,8 @@ final class OneSignalCoreTests: XCTestCase { func testOversizedCodeableDataIsDroppedNotDecoded() { let defaults = OneSignalUserDefaults.initShared() - defaults.saveObject(forKey: guardKey, withValue: Data(count: 1025)) + // A real archive over the cap; without the cap it would decode. + defaults.saveCodeableData(forKey: guardKey, withValue: [String(repeating: "x", count: 2048)]) let restored = defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"], maxBytes: 1024) as? [String] @@ -108,14 +109,15 @@ final class OneSignalCoreTests: XCTestCase { defaults.removeValue(forKey: guardKey) } - func testUnarchivableValueIsNotCachedAndClearsTheKey() { + func testUnarchivableValueLeavesTheCachedValueInPlace() { let defaults = OneSignalUserDefaults.initShared() defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) // NSObject does not adopt NSCoding, so archiving it raises. defaults.saveCodeableData(forKey: guardKey, withValue: NSObject()) - XCTAssertFalse(defaults.keyExists(guardKey)) + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: nil) as? [String], ["cached"]) + defaults.removeValue(forKey: guardKey) } func testValueAtThePlatformLimitIsNotCached() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 6e06cb820a..1cfd2438f0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -232,7 +232,7 @@ class OSUserExecutor { As of `5.2.3`, the SDK will no longer send Transfer Subscription requests, so migrate the request into an equivalent Create User request. */ private func migrateTransferSubscriptionRequests() { - if let transferSubscriptionRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_TRANSFER_SUBSCRIPTION_REQUEST_QUEUE_KEY, defaultValue: nil) as? [OSRequestTransferSubscription] { + if let transferSubscriptionRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_TRANSFER_SUBSCRIPTION_REQUEST_QUEUE_KEY, defaultValue: nil, maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestTransferSubscription] { OneSignalUserDefaults.initShared().removeValue(forKey: OS_USER_EXECUTOR_TRANSFER_SUBSCRIPTION_REQUEST_QUEUE_KEY) // Translate the last request into a Create User request, if the current user is the same diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index a1a4573b6a..04f0f2a86c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -268,6 +268,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager calling start") + removeIdentityVerificationBetaCaches() // 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 @@ -324,8 +325,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self?.pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabledInternally = false } - removeIdentityVerificationBetaCaches() - // Setup the executors // The OSUserExecutor has to run first, before other executors self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: identityVerificationService, auth: requestAuth) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index afa6b2f89a..dfbdd26b5d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -603,6 +603,41 @@ final class OneSignalUserTests: XCTestCase { XCTAssertEqual(subscriptions.first?["enabled"] as? Bool, false) XCTAssertEqual(subscriptions.first?["notification_types"] as? Int, -22) } + + func testStartRemovesIdentityVerificationBetaCachesAndAnOversizedQueue() throws { + let client = MockOneSignalClient() + client.executeInstantaneously = true + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + OneSignalCoreImpl.setSharedClient(client) + + let defaults = OneSignalUserDefaults.initShared() + let betaKeys = [ + OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY + ] + for key in betaKeys { + defaults.saveCodeableData(forKey: key, withValue: ["nan01": []]) + } + // A real archive over the cap. Without the cap it decodes to strings, which fails the cast and leaves the key as is. + // Distinct strings: the archiver stores a repeated object once, which would keep this under the cap. + let oversized = (0..<(Int(OS_CACHED_QUEUE_MAX_BYTES) / 1024 + 64)).map { String(repeating: "x", count: 1024) + String($0) } + defaults.saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: oversized) + + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + + for key in betaKeys { + XCTAssertFalse(defaults.keyExists(key), "\(key) survived start()") + } + // The property executor replaced the blob with its empty queue rather than decoding it. + let queue = defaults.getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: nil) as? [Any] + XCTAssertEqual(queue?.count, 0) + } } @@ -850,37 +885,4 @@ private class SpyModelChangedHandler: OSModelChangedHandler { serverUpdates.append(args.property) } } - - func testStartRemovesIdentityVerificationBetaCachesAndAnOversizedQueue() throws { - let client = MockOneSignalClient() - client.executeInstantaneously = true - MockUserRequests.setDefaultCreateAnonUserResponses(with: client) - OneSignalCoreImpl.setSharedClient(client) - - let defaults = OneSignalUserDefaults.initShared() - let betaKeys = [ - OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY - ] - for key in betaKeys { - defaults.saveCodeableData(forKey: key, withValue: ["nan01": []]) - } - // A queue blob too large to decode at launch. - defaults.saveObject(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: Data(count: Int(OS_CACHED_QUEUE_MAX_BYTES) + 1)) - - OneSignalUserManagerImpl.sharedInstance.start() - OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { - client.hasCompletedRequestOfType(OSRequestCreateUser.self) - } - - for key in betaKeys { - XCTAssertFalse(defaults.keyExists(key), "\(key) survived start()") - } - // The property executor replaced the blob with its empty queue rather than decoding it. - let queue = defaults.getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: nil) as? [Any] - XCTAssertEqual(queue?.count, 0) - } } From 4ebd81ee82a8c44fd0540f824cbf1d6674a56208 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 28 Sep 2026 13:44:16 -0700 Subject: [PATCH 04/11] chore: [SDK-5314] drop an out-of-context comment --- .../OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m | 1 - 1 file changed, 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m index 5602591591..b1dcccb976 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m @@ -187,7 +187,6 @@ - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable) @try { data = [NSKeyedArchiver archivedDataWithRootObject:value]; } @catch (NSException *exception) { - // The previous blob stays: for a model store a stale copy beats none. [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not archive %@: %@", key, exception.reason]]; return; } From 2a762795758b9acc96fe1843bf70617e45e31b37 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 28 Sep 2026 13:44:30 -0700 Subject: [PATCH 05/11] chore: [SDK-5314] leave the transfer-subscription migration read uncapped --- .../OneSignalUser/Source/Executors/OSUserExecutor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 1cfd2438f0..6e06cb820a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -232,7 +232,7 @@ class OSUserExecutor { As of `5.2.3`, the SDK will no longer send Transfer Subscription requests, so migrate the request into an equivalent Create User request. */ private func migrateTransferSubscriptionRequests() { - if let transferSubscriptionRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_TRANSFER_SUBSCRIPTION_REQUEST_QUEUE_KEY, defaultValue: nil, maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestTransferSubscription] { + if let transferSubscriptionRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_TRANSFER_SUBSCRIPTION_REQUEST_QUEUE_KEY, defaultValue: nil) as? [OSRequestTransferSubscription] { OneSignalUserDefaults.initShared().removeValue(forKey: OS_USER_EXECUTOR_TRANSFER_SUBSCRIPTION_REQUEST_QUEUE_KEY) // Translate the last request into a Create User request, if the current user is the same From 837486023a5046b1c9419b06e0d855c1d179903a Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 28 Sep 2026 14:12:15 -0700 Subject: [PATCH 06/11] chore: [SDK-5314] give the beta cache test its own class to satisfy swiftlint --- .../OneSignalUserTests.swift | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index dfbdd26b5d..6ea011fa3f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -603,6 +603,25 @@ final class OneSignalUserTests: XCTestCase { XCTAssertEqual(subscriptions.first?["enabled"] as? Bool, false) XCTAssertEqual(subscriptions.first?["notification_types"] as? Int, -22) } +} + +/** + `optedIn` is the only signal the public API gives an app for "will push reach this device", so a + subscription the app owner turned off remotely has to report false there. Kept in its own class + because these build bare models and never touch the user manager singleton. + */ +/// The 5.3.0-beta line left queues on disk that this SDK does not read. `start()` clears them and +/// refuses to decode an oversized queue it does read. +final class IdentityVerificationBetaCacheTests: XCTestCase { + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } func testStartRemovesIdentityVerificationBetaCachesAndAnOversizedQueue() throws { let client = MockOneSignalClient() @@ -640,12 +659,6 @@ final class OneSignalUserTests: XCTestCase { } } - -/** - `optedIn` is the only signal the public API gives an app for "will push reach this device", so a - subscription the app owner turned off remotely has to report false there. Kept in its own class - because these build bare models and never touch the user manager singleton. - */ final class RemoteDisableOptedInTests: XCTestCase { override func setUpWithError() throws { From b40bf6c59d369fbbf0c0e629a791bf576e96584a Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 6 Oct 2026 00:03:01 -0700 Subject: [PATCH 07/11] chore: [SDK-5314] keep the optedIn note next to its tests The beta cache test class sat between the note and RemoteDisableOptedInTests. --- .../OneSignalUserTests.swift | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 6ea011fa3f..8fa3fe5c7d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -610,55 +610,6 @@ final class OneSignalUserTests: XCTestCase { subscription the app owner turned off remotely has to report false there. Kept in its own class because these build bare models and never touch the user manager singleton. */ -/// The 5.3.0-beta line left queues on disk that this SDK does not read. `start()` clears them and -/// refuses to decode an oversized queue it does read. -final class IdentityVerificationBetaCacheTests: XCTestCase { - override func setUpWithError() throws { - OneSignalCoreMocks.clearUserDefaults() - OneSignalUserMocks.reset() - OneSignalIdentifiers.currentAppId = "test-app-id" - } - - override func tearDownWithError() throws { - OneSignalCoreMocks.clearUserDefaults() - } - - func testStartRemovesIdentityVerificationBetaCachesAndAnOversizedQueue() throws { - let client = MockOneSignalClient() - client.executeInstantaneously = true - MockUserRequests.setDefaultCreateAnonUserResponses(with: client) - OneSignalCoreImpl.setSharedClient(client) - - let defaults = OneSignalUserDefaults.initShared() - let betaKeys = [ - OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY - ] - for key in betaKeys { - defaults.saveCodeableData(forKey: key, withValue: ["nan01": []]) - } - // A real archive over the cap. Without the cap it decodes to strings, which fails the cast and leaves the key as is. - // Distinct strings: the archiver stores a repeated object once, which would keep this under the cap. - let oversized = (0..<(Int(OS_CACHED_QUEUE_MAX_BYTES) / 1024 + 64)).map { String(repeating: "x", count: 1024) + String($0) } - defaults.saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: oversized) - - OneSignalUserManagerImpl.sharedInstance.start() - OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { - client.hasCompletedRequestOfType(OSRequestCreateUser.self) - } - - for key in betaKeys { - XCTAssertFalse(defaults.keyExists(key), "\(key) survived start()") - } - // The property executor replaced the blob with its empty queue rather than decoding it. - let queue = defaults.getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: nil) as? [Any] - XCTAssertEqual(queue?.count, 0) - } -} - final class RemoteDisableOptedInTests: XCTestCase { override func setUpWithError() throws { @@ -877,6 +828,55 @@ final class RemoteDisableOptedInTests: XCTestCase { } } +/// The 5.3.0-beta line left queues on disk that this SDK does not read. `start()` clears them and +/// refuses to decode an oversized queue it does read. +final class IdentityVerificationBetaCacheTests: XCTestCase { + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + func testStartRemovesIdentityVerificationBetaCachesAndAnOversizedQueue() throws { + let client = MockOneSignalClient() + client.executeInstantaneously = true + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + OneSignalCoreImpl.setSharedClient(client) + + let defaults = OneSignalUserDefaults.initShared() + let betaKeys = [ + OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY + ] + for key in betaKeys { + defaults.saveCodeableData(forKey: key, withValue: ["nan01": []]) + } + // A real archive over the cap. Without the cap it decodes to strings, which fails the cast and leaves the key as is. + // Distinct strings: the archiver stores a repeated object once, which would keep this under the cap. + let oversized = (0..<(Int(OS_CACHED_QUEUE_MAX_BYTES) / 1024 + 64)).map { String(repeating: "x", count: 1024) + String($0) } + defaults.saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: oversized) + + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + + for key in betaKeys { + XCTAssertFalse(defaults.keyExists(key), "\(key) survived start()") + } + // The property executor replaced the blob with its empty queue rather than decoding it. + let queue = defaults.getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: nil) as? [Any] + XCTAssertEqual(queue?.count, 0) + } +} + /// Records the properties a model reported as changed, split by whether the change was meant to /// reach the server. `hydrating` is true for writes that only mirror state the server already has. private class SpyPushSubscriptionObserver: NSObject, OSPushSubscriptionObserver { From 9a4e7a8a44d4cc4990bcc66c6a6fddd4af66b77a Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 6 Oct 2026 00:03:01 -0700 Subject: [PATCH 08/11] chore: [SDK-5314] remove an undecodable blob only from a capped read The queue reads are the ones that self-heal by deletion. An uncapped read is a model store or a cache that owns its key, so a blob that is not archived data or raises on decode is now logged and left in place unless maxBytes is set. --- .../Source/OneSignalUserDefaults.h | 4 +- .../Source/OneSignalUserDefaults.m | 16 ++++++-- .../OneSignalCoreTests.swift | 40 +++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h index 908e632eb3..ff4208e834 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h @@ -69,9 +69,9 @@ - (void)saveObjectForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; // NSUserDefaults for storing and getting saved codeable data (custom objects) -/// A blob that is not archived data or cannot be decoded is removed and `value` returned. +/// A blob that is not archived data or cannot be decoded is logged, left in place and `value` returned. - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value; -/// Same, and a stored blob larger than `maxBytes` is removed and `value` returned instead of decoding it. 0 means no limit. +/// Same, except that a blob larger than `maxBytes`, not archived data or undecodable is removed. 0 means no limit, and nothing is removed. - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value maxBytes:(NSUInteger)maxBytes; - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m index b1dcccb976..57736e1970 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m @@ -162,8 +162,8 @@ - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue id stored = [self.userDefaults objectForKey:key]; if (![stored isKindOfClass:[NSData class]]) { - [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults dropping %@: not archived data", key]]; - [self removeValueForKey:key]; + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults cannot decode %@: not archived data", key]]; + [self removeUndecodableValueForKey:key maxBytes:maxBytes]; return value; } NSData *data = stored; @@ -176,12 +176,20 @@ - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue // A blob that decodes to nil is treated like a missing key. return [NSKeyedUnarchiver unarchiveObjectWithData:data] ?: value; } @catch (NSException *exception) { - [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults dropping %@: %@", key, exception.reason]]; - [self removeValueForKey:key]; + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults cannot decode %@: %@", key, exception.reason]]; + [self removeUndecodableValueForKey:key maxBytes:maxBytes]; return value; } } +/// Only a capped read, a queue of this SDK's own Requests, heals by deletion. An uncapped read leaves the blob to +/// whichever module wrote it; a model store, for one, is overwritten by the user the SDK creates in its place. +- (void)removeUndecodableValueForKey:(NSString *)key maxBytes:(NSUInteger)maxBytes { + if (maxBytes > 0) { + [self removeValueForKey:key]; + } +} + - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value { NSData *data; @try { diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift index 0b333c4d45..19fbfa189d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift @@ -128,4 +128,44 @@ final class OneSignalCoreTests: XCTestCase { XCTAssertFalse(defaults.keyExists(guardKey)) } + + func testBlobThatIsNotArchivedDataIsRemovedOnlyByACappedRead() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveObject(forKey: guardKey, withValue: "not data") + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"]) as? [String], ["default"]) + XCTAssertTrue(defaults.keyExists(guardKey)) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"], maxBytes: 1024) as? [String], ["default"]) + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + func testUndecodableBlobIsRemovedOnlyByACappedRead() throws { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveObject(forKey: guardKey, withValue: try archiveNamingAnUnknownClass()) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"]) as? [String], ["default"]) + XCTAssertTrue(defaults.keyExists(guardKey)) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"], maxBytes: 1024) as? [String], ["default"]) + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + /// A keyed archive whose root is of a class this process does not have. Decoding it raises, as a queue written + /// by a newer build does. + private func archiveNamingAnUnknownClass() throws -> Data { + let archive = try NSKeyedArchiver.archivedData(withRootObject: ["cached"], requiringSecureCoding: false) + var plist = try XCTUnwrap(try PropertyListSerialization.propertyList(from: archive, format: nil) as? [String: Any]) + var objects = try XCTUnwrap(plist["$objects"] as? [Any]) + for (index, object) in objects.enumerated() { + guard var descriptor = object as? [String: Any], descriptor["$classname"] != nil else { + continue + } + descriptor["$classname"] = "OSNoSuchClass" + descriptor["$classes"] = ["OSNoSuchClass"] + objects[index] = descriptor + } + plist["$objects"] = objects + return try PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + } } From 5972232c28ed6a9c137965a607c37cedc87aacfc Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 6 Oct 2026 00:03:01 -0700 Subject: [PATCH 09/11] chore: [SDK-5314] use one size limit for writing and reading cached values A queue between the 1 MB read cap and the 4 MB write limit was saved, then dropped at the next launch. The write path now refuses the same 1 MB, and still removes the previous blob: a queue that large is the bloat the read cap drops, and an older blob left under the key would resend the Requests it held. The beta cache test writes its oversized archive directly, since the save path refuses it now. --- .../OneSignalCore/Source/OneSignalCommonDefines.h | 7 +++---- .../OneSignalCore/Source/OneSignalUserDefaults.h | 1 + .../OneSignalCore/Source/OneSignalUserDefaults.m | 6 ++++-- .../OneSignalCoreTests/OneSignalCoreTests.swift | 2 +- .../OneSignalOSCore/Source/OSOperationRepo.swift | 2 +- .../Source/Executors/OSCustomEventsExecutor.swift | 4 ++-- .../Source/Executors/OSIdentityOperationExecutor.swift | 6 +++--- .../Source/Executors/OSPropertyOperationExecutor.swift | 4 ++-- .../Executors/OSSubscriptionOperationExecutor.swift | 8 ++++---- .../OneSignalUser/Source/Executors/OSUserExecutor.swift | 2 +- .../OneSignalUserTests/OneSignalUserTests.swift | 8 ++++++-- 11 files changed, 28 insertions(+), 22 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index a621afe313..3c54ee549e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -378,10 +378,9 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY" #define OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY" -// A cached queue past this size is dropped without decoding it. Real queues are a few KB. -#define OS_CACHED_QUEUE_MAX_BYTES 1048576 -// CFPreferences rejects a single value at or above this size on iOS. -#define OS_USER_DEFAULTS_MAX_VALUE_BYTES 4194304 +// A codeable value larger than this is not written, and a cached queue blob larger than it is dropped without +// decoding it. Real queues are a few KB. CFPreferences rejects a value of 4 MB or more on iOS. +#define OS_USER_DEFAULTS_MAX_VALUE_BYTES 1048576 // Written only by the 5.3.0-beta Identity Verification builds. Nothing reads them anymore. #define OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY @"OS_USER_EXECUTOR_PENDING_QUEUE_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h index ff4208e834..ed3dc6477e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h @@ -73,6 +73,7 @@ - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value; /// Same, except that a blob larger than `maxBytes`, not archived data or undecodable is removed. 0 means no limit, and nothing is removed. - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value maxBytes:(NSUInteger)maxBytes; +/// A value whose archive is larger than `OS_USER_DEFAULTS_MAX_VALUE_BYTES` is not written and the key is removed. - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m index 57736e1970..2f4eaf12e2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m @@ -198,8 +198,10 @@ - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable) [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not archive %@: %@", key, exception.reason]]; return; } - // CFPreferences refuses a value this large and may stop persisting the suite afterwards. - if (data.length >= OS_USER_DEFAULTS_MAX_VALUE_BYTES) { + // Nothing the SDK caches is legitimately this large: a queue that reached it is the bloat the read cap drops, + // so the previous blob goes too rather than resend the Requests it held. CFPreferences refuses a value of 4 MB + // or more and can stop persisting the suite afterwards. + if (data.length > OS_USER_DEFAULTS_MAX_VALUE_BYTES) { [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults not caching %@: %lu bytes", key, (unsigned long)data.length]]; [self removeValueForKey:key]; return; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift index 19fbfa189d..e3d3873668 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift @@ -120,7 +120,7 @@ final class OneSignalCoreTests: XCTestCase { defaults.removeValue(forKey: guardKey) } - func testValueAtThePlatformLimitIsNotCached() { + func testValueOverTheLimitIsNotCachedAndThePreviousBlobGoesWithIt() { let defaults = OneSignalUserDefaults.initShared() defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index 851a99c8c1..c1274ef848 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -78,7 +78,7 @@ public class OSOperationRepo: NSObject { } private func uncacheDeltaQueue() { - guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSDelta] else { + guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo is unable to uncache the OSDelta queue.") return } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index b65c2b9091..dc948efb72 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -63,7 +63,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { for (index, delta) in deltaQueue.enumerated().reversed() { if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { // The identity model does not exist, drop this Delta @@ -81,7 +81,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { } private func uncacheRequests() { - if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestCustomEvents] { + if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestCustomEvents] { // Hook each uncached Request to the model in the store for (index, request) in requestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index 3435a77a9e..93a15057de 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -50,7 +50,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { // Hook each uncached Delta to the model in the store for (index, delta) in deltaQueue.enumerated().reversed() { if let modelInStore = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.model.modelId) { @@ -70,7 +70,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheAddAliasRequests() { - if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestAddAliases] { + if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestAddAliases] { // Hook each uncached Request to the model in the store for (index, request) in addRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -93,7 +93,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheRemoveAliasRequests() { - if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestRemoveAlias] { + if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestRemoveAlias] { // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index d50b13ee51..2189459f50 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -85,7 +85,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { for (index, delta) in deltaQueue.enumerated().reversed() { if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { // The identity model does not exist, drop this Delta @@ -101,7 +101,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } private func uncacheUpdateRequests() { - if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestUpdateProperties] { + if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestUpdateProperties] { // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index c7d7ae68df..9b4c805f77 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -53,7 +53,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { // Hook each uncached Delta to the model in the store for (index, delta) in deltaQueue.enumerated().reversed() { if let modelInStore = getSubscriptionModelFromStores(modelId: delta.model.modelId) { @@ -75,7 +75,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { private func uncacheCreateSubscriptionRequests() { var requestQueue: [OSRequestCreateSubscription] = [] - if let cachedAddRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestCreateSubscription] { + if let cachedAddRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestCreateSubscription] { // Hook each uncached Request to the model in the store for request in cachedAddRequestQueue { // 1. Hook up the subscription model @@ -111,7 +111,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheDeleteSubscriptionRequests() { - if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestDeleteSubscription] { + if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestDeleteSubscription] { // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { @@ -136,7 +136,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheUpdateSubscriptionRequests() { - if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSRequestUpdateSubscription] { + if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestUpdateSubscription] { // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 6e06cb820a..282a90bc2e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -167,7 +167,7 @@ class OSUserExecutor { var userRequestQueue: [OSUserRequest] = [] // Read unfinished Create User + Identify User + Get Identity By Subscription requests from cache, if any... - if let cachedRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_CACHED_QUEUE_MAX_BYTES)) as? [OSUserRequest] { + if let cachedRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSUserRequest] { // Hook each uncached Request to the right model reference for request in cachedRequestQueue { if request.isKind(of: OSRequestFetchIdentityBySubscription.self), let req = request as? OSRequestFetchIdentityBySubscription { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 8fa3fe5c7d..42b18a85e7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -860,8 +860,12 @@ final class IdentityVerificationBetaCacheTests: XCTestCase { } // A real archive over the cap. Without the cap it decodes to strings, which fails the cast and leaves the key as is. // Distinct strings: the archiver stores a repeated object once, which would keep this under the cap. - let oversized = (0..<(Int(OS_CACHED_QUEUE_MAX_BYTES) / 1024 + 64)).map { String(repeating: "x", count: 1024) + String($0) } - defaults.saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: oversized) + let oversized = (0..<(Int(OS_USER_DEFAULTS_MAX_VALUE_BYTES) / 1024 + 64)).map { String(repeating: "x", count: 1024) + String($0) } + // Written directly: `saveCodeableData` refuses a value this large. The builds that wrote these blobs did not. + defaults.saveObject( + forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, + withValue: try NSKeyedArchiver.archivedData(withRootObject: oversized, requiringSecureCoding: false) + ) OneSignalUserManagerImpl.sharedInstance.start() OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { From 8c48e2ce480d8b998f4dc02883ce085c195b6da5 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 6 Oct 2026 00:03:01 -0700 Subject: [PATCH 10/11] fix: [SDK-5314] create a cached user whose Create User was lost A cached user with no onesignal_id has nothing left to create it once its queue is dropped as oversized or undecodable, or when a 5.3.0-beta build parked the Request under a key start() now removes. A login as the same user is a no-op, so the device stayed unreachable until a logout. The user executor now queues a Create User at start unless a Create User, Fetch Identity By Subscription or Identify User for that model is already queued. Under Identity Verification the Request parks and asks the app for a token; an anonymous cached user is left alone there, since it is never created. --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Source/Executors/OSUserExecutor.swift | 42 +++++ .../CachedUserWithoutAnIdTests.swift | 153 ++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 4a68492ba2..4eac096a71 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -367,6 +367,7 @@ 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */; }; E42087CB1AB15481E55D34FE /* UserStateReportingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */; }; 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */; }; + 59611FB5A88143468425CCB3 /* CachedUserWithoutAnIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5249D3FD71F02D4BCAF0995B /* CachedUserWithoutAnIdTests.swift */; }; 8D2F4893453206700BB60F85 /* OSOperationRepoTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */; }; 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */; }; 911E2CBD1E398AB3003112A4 /* UnitTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 911E2CBC1E398AB3003112A4 /* UnitTests.m */; }; @@ -1607,6 +1608,7 @@ 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtLifecycleTests.swift; sourceTree = ""; }; FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserStateReportingTests.swift; sourceTree = ""; }; 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtAskTests.swift; sourceTree = ""; }; + 5249D3FD71F02D4BCAF0995B /* CachedUserWithoutAnIdTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CachedUserWithoutAnIdTests.swift; sourceTree = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IamFetchIdentityVerificationTests.swift; sourceTree = ""; }; 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; @@ -2547,6 +2549,7 @@ 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */, 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */, + 5249D3FD71F02D4BCAF0995B /* CachedUserWithoutAnIdTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -4745,6 +4748,7 @@ 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, E42087CB1AB15481E55D34FE /* UserStateReportingTests.swift in Sources */, 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */, + 59611FB5A88143468425CCB3 /* CachedUserWithoutAnIdTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 282a90bc2e..3f5850b5d9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -50,6 +50,7 @@ class OSUserExecutor { self.auth = auth uncacheUserRequests() migrateTransferSubscriptionRequests() + createUserIfNeverCreated() identityVerificationService.addOnJwtConfigHydratedHandler(for: .userExecutor) { [weak self] _ in // Including an unchanged value: Requests held while `requirement` was unknown wait on this. @@ -244,6 +245,47 @@ class OSUserExecutor { } } + /// A cached user with no `onesignal_id` never reached the server, and only a Create User gets it there. + /// Queue one unless a queued Request already exists. Skip an anonymous user under Identity + /// Verification, where `reshapeInvalidRequests` would drop the Create User anyway. Runs on the executor + /// queue so it sees what `migrateTransferSubscriptionRequests` appended. + private func createUserIfNeverCreated() { + dispatchQueue.async { + guard let user = OneSignalUserManagerImpl.sharedInstance._user, + user.identityModel.onesignalId == nil, + user.identityModel.externalId != nil || !self.identityVerificationService.ivBehaviorActive, + !self.queueSuppliesAnId(for: user.identityModel.modelId) + else { + return + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSUserExecutor queued a Create User for the cached user") + let request = OSRequestCreateUser( + identityModel: user.identityModel, + propertiesModel: user.propertiesModel, + pushSubscriptionModel: user.pushSubscriptionModel, + originalPushToken: user.pushSubscriptionModel.address + ) + self.userRequestQueue.append(request) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: self.userRequestQueue) + } + } + + /// Whether a queued Request gives the identity model an `onesignal_id` when it completes. + private func queueSuppliesAnId(for modelId: String) -> Bool { + return userRequestQueue.contains { request in + switch request { + case let createUser as OSRequestCreateUser: + return createUser.identityModel.modelId == modelId + case let fetchIdentity as OSRequestFetchIdentityBySubscription: + return fetchIdentity.identityModel.modelId == modelId + case let identifyUser as OSRequestIdentifyUser: + return identifyUser.identityModelToUpdate.modelId == modelId + default: + return false + } + } + } + private func getIdentityModel(_ modelId: String) -> OSIdentityModel? { return OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift new file mode 100644 index 0000000000..77f8021f45 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift @@ -0,0 +1,153 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/** + A cached user with no `onesignal_id` was never created on the server. Only its Create User does that, and a + launch can lose the Request: the queue it sat in was dropped as oversized or unreadable, or a 5.3.0-beta build + parked it under a key `start()` removes. `start()` queues it again rather than leave the device unreachable. + */ +final class CachedUserWithoutAnIdTests: XCTestCase { + private var client = MockOneSignalClient() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + } + + override func tearDownWithError() throws { + // The mock answers 50ms late, so a Request still in flight would otherwise land mid-next-test. + OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle } + OneSignalCoreMocks.clearUserDefaults() + } + + /// The models a launch leaves behind when it is killed before its Create User is sent, with the queue empty. + @discardableResult + private func cacheUser(externalId: String?) -> OSUserInternal { + return OneSignalUserManagerImpl.sharedInstance.setNewInternalUser(externalId: externalId, pushSubscriptionModel: nil) + } + + private func sentCreateUsers() -> [OSRequestCreateUser] { + return client.executedRequests.compactMap { $0 as? OSRequestCreateUser } + } + + private func externalId(of request: OSRequestCreateUser) -> String? { + return (request.parameters?["identity"] as? [String: String])?[OS_EXTERNAL_ID] + } + + func testStartCreatesACachedIdentifiedUserWithNoOneSignalId() { + let user = cacheUser(externalId: userA_EUID) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The cached user was not created") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + XCTAssertEqual(sentCreateUsers().map(externalId(of:)), [userA_EUID]) + OneSignalCoreMocks.waitUntil("The response did not hydrate the cached user") { user.identityModel.onesignalId != nil } + } + + func testStartCreatesACachedAnonymousUserWithNoOneSignalId() { + cacheUser(externalId: nil) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The cached user was not created") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + XCTAssertEqual(sentCreateUsers().count, 1) + XCTAssertNil(sentCreateUsers().first.flatMap(externalId(of:))) + } + + func testStartSendsACachedCreateUserOnceWhenItIsStillQueued() { + let user = cacheUser(externalId: userA_EUID) + let queued = OSRequestCreateUser( + identityModel: user.identityModel, + propertiesModel: user.propertiesModel, + pushSubscriptionModel: user.pushSubscriptionModel, + originalPushToken: nil + ) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: [queued]) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The cached Create User did not complete") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + // A second one would still be queued here: it goes out only after the post-create delay. + OneSignalCoreMocks.waitUntil("A Create User is still queued") { + OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.contains { $0 is OSRequestCreateUser } == false + } + XCTAssertEqual(sentCreateUsers().count, 1) + } + + func testStartAsksForATokenForACachedIdentifiedUserUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + // Held strongly for the test's lifetime: the observer keeps listeners weakly. + let listener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + defer { OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) } + cacheUser(externalId: userA_EUID) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The app was not asked for a token") { listener.invalidatedExternalIds == [userA_EUID] } + XCTAssertTrue(sentCreateUsers().isEmpty) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "a-token") + + OneSignalCoreMocks.waitUntil("The Create User did not go out with the token") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + XCTAssertEqual(sentCreateUsers().map(externalId(of:)), [userA_EUID]) + } + + func testStartLeavesACachedAnonymousUserAloneUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + cacheUser(externalId: nil) + + OneSignalUserManagerImpl.sharedInstance.start() + + // Nothing positive to wait for: give the executor's queue a turn, then look. + Thread.sleep(forTimeInterval: 0.2) + XCTAssertTrue(sentCreateUsers().isEmpty) + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.isEmpty, true) + } +} From 5498904d8fb10f8bcc2d44987dad54fd7a960dcd Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 6 Oct 2026 12:44:24 -0700 Subject: [PATCH 11/11] chore: [SDK-5314] recover only the user start() loaded from the cache The recovery read the live user on the executor queue. Path 2 and 3 of start() set a new user before their own Request is appended, so a block that ran in between queued a second Create User on a fresh start, or one ahead of a legacy player's Fetch Identity By Subscription. The user is now captured in init, where it can only be the one Path 1 loaded, and the queue check still runs on the executor queue after the transfer migration so a migrated Create User is seen. Two tests pin the fresh-start and legacy-player paths. --- .../Source/Executors/OSUserExecutor.swift | 15 +++++--- .../CachedUserWithoutAnIdTests.swift | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 3f5850b5d9..da751dd62d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -50,7 +50,7 @@ class OSUserExecutor { self.auth = auth uncacheUserRequests() migrateTransferSubscriptionRequests() - createUserIfNeverCreated() + createUserIfNeverCreated(OneSignalUserManagerImpl.sharedInstance._user) identityVerificationService.addOnJwtConfigHydratedHandler(for: .userExecutor) { [weak self] _ in // Including an unchanged value: Requests held while `requirement` was unknown wait on this. @@ -247,12 +247,15 @@ class OSUserExecutor { /// A cached user with no `onesignal_id` never reached the server, and only a Create User gets it there. /// Queue one unless a queued Request already exists. Skip an anonymous user under Identity - /// Verification, where `reshapeInvalidRequests` would drop the Create User anyway. Runs on the executor - /// queue so it sees what `migrateTransferSubscriptionRequests` appended. - private func createUserIfNeverCreated() { + /// Verification, where `reshapeInvalidRequests` would drop the Create User anyway. `cachedUser` is the + /// user `start()` loaded from the cache, read in `init` before Path 2 or 3 can replace it. The check runs + /// on the executor queue so it sees what `migrateTransferSubscriptionRequests` appended. + private func createUserIfNeverCreated(_ cachedUser: OSUserInternal?) { + guard let user = cachedUser else { + return + } dispatchQueue.async { - guard let user = OneSignalUserManagerImpl.sharedInstance._user, - user.identityModel.onesignalId == nil, + guard user.identityModel.onesignalId == nil, user.identityModel.externalId != nil || !self.identityVerificationService.ivBehaviorActive, !self.queueSuppliesAnId(for: user.identityModel.modelId) else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift index 77f8021f45..c64e2aa6a5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift @@ -139,6 +139,44 @@ final class CachedUserWithoutAnIdTests: XCTestCase { XCTAssertEqual(sentCreateUsers().map(externalId(of:)), [userA_EUID]) } + /// Path 3 creates the user after the executor exists. The recovery must not see that user. + func testFreshStartSendsOneCreateUser() { + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The anonymous user was not created") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + OneSignalCoreMocks.waitUntil("A Create User is still queued") { + OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.contains { $0 is OSRequestCreateUser } == false + } + XCTAssertEqual(sentCreateUsers().count, 1) + } + + /// Path 2 creates the user after the executor exists too, and a Create User ahead of its Fetch Identity + /// By Subscription would make a new user instead of adopting the 3.x player. + func testLegacyPlayerStartQueuesNoCreateUser() { + let legacyPlayerId = "legacy_player_id" + let legacyOnesignalId = "legacy_player_onesignal_id" + OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: legacyPlayerId) + client.setMockResponseForRequest( + request: "OSRequestFetchIdentityBySubscription with subscriptionId: \(legacyPlayerId)", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: nil) + ) + client.setMockResponseForRequest( + request: "", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The legacy player's identity was not fetched") { + self.client.hasCompletedRequestOfType(OSRequestFetchIdentityBySubscription.self) + } + XCTAssertTrue(sentCreateUsers().isEmpty) + XCTAssertFalse(OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.contains { $0 is OSRequestCreateUser } ?? true) + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance._user?.identityModel.onesignalId, legacyOnesignalId) + } + func testStartLeavesACachedAnonymousUserAloneUnderIdentityVerification() { OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) cacheUser(externalId: nil)