@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
package/README.md CHANGED
@@ -68,12 +68,59 @@ See [DEBUGGING.md](./DEBUGGING.md) for detailed information about debugging feat
68
68
 
69
69
  ### Initialize the SDK
70
70
 
71
+ > **Platform difference:** iOS and Android have different initialization requirements.
72
+
73
+ #### iOS — Initialize from TypeScript
74
+
75
+ On iOS, call `initialize` from TypeScript as early as possible (e.g. the root `App` component's `useEffect`):
76
+
71
77
  ```typescript
72
- // 'initialize' should be called as soon as possible after the app starts (see the example app for an example of initializing the SDK in the App element)
73
- // Note: 'initialize' should only be called once per app session - if you call it multiple times it will throw an exception
78
+ // Called once per app session, before any other SDK operations.
74
79
  Attentive.initialize(config);
75
80
  ```
76
81
 
82
+ #### Android — Initialize from Native Code
83
+
84
+ On Android, `AttentiveSdk.initialize()` **must** be called from your `Application.onCreate()` in native Kotlin/Java code. This is required so that lifecycle observers (e.g. `AppLaunchTracker`) are registered before the React Native bridge is ready. Calling `initialize()` from TypeScript on Android is a **no-op** — the SDK will not be started and all subsequent event calls will fail.
85
+
86
+ Add the following to your `MainApplication.kt` (or `MainApplication.java`):
87
+
88
+ ```kotlin
89
+ import android.app.Application
90
+ import com.attentive.androidsdk.AttentiveConfig
91
+ import com.attentive.androidsdk.AttentiveSdk
92
+ import com.attentive.androidsdk.AttentiveLogLevel
93
+ import com.facebook.react.bridge.UiThreadUtil
94
+
95
+ class MainApplication : Application(), ReactApplication {
96
+
97
+ override fun onCreate() {
98
+ super.onCreate()
99
+ // ... your existing setup ...
100
+ initAttentiveSDK()
101
+ }
102
+
103
+ private fun initAttentiveSDK() {
104
+ val config = AttentiveConfig.Builder()
105
+ .applicationContext(this)
106
+ .domain("YOUR_ATTENTIVE_DOMAIN")
107
+ .mode(AttentiveConfig.Mode.PRODUCTION) // or Mode.DEBUG for testing
108
+ .skipFatigueOnCreatives(false)
109
+ .logLevel(AttentiveLogLevel.VERBOSE)
110
+ .build()
111
+
112
+ // AttentiveSdk.initialize registers lifecycle observers and must run on the main thread.
113
+ UiThreadUtil.runOnUiThread {
114
+ AttentiveSdk.initialize(config)
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ After the native initialization, all other SDK operations (`identify`, `recordAddToCartEvent`, `recordPurchaseEvent`, etc.) are called from TypeScript as normal on both platforms. The TypeScript `initialize()` call is still required on iOS but is safely ignored on Android.
121
+
122
+ > **Tip:** If you see `[AttentiveSDK] recordAddToCartEvent failed — SDK may not be initialized` in your Android logcat, it means `AttentiveSdk.initialize()` was not called from native code before the event was recorded. Check your `Application.onCreate()` setup.
123
+
77
124
  ### Destroy the creative
78
125
 
79
126
  ```typescript
@@ -173,6 +220,25 @@ Attentive.identify({phone: '+15556667777'};)
173
220
 
174
221
  The SDK supports push notification integration on both iOS (APNs) and Android (runtime permission + optional FCM). The following sections cover iOS-specific flows and a full **App events on Android** implementation that mirrors the behavior of the [Bonni](https://github.com/attentive-mobile/attentive-react-native-sdk/tree/main/Bonni) example app.
175
222
 
223
+ > **iOS — required setup:** Your AppDelegate **must** forward notification
224
+ > responses to the SDK for push tracking to work. Add this single line to your
225
+ > `userNotificationCenter(_:didReceive:withCompletionHandler:)`:
226
+ >
227
+ > ```swift
228
+ > AttentiveSDKManager.shared.handleNotificationResponse(response)
229
+ > ```
230
+ >
231
+ > Without this, push open and foreground push events **will not be tracked** on
232
+ > iOS. See [iOS AppDelegate Integration](#ios-appdelegate-integration) for full
233
+ > details.
234
+ >
235
+ > **Migrating from an earlier version?** If you previously called
236
+ > `AttentiveSDKManager.shared.handleForegroundPush(response:authorizationStatus:)`
237
+ > or `AttentiveSDKManager.shared.handlePushOpen(response:authorizationStatus:)`
238
+ > directly from your AppDelegate, **replace** that code with the single
239
+ > `handleNotificationResponse` call above. Using both will result in
240
+ > double-tracked events. The old methods are now deprecated.
241
+
176
242
  ---
177
243
 
178
244
  ### App Events on Android
@@ -205,7 +271,7 @@ This section describes how to implement Attentive app events on Android so they
205
271
  </manifest>
206
272
  ```
207
273
 
208
- 2. **Initialize and identify first** – In your app entry (e.g. root component `useEffect`), call `initialize(config)` and `identify(identifiers)` before any push or app-event logic.
274
+ 2. **Initialize and identify first** – The SDK must be initialized natively from `Application.onCreate()` on Android (see [Android Native Initialization](#android--initialize-from-native-code) above). Then, in your app entry (e.g. root component `useEffect`), call `identify(identifiers)` before any push or app-event logic.
209
275
 
210
276
  #### 1. On app launch (Android)
211
277
 
@@ -228,7 +294,13 @@ import {
228
294
  } from 'attentive-react-native-sdk';
229
295
 
230
296
  // Inside your root component (e.g. App.tsx useEffect):
231
- initialize(config);
297
+
298
+ // iOS only: initialize from TypeScript.
299
+ // Android: initialization must be done natively from Application.onCreate() — see README.
300
+ if (Platform.OS === 'ios') {
301
+ initialize(config);
302
+ }
303
+
232
304
  identify({ email: 'user@example.com', clientUserId: 'id-123' });
233
305
 
234
306
  if (Platform.OS === 'android') {
@@ -270,12 +342,13 @@ return () => subscription.remove();
270
342
 
271
343
  #### 3. Optional: Register FCM token (Android)
272
344
 
273
- If your app uses Firebase Cloud Messaging and you have an FCM token, register it with the Attentive backend and then call `handleRegularOpen` in the callback (same pattern as iOS):
345
+ **Recommended:** This React Native SDK’s Android native module depends on Attentive Android SDK **2.1.1**, which exposes `AttentiveSdk.getPushTokenWithCallback`. Calling `registerForPushNotifications()` from JS triggers that API: the SDK requests permission (when needed), fetches the FCM token, and registers it with Attentive. No separate native code is required.
346
+
347
+ **Alternative (token from JS):** If you obtain the FCM token elsewhere (e.g. Firebase Messaging), use `registerDeviceTokenWithCallback` and then call `handleRegularOpen` in the callback:
274
348
 
275
349
  ```typescript
276
350
  import { registerDeviceTokenWithCallback, handleRegularOpen } from 'attentive-react-native-sdk';
277
351
 
278
- // When you receive the FCM token (e.g. from Firebase Messaging):
279
352
  getPushAuthorizationStatus().then((authStatus) => {
280
353
  registerDeviceTokenWithCallback(
281
354
  fcmToken,
@@ -303,7 +376,7 @@ Get `authorizationStatus` via `getPushAuthorizationStatus()` when handling the e
303
376
 
304
377
  The [Bonni](https://github.com/attentive-mobile/attentive-react-native-sdk/tree/main/Bonni) example app ([App.tsx](https://github.com/attentive-mobile/attentive-react-native-sdk/blob/main/Bonni/App.tsx)) implements the full flow:
305
378
 
306
- 1. **Launch:** `initialize` → `identify` → (Android) `getPushAuthorizationStatus()` → `handleRegularOpen(authStatus)` → `registerForPushNotifications()`.
379
+ 1. **Launch:** Native `AttentiveSdk.initialize(config)` (from `Application.onCreate()`) TypeScript `identify` → `getPushAuthorizationStatus()` → `handleRegularOpen(authStatus)` → `registerForPushNotifications()`.
307
380
  2. **Foreground:** `AppState.addEventListener('change', …)` → when `active` and Android → `getPushAuthorizationStatus()` → `handleRegularOpen(authStatus)`.
308
381
  3. **Optional:** When FCM token is available → `registerDeviceTokenWithCallback(token, authStatus, callback)` → in callback call `handleRegularOpen(authStatus)`.
309
382
  4. **Optional:** When user opens a notification or receives one in foreground → `handlePushOpen` / `handleForegroundPush` with payload and status from `getPushAuthorizationStatus()`.
@@ -372,7 +445,40 @@ For proper push notification integration, your iOS AppDelegate needs to:
372
445
 
373
446
  1. Request notification permissions via the SDK
374
447
  2. Implement `application:didRegisterForRemoteNotificationsWithDeviceToken:` to register the token
375
- 3. Implement `UNUserNotificationCenterDelegate` methods to handle notification events
448
+ 3. **Forward notification responses to the SDK for push-open tracking**
449
+
450
+ ##### Push Open Tracking (Required)
451
+
452
+ Add **one line** to your AppDelegate's `didReceive` handler so the SDK can track
453
+ push opens and foreground push events. Without this, `handlePushOpen()` and
454
+ `handleForegroundPush()` called from JavaScript will not be able to track events
455
+ on iOS (the native SDK requires a `UNNotificationResponse` which cannot cross the
456
+ React Native bridge).
457
+
458
+ ```swift
459
+ // In AppDelegate.swift — UNUserNotificationCenterDelegate
460
+ func userNotificationCenter(
461
+ _ center: UNUserNotificationCenter,
462
+ didReceive response: UNNotificationResponse,
463
+ withCompletionHandler completionHandler: @escaping () -> Void
464
+ ) {
465
+ // Attentive push tracking (handles app-state + auth status automatically)
466
+ AttentiveSDKManager.shared.handleNotificationResponse(response)
467
+
468
+ // Forward to your push library (e.g. RNCPushNotificationIOS) for JS events
469
+ RNCPushNotificationIOS.didReceive(response)
470
+ completionHandler()
471
+ }
472
+ ```
473
+
474
+ `handleNotificationResponse` automatically:
475
+ - Detects whether the app is in the foreground or background
476
+ - Fetches the current authorization status
477
+ - Calls the correct native SDK method (`handlePushOpen` or `handleForegroundPush`)
478
+ - Caches the response so the JS-side `handlePushOpen()` / `handleForegroundPush()` calls
479
+ are fulfilled without double-tracking
480
+ - **Cold-launch safe:** If the user taps a push while the app is killed, the
481
+ response is cached and automatically tracked once the SDK initializes
376
482
 
377
483
  ##### Callback-Based Registration (Recommended)
378
484
 
@@ -389,13 +495,13 @@ func application(
389
495
  ) {
390
496
  UNUserNotificationCenter.current().getNotificationSettings { settings in
391
497
  let authStatus = settings.authorizationStatus
392
-
498
+
393
499
  // Get SDK instance with proper type
394
500
  guard let attentiveSdk = AttentiveSDKManager.shared.sdk as? ATTNNativeSDK else {
395
501
  print("[Attentive] SDK not initialized")
396
502
  return
397
503
  }
398
-
504
+
399
505
  // Register device token with callback
400
506
  attentiveSdk.registerDeviceToken(
401
507
  deviceToken,
@@ -406,7 +512,7 @@ func application(
406
512
  if let error = error {
407
513
  print("[Attentive] Registration failed: \(error.localizedDescription)")
408
514
  }
409
-
515
+
410
516
  // Trigger regular open event after registration
411
517
  attentiveSdk.handleRegularOpen(authorizationStatus: authStatus)
412
518
  }
@@ -82,7 +82,10 @@ dependencies {
82
82
  // For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin
83
83
  //noinspection GradleDynamicVersion
84
84
  implementation "com.facebook.react:react-native:+"
85
- implementation 'com.attentive:attentive-android-sdk:1.0.1'
85
+ // Use `api` so that the Attentive Android SDK types (AttentiveSdk, CustomEvent, etc.)
86
+ // are visible to app-level code (e.g. Bonni's AttentiveFirebaseMessagingService) that
87
+ // depends on this library and needs to call the SDK directly from native components.
88
+ api 'com.attentive:attentive-android-sdk:2.1.3'
86
89
  implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.10"
87
90
  implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.9.10"))
88
91
 
@@ -0,0 +1,60 @@
1
+ package com.attentivereactnativesdk
2
+
3
+ /**
4
+ * In-process store for a pending initial push notification payload.
5
+ *
6
+ * When the user taps an FCM notification while the app is in the killed state,
7
+ * Android launches the app's main activity before the React Native bridge is
8
+ * initialised. The notification payload is written here by the host app's
9
+ * `MainActivity` and consumed exactly once by [AttentiveReactNativeSdkModule.getInitialPushNotification]
10
+ * after the JS layer is ready.
11
+ *
12
+ * ## Usage pattern
13
+ *
14
+ * ```kotlin
15
+ * // In host app's MainActivity.onCreate:
16
+ * intent?.extras?.let { extras ->
17
+ * val payload = // … extract FCM data …
18
+ * AttentiveNotificationStore.setPendingInitialNotification(payload)
19
+ * }
20
+ * ```
21
+ *
22
+ * ```typescript
23
+ * // In JS (App.tsx), after initialize():
24
+ * const initial = await getInitialPushNotification()
25
+ * if (initial) handlePushOpen(initial, authStatus)
26
+ * ```
27
+ *
28
+ * Thread-safety: the two public methods are `@Synchronized` so concurrent access
29
+ * from the main UI thread (writer) and the JS bridge thread (reader) is safe.
30
+ */
31
+ object AttentiveNotificationStore {
32
+
33
+ @Volatile
34
+ private var pendingInitialNotification: Map<String, String>? = null
35
+
36
+ /**
37
+ * Stores [payload] as the pending initial push notification.
38
+ *
39
+ * Replaces any previously stored value (only one initial notification is tracked at a time).
40
+ *
41
+ * @param payload A map of string key-value pairs representing the notification data.
42
+ */
43
+ @Synchronized
44
+ fun setPendingInitialNotification(payload: Map<String, String>) {
45
+ pendingInitialNotification = payload
46
+ }
47
+
48
+ /**
49
+ * Returns the stored initial push notification payload and clears it atomically,
50
+ * ensuring the payload is delivered to the JS layer exactly once.
51
+ *
52
+ * @return The stored payload map, or `null` if no initial notification is pending.
53
+ */
54
+ @Synchronized
55
+ fun getAndClear(): Map<String, String>? {
56
+ val pending = pendingInitialNotification
57
+ pendingInitialNotification = null
58
+ return pending
59
+ }
60
+ }
@@ -26,8 +26,8 @@ object AttentivePushHelper {
26
26
  /**
27
27
  * Authorization status values aligned with iOS push authorization for use in handleRegularOpen etc.
28
28
  * - "authorized" – user has granted notification permission (or API < 33)
29
- * - "denied" – user denied or permission not granted
30
- * - "notDetermined" – not yet requested (API 33+ only)
29
+ * - "denied" – user was asked and denied (API 33+, when determinable via activity)
30
+ * - "notDetermined" – not yet requested, or unable to distinguish (API 33+ only)
31
31
  */
32
32
  const val STATUS_AUTHORIZED = "authorized"
33
33
  const val STATUS_DENIED = "denied"
@@ -36,14 +36,18 @@ object AttentivePushHelper {
36
36
  /**
37
37
  * Returns the current push notification authorization status.
38
38
  *
39
- * On API 33+: uses [android.permission.POST_NOTIFICATIONS].
39
+ * On API 33+: uses [android.permission.POST_NOTIFICATIONS]. When permission is not granted,
40
+ * uses [activity] (when provided) and [ActivityCompat.shouldShowRequestPermissionRationale]
41
+ * to distinguish "denied" (user was asked and declined) from "notDetermined" (not yet asked).
40
42
  * On API < 33: returns [STATUS_AUTHORIZED] (no runtime permission required).
41
43
  *
42
44
  * @param context Application or Activity context
45
+ * @param activity Current activity, or null. When non-null on API 33+, used to detect
46
+ * "denied" vs "notDetermined" so downstream logic and analytics are correct for denied users.
43
47
  * @return One of [STATUS_AUTHORIZED], [STATUS_DENIED], or [STATUS_NOT_DETERMINED]
44
48
  */
45
49
  @JvmStatic
46
- fun getAuthorizationStatus(context: Context): String {
50
+ fun getAuthorizationStatus(context: Context, activity: Activity? = null): String {
47
51
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
48
52
  // API 32 and below: notification permission not required at runtime
49
53
  return STATUS_AUTHORIZED
@@ -51,9 +55,13 @@ object AttentivePushHelper {
51
55
  return when (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS)) {
52
56
  PackageManager.PERMISSION_GRANTED -> STATUS_AUTHORIZED
53
57
  else -> {
54
- // Not granted. Without an Activity we cannot distinguish "denied" vs "not yet asked".
55
- // Return notDetermined so the app can call registerForPushNotifications to request.
56
- STATUS_NOT_DETERMINED
58
+ // Not granted. Use shouldShowRequestPermissionRationale when we have an Activity
59
+ // so we do not report "denied" users as "notDetermined" (fixes prompt-gating and analytics).
60
+ if (activity != null && ActivityCompat.shouldShowRequestPermissionRationale(activity, android.Manifest.permission.POST_NOTIFICATIONS)) {
61
+ STATUS_DENIED
62
+ } else {
63
+ STATUS_NOT_DETERMINED
64
+ }
57
65
  }
58
66
  }
59
67
  }