Skip to content

Commit 6fd6822

Browse files
Fix parallel requests for NSE
1 parent a49b543 commit 6fd6822

File tree

7 files changed

+790
-84
lines changed

7 files changed

+790
-84
lines changed

Sources/MessagingPush/MessagingPushImplementation.swift

Lines changed: 29 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import UserNotifications
55
#endif
66

77
class MessagingPushImplementation: MessagingPushInstance {
8-
let moduleConfig: MessagingPushConfigOptions
9-
let logger: Logger
108
let pushLogger: PushNotificationLogger
11-
let jsonAdapter: JsonAdapter
12-
let eventBusHandler: EventBusHandler
9+
private let moduleConfig: MessagingPushConfigOptions
10+
private let logger: Logger
11+
private let jsonAdapter: JsonAdapter
12+
private let eventBusHandler: EventBusHandler
13+
14+
/// Current NSE coordinator when a push is being processed; cleared when run completes or on expiry so `contentHandler` is only ever called once.
15+
var nseCoordinator: NSEPushCoordinator?
1316

1417
init(diGraph: DIGraphShared, moduleConfig: MessagingPushConfigOptions) {
1518
self.moduleConfig = moduleConfig
@@ -31,24 +34,6 @@ class MessagingPushImplementation: MessagingPushInstance {
3134
eventBusHandler.postEvent(TrackMetricEvent(deliveryID: deliveryID, event: event.rawValue, deviceToken: deviceToken))
3235
}
3336

34-
func trackMetricFromNSE(
35-
deliveryID: String,
36-
event: Metric,
37-
deviceToken: String
38-
) {
39-
// Access richPushDeliveryTracker from DIGraphShared.shared directly as it is only required for NSE.
40-
// Keeping it as class property results in initialization of UserAgentUtil before SDK client is overridden by wrapper SDKs.
41-
// In future, we can improve how we access SdkClient so that we don't need to worry about initialization order.
42-
DIGraphShared.shared.richPushDeliveryTracker.trackMetric(token: deviceToken, event: event, deliveryId: deliveryID, timestamp: nil) { result in
43-
switch result {
44-
case .success:
45-
self.pushLogger.logPushMetricTracked(deliveryId: deliveryID, event: event.rawValue)
46-
case .failure(let error):
47-
self.pushLogger.logPushMetricTrackingFailed(deliveryId: deliveryID, event: event.rawValue, error: error)
48-
}
49-
}
50-
}
51-
5237
#if canImport(UserNotifications)
5338
/**
5439
- returns:
@@ -63,44 +48,47 @@ class MessagingPushImplementation: MessagingPushInstance {
6348
let push = UNNotificationWrapper(notificationRequest: request)
6449
pushLogger.logReceivedPushMessage(notification: push)
6550

66-
guard let pushCioDeliveryInfo = push.cioDelivery else {
51+
guard push.cioDelivery != nil else {
6752
pushLogger.logReceivedNonCioPushMessage()
6853
return false
6954
}
7055

7156
pushLogger.logReceivedCioPushMessage()
72-
7357
if moduleConfig.autoTrackPushEvents {
74-
pushLogger.logTrackingPushMessageDelivered(deliveryId: pushCioDeliveryInfo.id)
75-
76-
trackMetricFromNSE(deliveryID: pushCioDeliveryInfo.id, event: .delivered, deviceToken: pushCioDeliveryInfo.token)
58+
pushLogger.logTrackingPushMessageDelivered(deliveryId: push.cioDelivery!.id)
7759
} else {
7860
pushLogger.logPushMetricsAutoTrackingDisabled()
7961
}
8062

81-
RichPushRequestHandler.shared.startRequest(
82-
push: push
83-
) { composedRichPush in
84-
self.logger.debug("rich push was composed \(composedRichPush).")
85-
86-
// This conditional will only work in production and not in automated tests. But this file cannot be in automated tests so this conditional is OK for now.
87-
if let composedRichPush = composedRichPush as? UNNotificationWrapper {
88-
self.logger.info("Customer.io push processing is done!")
89-
contentHandler(composedRichPush.notificationContent)
90-
}
63+
// Access from DIGraphShared at NSE handling time so wrapper SDKs can override sdkClient first.
64+
// Storing as instance properties would initialize UserAgentUtil (via httpClient) in init, before overrides.
65+
let coordinator = NSEPushCoordinator(
66+
deliveryTracker: DIGraphShared.shared.richPushDeliveryTracker,
67+
pushLogger: pushLogger,
68+
richPushHandler: RichPushRequestHandler.shared,
69+
httpClient: DIGraphShared.shared.httpClient
70+
)
71+
nseCoordinator = coordinator
72+
Task { [weak self] in
73+
await coordinator.handle(
74+
request: request,
75+
withContentHandler: contentHandler,
76+
autoTrackDelivery: self?.moduleConfig.autoTrackPushEvents ?? false
77+
)
78+
self?.nseCoordinator = nil
9179
}
92-
9380
return true
9481
}
9582

9683
/**
9784
iOS telling the notification service to hurry up and stop modifying the push notifications.
98-
Stop all network requests and modifying and show the push for what it looks like now.
9985
*/
10086
func serviceExtensionTimeWillExpire() {
10187
logger.info("notification service time will expire. Stopping all notification requests early.")
102-
103-
RichPushRequestHandler.shared.stopAll()
88+
if let coordinator = nseCoordinator {
89+
Task { await coordinator.cancel() }
90+
nseCoordinator = nil
91+
}
10492
}
10593
#endif
10694
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import CioInternalCommon
2+
import Foundation
3+
#if canImport(UserNotifications)
4+
import UserNotifications
5+
#endif
6+
7+
/// Abstraction for rich push (start and stop) so the coordinator can be tested with a mock.
8+
protocol RichPushRequestHandling {
9+
func startRequest(push: PushNotification, completionHandler: @escaping (PushNotification) -> Void)
10+
func stopAll()
11+
}
12+
13+
/// Coordinates delivery metric request and rich push modification in parallel for the Notification Service Extension.
14+
/// Holds all NSE logic: delivery tracking (with logging), rich push, parallel run, single contentHandler call, and cancel on expiry.
15+
/// Create one coordinator and call `handle(request:withContentHandler:autoTrackDelivery:)` to process a notification; call `cancel()` on expiry.
16+
/// Dependencies are injected for easier testing.
17+
actor NSEPushCoordinator {
18+
private let deliveryTracker: RichPushDeliveryTracker
19+
private let pushLogger: PushNotificationLogger
20+
private let richPushHandler: RichPushRequestHandling
21+
private let httpClient: HttpClient
22+
23+
private var originalContent: UNNotificationContent?
24+
private var contentHandler: ((UNNotificationContent) -> Void)?
25+
private var contentHandlerCalled = false
26+
private var richPushResult: PushNotification?
27+
28+
init(
29+
deliveryTracker: RichPushDeliveryTracker,
30+
pushLogger: PushNotificationLogger,
31+
richPushHandler: RichPushRequestHandling,
32+
httpClient: HttpClient
33+
) {
34+
self.deliveryTracker = deliveryTracker
35+
self.pushLogger = pushLogger
36+
self.richPushHandler = richPushHandler
37+
self.httpClient = httpClient
38+
}
39+
40+
/// Handles the notification: runs delivery (if autoTrackDelivery) and rich push in parallel, waits for both, then calls `contentHandler` exactly once.
41+
/// Call this once per notification. Expects a CIO push (cioDelivery non-nil); if not, calls contentHandler with request.content and returns.
42+
/// Final content: rich push modified content if present, otherwise original.
43+
func handle(
44+
request: UNNotificationRequest,
45+
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void,
46+
autoTrackDelivery: Bool
47+
) async {
48+
let push = UNNotificationWrapper(notificationRequest: request)
49+
guard let info = push.cioDelivery else {
50+
contentHandler(request.content)
51+
return
52+
}
53+
54+
self.originalContent = request.content
55+
self.contentHandler = contentHandler
56+
57+
async let delivery: Void = trackDeliveryIfNeeded(
58+
autoTrackDelivery: autoTrackDelivery,
59+
token: info.token,
60+
deliveryID: info.id
61+
)
62+
63+
async let richPush: PushNotification? = runRichPush(push)
64+
65+
let result = await richPush
66+
richPushResult = result
67+
68+
_ = await delivery
69+
70+
guard let originalContent = originalContent else { return }
71+
finishWithContent(contentFrom(result, originalContent: originalContent))
72+
}
73+
74+
/// Call when NSE time will expire. Signals cancellation to both operations and calls `contentHandler` exactly once with available content.
75+
func cancel() {
76+
guard !contentHandlerCalled else { return }
77+
guard let originalContent = originalContent else { return }
78+
79+
contentHandlerCalled = true
80+
richPushHandler.stopAll()
81+
httpClient.cancel(finishTasks: false)
82+
83+
contentHandler?(contentFrom(richPushResult, originalContent: originalContent))
84+
}
85+
86+
private func trackDeliveryIfNeeded(
87+
autoTrackDelivery: Bool,
88+
token: String,
89+
deliveryID: String
90+
) async {
91+
guard autoTrackDelivery else { return }
92+
93+
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
94+
deliveryTracker.trackMetric(
95+
token: token,
96+
event: .delivered,
97+
deliveryId: deliveryID,
98+
timestamp: nil
99+
) { result in
100+
switch result {
101+
case .success:
102+
self.pushLogger.logPushMetricTracked(
103+
deliveryId: deliveryID,
104+
event: Metric.delivered.rawValue
105+
)
106+
case .failure(let error):
107+
self.pushLogger.logPushMetricTrackingFailed(
108+
deliveryId: deliveryID,
109+
event: Metric.delivered.rawValue,
110+
error: error
111+
)
112+
}
113+
cont.resume()
114+
}
115+
}
116+
}
117+
118+
private func runRichPush(_ push: PushNotification) async -> PushNotification? {
119+
await withCheckedContinuation { cont in
120+
richPushHandler.startRequest(push: push) { composed in
121+
cont.resume(returning: composed)
122+
}
123+
}
124+
}
125+
126+
private func finishWithContent(_ content: UNNotificationContent) {
127+
guard !contentHandlerCalled else { return }
128+
contentHandlerCalled = true
129+
contentHandler?(content)
130+
}
131+
132+
private func contentFrom(
133+
_ push: PushNotification?,
134+
originalContent: UNNotificationContent
135+
) -> UNNotificationContent {
136+
if let wrapper = push as? UNNotificationWrapper {
137+
return wrapper.notificationContent
138+
}
139+
return originalContent
140+
}
141+
}
142+
143+
extension RichPushRequestHandler: RichPushRequestHandling {}
Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,51 @@
11
import CioInternalCommon
22
import Foundation
33

4+
/// Request to download and attach a rich push image. Only created by the handler when the push
5+
/// has a valid image URL, so this request always uses the shared HTTP client and cancel() always cancels it.
46
class RichPushRequest {
57
private let completionHandler: (PushNotification) -> Void
68
private var push: PushNotification
9+
private let imageURL: URL
710
private let httpClient: HttpClient
811

12+
private var isCompleted = false
13+
914
init(
1015
push: PushNotification,
16+
imageURL: URL,
1117
httpClient: HttpClient,
1218
completionHandler: @escaping (PushNotification) -> Void
1319
) {
1420
self.completionHandler = completionHandler
1521
self.push = push
22+
self.imageURL = imageURL
1623
self.httpClient = httpClient
1724
}
1825

1926
func start() {
20-
guard let image = push.cioImage?.url else {
21-
// no async operations or modifications to the notification to do. Therefore, let's just finish.
22-
return finishImmediately()
23-
}
24-
25-
httpClient.downloadFile(url: image, fileType: .richPushImage) { [weak self] localFilePath in
27+
httpClient.downloadFile(url: imageURL, fileType: .richPushImage) { [weak self] localFilePath in
2628
guard let self = self else { return }
2729

2830
if let localFilePath = localFilePath {
2931
self.push.cioRichPushImageFile = localFilePath
3032
}
3133

32-
self.finishImmediately()
34+
self.completeOnce()
3335
}
3436
}
3537

36-
func finishImmediately() {
38+
/// Call when the request must be aborted (e.g. NSE expiry). Cancels the download and completes.
39+
func cancel() {
40+
guard !isCompleted else { return }
41+
isCompleted = true
3742
httpClient.cancel(finishTasks: false)
43+
completionHandler(push)
44+
}
3845

46+
private func completeOnce() {
47+
guard !isCompleted else { return }
48+
isCompleted = true
3949
completionHandler(push)
4050
}
4151
}

Sources/MessagingPush/RichPush/RichPushRequestHandler.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ class RichPushRequestHandler {
1212
push: PushNotification,
1313
completionHandler: @escaping (PushNotification) -> Void
1414
) {
15+
guard let imageURLString = push.cioImage,
16+
!imageURLString.isEmpty,
17+
let imageURL = URL(string: imageURLString)
18+
else {
19+
completionHandler(push)
20+
return
21+
}
22+
1523
let requestId = push.pushId
1624

1725
let existingRequest = requests[requestId]
@@ -22,6 +30,7 @@ class RichPushRequestHandler {
2230

2331
let newRequest = RichPushRequest(
2432
push: push,
33+
imageURL: imageURL,
2534
httpClient: httpClient,
2635
completionHandler: completionHandler
2736
)
@@ -32,7 +41,7 @@ class RichPushRequestHandler {
3241

3342
func stopAll() {
3443
requests.forEach {
35-
$0.value.finishImmediately()
44+
$0.value.cancel()
3645
}
3746

3847
requests = [:]

0 commit comments

Comments
 (0)