Skip to content

Commit 350ae9d

Browse files
nan-licursoragent
andcommitted
fix: [SDK-4814] persist Live Activity receive-receipt dedup across launches
The in-memory dedup set did not survive app relaunches, so when ActivityKit re-emits an active activity's content on launch the same notificationId was reported again. Keep sent receipts in the persisted cache as dedup markers (shouldForgetWhenSuccessful = false); supersedes == false drops the re-send and pollPendingRequests skips successful entries, making the cache the single source of truth. Remove the redundant in-memory set and shorten the receive- receipts cache TTL from 7 to 3 days. Adds a relaunch regression test. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b36822e commit 350ae9d

3 files changed

Lines changed: 25 additions & 43 deletions

File tree

iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,11 @@ class StartRequestCache: RequestCache {
115115
}
116116

117117
class ReceiveReceiptsRequestCache: RequestCache {
118-
// Only pending receipts live here; de-duplication is handled by the executor's confirmedReceiptIds.
119-
static let OneWeekInSeconds = TimeInterval(60 * 60 * 24 * 7)
118+
// Sent receipts stay as dedup markers (not only pending retries), so re-emits after relaunch aren't re-sent.
119+
static let ThreeDaysInSeconds = TimeInterval(60 * 60 * 24 * 3)
120120

121121
init() {
122-
super.init(cacheKey: OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY, ttl: ReceiveReceiptsRequestCache.OneWeekInSeconds)
122+
super.init(cacheKey: OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY, ttl: ReceiveReceiptsRequestCache.ThreeDaysInSeconds)
123123
}
124124
}
125125

