Skip to content

Commit 73a8eda

Browse files
authored
Merge pull request #1592 from OneSignal/feat/live_activities_receive_receipts
Feat: live activities receive receipts
2 parents 701e624 + 2a2d695 commit 73a8eda

File tree

6 files changed

+204
-4
lines changed

6 files changed

+204
-4
lines changed

iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@
201201
3CFA8F572E9087DB00201FE5 /* OSRequestRemoveUpdateToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFA8F452E9087DB00201FE5 /* OSRequestRemoveUpdateToken.swift */; };
202202
3CFA8F582E9087DB00201FE5 /* OSLiveActivityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFA8F432E9087DB00201FE5 /* OSLiveActivityRequest.swift */; };
203203
3CFA8F592E9087DB00201FE5 /* OneSignalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFA8F4C2E9087DB00201FE5 /* OneSignalLiveActivityAttributes.swift */; };
204+
3CFA8F5B2E9091A200201FE5 /* OSRequestLiveActivityReceiveReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFA8F5A2E9091A200201FE5 /* OSRequestLiveActivityReceiveReceipts.swift */; };
204205
3E464ED71D88ED1F00DCF7E9 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; };
205206
3E66F5821D90A2C600E45A01 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E08E2701D49A5C8002176DE /* SystemConfiguration.framework */; };
206207
4529DED21FA81EA800CEAB1D /* NSObjectOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 4529DED11FA81EA800CEAB1D /* NSObjectOverrider.m */; };
@@ -1350,6 +1351,7 @@
13501351
3CFA8F4B2E9087DB00201FE5 /* OneSignalLiveActivitiesManagerImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalLiveActivitiesManagerImpl.swift; sourceTree = "<group>"; };
13511352
3CFA8F4C2E9087DB00201FE5 /* OneSignalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalLiveActivityAttributes.swift; sourceTree = "<group>"; };
13521353
3CFA8F4D2E9087DB00201FE5 /* OSLiveActivitiesExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivitiesExtension.swift; sourceTree = "<group>"; };
1354+
3CFA8F5A2E9091A200201FE5 /* OSRequestLiveActivityReceiveReceipts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestLiveActivityReceiveReceipts.swift; sourceTree = "<group>"; };
13531355
3E08E2701D49A5C8002176DE /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; };
13541356
3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalFramework.framework; sourceTree = BUILT_PRODUCTS_DIR; };
13551357
3E24003B1D4FFC31008BDE70 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -2255,6 +2257,7 @@
22552257
3CFA8F442E9087DB00201FE5 /* OSRequestRemoveStartToken.swift */,
22562258
3CFA8F472E9087DB00201FE5 /* OSRequestSetUpdateToken.swift */,
22572259
3CFA8F452E9087DB00201FE5 /* OSRequestRemoveUpdateToken.swift */,
2260+
3CFA8F5A2E9091A200201FE5 /* OSRequestLiveActivityReceiveReceipts.swift */,
22582261
);
22592262
path = Requests;
22602263
sourceTree = "<group>";
@@ -4311,6 +4314,7 @@
43114314
3CFA8F572E9087DB00201FE5 /* OSRequestRemoveUpdateToken.swift in Sources */,
43124315
3CFA8F582E9087DB00201FE5 /* OSLiveActivityRequest.swift in Sources */,
43134316
3CFA8F592E9087DB00201FE5 /* OneSignalLiveActivityAttributes.swift in Sources */,
4317+
3CFA8F5B2E9091A200201FE5 /* OSRequestLiveActivityReceiveReceipts.swift in Sources */,
43144318
);
43154319
runOnlyForDeploymentPostprocessing = 0;
43164320
};

iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,5 +364,6 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP
364364
// Live Activies Executor
365365
#define OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY"
366366
#define OS_LIVE_ACTIVITIES_EXECUTOR_START_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_START_TOKENS_KEY"
367+
#define OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY"
367368

