From a765274c45e76d4277a00189fd5ba6b8a99680ac Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 21 Oct 2026 10:18:08 -0700 Subject: [PATCH] fix: [SDK-5315] send one confirmed delivery per notification ID The notification service extension can run more than once for one notification, and each run sent a confirmed delivery. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Source/OneSignalCommonDefines.h | 2 + ...ignalNotificationServiceExtensionHandler.m | 42 ++++ ...NotificationServiceExtensionHandlerTests.m | 228 ++++++++++++++++++ 4 files changed, 276 insertions(+) create mode 100644 iOS_SDK/OneSignalSDK/UnitTests/OneSignalNotificationServiceExtensionHandlerTests.m diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 9fc71c326..69e3d41a7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -315,6 +315,7 @@ 4746E2A72B86B64100D6324C /* LiveActivitiesSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4746E2A62B86B64100D6324C /* LiveActivitiesSwiftTests.swift */; }; 4746E2AB2B8775C400D6324C /* LiveActivitiesObjcTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4746E2AA2B8775C400D6324C /* LiveActivitiesObjcTests.m */; }; 53075307A11C000000000A02 /* OneSignalAttachmentHandlerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 53075307A11C000000000A01 /* OneSignalAttachmentHandlerTests.m */; }; + 5315D0D0E5E0000000000002 /* OneSignalNotificationServiceExtensionHandlerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 5315D0D0E5E0000000000001 /* OneSignalNotificationServiceExtensionHandlerTests.m */; }; 475F47212B8E398E00EC05B3 /* OneSignalLiveActivities.h in Headers */ = {isa = PBXBuildFile; fileRef = 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */; settings = {ATTRIBUTES = (Public, ); }; }; 475F47242B8E398E00EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; }; 475F47252B8E398E00EC05B3 /* OneSignalLiveActivities.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -1607,6 +1608,7 @@ 4746E2A62B86B64100D6324C /* LiveActivitiesSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitiesSwiftTests.swift; sourceTree = ""; }; 4746E2AA2B8775C400D6324C /* LiveActivitiesObjcTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LiveActivitiesObjcTests.m; sourceTree = ""; }; 53075307A11C000000000A01 /* OneSignalAttachmentHandlerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalAttachmentHandlerTests.m; sourceTree = ""; }; + 5315D0D0E5E0000000000001 /* OneSignalNotificationServiceExtensionHandlerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalNotificationServiceExtensionHandlerTests.m; sourceTree = ""; }; 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalLiveActivities.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -2879,6 +2881,7 @@ 4746E2A62B86B64100D6324C /* LiveActivitiesSwiftTests.swift */, 4746E2AA2B8775C400D6324C /* LiveActivitiesObjcTests.m */, 53075307A11C000000000A01 /* OneSignalAttachmentHandlerTests.m */, + 5315D0D0E5E0000000000001 /* OneSignalNotificationServiceExtensionHandlerTests.m */, 3C4319082F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift */, 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */, ); @@ -4965,6 +4968,7 @@ DE7D18E12703B49B002D3A5D /* OSFocusRequests.m in Sources */, 4746E2AB2B8775C400D6324C /* LiveActivitiesObjcTests.m in Sources */, 53075307A11C000000000A02 /* OneSignalAttachmentHandlerTests.m in Sources */, + 5315D0D0E5E0000000000002 /* OneSignalNotificationServiceExtensionHandlerTests.m in Sources */, 03CCCC832835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m in Sources */, 03866CC12378A67B0009C1D8 /* RestClientAsserts.m in Sources */, 7ADF891C230DB5BD0054E0D6 /* UnitTestAppDelegate.m in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 2e9c9b2b4..54c18f0e6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -73,6 +73,7 @@ // Notification #define OSUD_LAST_MESSAGE_OPENED @"GT_LAST_MESSAGE_OPENED_" // * OSUD_MOST_RECENT_NOTIFICATION_OPENED #define OSUD_TEMP_CACHED_NOTIFICATION_MEDIA @"OSUD_TEMP_CACHED_NOTIFICATION_MEDIA" // OSUD_TEMP_CACHED_NOTIFICATION_MEDIA +#define OSUD_NSE_PROCESSED_NOTIFICATION_IDS @"OSUD_NSE_PROCESSED_NOTIFICATION_IDS" // Remote Params #define OSUD_LOCATION_ENABLED @"OSUD_LOCATION_ENABLED" #define OSUD_REQUIRES_USER_PRIVACY_CONSENT @"OSUD_REQUIRES_USER_PRIVACY_CONSENT" @@ -251,6 +252,7 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define DEVICE_TYPE_SMS 14 #define MAX_NSE_LIFETIME_SECOUNDS 30 +#define MAX_NSE_PROCESSED_NOTIFICATION_IDS 50 #ifndef OS_TEST // OneSignal API Client Defines diff --git a/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalNotificationServiceExtensionHandler.m b/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalNotificationServiceExtensionHandler.m index 381545340..aec33e633 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalNotificationServiceExtensionHandler.m +++ b/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalNotificationServiceExtensionHandler.m @@ -136,6 +136,15 @@ + (void)onNotificationReceived:(NSString *)receivedNotificationId withBlockingTa // If update was made without app being initialized/launched before -> migrate [OneSignalCoreImpl migrate]; [OSOutcomes migrate]; + + if (![self addProcessedNotificationId:receivedNotificationId]) { + [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:[NSString stringWithFormat:@"NSE request for already processed notificationId: %@, not sending another confirmed delivery", receivedNotificationId]]; + if (semaphore) { + dispatch_semaphore_signal(semaphore); + } + return; + } + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"NSE request received, sessionManager: %@", [OSSessionManager sharedSessionManager]]]; // Save received notification id [[OSSessionManager sharedSessionManager] onNotificationReceived:receivedNotificationId]; @@ -161,4 +170,37 @@ + (void)onNotificationReceived:(NSString *)receivedNotificationId withBlockingTa } } +/* + Returns NO if the extension already processed this notification ID. + The extension can run more than once for one notification. + The in-process list covers repeat runs before first unlock, when shared UserDefaults reads return nothing. + */ ++ (BOOL)addProcessedNotificationId:(NSString *)notificationId { + static NSMutableArray *processedInThisProcess; + static dispatch_once_t once; + dispatch_once(&once, ^{ + processedInThisProcess = [NSMutableArray new]; + }); + + @synchronized (processedInThisProcess) { + let sharedUserDefaults = OneSignalUserDefaults.initShared; + NSMutableArray *savedIds = [[sharedUserDefaults getSavedObjectForKey:OSUD_NSE_PROCESSED_NOTIFICATION_IDS defaultValue:[NSArray new]] mutableCopy]; + if ([processedInThisProcess containsObject:notificationId] || [savedIds containsObject:notificationId]) { + return NO; + } + + [self appendNotificationId:notificationId toProcessedIds:processedInThisProcess]; + [self appendNotificationId:notificationId toProcessedIds:savedIds]; + [sharedUserDefaults saveObjectForKey:OSUD_NSE_PROCESSED_NOTIFICATION_IDS withValue:savedIds]; + return YES; + } +} + ++ (void)appendNotificationId:(NSString *)notificationId toProcessedIds:(NSMutableArray *)processedIds { + [processedIds addObject:notificationId]; + if (processedIds.count > MAX_NSE_PROCESSED_NOTIFICATION_IDS) { + [processedIds removeObjectsInRange:NSMakeRange(0, processedIds.count - MAX_NSE_PROCESSED_NOTIFICATION_IDS)]; + } +} + @end diff --git a/iOS_SDK/OneSignalSDK/UnitTests/OneSignalNotificationServiceExtensionHandlerTests.m b/iOS_SDK/OneSignalSDK/UnitTests/OneSignalNotificationServiceExtensionHandlerTests.m new file mode 100644 index 000000000..c4a2b3d57 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/UnitTests/OneSignalNotificationServiceExtensionHandlerTests.m @@ -0,0 +1,228 @@ +/* + 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 +#import +#import +#import + +@interface OSReceiveReceiptRecordingClient : NSObject +- (NSUInteger)receiveReceiptCountForNotificationId:(NSString *)notificationId; +@end + +@implementation OSReceiveReceiptRecordingClient { + NSMutableArray *_requestPaths; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _requestPaths = [NSMutableArray new]; + } + return self; +} + +- (void)executeRequest:(OneSignalRequest *)request onSuccess:(OSResultSuccessBlock)successBlock onFailure:(OSClientFailureBlock)failureBlock { + @synchronized (self) { + [_requestPaths addObject:request.path]; + } + successBlock(@{}); +} + +- (NSUInteger)receiveReceiptCountForNotificationId:(NSString *)notificationId { + NSString *receiptPath = [NSString stringWithFormat:@"notifications/%@/report_received", notificationId]; + @synchronized (self) { + return [_requestPaths indexesOfObjectsPassingTest:^BOOL(NSString *path, NSUInteger idx, BOOL *stop) { + return [path isEqualToString:receiptPath]; + }].count; + } +} + +@end + +@interface OneSignalNotificationServiceExtensionHandlerTests : XCTestCase +@end + +@implementation OneSignalNotificationServiceExtensionHandlerTests { + id _originalClient; + OSReceiveReceiptRecordingClient *_client; + NSDictionary *_originalSharedValues; +} + ++ (NSArray *)sharedKeysUnderTest { + return @[OSUD_APP_ID, + OSUD_PUSH_SUBSCRIPTION_ID, + OSUD_RECEIVE_RECEIPTS_ENABLED, + OSUD_NSE_PROCESSED_NOTIFICATION_IDS, + OSUD_CACHED_RECEIVED_NOTIFICATION_IDS]; +} + +- (void)setUp { + [super setUp]; + + OneSignalUserDefaults *sharedUserDefaults = OneSignalUserDefaults.initShared; + NSMutableDictionary *originalValues = [NSMutableDictionary new]; + for (NSString *key in OneSignalNotificationServiceExtensionHandlerTests.sharedKeysUnderTest) { + originalValues[key] = [sharedUserDefaults getSavedObjectForKey:key defaultValue:nil]; + [sharedUserDefaults removeValueForKey:key]; + } + _originalSharedValues = originalValues; + + [sharedUserDefaults saveStringForKey:OSUD_APP_ID withValue:@"test-app-id"]; + [sharedUserDefaults saveStringForKey:OSUD_PUSH_SUBSCRIPTION_ID withValue:@"test-subscription-id"]; + [sharedUserDefaults saveBoolForKey:OSUD_RECEIVE_RECEIPTS_ENABLED withValue:YES]; + + _originalClient = OneSignalCoreImpl.sharedClient; + _client = [OSReceiveReceiptRecordingClient new]; + [OneSignalCoreImpl setSharedClient:_client]; +} + +- (void)tearDown { + [OneSignalCoreImpl setSharedClient:_originalClient]; + + OneSignalUserDefaults *sharedUserDefaults = OneSignalUserDefaults.initShared; + for (NSString *key in OneSignalNotificationServiceExtensionHandlerTests.sharedKeysUnderTest) { + id originalValue = _originalSharedValues[key]; + if (originalValue) { + [sharedUserDefaults saveObjectForKey:key withValue:originalValue]; + } else { + [sharedUserDefaults removeValueForKey:key]; + } + } + + [super tearDown]; +} + +- (UNNotificationRequest *)requestForNotificationId:(NSString *)notificationId { + UNMutableNotificationContent *content = [UNMutableNotificationContent new]; + content.body = @"body"; + content.userInfo = @{ + @"aps": @{@"alert": @"body", @"mutable-content": @1}, + @"custom": @{@"i": notificationId} + }; + return [UNNotificationRequest requestWithIdentifier:NSUUID.UUID.UUIDString content:content trigger:nil]; +} + +/* + Runs off the main thread because the extension run waits for its confirmed delivery, which the SDK sends on the main queue. + The timeout is shorter than MAX_NSE_LIFETIME_SECOUNDS, so a run that never signals its semaphore fails the test. + */ +- (void)receiveNotificationWithId:(NSString *)notificationId { + UNNotificationRequest *request = [self requestForNotificationId:notificationId]; + XCTestExpectation *contentHandled = [self expectationWithDescription:@"content handler called"]; + XCTestExpectation *runReturned = [self expectationWithDescription:@"extension run returned"]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [OneSignalNotificationServiceExtensionHandler didReceiveNotificationExtensionRequest:request + withMutableNotificationContent:nil + withContentHandler:^(UNNotificationContent *content) { + [contentHandled fulfill]; + }]; + [runReturned fulfill]; + }); + [self waitForExpectations:@[contentHandled, runReturned] timeout:5]; +} + +- (void)testSendsOneReceiveReceiptWhenTheExtensionRunsTwiceForOneNotification { + NSString *notificationId = NSUUID.UUID.UUIDString; + + [self receiveNotificationWithId:notificationId]; + [self receiveNotificationWithId:notificationId]; + + XCTAssertEqual([_client receiveReceiptCountForNotificationId:notificationId], 1); +} + +- (void)testSendsReceiveReceiptForEachNotification { + NSString *firstNotificationId = NSUUID.UUID.UUIDString; + NSString *secondNotificationId = NSUUID.UUID.UUIDString; + + [self receiveNotificationWithId:firstNotificationId]; + [self receiveNotificationWithId:secondNotificationId]; + + XCTAssertEqual([_client receiveReceiptCountForNotificationId:firstNotificationId], 1); + XCTAssertEqual([_client receiveReceiptCountForNotificationId:secondNotificationId], 1); +} + +- (void)testSendsOneReceiveReceiptWhenTheExtensionRunsConcurrentlyForOneNotification { + // Run once first so the first-run migrations do not run concurrently. + [self receiveNotificationWithId:NSUUID.UUID.UUIDString]; + + NSString *notificationId = NSUUID.UUID.UUIDString; + UNNotificationRequest *request = [self requestForNotificationId:notificationId]; + int runCount = 10; + XCTestExpectation *runsReturned = [self expectationWithDescription:@"extension runs returned"]; + runsReturned.expectedFulfillmentCount = runCount; + + for (int i = 0; i < runCount; i++) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [OneSignalNotificationServiceExtensionHandler didReceiveNotificationExtensionRequest:request + withMutableNotificationContent:nil + withContentHandler:^(UNNotificationContent *content) {}]; + [runsReturned fulfill]; + }); + } + [self waitForExpectations:@[runsReturned] timeout:5]; + + XCTAssertEqual([_client receiveReceiptCountForNotificationId:notificationId], 1); +} + +- (void)testSkipsReceiveReceiptForNotificationProcessedByAnEarlierExtensionProcess { + NSString *notificationId = NSUUID.UUID.UUIDString; + [OneSignalUserDefaults.initShared saveObjectForKey:OSUD_NSE_PROCESSED_NOTIFICATION_IDS withValue:@[notificationId]]; + + [self receiveNotificationWithId:notificationId]; + + XCTAssertEqual([_client receiveReceiptCountForNotificationId:notificationId], 0); +} + +- (void)testSkipsReceiveReceiptWhenSharedUserDefaultsLosesAnIdThisProcessHandled { + NSString *notificationId = NSUUID.UUID.UUIDString; + [self receiveNotificationWithId:notificationId]; + [OneSignalUserDefaults.initShared removeValueForKey:OSUD_NSE_PROCESSED_NOTIFICATION_IDS]; + + [self receiveNotificationWithId:notificationId]; + + XCTAssertEqual([_client receiveReceiptCountForNotificationId:notificationId], 1); +} + +- (void)testKeepsOnlyTheMostRecentProcessedNotificationIds { + NSMutableArray *notificationIds = [NSMutableArray new]; + for (int i = 0; i <= MAX_NSE_PROCESSED_NOTIFICATION_IDS; i++) { + NSString *notificationId = NSUUID.UUID.UUIDString; + [notificationIds addObject:notificationId]; + [self receiveNotificationWithId:notificationId]; + } + + NSArray *savedIds = [OneSignalUserDefaults.initShared getSavedObjectForKey:OSUD_NSE_PROCESSED_NOTIFICATION_IDS defaultValue:nil]; + NSArray *expectedIds = [notificationIds subarrayWithRange:NSMakeRange(1, MAX_NSE_PROCESSED_NOTIFICATION_IDS)]; + XCTAssertEqualObjects(savedIds, expectedIds); + + NSString *evictedNotificationId = notificationIds.firstObject; + [self receiveNotificationWithId:evictedNotificationId]; + XCTAssertEqual([_client receiveReceiptCountForNotificationId:evictedNotificationId], 2); +} + +@end