diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift index 23c7b3082..93b768240 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift @@ -115,11 +115,11 @@ class StartRequestCache: RequestCache { } class ReceiveReceiptsRequestCache: RequestCache { - // Keep receive receipts requests for up to 30 days. - static let OneMonthInSeconds = TimeInterval(60 * 60 * 24 * 30) + // Sent receipts stay as dedup markers (not only pending retries), so re-emits after relaunch aren't re-sent. + static let ThreeDaysInSeconds = TimeInterval(60 * 60 * 24 * 3) init() { - super.init(cacheKey: OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY, ttl: ReceiveReceiptsRequestCache.OneMonthInSeconds) + super.init(cacheKey: OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY, ttl: ReceiveReceiptsRequestCache.ThreeDaysInSeconds) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift index 64ee3d0d1..cdcf6fc8c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift @@ -334,7 +334,7 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { Task { for await content in activity.contentUpdates { // Don't track a live activity started / updated "in app" without a notification - if let notificationId = activity.content.state.onesignal?.notificationId { + if let notificationId = content.state.onesignal?.notificationId { OneSignalLiveActivitiesManagerImpl.addReceiveReceipts(notificationId: notificationId, activityType: "\(activityType)", activityId: activity.attributes.onesignal.activityId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift index f5ef0561b..f1058b995 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift @@ -36,7 +36,8 @@ class OSRequestLiveActivityReceiveReceipts: OneSignalRequest, OSLiveActivityRequ var activityType: String var activityId: String var requestSuccessful: Bool - var shouldForgetWhenSuccessful: Bool = true + // Kept after success so the persisted cache suppresses re-sends across relaunches. + var shouldForgetWhenSuccessful: Bool = false func prepareForExecution() -> Bool { guard let appId = OneSignalIdentifiers.currentAppId else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift index 6feb7c122..c98b878ea 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift @@ -49,9 +49,8 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { override func tearDownWithError() throws { } - func testAppendSetStartTokenWithSuccessfulRequest() throws { - /* Setup */ - let mockDispatchQueue = MockDispatchQueue() + // Subscribes a user, then resets the client so tests assert only on the requests they make. + private func setUpSubscribedUser() -> MockOneSignalClient { let mockClient = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(mockClient) OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") @@ -59,6 +58,13 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { // Wait for any user setup requests to complete OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) mockClient.reset() + return mockClient + } + + func testAppendSetStartTokenWithSuccessfulRequest() throws { + /* Setup */ + let mockDispatchQueue = MockDispatchQueue() + let mockClient = setUpSubscribedUser() let request = OSRequestSetStartToken(key: "my-activity-type", token: "my-token") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -79,13 +85,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testRemoveStartTokenWithSuccessfulRequest() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestRemoveStartToken(key: "my-activity-type") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -104,13 +104,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetUpdateTokenWithSuccessfulRequest() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -131,13 +125,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testRemoveUpdateTokenWithSuccessfulRequest() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestRemoveUpdateToken(key: "my-activity-id") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -156,13 +144,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testReceiveReceiptsWithSuccessfulRequest() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestLiveActivityReceiveReceipts(key: "notification-id", activityType: "my-activity-type", activityId: "my-activity-id") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -173,7 +155,9 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { mockDispatchQueue.waitForDispatches(2) /* Then */ - XCTAssertEqual(executor.receiveReceipts.items.count, 0) + // The sent receipt is kept as a dedup marker so the same notificationId isn't reported again. + XCTAssertEqual(executor.receiveReceipts.items.count, 1) + XCTAssertTrue(executor.receiveReceipts.items["notification-id"]?.requestSuccessful ?? false) XCTAssertEqual(mockClient.executedRequests.count, 1) XCTAssertTrue(mockClient.executedRequests[0] == request) } @@ -181,13 +165,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testClickEventWithSuccessfulRequest() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestLiveActivityClicked(key: "unique-click-id", activityType: "my-activity-type", activityId: "my-activity-id", notificationId: "my-notif-id") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -225,13 +203,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testRequestStaysInCacheForRetryableError() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestSetStartToken(key: "my-activity-type", token: "my-token") mockClient.setMockFailureResponseForRequest(request: String(describing: request), error: OneSignalClientError(code: 500, message: "not-important", responseHeaders: nil, response: nil, underlyingError: nil)) @@ -252,13 +224,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testRequestRemovedFromCacheForNonRetryableError() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestSetStartToken(key: "my-activity-type", token: "my-token") mockClient.setMockFailureResponseForRequest(request: String(describing: request), error: OneSignalClientError(code: 401, message: "not-important", responseHeaders: nil, response: nil, underlyingError: nil)) @@ -319,13 +285,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetStartRequestNotExecutedWithSameActivityTypeAndToken() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetStartToken(key: "my-activity-type", token: "my-token") let request2 = OSRequestSetStartToken(key: "my-activity-type", token: "my-token") @@ -348,13 +308,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetStartRequestNotExecutedWithSameActivityTypeAndDiffToken() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetStartToken(key: "my-activity-type", token: "my-token-1") let request2 = OSRequestSetStartToken(key: "my-activity-type", token: "my-token-2") @@ -378,13 +332,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetStartRequestFollowedByRemoveStartIsSuccessful() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetStartToken(key: "my-activity-type", token: "my-token-1") let request2 = OSRequestRemoveStartToken(key: "my-activity-type") @@ -407,13 +355,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetUpdateRequestNotExecutedWithSameActivityIdAndToken() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token") let request2 = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token") @@ -436,13 +378,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetUpdateRequestNotExecutedWithSameActivityIdAndDiffToken() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token-1") let request2 = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token-2") @@ -466,13 +402,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetUpdateRequestFollowedByRemoveUpdateIsSuccessful() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token-1") let request2 = OSRequestRemoveUpdateToken(key: "my-activity-id") @@ -495,13 +425,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testReceiveReceiptsRequestNotExecutedWithSameNotificationId() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type-1", activityId: "my-activity-id-1") let request2 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type-2", activityId: "my-activity-id-2") @@ -515,8 +439,42 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { mockDispatchQueue.waitForDispatches(3) /* Then */ - XCTAssertEqual(executor.receiveReceipts.items.count, 0) + // request2 is suppressed as a duplicate of request1, which is kept as a dedup marker. + XCTAssertEqual(executor.receiveReceipts.items.count, 1) + XCTAssertEqual(mockClient.executedRequests.count, 1) + XCTAssertTrue(mockClient.executedRequests[0] == request1) + } + + /** + A receive receipt must be sent at most once per device, even across app launches. The second executor + reloads the persisted cache with in-memory state gone, simulating a relaunch where ActivityKit re-emits + the active activity's current content and the same notificationId is reported again. + */ + func testReceiveReceiptsNotResentForSameNotificationIdAfterRelaunch() throws { + /* Setup */ + let mockClient = setUpSubscribedUser() + + let request1 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id") + let request2 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id") + mockClient.setMockResponseForRequest(request: String(describing: request1), response: [String: Any]()) + mockClient.setMockResponseForRequest(request: String(describing: request2), response: [String: Any]()) + + /* When */ + // First launch: the receipt is sent and succeeds. + let firstLaunch = MockDispatchQueue() + let executor1 = OSLiveActivitiesExecutor(requestDispatch: firstLaunch) + executor1.append(request1) + firstLaunch.waitForDispatches(2) XCTAssertEqual(mockClient.executedRequests.count, 1) + + // Relaunch: a fresh executor reloads the persisted cache; ActivityKit re-emits the same content. + let secondLaunch = MockDispatchQueue() + let executor2 = OSLiveActivitiesExecutor(requestDispatch: secondLaunch) + executor2.append(request2) + secondLaunch.waitForDispatches(1) + + /* Then */ + XCTAssertEqual(mockClient.executedRequests.count, 1, "Receive receipt must not be re-sent for the same notificationId after relaunch") XCTAssertTrue(mockClient.executedRequests[0] == request1) } }