@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.
Files changed (29) hide show
  1. package/README.md +117 -11
  2. package/android/build.gradle +4 -1
  3. package/android/src/main/kotlin/com/attentivereactnativesdk/AttentiveNotificationStore.kt +60 -0
  4. package/android/src/main/kotlin/com/attentivereactnativesdk/AttentivePushHelper.kt +15 -7
  5. package/android/src/main/kotlin/com/attentivereactnativesdk/AttentiveReactNativeSdkModule.kt +370 -140
  6. package/android/src/test/kotlin/com/attentivereactnativesdk/AttentiveNotificationStoreTest.kt +103 -0
  7. package/attentive-react-native-sdk.podspec +1 -1
  8. package/ios/AttentiveReactNativeSdk.mm +17 -2
  9. package/ios/AttentiveReactNativeSdk.xcodeproj/project.xcworkspace/xcuserdata/zheref.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
  10. package/ios/Bridging/ATTNNativeSDK.swift +116 -46
  11. package/ios/Bridging/AttentiveSDKManager.swift +196 -27
  12. package/ios/Podfile +1 -1
  13. package/lib/commonjs/NativeAttentiveReactNativeSdk.js +1 -1
  14. package/lib/commonjs/NativeAttentiveReactNativeSdk.js.map +1 -1
  15. package/lib/commonjs/eventTypes.js.map +1 -1
  16. package/lib/commonjs/index.js +50 -17
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/module/NativeAttentiveReactNativeSdk.js +2 -2
  19. package/lib/module/NativeAttentiveReactNativeSdk.js.map +1 -1
  20. package/lib/module/eventTypes.js.map +1 -1
  21. package/lib/module/index.js +50 -18
  22. package/lib/module/index.js.map +1 -1
  23. package/lib/typescript/NativeAttentiveReactNativeSdk.d.ts +12 -1
  24. package/lib/typescript/NativeAttentiveReactNativeSdk.d.ts.map +1 -1
  25. package/lib/typescript/index.d.ts +46 -18
  26. package/lib/typescript/index.d.ts.map +1 -1
  27. package/package.json +3 -2
  28. package/src/NativeAttentiveReactNativeSdk.ts +69 -52
  29. 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
+ }
@@ -16,7 +16,7 @@ Pod::Spec.new do |s|
16
16
 
17
17
  s.source_files = "ios/**/*.{h,m,mm,swift}"
18
18
 
19
- s.dependency 'attentive-ios-sdk', '2.0.8'
19
+ s.dependency 'attentive-ios-sdk', '2.0.13'
20
20
  s.swift_versions = ['5']
21
21
  s.dependency "React-Core"
22
22
 
@@ -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
 
@@ -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 2.0.8 changed the API to require UNNotificationResponse instead of userInfo dictionary.
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: SDK 2.0.8 changed the API to require UNNotificationResponse
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 in SDK 2.0.8")
297
- print("[AttentiveSDK] The native SDK now requires UNNotificationResponse for push tracking")
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 2.0.8 requires native UNNotificationResponse handling in AppDelegate"
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: SDK 2.0.8 changed the API to require UNNotificationResponse
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 in SDK 2.0.8")
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 2.0.8 requires native UNNotificationResponse handling"
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
- * Handle a push notification when the app is in the foreground (active state) - React Native version.
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
- * Note: This is a limited version since we don't have access to the full UNNotificationResponse.
388
- * For full functionality, use the native AppDelegate implementation.
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
- * @param userInfo The notification payload dictionary
391
- * @param authorizationStatus Current push authorization status string
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
- _ = mapAuthorizationStatus(authorizationStatus)
396
-
397
- // Note: SDK 2.0.8 requires UNNotificationResponse, but we only have userInfo from React Native
398
- // This is a workaround that logs the limitation
399
- print("[AttentiveSDK] handleForegroundPush called from React Native (limited functionality)")
400
- print("[AttentiveSDK] For full functionality, implement in native AppDelegate")
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
- if debuggingEnabled {
403
- showDebugInfo(event: "Foreground Push (React Native)", data: [
404
- "authorizationStatus": authorizationStatus,
405
- "userInfo": userInfo,
406
- "note": "Limited functionality - UNNotificationResponse not available from React Native"
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
- * Handle when a push notification is opened by the user (app in background/inactive state) - React Native version.
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
- * Note: This is a limited version since we don't have access to the full UNNotificationResponse.
416
- * For full functionality, use the native AppDelegate implementation.
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
- * @param userInfo The notification payload dictionary
419
- * @param authorizationStatus Current push authorization status string
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
- _ = mapAuthorizationStatus(authorizationStatus)
424
-
425
- // Note: SDK 2.0.8 requires UNNotificationResponse, but we only have userInfo from React Native
426
- // This is a workaround that logs the limitation
427
- print("[AttentiveSDK] handlePushOpen called from React Native (limited functionality)")
428
- print("[AttentiveSDK] For full functionality, implement in native AppDelegate")
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
- if debuggingEnabled {
431
- showDebugInfo(event: "Push Open (React Native)", data: [
432
- "authorizationStatus": authorizationStatus,
433
- "userInfo": userInfo,
434
- "note": "Limited functionality - UNNotificationResponse not available from React Native"
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
- * Usage in AppDelegate:
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
- * // In userNotificationCenter(_:didReceive:withCompletionHandler:)
20
- * UNUserNotificationCenter.current().getNotificationSettings { settings in
21
- * let authStatus = settings.authorizationStatus
22
- * DispatchQueue.main.async {
23
- * switch UIApplication.shared.applicationState {
24
- * case .active:
25
- * AttentiveSDKManager.shared.handleForegroundPush(response: response, authorizationStatus: authStatus)
26
- * case .background, .inactive:
27
- * AttentiveSDKManager.shared.handlePushOpen(response: response, authorizationStatus: authStatus)
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
- /// This should be set when the SDK is initialized from React Native
41
- /// Cast to ATTNNativeSDK in Swift code
42
- @objc public var sdk: AnyObject?
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 Handlers (for AppDelegate)
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
- * @param response The UNNotificationResponse from the notification center delegate
66
- * @param authorizationStatus Current push authorization status from notification settings
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
- * @param response The UNNotificationResponse from the notification center delegate
78
- * @param authorizationStatus Current push authorization status from notification settings
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
  }
package/ios/Podfile CHANGED
@@ -20,7 +20,7 @@ target 'AttentiveReactNativeSdk' do
20
20
  flags = get_default_flags()
21
21
 
22
22
  # Ensure Swift support
23
- pod 'attentive-ios-sdk', '2.0.8'
23
+ pod 'attentive-ios-sdk', '2.0.13'
24
24
 
25
25
  use_react_native!(
26
26
  :path => config[:reactNativePath],