react-native-notify-kit 9.6.0 → 10.0.0

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 (107) hide show
  1. package/README.md +87 -5
  2. package/RNNotifeeCore.podspec +2 -0
  3. package/android/src/main/java/app/notifee/core/NotificationManager.java +98 -0
  4. package/android/src/test/java/app/notifee/core/GetDisplayedNotificationsDataTest.java +242 -0
  5. package/cli/bin/react-native-notify-kit +2 -0
  6. package/cli/dist/cli.bundle.js +20834 -0
  7. package/cli/dist/cli.d.ts +2 -0
  8. package/cli/dist/cli.js +87 -0
  9. package/cli/dist/cli.js.map +1 -0
  10. package/cli/dist/commands/initNse.d.ts +12 -0
  11. package/cli/dist/commands/initNse.js +212 -0
  12. package/cli/dist/commands/initNse.js.map +1 -0
  13. package/cli/dist/lib/detectProject.d.ts +15 -0
  14. package/cli/dist/lib/detectProject.js +156 -0
  15. package/cli/dist/lib/detectProject.js.map +1 -0
  16. package/cli/dist/lib/logger.d.ts +4 -0
  17. package/cli/dist/lib/logger.js +24 -0
  18. package/cli/dist/lib/logger.js.map +1 -0
  19. package/cli/dist/lib/patchPodfile.d.ts +12 -0
  20. package/cli/dist/lib/patchPodfile.js +143 -0
  21. package/cli/dist/lib/patchPodfile.js.map +1 -0
  22. package/cli/dist/lib/patchXcodeProject.d.ts +25 -0
  23. package/cli/dist/lib/patchXcodeProject.js +239 -0
  24. package/cli/dist/lib/patchXcodeProject.js.map +1 -0
  25. package/cli/dist/lib/writeTemplates.d.ts +11 -0
  26. package/cli/dist/lib/writeTemplates.js +82 -0
  27. package/cli/dist/lib/writeTemplates.js.map +1 -0
  28. package/cli/dist/templates/Info.plist.tmpl +29 -0
  29. package/cli/dist/templates/NotificationService.swift.tmpl +32 -0
  30. package/cli/dist/templates/NotifyKitNSE.entitlements.tmpl +6 -0
  31. package/cli/dist/templates/templates/Info.plist.tmpl +29 -0
  32. package/cli/dist/templates/templates/NotificationService.swift.tmpl +73 -0
  33. package/cli/dist/templates/templates/NotifyKitNSE.entitlements.tmpl +6 -0
  34. package/dist/NotifeeApiModule.d.ts +17 -0
  35. package/dist/NotifeeApiModule.js +66 -1
  36. package/dist/NotifeeApiModule.js.map +1 -1
  37. package/dist/fcm/index.d.ts +4 -0
  38. package/dist/fcm/index.js +3 -0
  39. package/dist/fcm/index.js.map +1 -0
  40. package/dist/fcm/parseFcmPayload.d.ts +16 -0
  41. package/dist/fcm/parseFcmPayload.js +37 -0
  42. package/dist/fcm/parseFcmPayload.js.map +1 -0
  43. package/dist/fcm/reconstructNotification.d.ts +8 -0
  44. package/dist/fcm/reconstructNotification.js +167 -0
  45. package/dist/fcm/reconstructNotification.js.map +1 -0
  46. package/dist/fcm/types.d.ts +51 -0
  47. package/dist/fcm/types.js +6 -0
  48. package/dist/fcm/types.js.map +1 -0
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.js +1 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/types/Module.d.ts +21 -0
  53. package/dist/types/Notification.d.ts +35 -4
  54. package/dist/types/Notification.js.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/dist/version.js.map +1 -1
  58. package/jest-mock.js +1 -0
  59. package/package.json +38 -3
  60. package/server/README.md +129 -0
  61. package/server/dist/android.d.ts +8 -0
  62. package/server/dist/android.d.ts.map +1 -0
  63. package/server/dist/android.js +20 -0
  64. package/server/dist/android.js.map +1 -0
  65. package/server/dist/buildPayload.d.ts +3 -0
  66. package/server/dist/buildPayload.d.ts.map +1 -0
  67. package/server/dist/buildPayload.js +82 -0
  68. package/server/dist/buildPayload.js.map +1 -0
  69. package/server/dist/index.d.ts +6 -0
  70. package/server/dist/index.d.ts.map +1 -0
  71. package/server/dist/index.js +12 -0
  72. package/server/dist/index.js.map +1 -0
  73. package/server/dist/ios.d.ts +11 -0
  74. package/server/dist/ios.d.ts.map +1 -0
  75. package/server/dist/ios.js +61 -0
  76. package/server/dist/ios.js.map +1 -0
  77. package/server/dist/serialize.d.ts +11 -0
  78. package/server/dist/serialize.d.ts.map +1 -0
  79. package/server/dist/serialize.js +32 -0
  80. package/server/dist/serialize.js.map +1 -0
  81. package/server/dist/types.d.ts +2 -0
  82. package/server/dist/types.d.ts.map +1 -0
  83. package/server/dist/types.js +3 -0
  84. package/server/dist/types.js.map +1 -0
  85. package/server/dist/validation.d.ts +3 -0
  86. package/server/dist/validation.d.ts.map +1 -0
  87. package/server/dist/validation.js +101 -0
  88. package/server/dist/validation.js.map +1 -0
  89. package/server/package.json +5 -0
  90. package/server/src/android.ts +27 -0
  91. package/server/src/buildPayload.ts +91 -0
  92. package/server/src/index.ts +12 -0
  93. package/server/src/ios.ts +88 -0
  94. package/server/src/serialize.ts +39 -0
  95. package/server/src/types.ts +24 -0
  96. package/server/src/validation.ts +134 -0
  97. package/server/tsconfig.json +25 -0
  98. package/src/NotifeeApiModule.ts +80 -1
  99. package/src/fcm/index.ts +4 -0
  100. package/src/fcm/parseFcmPayload.ts +56 -0
  101. package/src/fcm/reconstructNotification.ts +201 -0
  102. package/src/fcm/types.ts +57 -0
  103. package/src/index.ts +2 -0
  104. package/src/internal/fcmContract.d.ts +150 -0
  105. package/src/types/Module.ts +23 -0
  106. package/src/types/Notification.ts +35 -4
  107. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -135,10 +135,30 @@ useEffect(() => {
135
135
  }, []);