@@ -138,8 +138,6 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver {
138138
let startTokens: StartRequestCache = StartRequestCache()
139139
let receiveReceipts: ReceiveReceiptsRequestCache = ReceiveReceiptsRequestCache()
140140
let clickEvents: ClickedRequestCache = ClickedRequestCache()
141-
// Dedupes receipts per session so duplicate content-update listeners can't over-report.
142-
var confirmedReceiptIds: Set<String> = []
143141

144142
// The live activities request dispatch queue, serial. This synchronizes access to `updateTokens` and `startTokens`.
145143
private var requestDispatch: OSDispatchQueue
@@ -174,10 +172,6 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver {
174172

175173
func append(_ request: OSLiveActivityRequest) {
176174
self.requestDispatch.async {
177-
if request is OSRequestLiveActivityReceiveReceipts, !self.claimReceipt(request.key) {
178-
return
179-
}
180-
181175
let cache = self.getCache(request)
182176
let existingRequest = cache.items[request.key]
183177

@@ -190,16 +184,6 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver {
190184
}
191185
}
192186

193-
// False if this notificationId was already claimed this session. Call on requestDispatch.
194-
private func claimReceipt(_ notificationId: String) -> Bool {
195-
if confirmedReceiptIds.contains(notificationId) {
196-
OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.LiveActivities duplicate receive receipt not sent for notificationId: \(notificationId)")
197-
return false
198-
}
199-
confirmedReceiptIds.insert(notificationId)
200-
return true
201-
}
202-
203187
private func pollPendingRequests() {
204188
OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.LiveActivities pollPendingRequests")
205189

iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ class OSRequestLiveActivityReceiveReceipts: OneSignalRequest, OSLiveActivityRequ
3636
var activityType: String
3737
var activityId: String
3838
var requestSuccessful: Bool
39-
// Forgotten once sent; duplicate suppression is handled by the executor's confirmedReceiptIds.
40-
var shouldForgetWhenSuccessful: Bool = true
39+
// Kept after success so the persisted cache suppresses re-sends across relaunches.
40+
var shouldForgetWhenSuccessful: Bool = false
4141

4242
func prepareForExecution() -> Bool {
4343
guard let appId = OneSignalIdentifiers.currentAppId else {

iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,9 @@ final class OSLiveActivitiesExecutorTests: XCTestCase {
173173
mockDispatchQueue.waitForDispatches(2)
174174

175175
/* Then */
176-
// Forgotten from the cache, but its notificationId is remembered so it isn't reported again.
177-
XCTAssertEqual(executor.receiveReceipts.items.count, 0)
178-
XCTAssertTrue(executor.confirmedReceiptIds.contains("notification-id"))
176+
// The sent receipt is kept as a dedup marker so the same notificationId isn't reported again.
177+
XCTAssertEqual(executor.receiveReceipts.items.count, 1)
178+
XCTAssertTrue(executor.receiveReceipts.items["notification-id"]?.requestSuccessful ?? false)
179179
XCTAssertEqual(mockClient.executedRequests.count, 1)
180180
XCTAssertTrue(mockClient.executedRequests[0] == request)
181181
}
@@ -517,21 +517,19 @@ final class OSLiveActivitiesExecutorTests: XCTestCase {
517517
mockDispatchQueue.waitForDispatches(3)
518518

519519
/* Then */
520-
// request2 is suppressed as a duplicate of request1; the sent receipt is then forgotten.
521-
XCTAssertEqual(executor.receiveReceipts.items.count, 0)
520+
// request2 is suppressed as a duplicate of request1, which is kept as a dedup marker.
521+
XCTAssertEqual(executor.receiveReceipts.items.count, 1)
522522
XCTAssertEqual(mockClient.executedRequests.count, 1)
523523
XCTAssertTrue(mockClient.executedRequests[0] == request1)
524524
}
525525

526526
/**
527-
A receive-receipt for a notificationId must be sent at most once per device. This covers the
528-
cross-cycle case: unlike `testReceiveReceiptsRequestNotExecutedWithSameNotificationId` (both receipts
529-
appended in one cycle), here the first fully completes and is forgotten before the second arrives, so
530-
the duplicate is caught by the remembered notificationIds rather than by the request cache.
527+
A receive receipt must be sent at most once per device, even across app launches. The second executor
528+
reloads the persisted cache with in-memory state gone, simulating a relaunch where ActivityKit re-emits
529+
the active activity's current content and the same notificationId is reported again.
531530
*/
532-
func testReceiveReceiptsNotResentForSameNotificationIdAcrossDeliveryCycles() throws {
531+
func testReceiveReceiptsNotResentForSameNotificationIdAfterRelaunch() throws {
533532
/* Setup */
534-
let mockDispatchQueue = MockDispatchQueue()
535533
let mockClient = MockOneSignalClient()
536534
OneSignalCoreImpl.setSharedClient(mockClient)
537535
OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id")
@@ -546,21 +544,21 @@ final class OSLiveActivitiesExecutorTests: XCTestCase {
546544
mockClient.setMockResponseForRequest(request: String(describing: request2), response: [String: Any]())
547545

548546
/* When */
549-
let executor = OSLiveActivitiesExecutor(requestDispatch: mockDispatchQueue)
550-
551-
// First cycle: receipt is sent, succeeds, is forgotten, and its notificationId is remembered.
552-
executor.append(request1)
553-
mockDispatchQueue.waitForDispatches(2)
547+
// First launch: the receipt is sent and succeeds.
548+
let firstLaunch = MockDispatchQueue()
549+
let executor1 = OSLiveActivitiesExecutor(requestDispatch: firstLaunch)
550+
executor1.append(request1)
551+
firstLaunch.waitForDispatches(2)
554552
XCTAssertEqual(mockClient.executedRequests.count, 1)
555-
XCTAssertEqual(executor.receiveReceipts.items.count, 0)
556-
XCTAssertTrue(executor.confirmedReceiptIds.contains("my-notification-id"))
557553

558-
// Second cycle, same notificationId (e.g. another content update / relaunch): a duplicate.
559-
executor.append(request2)
560-
mockDispatchQueue.waitForDispatches(3)
554+
// Relaunch: a fresh executor reloads the persisted cache; ActivityKit re-emits the same content.
555+
let secondLaunch = MockDispatchQueue()
556+
let executor2 = OSLiveActivitiesExecutor(requestDispatch: secondLaunch)
557+
executor2.append(request2)
558+
secondLaunch.waitForDispatches(1)
561559

562560
/* Then */
563-
XCTAssertEqual(mockClient.executedRequests.count, 1, "Duplicate receive-receipt must not be sent for the same notificationId across delivery cycles")
561+
XCTAssertEqual(mockClient.executedRequests.count, 1, "Receive receipt must not be re-sent for the same notificationId after relaunch")
564562
XCTAssertTrue(mockClient.executedRequests[0] == request1)
565563
}
566564
}

0 commit comments

Comments
 (0)