Skip to content

Commit b36822e

Browse files
nan-licursoragent
andcommitted
fix: [SDK-4814] dedup Live Activity receive receipts per session
A receive receipt for a notificationId was re-sent on later content updates or app relaunches once the prior request was forgotten on success, inflating Confirmed Receipts above Delivered. Track sent notificationIds in an in-memory set, claimed at enqueue, so each is reported at most once per session, and shorten the unsent-receipt retry window from 30 to 7 days. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 028fde0 commit b36822e

3 files changed

Lines changed: 64 additions & 3 deletions

File tree

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

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

117117
class ReceiveReceiptsRequestCache: RequestCache {
118-
// Keep receive receipts requests for up to 30 days.
119-
static let OneMonthInSeconds = TimeInterval(60 * 60 * 24 * 30)
118+
// Only pending receipts live here; de-duplication is handled by the executor's confirmedReceiptIds.
119+
static let OneWeekInSeconds = TimeInterval(60 * 60 * 24 * 7)
120120

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

@@ -138,6 +138,8 @@ 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> = []
141143

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

173175
func append(_ request: OSLiveActivityRequest) {
174176
self.requestDispatch.async {
177+
if request is OSRequestLiveActivityReceiveReceipts, !self.claimReceipt(request.key) {
178+
return
179+
}
180+
175181
let cache = self.getCache(request)
176182
let existingRequest = cache.items[request.key]
177183

@@ -184,6 +190,16 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver {
184190
}
185191
}
186192

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+
187203
private func pollPendingRequests() {
188204
OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.LiveActivities pollPendingRequests")
189205

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ 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.
3940
var shouldForgetWhenSuccessful: Bool = true
4041

4142
func prepareForExecution() -> Bool {

iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +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.
176177
XCTAssertEqual(executor.receiveReceipts.items.count, 0)
178+
XCTAssertTrue(executor.confirmedReceiptIds.contains("notification-id"))
177179
XCTAssertEqual(mockClient.executedRequests.count, 1)
178180
XCTAssertTrue(mockClient.executedRequests[0] == request)
179181
}
@@ -515,8 +517,50 @@ final class OSLiveActivitiesExecutorTests: XCTestCase {
515517
mockDispatchQueue.waitForDispatches(3)
516518

517519
/* Then */
520+
// request2 is suppressed as a duplicate of request1; the sent receipt is then forgotten.
518521
XCTAssertEqual(executor.receiveReceipts.items.count, 0)
519522
XCTAssertEqual(mockClient.executedRequests.count, 1)
520523
XCTAssertTrue(mockClient.executedRequests[0] == request1)
521524
}
525+
526+
/**
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.
531+
*/
532+
func testReceiveReceiptsNotResentForSameNotificationIdAcrossDeliveryCycles() throws {
533+
/* Setup */
534+
let mockDispatchQueue = MockDispatchQueue()
535+
let mockClient = MockOneSignalClient()
536+
OneSignalCoreImpl.setSharedClient(mockClient)
537+
OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id")
538+
OneSignalUserManagerImpl.sharedInstance.start()
539+
// Wait for any user setup requests to complete
540+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2)
541+
mockClient.reset()
542+
543+
let request1 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id")
544+
let request2 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id")
545+
mockClient.setMockResponseForRequest(request: String(describing: request1), response: [String: Any]())
546+
mockClient.setMockResponseForRequest(request: String(describing: request2), response: [String: Any]())
547+
548+
/* 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)
554+
XCTAssertEqual(mockClient.executedRequests.count, 1)
555+
XCTAssertEqual(executor.receiveReceipts.items.count, 0)
556+
XCTAssertTrue(executor.confirmedReceiptIds.contains("my-notification-id"))
557+
558+
// Second cycle, same notificationId (e.g. another content update / relaunch): a duplicate.
559+
executor.append(request2)
560+
mockDispatchQueue.waitForDispatches(3)
561+
562+
/* Then */
563+
XCTAssertEqual(mockClient.executedRequests.count, 1, "Duplicate receive-receipt must not be sent for the same notificationId across delivery cycles")
564+
XCTAssertTrue(mockClient.executedRequests[0] == request1)
565+
}
522566
}

0 commit comments

Comments
 (0)