136
136
  ```
137
137
 
138
+ ## Notifee FCM Mode (NEW in 10.0.0)
139
+
140
+ **Use `react-native-notify-kit` as the sole FCM display layer on both Android and iOS** — one developer API, no duplicate notifications on Android, no silent-push drops on iOS. Ship a server SDK payload, let the client handle it in one line, and scaffold the iOS Notification Service Extension with a single CLI command:
141
+
142
+ ```bash
143
+ # server: build the payload
144
+ import { buildNotifyKitPayload } from 'react-native-notify-kit/server';
145
+ await admin.messaging().send(buildNotifyKitPayload({ token, notification: { title, body, android, ios } }));
146
+
147
+ # client (Android + iOS): one line in setBackgroundMessageHandler / onMessage
148
+ await notifee.handleFcmMessage(remoteMessage);
149
+
150
+ # iOS NSE scaffold
151
+ npx react-native-notify-kit init-nse && cd ios && pod install
152
+ ```
153
+
154
+ See the full guide: **[docs/fcm-mode.md](docs/fcm-mode.md)** — covers architecture, server SDK reference, client API, NSE setup, payload schema, migration, troubleshooting, and known limitations.
155
+
138
156
  ## Push Notifications (Firebase)
139
157
 
140
158
  This library handles notification **display and management**. For receiving push notifications, pair it with [`@react-native-firebase/messaging`](https://rnfirebase.io/messaging/usage):
141
159
 
160
+ > **New in 10.0.0:** for a turnkey FCM integration that handles both platforms (no duplicate on Android, APNs-reliable on iOS), use **[Notifee FCM Mode](docs/fcm-mode.md)** instead of the manual pattern below. The sections that follow still apply for basic Firebase setup (google-services plugin, permissions, APNs capability).
161
+
142
162
  ### Android setup
143
163
 
144
164
  1. Add Firebase dependencies to your app:
@@ -196,7 +216,22 @@ messaging().onMessage(async remoteMessage => {
196
216
 
197
217
  ## iOS Notification Service Extension
198
218
 
199
- To modify push notification content before display (e.g., attach images), create a Notification Service Extension:
219
+ To modify push notification content before display (e.g., attach images), create a Notification Service Extension.
220
+
221
+ ### Automated setup (recommended)
222
+
223
+ From your project root, with `react-native-notify-kit` installed:
224
+
225
+ ```bash
226
+ npx react-native-notify-kit init-nse
227
+ cd ios && pod install
228
+ ```
229
+
230
+ The CLI scaffolds a Swift NSE target (default name: `NotifyKitNSE`), patches your Podfile, and wires `.pbxproj`. Open your `.xcworkspace` in Xcode, verify the NSE target's signing, and build. See [docs/fcm-mode.md#ios-nse-setup](docs/fcm-mode.md#ios-nse-setup) for the full CLI reference (target name, bundle suffix, `--dry-run`, `--force`).
231
+
232
+ ### Manual setup
233
+
234
+ For Expo, monorepos with non-standard iOS paths, or heavily-customized Xcode configurations where the CLI can't patch the project cleanly:
200
235
 
201
236
  1. In Xcode: **File > New > Target > Notification Service Extension**
202
237
  2. Add to your Podfile:
@@ -234,6 +269,40 @@ To modify push notification content before display (e.g., attach images), create
234
269
 
235
270
  5. Run `cd ios && pod install`
236
271
 
272
+ ## Server SDK
273
+
274
+ `react-native-notify-kit` ships a **zero-dependency server SDK** under the `/server` subpath, for building FCM HTTP v1 payloads that the client `handleFcmMessage` handler consumes. Runs in Node.js 22+ and Firebase Cloud Functions.
275
+
276
+ ```ts
277
+ import { buildNotifyKitPayload } from 'react-native-notify-kit/server';
278
+ import * as admin from 'firebase-admin';
279
+
280
+ const message = buildNotifyKitPayload({
281
+ token: '<device FCM token>',
282
+ notification: {
283
+ id: 'order-42',
284
+ title: 'Your order is on the way',
285
+ body: 'Tap to see live tracking',
286
+ data: { orderId: '42' },
287
+ android: { channelId: 'orders', smallIcon: 'ic_notification' },
288
+ ios: { sound: 'default', interruptionLevel: 'timeSensitive' },
289
+ },
290
+ options: { androidPriority: 'high', ttl: 3600 },
291
+ });
292
+
293
+ await admin.messaging().send(message);
294
+ ```
295
+
296
+ Full reference: [docs/fcm-mode.md#server-sdk-reference](docs/fcm-mode.md#server-sdk-reference) (types, validation rules, payload shape, FCM 4 KB limit).
297
+
298
+ ## CLI Tools
299
+
300
+ The library ships a small CLI at `npx react-native-notify-kit`. Currently one command is available:
301
+
302
+ - `npx react-native-notify-kit init-nse` — scaffolds an iOS Notification Service Extension (Swift), patches the Podfile, and wires `.pbxproj`. See [docs/fcm-mode.md#ios-nse-setup](docs/fcm-mode.md#ios-nse-setup) for options.
303
+
304
+ The CLI is prepacked into the main package at publish time, so `npx react-native-notify-kit` works immediately after `yarn add react-native-notify-kit` — no separate install.
305
+
237
306
  ## Jest Testing
238
307
 
239
308
  Mock the native module in your Jest setup file:
@@ -263,7 +332,7 @@ This fork is a complete migration to React Native's **New Architecture**:
263
332
  - **Toolchain**: Yarn 4, Node 22+, Java 17, compileSdk/targetSdk 35
264
333
  - **Single Android module** — the original Notifee shipped a pre-compiled AAR bundled inside the npm tarball under a frozen Maven coordinate; this fork compiles the core from source as part of the React Native bridge module on every consumer build. Eliminates the `FAIL_ON_PROJECT_REPOS` issue on RN 0.74+ and the Gradle cache staleness bug that could serve outdated bytecode after `yarn upgrade`.
265
334
  - **Core notification logic (NotifeeCore) is unchanged** — the public API is fully compatible with the original Notifee
266
- - **30 upstream bugs fixed** — see [Bugs Fixed from Upstream Notifee](#bugs-fixed-from-upstream-notifee) below
335
+ - **33 upstream bugs fixed** — see [Bugs Fixed from Upstream Notifee](#bugs-fixed-from-upstream-notifee) below
267
336
  - **Reliable trigger notifications** — AlarmManager is the default backend instead of WorkManager, with automatic fallback when exact alarm permission is not granted
268
337
  - **New API: `setNotificationConfig()`** — opt-out flag to prevent Notifee from intercepting iOS remote notification handlers (see [New APIs](#new-apis) below)
269
338
  - **Baseline Profile** — the library AAR ships a Baseline Profile that instructs ART to AOT-compile the foreground service notification hot path at install time, eliminating JIT penalty on first invocation
@@ -304,8 +373,21 @@ This fork fixes the following bugs that were never resolved in the original Noti
304
373
  | `getBadgeCount:` completion block never called when running in an app extension, causing JS promises to hang forever in NSE handlers | iOS | Pre-existing | 9.4.0 |
305
374
  | Notification Service Extension attachment downloads had no timeout cap (default 60-second `NSURLSession` timeout exceeds iOS's ~30-second NSE budget), causing extension process kill and notification loss on slow networks | iOS | Pre-existing | 9.4.0 |
306
375
  | `cancelTriggerNotifications()` / `createTriggerNotification()` promises resolve before Room DB write completes, causing ~3% race on cancel-then-create patterns. Also fixes a previously-undocumented reboot-recovery data-loss bug in `NotifeeAlarmManager.rescheduleNotification` and an ordering bug in `NotificationManager.doScheduledWork` | Android | [#549](https://github.com/invertase/notifee/issues/549) | 9.5.0 |
307
- | Scheduled trigger notifications silently lost across device reboot on OEM devices (Xiaomi MIUI, OnePlus, Huawei EMUI, Oppo ColorOS, Vivo FuntouchOS) whose vendor OS suppresses `BOOT_COMPLETED` until the user manually enables autostart. Also handles zombie non-repeating triggers whose fire time already passed (fire-once within a 24-hour grace period, then delete the Room row; delete silently beyond the grace period) and adds try/catch/finally guards to all notifee `BroadcastReceiver` async paths. | Android | [#734](https://github.com/invertase/notifee/issues/734) | Unreleased |
308
- | `ObjectAlreadyConsumedException` in headless task when the same `WritableMap` is reused or the `taskConfig` accessor is read twice — `TaskConfig.init` mutated the caller's map instead of copying it first. Latent in most apps but observed in production by upstream users with high-frequency headless events | Android | [#266](https://github.com/invertase/notifee/issues/266) | Unreleased |
376
+ | Scheduled trigger notifications silently lost across device reboot on OEM devices (Xiaomi MIUI, OnePlus, Huawei EMUI, Oppo ColorOS, Vivo FuntouchOS) whose vendor OS suppresses `BOOT_COMPLETED` until the user manually enables autostart. Also handles zombie non-repeating triggers whose fire time already passed (fire-once within a 24-hour grace period, then delete the Room row; delete silently beyond the grace period) and adds try/catch/finally guards to all notifee `BroadcastReceiver` async paths. | Android | [#734](https://github.com/invertase/notifee/issues/734) | 9.6.0 |
377
+ | `ObjectAlreadyConsumedException` in headless task when the same `WritableMap` is reused or the `taskConfig` accessor is read twice — `TaskConfig.init` mutated the caller's map instead of copying it first. Latent in most apps but observed in production by upstream users with high-frequency headless events | Android | [#266](https://github.com/invertase/notifee/issues/266) | 9.6.0 |
378
+ | `getDisplayedNotifications()` returned no `data` field on Android, breaking iOS/Android API symmetry where iOS exposes custom keys via `parseDataFromUserInfo:` (see platform limitation note below — the fix is API parity for app-posted notifications, not a workaround for FCM background auto-display) | Android | [#393](https://github.com/invertase/notifee/issues/393) | 9.7.0 |
379
+
380
+ > **Important note on `getDisplayedNotifications()` and FCM custom data on Android.**
381
+ >
382
+ > The 9.7.0 fix for [#393](https://github.com/invertase/notifee/issues/393) makes `getDisplayedNotifications()` expose a `data` field for notifications stored in `Notification.extras` (matching iOS shape). However, when the FCM Android SDK auto-displays a `notification`+`data` push while the app is in background or killed, custom `data` fields are placed only on the tap-action `PendingIntent` — never on `Notification.extras`. This is the FCM SDK's original design (see `CommonNotificationBuilder.createContentIntent` in [firebase-android-sdk](https://github.com/firebase/firebase-android-sdk/blob/master/firebase-messaging/src/main/java/com/google/firebase/messaging/CommonNotificationBuilder.java)) and is not workaroundable from any library — the PendingIntent's extras are sealed by Android's security model. Firebase tracks this gap in [firebase-android-sdk#2639](https://github.com/firebase/firebase-android-sdk/issues/2639) (open since 2021).
383
+ >
384
+ > **The fix does work** for these scenarios on Android: notifications created via `notifee.displayNotification({ data: {...} })`, FCM data-only messages handled in `onMessageReceived` followed by an explicit `displayNotification()` call, custom `FirebaseMessagingService.handleIntent` overrides that inject extras before display, and notifications from other libraries that call `NotificationCompat.Builder.addExtras(bundle)`.
385
+ >
386
+ > **Recommended pattern for full control over FCM push notifications on Android**: send FCM data-only messages (omit the `notification` field server-side), handle them in `onMessageReceived` (or a headless task in killed state), and render the notification yourself via `notifee.displayNotification()`. This gives full control over `data`, channel, styling, tap behavior, and is also Firebase's [official recommendation](https://firebase.blog/posts/2018/09/handle-fcm-messages-on-android/).
387
+ >
388
+ > **Reserved keys filtered from `data`** (custom payload keys matching these are dropped): prefixes `android.`, `google.`, `gcm.`, `fcm.` (with trailing dot — `fcmRegion`, `googleish` survive), `notifee` (no trailing dot — library's reserved namespace, `notifeeFoo` is also filtered), plus exact keys `from`, `collapse_key`, `message_type`, `message_id`, `aps`, `fcm_options`. The `fcm_options` exact-match matches iOS behavior on the Firebase analytics-label key. **Cross-platform note**: bare-`fcm` keys other than `fcm_options` (e.g. `fcmRegion`, `fcmToken`) are preserved on Android but filtered on iOS — rename server-side if you need strict parity.
389
+
390
+ <!-- markdownlint-disable-line MD028 -->
309
391
 
310
392
  > **Note for apps requiring guaranteed exact alarms (alarm clocks, timers, calendars):**
311
393
  > Add `<uses-permission android:name="android.permission.USE_EXACT_ALARM" />` to your app's
@@ -675,7 +757,7 @@ The script runs the macrobenchmark test in `apps/smoke/android/baselineprofile/`
675
757
 
676
758
  ## Documentation
677
759
 
678
- The upstream Notifee documentation remains the best reference for the public API and platform guides used by this fork.
760
+ The full `react-native-notify-kit` documentation is hosted on docs.page and is kept in sync with this repo. It covers the public API, platform guides, FCM Mode, the server SDK, and the `init-nse` CLI.
679
761
 
680
762
  - [Overview](https://docs.page/marcocrupi/react-native-notify-kit/react-native/overview)
681
763
  - [Reference](https://docs.page/marcocrupi/react-native-notify-kit/react-native/reference)
@@ -16,6 +16,8 @@ Pod::Spec.new do |s|
16
16
 
17
17
  s.cocoapods_version = '>= 1.10.0'
18
18
  s.ios.deployment_target = '15.1'
19
+ s.module_name = 'RNNotifeeCore'
20
+ s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
19
21
 
20
22
  if defined?($NotifeeCoreFromSources) && $NotifeeCoreFromSources == true
21
23
  # internal dev flag used by Notifee devs, ignore
@@ -72,8 +72,11 @@ import com.google.common.util.concurrent.ListenableFuture;
72
72
  import com.google.common.util.concurrent.ListeningExecutorService;
73
73
  import com.google.common.util.concurrent.MoreExecutors;
74
74
  import java.util.ArrayList;
75
+ import java.util.Arrays;
76
+ import java.util.HashSet;
75
77
  import java.util.List;
76
78
  import java.util.Objects;
79
+ import java.util.Set;
77
80
  import java.util.concurrent.Callable;
78
81
  import java.util.concurrent.ExecutorService;
79
82
  import java.util.concurrent.Executors;
@@ -84,6 +87,43 @@ class NotificationManager {
84
87
  private static final String TAG = "NotificationManager";
85
88
  private static final String EXTRA_NOTIFEE_NOTIFICATION = "notifee.notification";
86
89
  private static final String EXTRA_NOTIFEE_TRIGGER = "notifee.trigger";
90
+
91
+ // Denylist for getDisplayedNotifications() data extraction — pairs with
92
+ // EXCLUDED_DATA_KEY_PREFIXES below. `fcm_options` is the Firebase HTTP v1
93
+ // `Message.fcm_options` analytics label; on iOS it is caught by the bare
94
+ // `hasPrefix:@"fcm"` filter in NotifeeCoreUtil.m:627, and we match that
95
+ // filter exactly on Android via this set so the analytics label never
96
+ // leaks into `notification.data`.
97
+ private static final Set<String> EXCLUDED_DATA_KEYS =
98
+ new HashSet<>(
99
+ Arrays.asList(
100
+ "from", "collapse_key", "message_type", "message_id", "aps", "fcm_options"));
101
+
102
+ // Prefixes that denote system or internal keys.
103
+ // - `android.`, `gcm.`, `google.`, `fcm.` include the trailing dot so
104
+ // legitimate custom keys like `androidify`, `googleish`, or `fcmRegion`
105
+ // survive the filter.
106
+ // - `notifee` is intentionally without dot: it's the library's own namespace
107
+ // and we reserve it entirely (matches the internal constants
108
+ // `notifee.notification` / `notifee.trigger`, plus `notifee_options` and
109
+ // any future addition — mirrors iOS NotifeeCoreUtil.m:626 which uses
110
+ // hasPrefix:@"notifee" for the same reason).
111
+ //
112
+ // Note: `fcm.` with the dot on Android diverges from iOS, which uses bare
113
+ // `hasPrefix:@"fcm"` (NotifeeCoreUtil.m:627-628). The iOS filter exists
114
+ // specifically to drop `fcm_options` (the Firebase HTTP v1 analytics
115
+ // label, documented inline in that iOS source as `// fcm_options`) — it
116
+ // is NOT a historical/broad match. We handle `fcm_options` via the
117
+ // EXCLUDED_DATA_KEYS exact-match set above, which gives us parity on the
118
+ // Firebase-reserved key while preserving realistic user keys like
119
+ // `fcmRegion` and `fcmToken` that iOS's broader filter would also drop.
120
+ // Apps that need strict cross-platform consistency for custom data keys
121
+ // should avoid names starting with `fcm` entirely (iOS drops them, Android
122
+ // preserves everything except the exact `fcm_options` label).
123
+ private static final String[] EXCLUDED_DATA_KEY_PREFIXES = {
124
+ "android.", "gcm.", "google.", "notifee", "fcm."
125
+ };
126
+
87
127
  private static final ExecutorService CACHED_THREAD_POOL = Executors.newCachedThreadPool();
