@attentive-mobile/attentive-react-native-sdk 2.0.0-beta.5 → 2.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -11
- package/android/build.gradle +4 -1
- package/android/src/main/kotlin/com/attentivereactnativesdk/AttentiveNotificationStore.kt +60 -0
- package/android/src/main/kotlin/com/attentivereactnativesdk/AttentivePushHelper.kt +15 -7
- package/android/src/main/kotlin/com/attentivereactnativesdk/AttentiveReactNativeSdkModule.kt +370 -140
- package/android/src/test/kotlin/com/attentivereactnativesdk/AttentiveNotificationStoreTest.kt +103 -0
- package/attentive-react-native-sdk.podspec +1 -1
- package/ios/AttentiveReactNativeSdk.mm +17 -2
- package/ios/AttentiveReactNativeSdk.xcodeproj/project.xcworkspace/xcuserdata/zheref.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
- package/ios/Bridging/ATTNNativeSDK.swift +116 -46
- package/ios/Bridging/AttentiveSDKManager.swift +196 -27
- package/ios/Podfile +1 -1
- package/lib/commonjs/NativeAttentiveReactNativeSdk.js +1 -1
- package/lib/commonjs/NativeAttentiveReactNativeSdk.js.map +1 -1
- package/lib/commonjs/eventTypes.js.map +1 -1
- package/lib/commonjs/index.js +50 -17
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/NativeAttentiveReactNativeSdk.js +2 -2
- package/lib/module/NativeAttentiveReactNativeSdk.js.map +1 -1
- package/lib/module/eventTypes.js.map +1 -1
- package/lib/module/index.js +50 -18
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/NativeAttentiveReactNativeSdk.d.ts +12 -1
- package/lib/typescript/NativeAttentiveReactNativeSdk.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +46 -18
- package/lib/typescript/index.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/NativeAttentiveReactNativeSdk.ts +69 -52
- package/src/index.tsx +53 -17
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package com.attentivereactnativesdk
|
|
2
|
+
|
|
3
|
+
import org.junit.After
|
|
4
|
+
import org.junit.Assert.assertEquals
|
|
5
|
+
import org.junit.Assert.assertNull
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Unit tests for [AttentiveNotificationStore].
|
|
10
|
+
*
|
|
11
|
+
* Verifies the store's core contract:
|
|
12
|
+
* - Starts empty.
|
|
13
|
+
* - Accepts a payload via [AttentiveNotificationStore.setPendingInitialNotification].
|
|
14
|
+
* - Returns the payload exactly once via [AttentiveNotificationStore.getAndClear].
|
|
15
|
+
* - Clears the store after retrieval (subsequent calls return null).
|
|
16
|
+
* - Replaces any previously stored value when set is called again.
|
|
17
|
+
*/
|
|
18
|
+
class AttentiveNotificationStoreTest {
|
|
19
|
+
|
|
20
|
+
@After
|
|
21
|
+
fun tearDown() {
|
|
22
|
+
// Ensure no state leaks between tests.
|
|
23
|
+
AttentiveNotificationStore.getAndClear()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test
|
|
27
|
+
fun `getAndClear returns null when no notification has been set`() {
|
|
28
|
+
val result = AttentiveNotificationStore.getAndClear()
|
|
29
|
+
|
|
30
|
+
assertNull("Expected null when nothing was stored", result)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Test
|
|
34
|
+
fun `getAndClear returns the payload that was previously set`() {
|
|
35
|
+
val payload = mapOf(
|
|
36
|
+
"google.message_id" to "fcm-123",
|
|
37
|
+
"title" to "Flash Sale",
|
|
38
|
+
"body" to "40% off today only",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
AttentiveNotificationStore.setPendingInitialNotification(payload)
|
|
42
|
+
|
|
43
|
+
val result = AttentiveNotificationStore.getAndClear()
|
|
44
|
+
|
|
45
|
+
assertEquals(payload, result)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Test
|
|
49
|
+
fun `getAndClear returns null on the second call after the first consumed the payload`() {
|
|
50
|
+
val payload = mapOf("messageId" to "msg-1")
|
|
51
|
+
AttentiveNotificationStore.setPendingInitialNotification(payload)
|
|
52
|
+
|
|
53
|
+
// First retrieval — should return the payload.
|
|
54
|
+
val firstResult = AttentiveNotificationStore.getAndClear()
|
|
55
|
+
assertEquals(payload, firstResult)
|
|
56
|
+
|
|
57
|
+
// Second retrieval — store should be cleared.
|
|
58
|
+
val secondResult = AttentiveNotificationStore.getAndClear()
|
|
59
|
+
assertNull("Store should be empty after first getAndClear", secondResult)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Test
|
|
63
|
+
fun `setPendingInitialNotification replaces any previously stored payload`() {
|
|
64
|
+
val firstPayload = mapOf("title" to "First Notification")
|
|
65
|
+
val secondPayload = mapOf("title" to "Second Notification", "body" to "Replacement")
|
|
66
|
+
|
|
67
|
+
AttentiveNotificationStore.setPendingInitialNotification(firstPayload)
|
|
68
|
+
AttentiveNotificationStore.setPendingInitialNotification(secondPayload)
|
|
69
|
+
|
|
70
|
+
val result = AttentiveNotificationStore.getAndClear()
|
|
71
|
+
|
|
72
|
+
assertEquals("Expected the second (replacement) payload", secondPayload, result)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Test
|
|
76
|
+
fun `store handles payloads with all standard FCM fields`() {
|
|
77
|
+
val payload = mapOf(
|
|
78
|
+
"google.message_id" to "0:1234%abc",
|
|
79
|
+
"google.sent_time" to "1700000000000",
|
|
80
|
+
"from" to "123456789",
|
|
81
|
+
"collapse_key" to "com.bonni",
|
|
82
|
+
"title" to "New Order",
|
|
83
|
+
"body" to "Your order has shipped",
|
|
84
|
+
"channelId" to "orders",
|
|
85
|
+
"campaignId" to "camp-999",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
AttentiveNotificationStore.setPendingInitialNotification(payload)
|
|
89
|
+
val result = AttentiveNotificationStore.getAndClear()
|
|
90
|
+
|
|
91
|
+
assertEquals(payload, result)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@Test
|
|
95
|
+
fun `store handles empty payloads without throwing`() {
|
|
96
|
+
val emptyPayload = emptyMap<String, String>()
|
|
97
|
+
|
|
98
|
+
AttentiveNotificationStore.setPendingInitialNotification(emptyPayload)
|
|
99
|
+
val result = AttentiveNotificationStore.getAndClear()
|
|
100
|
+
|
|
101
|
+
assertEquals(emptyPayload, result)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
RCT_EXPORT_MODULE()
|
|
23
23
|
|
|
24
24
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
25
|
-
// New Architecture implementation with flattened parameters
|
|
25
|
+
// New Architecture implementation with flattened parameters.
|
|
26
|
+
// Initialize is invoked only from TypeScript (e.g. Bonni App); native must not auto-initialize.
|
|
26
27
|
- (void)initialize:(NSString *)attentiveDomain
|
|
27
28
|
mode:(NSString *)mode
|
|
28
29
|
skipFatigueOnCreatives:(BOOL)skipFatigueOnCreatives
|
|
@@ -189,7 +190,8 @@ customIdentifiers:(NSDictionary *)customIdentifiers {
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
#else
|
|
192
|
-
// Old Architecture implementation with dictionary parameters
|
|
193
|
+
// Old Architecture implementation with dictionary parameters.
|
|
194
|
+
// Initialize is invoked only from TypeScript (e.g. Bonni App); native must not auto-initialize.
|
|
193
195
|
- (void)initialize:(NSDictionary*)configuration {
|
|
194
196
|
_sdk = [[ATTNNativeSDK alloc] initWithDomain:configuration[@"attentiveDomain"]
|
|
195
197
|
mode:configuration[@"mode"]
|
|
@@ -311,6 +313,19 @@ customIdentifiers:(NSDictionary *)customIdentifiers {
|
|
|
311
313
|
authorizationStatus:(NSString *)authorizationStatus {
|
|
312
314
|
[_sdk handlePushOpenFromRN:userInfo authorizationStatus:authorizationStatus];
|
|
313
315
|
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* iOS stub for getInitialPushNotification.
|
|
319
|
+
*
|
|
320
|
+
* On iOS, the killed-state push-open event is tracked natively in
|
|
321
|
+
* AppDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:) via
|
|
322
|
+
* AttentiveSDKManager.shared, so there is no pending payload to return here.
|
|
323
|
+
* Resolves with nil so callers on both platforms can share the same code path.
|
|
324
|
+
*/
|
|
325
|
+
- (void)getInitialPushNotification:(RCTPromiseResolveBlock)resolve
|
|
326
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
327
|
+
resolve(nil);
|
|
328
|
+
}
|
|
314
329
|
#endif
|
|
315
330
|
|
|
316
331
|
|
|
Binary file
|
|
@@ -280,7 +280,7 @@ struct DebugEvent {
|
|
|
280
280
|
|
|
281
281
|
/**
|
|
282
282
|
* Handle when a push notification is opened by the user.
|
|
283
|
-
* Note: SDK
|
|
283
|
+
* Note: The SDK requires UNNotificationResponse instead of a plain userInfo dictionary.
|
|
284
284
|
* This method is kept for backward compatibility but has limited functionality.
|
|
285
285
|
* For full functionality, handle push notifications natively in AppDelegate.
|
|
286
286
|
*
|
|
@@ -290,11 +290,11 @@ struct DebugEvent {
|
|
|
290
290
|
*/
|
|
291
291
|
@objc(handlePushOpened:applicationState:authorizationStatus:)
|
|
292
292
|
public func handlePushOpened(_ userInfo: [String: Any], applicationState: String, authorizationStatus: String) {
|
|
293
|
-
// Note:
|
|
293
|
+
// Note: The native SDK requires UNNotificationResponse for push tracking.
|
|
294
294
|
// Since React Native doesn't provide direct access to UNNotificationResponse,
|
|
295
|
-
// apps should handle push notifications natively in AppDelegate for full functionality
|
|
296
|
-
print("[AttentiveSDK] Warning: Push notification handling from React Native is limited
|
|
297
|
-
print("[AttentiveSDK] The native SDK
|
|
295
|
+
// apps should handle push notifications natively in AppDelegate for full functionality.
|
|
296
|
+
print("[AttentiveSDK] Warning: Push notification handling from React Native is limited")
|
|
297
|
+
print("[AttentiveSDK] The native SDK requires UNNotificationResponse for push tracking")
|
|
298
298
|
print("[AttentiveSDK] Please implement push handling in AppDelegate for full functionality")
|
|
299
299
|
|
|
300
300
|
if debuggingEnabled {
|
|
@@ -302,7 +302,7 @@ struct DebugEvent {
|
|
|
302
302
|
"applicationState": applicationState,
|
|
303
303
|
"authorizationStatus": authorizationStatus,
|
|
304
304
|
"userInfo": userInfo,
|
|
305
|
-
"warning": "SDK
|
|
305
|
+
"warning": "Native SDK requires UNNotificationResponse handling in AppDelegate"
|
|
306
306
|
])
|
|
307
307
|
}
|
|
308
308
|
}
|
|
@@ -313,15 +313,15 @@ struct DebugEvent {
|
|
|
313
313
|
*/
|
|
314
314
|
@objc(handleForegroundNotification:)
|
|
315
315
|
public func handleForegroundNotification(_ userInfo: [String: Any]) {
|
|
316
|
-
// Note:
|
|
317
|
-
// Since React Native doesn't provide this, we'll log a warning
|
|
318
|
-
print("[AttentiveSDK] Warning: Foreground notification handling from React Native is limited
|
|
316
|
+
// Note: The native SDK requires UNNotificationResponse for push tracking.
|
|
317
|
+
// Since React Native doesn't provide this, we'll log a warning.
|
|
318
|
+
print("[AttentiveSDK] Warning: Foreground notification handling from React Native is limited")
|
|
319
319
|
print("[AttentiveSDK] Please handle foreground notifications natively in AppDelegate for full functionality")
|
|
320
320
|
|
|
321
321
|
if debuggingEnabled {
|
|
322
322
|
showDebugInfo(event: "Foreground Notification", data: [
|
|
323
323
|
"userInfo": userInfo,
|
|
324
|
-
"warning": "SDK
|
|
324
|
+
"warning": "Native SDK requires UNNotificationResponse handling"
|
|
325
325
|
])
|
|
326
326
|
}
|
|
327
327
|
}
|
|
@@ -381,58 +381,128 @@ struct DebugEvent {
|
|
|
381
381
|
}
|
|
382
382
|
|
|
383
383
|
/**
|
|
384
|
-
*
|
|
385
|
-
* This method accepts userInfo dictionary instead of UNNotificationResponse, making it callable from React Native.
|
|
384
|
+
* React Native bridge entry point for foreground push tracking.
|
|
386
385
|
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
386
|
+
* The Attentive iOS SDK requires a `UNNotificationResponse` which cannot be
|
|
387
|
+
* serialised across the React Native bridge. To work around this, the method
|
|
388
|
+
* checks `AttentiveSDKManager.shared` for a cached response (set by the
|
|
389
|
+
* recommended `handleNotificationResponse(_:)` call in AppDelegate).
|
|
389
390
|
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
391
|
+
* - If a cached response exists **and was already tracked**, this is a no-op
|
|
392
|
+
* (prevents double-tracking).
|
|
393
|
+
* - If a cached response exists **but was not yet tracked**, it is used to
|
|
394
|
+
* call the real native SDK method.
|
|
395
|
+
* - If no cached response is available, a warning is logged instructing the
|
|
396
|
+
* developer to add `AttentiveSDKManager.shared.handleNotificationResponse(response)`
|
|
397
|
+
* to their AppDelegate.
|
|
398
|
+
*
|
|
399
|
+
* @param userInfo The notification payload dictionary.
|
|
400
|
+
* @param authorizationStatus Current push authorization status string.
|
|
392
401
|
*/
|
|
393
402
|
@objc(handleForegroundPushFromRN:authorizationStatus:)
|
|
394
403
|
public func handleForegroundPushFromRN(_ userInfo: [String: Any], authorizationStatus: String) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
404
|
+
if let pending = AttentiveSDKManager.shared.consumePendingResponse() {
|
|
405
|
+
if pending.alreadyTracked {
|
|
406
|
+
// Already tracked by handleNotificationResponse — nothing to do.
|
|
407
|
+
if debuggingEnabled {
|
|
408
|
+
showDebugInfo(event: "Foreground Push (already tracked natively)", data: [
|
|
409
|
+
"authorizationStatus": authorizationStatus,
|
|
410
|
+
"note": "Push was already tracked via AttentiveSDKManager.handleNotificationResponse."
|
|
411
|
+
])
|
|
412
|
+
}
|
|
413
|
+
return
|
|
414
|
+
}
|
|
401
415
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
416
|
+
// Cached response exists but wasn't tracked yet — track it now.
|
|
417
|
+
let authStatus = mapAuthorizationStatus(authorizationStatus)
|
|
418
|
+
sdk.handleForegroundPush(response: pending.response, authorizationStatus: authStatus)
|
|
419
|
+
|
|
420
|
+
if debuggingEnabled {
|
|
421
|
+
let cachedUserInfo = pending.response.notification.request.content.userInfo
|
|
422
|
+
showDebugInfo(event: "Foreground Push (tracked via cached response)", data: [
|
|
423
|
+
"authorizationStatus": authorizationStatus,
|
|
424
|
+
"userInfo": cachedUserInfo as? [String: Any] ?? [:],
|
|
425
|
+
"actionIdentifier": pending.response.actionIdentifier
|
|
426
|
+
])
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
// No cached response. Either:
|
|
430
|
+
// (a) The client's AppDelegate is missing handleNotificationResponse, or
|
|
431
|
+
// (b) A prior bridge call (handlePushOpen / handleForegroundPush) already consumed it.
|
|
432
|
+
print("[AttentiveSDK] handleForegroundPushFromRN: No cached UNNotificationResponse available.")
|
|
433
|
+
print("[AttentiveSDK] If push tracking is not working, add this line to your AppDelegate's didReceive handler:")
|
|
434
|
+
print("[AttentiveSDK] AttentiveSDKManager.shared.handleNotificationResponse(response)")
|
|
435
|
+
|
|
436
|
+
if debuggingEnabled {
|
|
437
|
+
showDebugInfo(event: "Foreground Push (no cached response)", data: [
|
|
438
|
+
"authorizationStatus": authorizationStatus,
|
|
439
|
+
"userInfo": userInfo,
|
|
440
|
+
"note": "No cached response — either already consumed by a prior call or AppDelegate setup missing."
|
|
441
|
+
])
|
|
442
|
+
}
|
|
408
443
|
}
|
|
409
444
|
}
|
|
410
445
|
|
|
411
446
|
/**
|
|
412
|
-
*
|
|
413
|
-
* This method accepts userInfo dictionary instead of UNNotificationResponse, making it callable from React Native.
|
|
447
|
+
* React Native bridge entry point for push-open tracking (background/inactive tap).
|
|
414
448
|
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
449
|
+
* The Attentive iOS SDK requires a `UNNotificationResponse` which cannot be
|
|
450
|
+
* serialised across the React Native bridge. To work around this, the method
|
|
451
|
+
* checks `AttentiveSDKManager.shared` for a cached response (set by the
|
|
452
|
+
* recommended `handleNotificationResponse(_:)` call in AppDelegate).
|
|
417
453
|
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
454
|
+
* - If a cached response exists **and was already tracked**, this is a no-op
|
|
455
|
+
* (prevents double-tracking).
|
|
456
|
+
* - If a cached response exists **but was not yet tracked**, it is used to
|
|
457
|
+
* call the real native SDK method.
|
|
458
|
+
* - If no cached response is available, a warning is logged instructing the
|
|
459
|
+
* developer to add `AttentiveSDKManager.shared.handleNotificationResponse(response)`
|
|
460
|
+
* to their AppDelegate.
|
|
461
|
+
*
|
|
462
|
+
* @param userInfo The notification payload dictionary.
|
|
463
|
+
* @param authorizationStatus Current push authorization status string.
|
|
420
464
|
*/
|
|
421
465
|
@objc(handlePushOpenFromRN:authorizationStatus:)
|
|
422
466
|
public func handlePushOpenFromRN(_ userInfo: [String: Any], authorizationStatus: String) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
467
|
+
if let pending = AttentiveSDKManager.shared.consumePendingResponse() {
|
|
468
|
+
if pending.alreadyTracked {
|
|
469
|
+
// Already tracked by handleNotificationResponse — nothing to do.
|
|
470
|
+
if debuggingEnabled {
|
|
471
|
+
showDebugInfo(event: "Push Open (already tracked natively)", data: [
|
|
472
|
+
"authorizationStatus": authorizationStatus,
|
|
473
|
+
"note": "Push was already tracked via AttentiveSDKManager.handleNotificationResponse."
|
|
474
|
+
])
|
|
475
|
+
}
|
|
476
|
+
return
|
|
477
|
+
}
|
|
429
478
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
479
|
+
// Cached response exists but wasn't tracked yet — track it now.
|
|
480
|
+
let authStatus = mapAuthorizationStatus(authorizationStatus)
|
|
481
|
+
sdk.handlePushOpen(response: pending.response, authorizationStatus: authStatus)
|
|
482
|
+
|
|
483
|
+
if debuggingEnabled {
|
|
484
|
+
let cachedUserInfo = pending.response.notification.request.content.userInfo
|
|
485
|
+
showDebugInfo(event: "Push Open (tracked via cached response)", data: [
|
|
486
|
+
"authorizationStatus": authorizationStatus,
|
|
487
|
+
"userInfo": cachedUserInfo as? [String: Any] ?? [:],
|
|
488
|
+
"actionIdentifier": pending.response.actionIdentifier
|
|
489
|
+
])
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
// No cached response. Either:
|
|
493
|
+
// (a) The client's AppDelegate is missing handleNotificationResponse, or
|
|
494
|
+
// (b) A prior bridge call (handlePushOpen / handleForegroundPush) already consumed it.
|
|
495
|
+
print("[AttentiveSDK] handlePushOpenFromRN: No cached UNNotificationResponse available.")
|
|
496
|
+
print("[AttentiveSDK] If push tracking is not working, add this line to your AppDelegate's didReceive handler:")
|
|
497
|
+
print("[AttentiveSDK] AttentiveSDKManager.shared.handleNotificationResponse(response)")
|
|
498
|
+
|
|
499
|
+
if debuggingEnabled {
|
|
500
|
+
showDebugInfo(event: "Push Open (no cached response)", data: [
|
|
501
|
+
"authorizationStatus": authorizationStatus,
|
|
502
|
+
"userInfo": userInfo,
|
|
503
|
+
"note": "No cached response — either already consumed by a prior call or AppDelegate setup missing."
|
|
504
|
+
])
|
|
505
|
+
}
|
|
436
506
|
}
|
|
437
507
|
}
|
|
438
508
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
|
|
9
9
|
import Foundation
|
|
10
|
+
import UIKit
|
|
10
11
|
import UserNotifications
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -14,21 +15,21 @@ import UserNotifications
|
|
|
14
15
|
* This allows AppDelegate and other native code to access the SDK instance
|
|
15
16
|
* that was initialized from React Native.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
+
* ## Minimal Integration (recommended)
|
|
19
|
+
*
|
|
20
|
+
* In your AppDelegate's `userNotificationCenter(_:didReceive:withCompletionHandler:)`,
|
|
21
|
+
* add **one line** to enable push-open and foreground-push tracking:
|
|
22
|
+
*
|
|
18
23
|
* ```swift
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* @unknown default:
|
|
29
|
-
* AttentiveSDKManager.shared.handlePushOpen(response: response, authorizationStatus: authStatus)
|
|
30
|
-
* }
|
|
31
|
-
* }
|
|
24
|
+
* func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
25
|
+
* didReceive response: UNNotificationResponse,
|
|
26
|
+
* withCompletionHandler completionHandler: @escaping () -> Void) {
|
|
27
|
+
* // Track push interaction with Attentive (handles app-state detection + auth status internally)
|
|
28
|
+
* AttentiveSDKManager.shared.handleNotificationResponse(response)
|
|
29
|
+
*
|
|
30
|
+
* // Forward to your push library (e.g. RNCPushNotificationIOS) for JS-side handling
|
|
31
|
+
* RNCPushNotificationIOS.didReceive(response)
|
|
32
|
+
* completionHandler()
|
|
32
33
|
* }
|
|
33
34
|
* ```
|
|
34
35
|
*/
|
|
@@ -36,10 +37,40 @@ import UserNotifications
|
|
|
36
37
|
/// Shared singleton instance
|
|
37
38
|
@objc public static let shared: AttentiveSDKManager = AttentiveSDKManager()
|
|
38
39
|
|
|
39
|
-
/// The Attentive SDK instance as AnyObject for Objective-C compatibility
|
|
40
|
-
///
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/// The Attentive SDK instance as AnyObject for Objective-C compatibility.
|
|
41
|
+
/// When set, any pending (untracked) notification response is automatically flushed.
|
|
42
|
+
@objc public var sdk: AnyObject? {
|
|
43
|
+
didSet {
|
|
44
|
+
if sdk != nil {
|
|
45
|
+
flushPendingResponseIfNeeded()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// MARK: - Notification Response Cache
|
|
51
|
+
|
|
52
|
+
/// The most recent UNNotificationResponse, cached so the RN bridge can use it
|
|
53
|
+
/// when JS calls handlePushOpen / handleForegroundPush.
|
|
54
|
+
///
|
|
55
|
+
/// **Known limitation:** This is a single-slot cache. If two notifications
|
|
56
|
+
/// arrive in rapid succession (e.g. a tap followed immediately by a
|
|
57
|
+
/// foreground delivery), the second call to `handleNotificationResponse`
|
|
58
|
+
/// overwrites the first before its async tracking completes. In practice
|
|
59
|
+
/// this is extremely rare because `didReceive` is serialized by the system.
|
|
60
|
+
private var pendingResponse: UNNotificationResponse?
|
|
61
|
+
|
|
62
|
+
/// Whether the pending response has already been tracked via `handleNotificationResponse`.
|
|
63
|
+
/// Prevents double-tracking when both the native convenience method and the JS bridge fire.
|
|
64
|
+
private var pendingResponseTracked: Bool = false
|
|
65
|
+
|
|
66
|
+
/// The app state captured at the moment `handleNotificationResponse` was called.
|
|
67
|
+
/// Stored so that cold-launch replays use the original state (typically `.inactive`
|
|
68
|
+
/// or `.background`) rather than `.active` which is what the app will be in by the
|
|
69
|
+
/// time the SDK finishes initializing.
|
|
70
|
+
private var pendingAppState: UIApplication.State = .inactive
|
|
71
|
+
|
|
72
|
+
/// Serialises access to the cache fields above.
|
|
73
|
+
private let responseLock = NSLock()
|
|
43
74
|
|
|
44
75
|
private override init() {
|
|
45
76
|
super.init()
|
|
@@ -55,29 +86,167 @@ import UserNotifications
|
|
|
55
86
|
sdk = nativeSDK
|
|
56
87
|
}
|
|
57
88
|
|
|
58
|
-
// MARK: - Push Notification
|
|
89
|
+
// MARK: - Push Notification Convenience Method
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Handle a push notification response in one call.
|
|
93
|
+
*
|
|
94
|
+
* This is the **recommended** way to track push interactions on iOS.
|
|
95
|
+
* It automatically determines the app state, fetches the current
|
|
96
|
+
* authorization status, and calls the appropriate native SDK method
|
|
97
|
+
* (`handlePushOpen` or `handleForegroundPush`).
|
|
98
|
+
*
|
|
99
|
+
* Call this from your AppDelegate's
|
|
100
|
+
* `userNotificationCenter(_:didReceive:withCompletionHandler:)`.
|
|
101
|
+
*
|
|
102
|
+
* The response is also cached so that if the JS layer later calls
|
|
103
|
+
* `handlePushOpen()` or `handleForegroundPush()` via the RN bridge,
|
|
104
|
+
* the bridge can fulfil the call using the real `UNNotificationResponse`
|
|
105
|
+
* without double-tracking.
|
|
106
|
+
*
|
|
107
|
+
* **Cold-launch safety:** If the SDK has not yet been initialized (e.g. the
|
|
108
|
+
* app was launched from a killed state via a push tap), the response is
|
|
109
|
+
* cached and will be automatically tracked once `sdk` is set during RN
|
|
110
|
+
* bridge initialization.
|
|
111
|
+
*
|
|
112
|
+
* @param response The `UNNotificationResponse` from the notification center delegate.
|
|
113
|
+
*/
|
|
114
|
+
@objc public func handleNotificationResponse(_ response: UNNotificationResponse) {
|
|
115
|
+
// UIApplication.shared.applicationState is a UIKit property that must
|
|
116
|
+
// be read on the main thread. userNotificationCenter(_:didReceive:) is
|
|
117
|
+
// almost always delivered on main, but we guard defensively.
|
|
118
|
+
let cacheAndTrack = { [self] (appState: UIApplication.State) in
|
|
119
|
+
// Cache the response for the RN bridge (consumed by handlePushOpenFromRN /
|
|
120
|
+
// handleForegroundPushFromRN).
|
|
121
|
+
responseLock.lock()
|
|
122
|
+
pendingResponse = response
|
|
123
|
+
pendingResponseTracked = false
|
|
124
|
+
pendingAppState = appState
|
|
125
|
+
responseLock.unlock()
|
|
126
|
+
|
|
127
|
+
trackResponse(response, appState: appState)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if Thread.isMainThread {
|
|
131
|
+
cacheAndTrack(UIApplication.shared.applicationState)
|
|
132
|
+
} else {
|
|
133
|
+
DispatchQueue.main.async {
|
|
134
|
+
cacheAndTrack(UIApplication.shared.applicationState)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// MARK: - Push Notification Handlers (legacy — prefer handleNotificationResponse)
|
|
59
140
|
|
|
60
141
|
/**
|
|
61
142
|
* Handle a push notification when the app is in the foreground (active state).
|
|
62
|
-
* Call this from AppDelegate's userNotificationCenter(_:didReceive:withCompletionHandler:)
|
|
63
|
-
* when UIApplication.shared.applicationState == .active
|
|
64
143
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
144
|
+
* - Important: Deprecated — use ``handleNotificationResponse(_:)`` instead,
|
|
145
|
+
* which handles app-state detection and auth-status lookup automatically.
|
|
146
|
+
* Do **not** call both methods for the same notification.
|
|
67
147
|
*/
|
|
148
|
+
@available(*, deprecated, message: "Use handleNotificationResponse(_:) instead")
|
|
68
149
|
@objc public func handleForegroundPush(response: UNNotificationResponse, authorizationStatus: UNAuthorizationStatus) {
|
|
69
150
|
nativeSDK?.handleForegroundPush(response: response, authorizationStatus: authorizationStatus)
|
|
70
151
|
}
|
|
71
152
|
|
|
72
153
|
/**
|
|
73
154
|
* Handle when a push notification is opened by the user (app in background/inactive state).
|
|
74
|
-
* Call this from AppDelegate's userNotificationCenter(_:didReceive:withCompletionHandler:)
|
|
75
|
-
* when UIApplication.shared.applicationState == .background or .inactive
|
|
76
155
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
156
|
+
* - Important: Deprecated — use ``handleNotificationResponse(_:)`` instead,
|
|
157
|
+
* which handles app-state detection and auth-status lookup automatically.
|
|
158
|
+
* Do **not** call both methods for the same notification.
|
|
79
159
|
*/
|
|
160
|
+
@available(*, deprecated, message: "Use handleNotificationResponse(_:) instead")
|
|
80
161
|
@objc public func handlePushOpen(response: UNNotificationResponse, authorizationStatus: UNAuthorizationStatus) {
|
|
81
162
|
nativeSDK?.handlePushOpen(response: response, authorizationStatus: authorizationStatus)
|
|
82
163
|
}
|
|
164
|
+
|
|
165
|
+
// MARK: - Response Cache (internal, used by RN bridge)
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Consume the cached notification response.
|
|
169
|
+
*
|
|
170
|
+
* Returns the cached `UNNotificationResponse` and whether it was already
|
|
171
|
+
* tracked by `handleNotificationResponse`. The bridge uses this to decide
|
|
172
|
+
* whether to call the native SDK again.
|
|
173
|
+
*
|
|
174
|
+
* After this call the cache is cleared.
|
|
175
|
+
*/
|
|
176
|
+
func consumePendingResponse() -> (response: UNNotificationResponse, alreadyTracked: Bool)? {
|
|
177
|
+
responseLock.lock()
|
|
178
|
+
defer { responseLock.unlock() }
|
|
179
|
+
|
|
180
|
+
guard let response = pendingResponse else { return nil }
|
|
181
|
+
let tracked = pendingResponseTracked
|
|
182
|
+
pendingResponse = nil
|
|
183
|
+
pendingResponseTracked = false
|
|
184
|
+
return (response, tracked)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// MARK: - Private
|
|
188
|
+
|
|
189
|
+
/// Attempt to track a cached response via the native SDK.
|
|
190
|
+
/// If nativeSDK is nil (cold launch), returns without marking tracked;
|
|
191
|
+
/// the `sdk` didSet will call `flushPendingResponseIfNeeded` later.
|
|
192
|
+
///
|
|
193
|
+
/// Uses the supplied `appState` (captured at `handleNotificationResponse` time)
|
|
194
|
+
/// rather than the current app state, so cold-launch replays classify the
|
|
195
|
+
/// event correctly.
|
|
196
|
+
private func trackResponse(_ response: UNNotificationResponse, appState: UIApplication.State) {
|
|
197
|
+
UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
|
|
198
|
+
guard let self = self else { return }
|
|
199
|
+
let authStatus = settings.authorizationStatus
|
|
200
|
+
DispatchQueue.main.async {
|
|
201
|
+
// Check that this response is still pending and untracked, then
|
|
202
|
+
// mark tracked optimistically *before* releasing the lock. This
|
|
203
|
+
// closes the window where consumePendingResponse() could run on
|
|
204
|
+
// another thread, see alreadyTracked: false, and also call the
|
|
205
|
+
// native SDK — resulting in double-tracking.
|
|
206
|
+
self.responseLock.lock()
|
|
207
|
+
guard self.pendingResponse === response, !self.pendingResponseTracked else {
|
|
208
|
+
self.responseLock.unlock()
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
guard self.nativeSDK != nil else {
|
|
213
|
+
// SDK not yet initialized (cold launch). Leave
|
|
214
|
+
// pendingResponseTracked = false so the didSet observer
|
|
215
|
+
// on `sdk` can flush later.
|
|
216
|
+
self.responseLock.unlock()
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Mark tracked while still holding the lock, before the SDK call.
|
|
221
|
+
self.pendingResponseTracked = true
|
|
222
|
+
self.responseLock.unlock()
|
|
223
|
+
|
|
224
|
+
// Safe to call outside the lock — we've already claimed ownership.
|
|
225
|
+
switch appState {
|
|
226
|
+
case .active:
|
|
227
|
+
self.nativeSDK?.handleForegroundPush(response: response, authorizationStatus: authStatus)
|
|
228
|
+
case .background, .inactive:
|
|
229
|
+
self.nativeSDK?.handlePushOpen(response: response, authorizationStatus: authStatus)
|
|
230
|
+
@unknown default:
|
|
231
|
+
self.nativeSDK?.handlePushOpen(response: response, authorizationStatus: authStatus)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// Called from `sdk` didSet when the SDK becomes available.
|
|
238
|
+
/// If there is a pending untracked response (cold-launch scenario),
|
|
239
|
+
/// track it now using the app state captured at the original
|
|
240
|
+
/// `handleNotificationResponse` call.
|
|
241
|
+
private func flushPendingResponseIfNeeded() {
|
|
242
|
+
responseLock.lock()
|
|
243
|
+
guard let response = pendingResponse, !pendingResponseTracked else {
|
|
244
|
+
responseLock.unlock()
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
let appState = pendingAppState
|
|
248
|
+
responseLock.unlock()
|
|
249
|
+
|
|
250
|
+
trackResponse(response, appState: appState)
|
|
251
|
+
}
|
|
83
252
|
}
|