diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index e26c3e398..2b3289e0a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -201,6 +201,7 @@ 3CFA8F572E9087DB00201FE5 /* OSRequestRemoveUpdateToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFA8F452E9087DB00201FE5 /* OSRequestRemoveUpdateToken.swift */; }; 3CFA8F582E9087DB00201FE5 /* OSLiveActivityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFA8F432E9087DB00201FE5 /* OSLiveActivityRequest.swift */; }; 3CFA8F592E9087DB00201FE5 /* OneSignalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFA8F4C2E9087DB00201FE5 /* OneSignalLiveActivityAttributes.swift */; }; + 3CFA8F5B2E9091A200201FE5 /* OSRequestLiveActivityReceiveReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFA8F5A2E9091A200201FE5 /* OSRequestLiveActivityReceiveReceipts.swift */; }; 3E464ED71D88ED1F00DCF7E9 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; }; 3E66F5821D90A2C600E45A01 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E08E2701D49A5C8002176DE /* SystemConfiguration.framework */; }; 4529DED21FA81EA800CEAB1D /* NSObjectOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 4529DED11FA81EA800CEAB1D /* NSObjectOverrider.m */; }; @@ -1350,6 +1351,7 @@ 3CFA8F4B2E9087DB00201FE5 /* OneSignalLiveActivitiesManagerImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalLiveActivitiesManagerImpl.swift; sourceTree = ""; }; 3CFA8F4C2E9087DB00201FE5 /* OneSignalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalLiveActivityAttributes.swift; sourceTree = ""; }; 3CFA8F4D2E9087DB00201FE5 /* OSLiveActivitiesExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivitiesExtension.swift; sourceTree = ""; }; + 3CFA8F5A2E9091A200201FE5 /* OSRequestLiveActivityReceiveReceipts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestLiveActivityReceiveReceipts.swift; sourceTree = ""; }; 3E08E2701D49A5C8002176DE /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; 3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalFramework.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3E24003B1D4FFC31008BDE70 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -2255,6 +2257,7 @@ 3CFA8F442E9087DB00201FE5 /* OSRequestRemoveStartToken.swift */, 3CFA8F472E9087DB00201FE5 /* OSRequestSetUpdateToken.swift */, 3CFA8F452E9087DB00201FE5 /* OSRequestRemoveUpdateToken.swift */, + 3CFA8F5A2E9091A200201FE5 /* OSRequestLiveActivityReceiveReceipts.swift */, ); path = Requests; sourceTree = ""; @@ -4311,6 +4314,7 @@ 3CFA8F572E9087DB00201FE5 /* OSRequestRemoveUpdateToken.swift in Sources */, 3CFA8F582E9087DB00201FE5 /* OSLiveActivityRequest.swift in Sources */, 3CFA8F592E9087DB00201FE5 /* OneSignalLiveActivityAttributes.swift in Sources */, + 3CFA8F5B2E9091A200201FE5 /* OSRequestLiveActivityReceiveReceipts.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index ab169de85..db1841fd9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -364,5 +364,6 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP // 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" +#define OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY" #endif /* OneSignalCommonDefines_h */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift index 47b93ffb0..038a58cd2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift @@ -98,7 +98,7 @@ class RequestCache { class UpdateRequestCache: RequestCache { // An update token should not last longer than 8 hours, we keep for 24 hours to be safe. - static let OneDayInSeconds = TimeInterval(60 * 60 * 24 * 365) + static let OneDayInSeconds = TimeInterval(60 * 60 * 24) init() { super.init(cacheKey: OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY, ttl: UpdateRequestCache.OneDayInSeconds) @@ -114,10 +114,20 @@ class StartRequestCache: RequestCache { } } +class ReceiveReceiptsRequestCache: RequestCache { + // Keep receive receipts requests for up to 30 days. + static let OneMonthInSeconds = TimeInterval(60 * 60 * 24 * 30) + + init() { + super.init(cacheKey: OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY, ttl: ReceiveReceiptsRequestCache.OneMonthInSeconds) + } +} + class OSLiveActivitiesExecutor: OSPushSubscriptionObserver { // The currently tracked update and start tokens (key) and their associated request (value). THESE ARE NOT THREAD SAFE let updateTokens: UpdateRequestCache = UpdateRequestCache() let startTokens: StartRequestCache = StartRequestCache() + let receiveReceipts: ReceiveReceiptsRequestCache = ReceiveReceiptsRequestCache() // The live activities request dispatch queue, serial. This synchronizes access to `updateTokens` and `startTokens`. private var requestDispatch: OSDispatchQueue @@ -182,14 +192,17 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver { private func caches(_ block: (RequestCache) -> Void) { block(self.startTokens) block(self.updateTokens) + block(self.receiveReceipts) } private func getCache(_ request: OSLiveActivityRequest) -> RequestCache { if request is OSLiveActivityUpdateTokenRequest { return self.updateTokens + } else if request is OSLiveActivityStartTokenRequest { + return self.startTokens } - return self.startTokens + return self.receiveReceipts } private func executeRequest(_ cache: RequestCache, request: OSLiveActivityRequest) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift index 95c381bc1..bc59449fa 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift @@ -202,6 +202,9 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { for activity in Activity.activities { listenForActivityStateUpdates(activityType, activity: activity, options: options) listenForActivityPushToUpdate(activityType, activity: activity, options: options) + if #available(iOS 16.2, *) { + listenForContentUpdates(activityType, activity: activity) + } } // Establish listeners for activity updates @@ -221,6 +224,9 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { listenForActivityStateUpdates(activityType, activity: activity, options: options) listenForActivityPushToUpdate(activityType, activity: activity, options: options) + if #available(iOS 16.2, *) { + listenForContentUpdates(activityType, activity: activity) + } } } } @@ -272,5 +278,23 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { } } } + + @available(iOS 16.2, *) + private static func listenForContentUpdates(_ activityType: Attributes.Type, activity: Activity) { + 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 { + OneSignalLiveActivitiesManagerImpl.addReceiveReceipts(notificationId: notificationId, activityType: "\(activityType)", activityId: activity.attributes.onesignal.activityId) + } + } + } + } + + private static func addReceiveReceipts(notificationId: String, activityType: String, activityId: String) { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.LiveActivities addReceiveReceipts called with notificationId: \(notificationId), activityType: \(activityType), activityId: \(activityId)") + let req = OSRequestLiveActivityReceiveReceipts(key: notificationId, activityType: activityType, activityId: activityId) + _executor.append(req) + } } #endif diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift new file mode 100644 index 000000000..6bae8f371 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift @@ -0,0 +1,100 @@ +/* + Modified MIT License + + Copyright 2025 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 OneSignalCore +import OneSignalUser + +class OSRequestLiveActivityReceiveReceipts: OneSignalRequest, OSLiveActivityRequest { + override var description: String { return "(OSRequestLiveActivityReceiveReceipts) key:\(key) requestSuccessful:\(requestSuccessful) activityType:\(activityType) activityId:\(activityId)" } + + var key: String // notification Id + var activityType: String + var activityId: String + var requestSuccessful: Bool + var shouldForgetWhenSuccessful: Bool = true + + func prepareForExecution() -> Bool { + guard let appId = OneSignalConfigManager.getAppId() else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the OSRequestLiveActivityReceiveReceipts due to null app ID.") + return false + } + + guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the OSRequestLiveActivityReceiveReceipts due to null subscription ID.") + return false + } + + self.path = "notifications/\(key)/report_received" + self.parameters = [ + "app_id": appId, + "player_id": subscriptionId, + "device_type": 0, + "live_activity_id": activityId, + "live_activity_type": activityType + ] + self.method = PUT + + return true + } + + func supersedes(_ existing: any OSLiveActivityRequest) -> Bool { + return false + } + + init(key: String, activityType: String, activityId: String) { + self.key = key + self.activityType = activityType + self.activityId = activityId + self.requestSuccessful = false + super.init() + } + + func encode(with coder: NSCoder) { + coder.encode(key, forKey: "key") + coder.encode(activityType, forKey: "activityType") + coder.encode(activityId, forKey: "activityId") + coder.encode(requestSuccessful, forKey: "requestSuccessful") + coder.encode(timestamp, forKey: "timestamp") + } + + required init?(coder: NSCoder) { + guard + let key = coder.decodeObject(forKey: "key") as? String, + let activityType = coder.decodeObject(forKey: "activityType") as? String, + let activityId = coder.decodeObject(forKey: "activityId") as? String, + let timestamp = coder.decodeObject(forKey: "timestamp") as? Date + else { + return nil + } + self.key = key + self.activityType = activityType + self.activityId = activityId + self.requestSuccessful = coder.decodeBool(forKey: "requestSuccessful") + super.init() + self.timestamp = timestamp + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift index e610fd9ce..635a515f5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift @@ -138,7 +138,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) mockClient.reset() - let request = OSRequestRemoveStartToken(key: "my-activity-id") + let request = OSRequestRemoveUpdateToken(key: "my-activity-id") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) /* When */ @@ -152,6 +152,31 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { XCTAssertTrue(mockClient.executedRequests[0] == request) } + 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 request = OSRequestLiveActivityReceiveReceipts(key: "notification-id", activityType: "my-activity-type", activityId: "my-activity-id") + mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) + + /* When */ + let executor = OSLiveActivitiesExecutor(requestDispatch: mockDispatchQueue) + executor.append(request) + mockDispatchQueue.waitForDispatches(2) + + /* Then */ + XCTAssertEqual(executor.receiveReceipts.items.count, 0) + XCTAssertEqual(mockClient.executedRequests.count, 1) + XCTAssertTrue(mockClient.executedRequests[0] == request) + } + func testRequestWillNotExecuteWhenNoSubscription() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() @@ -235,12 +260,14 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { let removeStartToken = OSRequestRemoveStartToken(key: "key-removeStartToken") let setUpdateToken = OSRequestSetUpdateToken(key: "key-setUpdateToken", token: "my-token") let removeUpdateToken = OSRequestRemoveUpdateToken(key: "key-removeUpdateToken") + let receiveReceipt = OSRequestLiveActivityReceiveReceipts(key: "key-receiveReceipt", activityType: "my-activity-type", activityId: "my-activity-id") executor1.append(setStartToken) executor1.append(removeStartToken) executor1.append(setUpdateToken) executor1.append(removeUpdateToken) - mockDispatchQueue.waitForDispatches(4) + executor1.append(receiveReceipt) + mockDispatchQueue.waitForDispatches(5) // create a new executor which will uncache requests let executor2 = OSLiveActivitiesExecutor(requestDispatch: MockDispatchQueue()) @@ -253,6 +280,9 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { XCTAssertEqual(executor2.updateTokens.items.count, 2) XCTAssertTrue(executor2.updateTokens.items["key-setUpdateToken"] is OSRequestSetUpdateToken) XCTAssertTrue(executor2.updateTokens.items["key-removeUpdateToken"] is OSRequestRemoveUpdateToken) + + XCTAssertEqual(executor2.receiveReceipts.items.count, 1) + XCTAssertTrue(executor2.receiveReceipts.items["key-receiveReceipt"] is OSRequestLiveActivityReceiveReceipts) } func testSetStartRequestNotExecutedWithSameActivityTypeAndToken() throws { @@ -430,4 +460,32 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { XCTAssertTrue(mockClient.executedRequests[0] == request1) XCTAssertTrue(mockClient.executedRequests[1] == request2) } + + 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 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") + mockClient.setMockResponseForRequest(request: String(describing: request1), response: [String: Any]()) + mockClient.setMockResponseForRequest(request: String(describing: request2), response: [String: Any]()) + + /* When */ + let executor = OSLiveActivitiesExecutor(requestDispatch: mockDispatchQueue) + executor.append(request1) + executor.append(request2) + mockDispatchQueue.waitForDispatches(3) + + /* Then */ + XCTAssertEqual(executor.receiveReceipts.items.count, 0) + XCTAssertEqual(mockClient.executedRequests.count, 1) + XCTAssertTrue(mockClient.executedRequests[0] == request1) + } }