368369
#endif /* OneSignalCommonDefines_h */

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ class RequestCache {
9898

9999
class UpdateRequestCache: RequestCache {
100100
// An update token should not last longer than 8 hours, we keep for 24 hours to be safe.
101-
static let OneDayInSeconds = TimeInterval(60 * 60 * 24 * 365)
101+
static let OneDayInSeconds = TimeInterval(60 * 60 * 24)
102102

103103
init() {
104104
super.init(cacheKey: OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY, ttl: UpdateRequestCache.OneDayInSeconds)
@@ -114,10 +114,20 @@ class StartRequestCache: RequestCache {
114114
}
115115
}
116116

117+
class ReceiveReceiptsRequestCache: RequestCache {
118+
// Keep receive receipts requests for up to 30 days.
119+
static let OneMonthInSeconds = TimeInterval(60 * 60 * 24 * 30)
120+
121+
init() {
122+
super.init(cacheKey: OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY, ttl: ReceiveReceiptsRequestCache.OneMonthInSeconds)
123+
}
124+
}
125+
117126
class OSLiveActivitiesExecutor: OSPushSubscriptionObserver {
118127
// The currently tracked update and start tokens (key) and their associated request (value). THESE ARE NOT THREAD SAFE
119128
let updateTokens: UpdateRequestCache = UpdateRequestCache()
120129
let startTokens: StartRequestCache = StartRequestCache()
130+
let receiveReceipts: ReceiveReceiptsRequestCache = ReceiveReceiptsRequestCache()
121131

122132
// The live activities request dispatch queue, serial. This synchronizes access to `updateTokens` and `startTokens`.
123133
private var requestDispatch: OSDispatchQueue
@@ -182,14 +192,17 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver {
182192
private func caches(_ block: (RequestCache) -> Void) {
183193
block(self.startTokens)
184194
block(self.updateTokens)
195+
block(self.receiveReceipts)
185196
}
186197

187198
private func getCache(_ request: OSLiveActivityRequest) -> RequestCache {
188199
if request is OSLiveActivityUpdateTokenRequest {
189200
return self.updateTokens
201+
} else if request is OSLiveActivityStartTokenRequest {
202+
return self.startTokens
190203
}
191204

192-
return self.startTokens
205+
return self.receiveReceipts
193206
}
194207

195208
private func executeRequest(_ cache: RequestCache, request: OSLiveActivityRequest) {

iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities {
202202
for activity in Activity<Attributes>.activities {
203203
listenForActivityStateUpdates(activityType, activity: activity, options: options)
204204
listenForActivityPushToUpdate(activityType, activity: activity, options: options)
205+
if #available(iOS 16.2, *) {
206+
listenForContentUpdates(activityType, activity: activity)
207+
}
205208
}
206209

207210
// Establish listeners for activity updates
@@ -221,6 +224,9 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities {
221224

222225
listenForActivityStateUpdates(activityType, activity: activity, options: options)
223226
listenForActivityPushToUpdate(activityType, activity: activity, options: options)
227+
if #available(iOS 16.2, *) {
228+
listenForContentUpdates(activityType, activity: activity)
229+
}
224230
}
225231
}
226232
}
@@ -272,5 +278,23 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities {
272278
}
273279
}
274280
}
281+
282+
@available(iOS 16.2, *)
283+
private static func listenForContentUpdates<Attributes: OneSignalLiveActivityAttributes>(_ activityType: Attributes.Type, activity: Activity<Attributes>) {
284+
Task {
285+
for await content in activity.contentUpdates {
286+
// Don't track a live activity started / updated "in app" without a notification
287+
if let notificationId = activity.content.state.onesignal?.notificationId {
288+
OneSignalLiveActivitiesManagerImpl.addReceiveReceipts(notificationId: notificationId, activityType: "\(activityType)", activityId: activity.attributes.onesignal.activityId)
289+
}
290+
}
291+
}
292+
}
293+
294+
private static func addReceiveReceipts(notificationId: String, activityType: String, activityId: String) {
295+
OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.LiveActivities addReceiveReceipts called with notificationId: \(notificationId), activityType: \(activityType), activityId: \(activityId)")
296+
let req = OSRequestLiveActivityReceiveReceipts(key: notificationId, activityType: activityType, activityId: activityId)
297+
_executor.append(req)
298+
}
275299
}
276300
#endif
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
Modified MIT License
3+
4+
Copyright 2025 OneSignal
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
1. The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
2. All copies of substantial portions of the Software may only be used in connection
17+
with services provided by OneSignal.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
THE SOFTWARE.
26+
*/
27+
28+
import OneSignalCore
29+
import OneSignalUser
30+
31+
class OSRequestLiveActivityReceiveReceipts: OneSignalRequest, OSLiveActivityRequest {
32+
override var description: String { return "(OSRequestLiveActivityReceiveReceipts) key:\(key) requestSuccessful:\(requestSuccessful) activityType:\(activityType) activityId:\(activityId)" }
33+
34+
var key: String // notification Id
35+
var activityType: String
36+
var activityId: String
37+
var requestSuccessful: Bool
38+
var shouldForgetWhenSuccessful: Bool = true
39+
40+
func prepareForExecution() -> Bool {
41+
guard let appId = OneSignalConfigManager.getAppId() else {
42+
OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the OSRequestLiveActivityReceiveReceipts due to null app ID.")
43+
return false
44+
}
45+
46+
guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else {
47+
OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the OSRequestLiveActivityReceiveReceipts due to null subscription ID.")
48+
return false
49+
}
50+
51+
self.path = "notifications/\(key)/report_received"
52+
self.parameters = [
53+
"app_id": appId,
54+
"player_id": subscriptionId,
55+
"device_type": 0,
56+
"live_activity_id": activityId,
57+
"live_activity_type": activityType
58+
]
59+
self.method = PUT
60+
61+
return true
62+
}
63+
64+
func supersedes(_ existing: any OSLiveActivityRequest) -> Bool {
65+
return false
66+
}
67+
68+
init(key: String, activityType: String, activityId: String) {
69+
self.key = key
70+
self.activityType = activityType
71+
self.activityId = activityId
72+
self.requestSuccessful = false
73+
super.init()
74+
}
75+
76+
func encode(with coder: NSCoder) {
77+
coder.encode(key, forKey: "key")
78+
coder.encode(activityType, forKey: "activityType")
79+
coder.encode(activityId, forKey: "activityId")
80+
coder.encode(requestSuccessful, forKey: "requestSuccessful")
81+
coder.encode(timestamp, forKey: "timestamp")
82+
}
83+
84+
required init?(coder: NSCoder) {
85+
guard
86+
let key = coder.decodeObject(forKey: "key") as? String,
87+
let activityType = coder.decodeObject(forKey: "activityType") as? String,
88+
let activityId = coder.decodeObject(forKey: "activityId") as? String,
89+
let timestamp = coder.decodeObject(forKey: "timestamp") as? Date
90+
else {
91+
return nil
92+
}
93+
self.key = key
94+
self.activityType = activityType
95+
self.activityId = activityId
96+
self.requestSuccessful = coder.decodeBool(forKey: "requestSuccessful")
97+
super.init()
98+
self.timestamp = timestamp
99+
}
100+
}

iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase {
138138
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2)
139139
mockClient.reset()
140140

141-
let request = OSRequestRemoveStartToken(key: "my-activity-id")
141+
let request = OSRequestRemoveUpdateToken(key: "my-activity-id")
142142
mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]())
143143

144144
/* When */
@@ -152,6 +152,31 @@ final class OSLiveActivitiesExecutorTests: XCTestCase {
152152
XCTAssertTrue(mockClient.executedRequests[0] == request)
153153
}
154154

155+
func testReceiveReceiptsWithSuccessfulRequest() throws {
156+
/* Setup */
157+
let mockDispatchQueue = MockDispatchQueue()
158+
let mockClient = MockOneSignalClient()
159+
OneSignalCoreImpl.setSharedClient(mockClient)
160+
OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id")
161+
OneSignalUserManagerImpl.sharedInstance.start()
162+
// Wait for any user setup requests to complete
163+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2)
164+
mockClient.reset()
165+
166+
let request = OSRequestLiveActivityReceiveReceipts(key: "notification-id", activityType: "my-activity-type", activityId: "my-activity-id")
167+
mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]())
168+
169+
/* When */
170+
let executor = OSLiveActivitiesExecutor(requestDispatch: mockDispatchQueue)
171+
executor.append(request)
172+
mockDispatchQueue.waitForDispatches(2)
173+
174+
/* Then */
175+
XCTAssertEqual(executor.receiveReceipts.items.count, 0)
176+
XCTAssertEqual(mockClient.executedRequests.count, 1)
177+
XCTAssertTrue(mockClient.executedRequests[0] == request)
178+
}
179+
155180
func testRequestWillNotExecuteWhenNoSubscription() throws {
156181
/* Setup */
157182
let mockDispatchQueue = MockDispatchQueue()
@@ -235,12 +260,14 @@ final class OSLiveActivitiesExecutorTests: XCTestCase {
235260
let removeStartToken = OSRequestRemoveStartToken(key: "key-removeStartToken")
236261
let setUpdateToken = OSRequestSetUpdateToken(key: "key-setUpdateToken", token: "my-token")
237262
let removeUpdateToken = OSRequestRemoveUpdateToken(key: "key-removeUpdateToken")
263+
let receiveReceipt = OSRequestLiveActivityReceiveReceipts(key: "key-receiveReceipt", activityType: "my-activity-type", activityId: "my-activity-id")
238264

239265
executor1.append(setStartToken)
240266
executor1.append(removeStartToken)
241267
executor1.append(setUpdateToken)
242268
executor1.append(removeUpdateToken)
243-
mockDispatchQueue.waitForDispatches(4)
269+
executor1.append(receiveReceipt)
270+
mockDispatchQueue.waitForDispatches(5)
244271

245272
// create a new executor which will uncache requests
246273
let executor2 = OSLiveActivitiesExecutor(requestDispatch: MockDispatchQueue())
@@ -253,6 +280,9 @@ final class OSLiveActivitiesExecutorTests: XCTestCase {
253280
XCTAssertEqual(executor2.updateTokens.items.count, 2)
254281
XCTAssertTrue(executor2.updateTokens.items["key-setUpdateToken"] is OSRequestSetUpdateToken)
255282
XCTAssertTrue(executor2.updateTokens.items["key-removeUpdateToken"] is OSRequestRemoveUpdateToken)
283+
284+
XCTAssertEqual(executor2.receiveReceipts.items.count, 1)
285+
XCTAssertTrue(executor2.receiveReceipts.items["key-receiveReceipt"] is OSRequestLiveActivityReceiveReceipts)
256286
}
257287

258288
func testSetStartRequestNotExecutedWithSameActivityTypeAndToken() throws {
@@ -430,4 +460,32 @@ final class OSLiveActivitiesExecutorTests: XCTestCase {
430460
XCTAssertTrue(mockClient.executedRequests[0] == request1)
431461
XCTAssertTrue(mockClient.executedRequests[1] == request2)
432462
}
463+
464+
func testReceiveReceiptsRequestNotExecutedWithSameNotificationId() throws {
465+
/* Setup */
466+
let mockDispatchQueue = MockDispatchQueue()
467+
let mockClient = MockOneSignalClient()
468+
OneSignalCoreImpl.setSharedClient(mockClient)
469+
OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id")
470+
OneSignalUserManagerImpl.sharedInstance.start()
471+
// Wait for any user setup requests to complete
472+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2)
473+
mockClient.reset()
474+
475+
let request1 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type-1", activityId: "my-activity-id-1")
476+
let request2 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type-2", activityId: "my-activity-id-2")
477+
mockClient.setMockResponseForRequest(request: String(describing: request1), response: [String: Any]())
478+
mockClient.setMockResponseForRequest(request: String(describing: request2), response: [String: Any]())
479+
480+
/* When */
481+
let executor = OSLiveActivitiesExecutor(requestDispatch: mockDispatchQueue)
482+
executor.append(request1)
483+
executor.append(request2)
484+
mockDispatchQueue.waitForDispatches(3)
485+
486+
/* Then */
487+
XCTAssertEqual(executor.receiveReceipts.items.count, 0)
488+
XCTAssertEqual(mockClient.executedRequests.count, 1)
489+
XCTAssertTrue(mockClient.executedRequests[0] == request1)
490+
}
433491
}

0 commit comments

Comments
 (0)