88
128
  private static final ListeningExecutorService LISTENING_CACHED_THREAD_POOL =
89
129
  MoreExecutors.listeningDecorator(CACHED_THREAD_POOL);
@@ -866,6 +906,8 @@ class NotificationManager {
866
906
  notificationBundle.putString("subtitle", subtitle.toString());
867
907
  }
868
908
 
909
+ notificationBundle.putBundle("data", extractDataFromExtras(extras));
910
+
869
911
  Bundle androidBundle = new Bundle();
870
912
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
871
913
  androidBundle.putString("channelId", original.getChannelId());
@@ -894,6 +936,62 @@ class NotificationManager {
894
936
  });
895
937
  }
896
938
 
939
+ /**
940
+ * Builds a {@link Bundle} of custom data keys from a {@link StatusBarNotification} extras bundle,
941
+ * filtering out Android system keys, FCM/Firebase internals, and Notifee-reserved keys via {@link
942
+ * #shouldIncludeInData(String)}. Mirrors iOS {@code parseDataFromUserInfo:} in {@code
943
+ * NotifeeCoreUtil.m} on the Firebase-reserved keys ({@code fcm_options}, {@code gcm.*}, {@code
944
+ * google.*}, {@code aps}, etc.), with a deliberate divergence: Android uses {@code fcm.} (with
945
+ * dot) plus an exact-match on {@code fcm_options} to preserve realistic user keys like {@code
946
+ * fcmRegion} and {@code fcmToken} that iOS's broader bare-{@code fcm} prefix would also drop. See
947
+ * {@link #EXCLUDED_DATA_KEY_PREFIXES} and {@link #EXCLUDED_DATA_KEYS} for the full rationale.
948
+ *
949
+ * <p>Values are coerced to {@code String} via {@code toString()}. FCM {@code data} payloads are
950
+ * {@code Map<String, String>} by contract, so the coercion is a no-op in practice; the defensive
951
+ * {@code toString()} call only matters for the rare non-String extra that might survive the
952
+ * denylist (e.g. a numeric value added via {@code Bundle.putInt}).
953
+ *
954
+ * <p>Always returns a non-null {@code Bundle} (possibly empty) so that JS consumers can
955
+ * dereference {@code notification.data.foo} without null-checking {@code data} itself — matching
956
+ * iOS, which always returns a dictionary.
957
+ */
958
+ static Bundle extractDataFromExtras(Bundle extras) {
959
+ Bundle dataBundle = new Bundle();
960
+ if (extras == null) {
961
+ return dataBundle;
962
+ }
963
+ for (String key : extras.keySet()) {
964
+ if (!shouldIncludeInData(key)) {
965
+ continue;
966
+ }
967
+ Object value = extras.get(key);
968
+ if (value != null) {
969
+ dataBundle.putString(key, value.toString());
970
+ }
971
+ }
972
+ return dataBundle;
973
+ }
974
+
975
+ /**
976
+ * Pure denylist predicate used by {@link #extractDataFromExtras(Bundle)}. Extracted for direct
977
+ * unit testing without having to construct a {@link Bundle}. See the {@link
978
+ * #EXCLUDED_DATA_KEY_PREFIXES} docs for the reasoning behind each prefix choice.
979
+ */
980
+ static boolean shouldIncludeInData(String key) {
981
+ if (key == null) {
982
+ return false;
983
+ }
984
+ if (EXCLUDED_DATA_KEYS.contains(key)) {
985
+ return false;
986
+ }
987
+ for (String prefix : EXCLUDED_DATA_KEY_PREFIXES) {
988
+ if (key.startsWith(prefix)) {
989
+ return false;
990
+ }
991
+ }
992
+ return true;
993
+ }
994
+
897
995
  static void getTriggerNotifications(MethodCallResult<List<Bundle>> result) {
898
996
  WorkDataRepository workDataRepository = new WorkDataRepository(getApplicationContext());
899
997
 
@@ -0,0 +1,242 @@
1
+ package app.notifee.core;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */
18
+
19
+ import static org.junit.Assert.assertEquals;
20
+ import static org.junit.Assert.assertFalse;
21
+ import static org.junit.Assert.assertNotNull;
22
+ import static org.junit.Assert.assertTrue;
23
+
24
+ import android.os.Bundle;
25
+ import java.util.Arrays;
26
+ import java.util.HashSet;
27
+ import java.util.Set;
28
+ import org.junit.Test;
29
+ import org.junit.runner.RunWith;
30
+ import org.robolectric.RobolectricTestRunner;
31
+
32
+ /**
33
+ * Unit tests for {@link NotificationManager#shouldIncludeInData(String)} and {@link
34
+ * NotificationManager#extractDataFromExtras(android.os.Bundle)} — the denylist filter and
35
+ * bundle-to-bundle helper added for upstream invertase/notifee#393.
36
+ *
37
+ * <p>The end-to-end {@code getDisplayedNotifications()} path is intentionally not exercised here
38
+ * because it depends on {@code android.app.NotificationManager.getActiveNotifications()}, which
39
+ * would require a system service mock. Extracting the pure predicate and the extras iteration into
40
+ * helper methods lets these tests run under Robolectric with a real {@link Bundle} implementation
41
+ * and no system service at all.
42
+ */
43
+ @RunWith(RobolectricTestRunner.class)
44
+ public class GetDisplayedNotificationsDataTest {
45
+
46
+ // -------- shouldIncludeInData (pure predicate) --------
47
+
48
+ @Test
49
+ public void shouldIncludeInData_plainKey_returnsTrue() {
50
+ assertTrue(NotificationManager.shouldIncludeInData("event"));
51
+ assertTrue(NotificationManager.shouldIncludeInData("userId"));
52
+ assertTrue(NotificationManager.shouldIncludeInData("conversation_id"));
53
+ }
54
+
55
+ @Test
56
+ public void shouldIncludeInData_androidPrefix_returnsFalse() {
57
+ assertFalse(NotificationManager.shouldIncludeInData("android.title"));
58
+ assertFalse(NotificationManager.shouldIncludeInData("android.text"));
59
+ }
60
+
61
+ @Test
62
+ public void shouldIncludeInData_androidLookalike_returnsTrue() {
63
+ // The `android.` prefix intentionally includes the dot, so custom keys that
64
+ // merely begin with the substring `android` are not accidentally filtered.
65
+ assertTrue(NotificationManager.shouldIncludeInData("androidify"));
66
+ assertTrue(NotificationManager.shouldIncludeInData("androidish"));
67
+ }
68
+
69
+ @Test
70
+ public void shouldIncludeInData_googlePrefix_returnsFalse() {
71
+ assertFalse(NotificationManager.shouldIncludeInData("google.sent_time"));
72
+ assertFalse(NotificationManager.shouldIncludeInData("google.delivered_priority"));
73
+ }
74
+
75
+ @Test
76
+ public void shouldIncludeInData_googleLookalike_returnsTrue() {
77
+ // Same reasoning as androidLookalike — `google.` with dot keeps the filter precise.
78
+ assertTrue(NotificationManager.shouldIncludeInData("googleish"));
79
+ assertTrue(NotificationManager.shouldIncludeInData("googlebot"));
80
+ }
81
+
82
+ @Test
83
+ public void shouldIncludeInData_gcmPrefix_returnsFalse() {
84
+ assertFalse(NotificationManager.shouldIncludeInData("gcm.notification.foo"));
85
+ assertFalse(NotificationManager.shouldIncludeInData("gcm.n.e"));
86
+ }
87
+
88
+ @Test
89
+ public void shouldIncludeInData_notifeePrefix_returnsFalse() {
90
+ // "notifee.notification" and "notifee.trigger" are the current internal
91
+ // constants (EXTRA_NOTIFEE_NOTIFICATION / EXTRA_NOTIFEE_TRIGGER). They
92
+ // are caught by the no-dot `notifee` prefix.
93
+ assertFalse(NotificationManager.shouldIncludeInData("notifee.notification"));
94
+ assertFalse(NotificationManager.shouldIncludeInData("notifee.trigger"));
95
+
96
+ // `notifee` is INTENTIONALLY kept without the dot: the library reserves
97
+ // its own namespace entirely, so `notifeeFoo` is also filtered. This
98
+ // differs from `fcm.` (with dot, see below): for `notifee` the whole
99
+ // prefix is library-owned and can collide with future internal constants,
100
+ // whereas `fcm` is a third-party namespace where we prefer to let
101
+ // realistic user keys like `fcmRegion` or `fcmToken` survive.
102
+ assertFalse(NotificationManager.shouldIncludeInData("notifeeFoo"));
103
+ }
104
+
105
+ @Test
106
+ public void shouldIncludeInData_fcmDottedPrefix_returnsFalse() {
107
+ // `fcm.` uses the trailing dot so realistic user custom keys (fcmRegion,
108
+ // fcmToken, fcmlike) can round-trip through `data`. This diverges from
109
+ // iOS parseDataFromUserInfo:, which uses bare `[key hasPrefix:@"fcm"]`
110
+ // specifically to catch `fcm_options` (see the `// fcm_options` marker
111
+ // at NotifeeCoreUtil.m:627-628 and the dedicated
112
+ // shouldIncludeInData_fcmOptionsExactKey_returnsFalse test above). Both
113
+ // platforms drop `fcm_options`; Android additionally preserves bare-fcm
114
+ // user keys (fcmRegion, fcmToken, …) that iOS drops as collateral.
115
+ assertFalse(NotificationManager.shouldIncludeInData("fcm.notification"));
116
+ assertFalse(NotificationManager.shouldIncludeInData("fcm.foo"));
117
+ }
118
+
119
+ @Test
120
+ public void shouldIncludeInData_fcmLookalike_returnsTrue() {
121
+ // These survive the Android filter. They would NOT survive the iOS
122
+ // filter, which is an accepted cross-platform divergence in favor of
123
+ // preserving realistic user data on Android. Note: `fcm_options` is a
124
+ // deliberate EXCEPTION to this rule — see the dedicated test
125
+ // shouldIncludeInData_fcmOptionsExactKey_returnsFalse below.
126
+ assertTrue(NotificationManager.shouldIncludeInData("fcm"));
127
+ assertTrue(NotificationManager.shouldIncludeInData("fcmlike"));
128
+ assertTrue(NotificationManager.shouldIncludeInData("fcmRegion"));
129
+ assertTrue(NotificationManager.shouldIncludeInData("fcmToken"));
130
+ }
131
+
132
+ @Test
133
+ public void shouldIncludeInData_fcmOptionsExactKey_returnsFalse() {
134
+ // `fcm_options` is the Firebase HTTP v1 Message.fcm_options analytics
135
+ // label. On iOS it is caught by the bare `[key hasPrefix:@"fcm"]` filter
136
+ // in NotifeeCoreUtil.m:627-628, which has an inline `// fcm_options`
137
+ // comment documenting that the whole reason the iOS prefix is bare (not
138
+ // dotted) is specifically to drop this key. On Android we switched the
139
+ // prefix to `fcm.` (with dot) so realistic user keys like `fcmRegion`
140
+ // can survive — but that alone would leak `fcm_options`. This test
141
+ // guards the exact-match entry in EXCLUDED_DATA_KEYS that restores
142
+ // parity with iOS for the one Firebase-reserved bare-`fcm` key.
143
+ assertFalse(NotificationManager.shouldIncludeInData("fcm_options"));
144
+ }
145
+
146
+ @Test
147
+ public void shouldIncludeInData_exactKeyBlocklist_returnsFalse() {
148
+ assertFalse(NotificationManager.shouldIncludeInData("from"));
149
+ assertFalse(NotificationManager.shouldIncludeInData("collapse_key"));
150
+ assertFalse(NotificationManager.shouldIncludeInData("message_type"));
151
+ assertFalse(NotificationManager.shouldIncludeInData("message_id"));
152
+ assertFalse(NotificationManager.shouldIncludeInData("aps"));
153
+ }
154
+
155
+ @Test
156
+ public void shouldIncludeInData_nullKey_returnsFalse() {
157
+ assertFalse(NotificationManager.shouldIncludeInData(null));
158
+ }
159
+
160
+ // -------- extractDataFromExtras (bundle → bundle) --------
161
+
162
+ @Test
163
+ public void extractDataFromExtras_nullExtras_returnsEmptyBundle() {
164
+ Bundle result = NotificationManager.extractDataFromExtras(null);
165
+ assertNotNull(result);
166
+ assertEquals(0, result.size());
167
+ }
168
+
169
+ @Test
170
+ public void extractDataFromExtras_emptyExtras_returnsEmptyBundle() {
171
+ Bundle result = NotificationManager.extractDataFromExtras(new Bundle());
172
+ assertNotNull(result);
173
+ assertEquals(0, result.size());
174
+ }
175
+
176
+ @Test
177
+ public void extractDataFromExtras_mixedKeys_returnsOnlyCustomKeys() {
178
+ Bundle extras = new Bundle();
179
+
180
+ // Custom keys that should survive the filter.
181
+ extras.putString("event", "chat_msg");
182
+ extras.putString("userId", "42");
183
+
184
+ // System keys that must be dropped.
185
+ extras.putString("android.title", "Hello");
186
+ extras.putString("android.text", "Body");
187
+ extras.putString("google.sent_time", "123456");
188
+ extras.putString("gcm.notification.e", "1");
189
+ extras.putString("from", "12345");
190
+ extras.putString("collapse_key", "do_not_collapse");
191
+ extras.putString("message_id", "msg-abc");
192
+ extras.putString("notifee.notification", "internal");
193
+ extras.putString("fcm.foo", "reserved-dotted");
194
+
195
+ // Custom key matching the iOS-divergent survivor pattern: `fcmRegion`
196
+ // must appear in the result on Android, even though iOS would drop it.
197
+ extras.putString("fcmRegion", "eu-west-1");
198
+
199
+ // Non-String value to verify toString() coercion for the rare survivor.
200
+ extras.putInt("count", 5);
201
+
202
+ Bundle result = NotificationManager.extractDataFromExtras(extras);
203
+ assertNotNull(result);
204
+
205
+ Set<String> expected = new HashSet<>(Arrays.asList("event", "userId", "fcmRegion", "count"));
206
+ assertEquals(expected, result.keySet());
207
+ assertEquals("chat_msg", result.getString("event"));
208
+ assertEquals("42", result.getString("userId"));
209
+ assertEquals("eu-west-1", result.getString("fcmRegion"));
210
+ assertEquals("5", result.getString("count"));
211
+ }
212
+
213
+ @Test
214
+ public void extractDataFromExtras_onlySystemKeys_returnsEmptyBundle() {
215
+ Bundle extras = new Bundle();
216
+ extras.putString("android.title", "Hello");
217
+ extras.putString("google.sent_time", "123");
218
+ extras.putString("gcm.notification.foo", "bar");
219
+ extras.putString("notifee.trigger", "internal");
220
+ extras.putString("from", "12345");
221
+ extras.putString("fcm.options", "reserved-dotted");
222
+ // Firebase analytics label — exact-match filtered for iOS parity,
223
+ // see shouldIncludeInData_fcmOptionsExactKey_returnsFalse.
224
+ extras.putString("fcm_options", "{\"analytics_label\":\"foo\"}");
225
+
226
+ Bundle result = NotificationManager.extractDataFromExtras(extras);
227
+ assertNotNull(result);
228
+ assertEquals(0, result.size());
229
+ }
230
+
231
+ @Test
232
+ public void extractDataFromExtras_nullValueForCustomKey_isSkipped() {
233
+ Bundle extras = new Bundle();
234
+ extras.putString("event", "chat_msg");
235
+ extras.putString("nullable", null);
236
+
237
+ Bundle result = NotificationManager.extractDataFromExtras(extras);
238
+ assertNotNull(result);
239
+ assertEquals(1, result.size());
240
+ assertEquals("chat_msg", result.getString("event"));
241
+ }
242
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../dist/cli.js');