react-native-notify-kit 9.3.0 → 9.5.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 (53) hide show
  1. package/README.md +68 -2
  2. package/android/build.gradle +5 -0
  3. package/android/proguard-rules.pro +1 -0
  4. package/android/src/androidTest/java/app/notifee/core/DoScheduledWorkOrderingTest.java +234 -0
  5. package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +204 -0
  6. package/android/src/androidTest/java/app/notifee/core/database/TimingWorkDataRepository.java +56 -0
  7. package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java +221 -0
  8. package/android/src/androidTest/res/drawable/test_icon.xml +14 -0
  9. package/android/src/main/baseline-prof.txt +382 -0
  10. package/android/src/main/java/app/notifee/core/ForegroundService.java +269 -80
  11. package/android/src/main/java/app/notifee/core/InitProvider.java +48 -5
  12. package/android/src/main/java/app/notifee/core/Logger.java +5 -0
  13. package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +160 -79
  14. package/android/src/main/java/app/notifee/core/NotificationManager.java +370 -288
  15. package/android/src/main/java/app/notifee/core/WarmupHelper.java +84 -0
  16. package/android/src/main/java/app/notifee/core/database/WorkDataRepository.java +38 -34
  17. package/android/src/main/java/app/notifee/core/model/NotificationAndroidModel.java +17 -1
  18. package/android/src/main/kotlin/io/invertase/notifee/NotifeeApiModule.kt +30 -0
  19. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +182 -0
  20. package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java +279 -0
  21. package/dist/NotifeeApiModule.d.ts +1 -0
  22. package/dist/NotifeeApiModule.js +6 -0
  23. package/dist/NotifeeApiModule.js.map +1 -1
  24. package/dist/specs/NativeNotifeeModule.d.ts +1 -0
  25. package/dist/specs/NativeNotifeeModule.js.map +1 -1
  26. package/dist/types/Module.d.ts +19 -0
  27. package/dist/types/NotificationAndroid.d.ts +58 -0
  28. package/dist/types/NotificationAndroid.js +43 -0
  29. package/dist/types/NotificationAndroid.js.map +1 -1
  30. package/dist/utils/validate.d.ts +1 -0
  31. package/dist/utils/validate.js +10 -4
  32. package/dist/utils/validate.js.map +1 -1
  33. package/dist/validators/validateAndroidChannel.js +3 -3
  34. package/dist/validators/validateAndroidChannel.js.map +1 -1
  35. package/dist/validators/validateAndroidNotification.js +36 -12
  36. package/dist/validators/validateAndroidNotification.js.map +1 -1
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/ios/NotifeeCore/NotifeeCore+NSNotificationCenter.m +5 -1
  40. package/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m +5 -5
  41. package/ios/NotifeeCore/NotifeeCore.m +28 -9
  42. package/ios/NotifeeCore/NotifeeCoreDelegateHolder.m +5 -1
  43. package/ios/NotifeeCore/NotifeeCoreExtensionHelper.m +7 -2
  44. package/ios/RNNotifee/NotifeeApiModule.mm +31 -10
  45. package/package.json +1 -1
  46. package/src/NotifeeApiModule.ts +7 -0
  47. package/src/specs/NativeNotifeeModule.ts +1 -0
  48. package/src/types/Module.ts +20 -0
  49. package/src/types/NotificationAndroid.ts +62 -0
  50. package/src/utils/validate.ts +12 -4
  51. package/src/validators/validateAndroidChannel.ts +3 -3
  52. package/src/validators/validateAndroidNotification.ts +43 -10
  53. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -222,7 +222,17 @@ To modify push notification content before display (e.g., attach images), create
222
222
  }
223
223
  ```
224
224
 
225
- 4. Run `cd ios && pod install`
225
+ 4. Implement `serviceExtensionTimeWillExpire` as a safety net. Notification Service Extensions have a ~30-second time budget; if your notification includes a large image attachment and the download is slow, the extension may be terminated before the content handler is called. Deliver a best-effort notification in the expiration handler:
226
+
227
+ ```objc
228
+ - (void)serviceExtensionTimeWillExpire {
229
+ // Deliver the notification with whatever content we have so far
230
+ // (e.g., without the image attachment if the download didn't finish).
231
+ self.contentHandler(self.bestAttemptContent);
232
+ }
233
+ ```
234
+
235
+ 5. Run `cd ios && pod install`
226
236
 
227
237
  ## Jest Testing
228
238
 
@@ -253,9 +263,10 @@ This fork is a complete migration to React Native's **New Architecture**:
253
263
  - **Toolchain**: Yarn 4, Node 22+, Java 17, compileSdk/targetSdk 35
254
264
  - **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`.
255
265
  - **Core notification logic (NotifeeCore) is unchanged** — the public API is fully compatible with the original Notifee
256
- - **22 upstream bugs fixed** — see [Bugs Fixed from Upstream Notifee](#bugs-fixed-from-upstream-notifee) below
266
+ - **30 upstream bugs fixed** — see [Bugs Fixed from Upstream Notifee](#bugs-fixed-from-upstream-notifee) below
257
267
  - **Reliable trigger notifications** — AlarmManager is the default backend instead of WorkManager, with automatic fallback when exact alarm permission is not granted
258
268
  - **New API: `setNotificationConfig()`** — opt-out flag to prevent Notifee from intercepting iOS remote notification handlers (see [New APIs](#new-apis) below)
269
+ - **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
259
270
 
260
271
  ## Bugs Fixed from Upstream Notifee
261
272
 
@@ -286,6 +297,13 @@ This fork fixes the following bugs that were never resolved in the original Noti
286
297
  | Stale Gradle cache could serve outdated AAR bytecode after `yarn upgrade` — same Maven coordinate reused across releases violated Gradle's coordinate-immutability assumption | Android | N/A (architectural) | 9.2.0 |
287
298
  | `EventType.DELIVERED` not emitted for `displayNotification()` in foreground (only for trigger notifications) — `notifeeTrigger != nil` guard in `willPresentNotification:` suppressed the event, breaking iOS/Android symmetry | iOS | Pre-existing | 9.3.0 |
288
299
  | Tapping a notification without explicit `pressAction` does nothing (app doesn't open) — `NotificationPendingIntent.createIntent()` creates a tap-less PendingIntent when `pressActionModelBundle` is null, especially visible on trigger notifications after app kill | Android | Pre-existing (latent) | 9.3.0 |
300
+ | Foreground service notifications delayed up to 10 seconds on Android 12+ — library never calls `setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE)` | Android | [#272](https://github.com/invertase/notifee/issues/272), [#1242](https://github.com/invertase/notifee/issues/1242) | 9.4.0 |
301
+ | `didReceiveNotificationResponse:` completionHandler delayed by 15 seconds via `dispatch_after`, blocking subsequent notification taps and risking handler leaks if the app is suspended during the wait | iOS | Pre-existing (TODO since 2020) | 9.4.0 |
302
+ | `requestPermission:` silently swallows `NSError` from `requestAuthorizationWithOptions`, making MDM and parental-control authorization failures invisible to JS consumers | iOS | Pre-existing (TODO since day 1) | 9.4.0 |
303
+ | `contentByUpdatingWithProvider:` errors suppressed via `nil` error pointer in `displayNotification:` and `createTriggerNotification:` — communication notifications with malformed SiriKit intents silently fail with nil content | iOS | Pre-existing | 9.4.0 |
304
+ | `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
+ | 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
+ | `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 |
289
307
 
290
308
  > **Note for apps requiring guaranteed exact alarms (alarm clocks, timers, calendars):**
291
309
  > Add `<uses-permission android:name="android.permission.USE_EXACT_ALARM" />` to your app's
@@ -314,6 +332,8 @@ In addition to bug fixes, the fork makes a few opinionated default changes vs `@
314
332
 
315
333
  - **Library no longer hardcodes `foregroundServiceType` in its manifest** (since 9.1.13 — **BREAKING vs upstream**). Apps using `asForegroundService: true` on Android 14+ must declare their own `foregroundServiceType` on `app.notifee.core.ForegroundService` in their app manifest. See [Foreground Service Setup](#foreground-service-setup-android-14) below for migration instructions. Upstream hardcoded `shortService`, which caused a manifest merger failure ([#1108](https://github.com/invertase/notifee/issues/1108)) and a 3-minute timeout ANR crash ([#703](https://github.com/invertase/notifee/issues/703)).
316
334
 
335
+ - **Foreground service notifications use `FOREGROUND_SERVICE_IMMEDIATE` by default** (since 9.4.0 — **BREAKING vs upstream**). Upstream Notifee never called `setForegroundServiceBehavior()`, causing Android 12+ to defer foreground service notification display by up to 10 seconds unless the notification qualified for a system exemption. The fork now sets `FOREGROUND_SERVICE_IMMEDIATE` by default when `asForegroundService: true`, eliminating the delay. Opt out per-notification with `foregroundServiceBehavior: AndroidForegroundServiceBehavior.DEFERRED`. Additionally, the library now pre-loads critical foreground service classes and Binder proxies on a background thread during app startup (`InitProvider.onCreate`), reducing first-display cold-start latency by ~50–100 ms. Opt out of the warmup via `<meta-data android:name="notifee_init_warmup_enabled" android:value="false" />` in your app's `AndroidManifest.xml`.
336
+
317
337
  - **iOS `EventType.DELIVERED` now emitted for all foreground notifications** (since 9.3.0 — **BREAKING vs upstream**). Upstream Notifee had a guard in `willPresentNotification:` that suppressed DELIVERED for notifications created via `displayNotification()` (immediate display), emitting it only for trigger notifications. Android always emitted DELIVERED in both cases. The fork removes the guard so iOS matches Android. If you registered `onForegroundEvent` listeners that did heavy work on DELIVERED assuming the event would only fire for trigger notifications, audit them — you may now receive an event per `displayNotification()` call while in foreground. **Known limitation**: trigger notifications that fire while the app is in background or killed still do not emit DELIVERED on iOS — this is a platform limitation (`willPresentNotification:` is only invoked in foreground, and iOS provides no delegate callback for background-delivered triggers). If you need delivery confirmation for background trigger notifications on iOS, check the notification's presence via `getDisplayedNotifications()` after the app returns to foreground.
318
338
 
319
339
  These changes are documented in the [CHANGELOG](CHANGELOG.md) under the release that introduced them. If you rely on any of the upstream defaults, you can either pin to the specific behavior via the opt-out flags listed above, or open an issue to discuss.
@@ -418,6 +438,52 @@ With `handleRemoteNotifications: false`:
418
438
 
419
439
  Default is `true` (backward compatible — Notifee handles everything, same as original Notifee behavior).
420
440
 
441
+ ## Advanced / Troubleshooting
442
+
443
+ ### Manual warmup control
444
+
445
+ The library automatically pre-warms the foreground service notification path during app startup via `InitProvider`. **Most apps do not need to do anything extra.** However, in certain edge cases the automatic warmup may not be sufficient:
446
+
447
+ - **Lazy-loaded library** — if `react-native-notify-kit` is code-split or lazy-loaded, `InitProvider` runs but the TurboModule/JS bridge side isn't initialized yet.
448
+ - **Post-splash-screen warmup** — apps that want to defer warmup to after the splash screen instead of during `Application.onCreate()`.
449
+ - **Low-end devices** — rare cases where the `InitProvider` warmup hasn't finished by the time the user triggers the first notification.
450
+
451
+ For these cases, call `prewarmForegroundService()` at a moment of your choosing:
452
+
453
+ ```typescript
454
+ import notifee from 'react-native-notify-kit';
455
+
456
+ // Call after splash screen, during onboarding, or before the user
457
+ // is likely to trigger a foreground service notification.
458
+ await notifee.prewarmForegroundService();
459
+ ```
460
+
461
+ **Key facts:**
462
+
463
+ - **Idempotent** — safe to call multiple times; class loading after the first call is a no-op from ART's perspective.
464
+ - **iOS no-op** — resolves immediately on iOS (Android-only optimization).
465
+ - **Does NOT start a foreground service** — it only performs class loading and Binder proxy warming. No Google Play policy risk.
466
+ - **Best-effort** — internal failures are logged and swallowed; the promise always resolves.
467
+
468
+ To verify whether calling this method provides a measurable benefit for your app, capture a Perfetto trace with the `notifee:*` trace sections enabled and compare the `notifee:displayNotification` duration with and without the prewarm call.
469
+
470
+ ### Regenerating the Baseline Profile
471
+
472
+ The library ships a Baseline Profile (`packages/react-native/android/src/main/baseline-prof.txt`) that instructs ART to AOT-compile the notification hot path at install time. This profile should be regenerated after significant changes to the notification display code path.
473
+
474
+ **Prerequisites:**
475
+
476
+ - A physical device connected via adb (Pixel 9 Pro XL with Android 16+ recommended) or a running emulator with API 33+
477
+ - The smoke app must be buildable (`yarn install` in the repo root)
478
+
479
+ **Command:**
480
+
481
+ ```bash
482
+ bash scripts/generate-baseline-profile.sh
483
+ ```
484
+
485
+ The script runs the macrobenchmark test in `apps/smoke/android/baselineprofile/`, captures the profile on the connected device, filters it to library-only rules, and copies it to the library's `src/main/baseline-prof.txt`. Review the generated file for unexpected entries, then commit it.
486
+
421
487
  ## Documentation
422
488
 
423
489
  The upstream Notifee documentation remains the best reference for the public API and platform guides used by this fork.
@@ -139,6 +139,10 @@ dependencies {
139
139
 
140
140
  api 'org.greenrobot:eventbus:3.3.1' // https://github.com/greenrobot/EventBus/releases
141
141
 
142
+ // ProfileInstaller bootstraps baseline profiles on devices that do not receive
143
+ // cloud-compiled profiles from Play Store (sideloads, debug builds).
144
+ api 'androidx.profileinstaller:profileinstaller:1.4.1'
145
+
142
146
  // --- Bridge dependencies ---
143
147
  implementation 'androidx.lifecycle:lifecycle-process:2.3.1'
144
148
  //noinspection GradleDynamicVersion
@@ -147,6 +151,7 @@ dependencies {
147
151
  // --- Test dependencies ---
148
152
  testImplementation 'junit:junit:4.13.2' // https://github.com/junit-team/junit4/releases
149
153
  testImplementation 'org.robolectric:robolectric:4.14.1' // https://github.com/robolectric/robolectric/releases
154
+ testImplementation 'org.mockito:mockito-core:5.19.0' // https://github.com/mockito/mockito/releases
150
155
  androidTestImplementation 'androidx.test.ext:junit:1.1.3' // https://developer.android.com/jetpack/androidx/releases/test
151
156
  androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' // see above
152
157
  androidTestImplementation "androidx.room:room-testing:$room_version"
@@ -19,6 +19,7 @@
19
19
  # InitProvider is subclassed by the RN bridge module (NotifeeInitProvider).
20
20
  # R8 must not finalize its methods, otherwise the bridge cannot override onCreate().
21
21
  -keep class app.notifee.core.InitProvider { *; }
22
+ -keep class app.notifee.core.WarmupHelper { *; }
22
23
  -keeppackagenames app.notifee.core.**
23
24
 
24
25
  # --- Annotations ---
@@ -0,0 +1,234 @@
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
+ * ---
13
+ *
14
+ * NOT RUN IN CI.
15
+ *
16
+ * Instrumented regression test for caller #5 (NotificationManager.doScheduledWork
17
+ * delete-then-completer ordering) from pre-fix-549-audit.md. Seeds a single
18
+ * one-time trigger row, invokes doScheduledWork via a real
19
+ * CallbackToFutureAdapter.Completer, and asserts both ordering (delete DAO
20
+ * call returned strictly before completer.set fired) and side-effect
21
+ * (the row is gone from Room after the test).
22
+ *
23
+ * Observation mechanism:
24
+ *
25
+ * - TimingWorkDataRepository (in package app.notifee.core.database) subclasses
26
+ * WorkDataRepository via the @VisibleForTesting package-private constructor
27
+ * and records the nanoTime at which deleteById's future completes.
28
+ * - The production singleton is swapped to the TimingWorkDataRepository
29
+ * instance via reflection before the test runs and restored in @After.
30
+ * - completerSetNanos is captured via a directExecutor listener on the
31
+ * resultFuture returned by CallbackToFutureAdapter.getFuture — this runs
32
+ * synchronously on the thread that called completer.set(...).
33
+ *
34
+ * Run manually:
35
+ * cd apps/smoke/android
36
+ * ./gradlew :react-native-notify-kit:connectedDebugAndroidTest \
37
+ * --tests app.notifee.core.DoScheduledWorkOrderingTest
38
+ */
39
+
40
+ import static org.junit.Assert.assertEquals;
41
+ import static org.junit.Assert.assertNotNull;
42
+ import static org.junit.Assert.assertNull;
43
+ import static org.junit.Assert.assertTrue;
44
+
45
+ import android.content.Context;
46
+ import android.os.Bundle;
47
+ import androidx.concurrent.futures.CallbackToFutureAdapter;
48
+ import androidx.core.app.NotificationChannelCompat;
49
+ import androidx.core.app.NotificationManagerCompat;
50
+ import androidx.test.ext.junit.runners.AndroidJUnit4;
51
+ import androidx.test.platform.app.InstrumentationRegistry;
52
+ import androidx.work.Data;
53
+ import androidx.work.ListenableWorker;
54
+ import app.notifee.core.database.TimingWorkDataRepository;
55
+ import app.notifee.core.database.WorkDataEntity;
56
+ import app.notifee.core.database.WorkDataRepository;
57
+ import app.notifee.core.utility.ObjectUtils;
58
+ import com.google.common.util.concurrent.ListenableFuture;
59
+ import com.google.common.util.concurrent.MoreExecutors;
60
+ import java.lang.reflect.Field;
61
+ import java.util.concurrent.CountDownLatch;
62
+ import java.util.concurrent.TimeUnit;
63
+ import java.util.concurrent.atomic.AtomicLong;
64
+ import java.util.concurrent.atomic.AtomicReference;
65
+ import org.junit.After;
66
+ import org.junit.Before;
67
+ import org.junit.Test;
68
+ import org.junit.runner.RunWith;
69
+
70
+ @RunWith(AndroidJUnit4.class)
71
+ public class DoScheduledWorkOrderingTest {
72
+
73
+ private static final String TEST_ID = "do-scheduled-work-ordering-test";
74
+ private static final String TEST_CHANNEL_ID = "do-scheduled-work-ordering-test-channel";
75
+
76
+ private Context context;
77
+ private WorkDataRepository realRepo;
78
+ private TimingWorkDataRepository timingRepo;
79
+ private Field singletonField;
80
+
81
+ @Before
82
+ public void setUp() throws Exception {
83
+ context = InstrumentationRegistry.getInstrumentation().getTargetContext();
84
+ ContextHolder.setApplicationContext(context.getApplicationContext());
85
+
86
+ // Prime the production singleton and seed the row via the real DB.
87
+ realRepo = WorkDataRepository.getInstance(context);
88
+ realRepo.deleteAll().get(5, TimeUnit.SECONDS);
89
+
90
+ // Create a channel for the displayNotification call inside doScheduledWork.
91
+ // Use NotificationManagerCompat directly — Notifee.getInstance() would
92
+ // require Notifee SDK initialization which is not available in
93
+ // instrumented tests.
94
+ NotificationManagerCompat.from(context)
95
+ .createNotificationChannel(
96
+ new NotificationChannelCompat.Builder(
97
+ TEST_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
98
+ .setName("DoScheduledWorkOrderingTest")
99
+ .build());
100
+
101
+ // Build the timing-instrumented repo that wraps the SAME DAO as the real
102
+ // singleton. Because NotifeeCoreDatabase.getDatabase(context) is itself a
103
+ // singleton, realRepo and timingRepo read/write the same underlying Room
104
+ // tables — seeding via realRepo is visible to timingRepo and vice versa.
105
+ // The factory lives in the database package because the constructor and
106
+ // the NotifeeCoreDatabase accessors are package-private.
107
+ timingRepo = TimingWorkDataRepository.createForProductionDb(context);
108
+
109
+ // Swap the singleton so NotificationManager.doScheduledWork's delete path
110
+ // (WorkDataRepository.getInstance(ctx).deleteById(...)) goes through the
111
+ // timing instrumentation.
112
+ singletonField = WorkDataRepository.class.getDeclaredField("mInstance");
113
+ singletonField.setAccessible(true);
114
+ singletonField.set(null, timingRepo);
115
+ }
116
+
117
+ @After
118
+ public void tearDown() throws Exception {
119
+ if (singletonField != null) {
120
+ singletonField.set(null, realRepo);
121
+ }
122
+ if (realRepo != null) {
123
+ realRepo.deleteAll().get(5, TimeUnit.SECONDS);
124
+ }
125
+ // Best-effort: dismiss any notification the test displayed.
126
+ androidx.core.app.NotificationManagerCompat.from(context).cancelAll();
127
+ }
128
+
129
+ @Test
130
+ public void doScheduledWork_deleteCompletesBeforeCompleterSet() throws Exception {
131
+ // Seed the row that doScheduledWork will read, display, and then delete.
132
+ WorkDataEntity seed = buildSeedEntity(TEST_ID);
133
+ timingRepo.insert(seed).get(5, TimeUnit.SECONDS);
134
+ assertEquals(1, timingRepo.getAll().get(5, TimeUnit.SECONDS).size());
135
+
136
+ // Build a real Completer via CallbackToFutureAdapter so we can wait on the
137
+ // returned future and record the moment at which completer.set(...) fires.
138
+ AtomicReference<CallbackToFutureAdapter.Completer<ListenableWorker.Result>> completerRef =
139
+ new AtomicReference<>();
140
+ ListenableFuture<ListenableWorker.Result> resultFuture =
141
+ CallbackToFutureAdapter.getFuture(
142
+ completer -> {
143
+ completerRef.set(completer);
144
+ return "DoScheduledWorkOrderingTest";
145
+ });
146
+ CallbackToFutureAdapter.Completer<ListenableWorker.Result> completer = completerRef.get();
147
+ assertNotNull("Completer must be populated synchronously by getFuture", completer);
148
+
149
+ // The listener records completerSetNanos AND counts down the latch, giving
150
+ // the test a happens-before barrier to await on. resultFuture.get() alone
151
+ // is insufficient — Guava only guarantees that .get() returns after the
152
+ // future is marked done, not after all listeners have fired, so the test
153
+ // could race the listener on a cached-threadpool scheduler.
154
+ AtomicLong completerSetNanos = new AtomicLong();
155
+ CountDownLatch completerListenerRan = new CountDownLatch(1);
156
+ resultFuture.addListener(
157
+ () -> {
158
+ completerSetNanos.compareAndSet(0, System.nanoTime());
159
+ completerListenerRan.countDown();
160
+ },
161
+ MoreExecutors.directExecutor());
162
+
163
+ // Build the Data payload doScheduledWork expects.
164
+ Data data =
165
+ new Data.Builder()
166
+ .putString("id", TEST_ID)
167
+ .putString(Worker.KEY_WORK_REQUEST, Worker.WORK_REQUEST_ONE_TIME)
168
+ .build();
169
+
170
+ // Invoke the production entry point. Futures are chained internally; the
171
+ // resultFuture resolves when completer.set(...) fires.
172
+ NotificationManager.doScheduledWork(data, completer);
173
+
174
+ // Wait for the work to report completion. 15s is generous — the whole chain
175
+ // (Room read → displayNotification → Room delete → completer.set) usually
176
+ // takes <200ms on a Pixel 9 Pro XL warm.
177
+ ListenableWorker.Result result = resultFuture.get(15, TimeUnit.SECONDS);
178
+ assertEquals(ListenableWorker.Result.success(), result);
179
+
180
+ // Explicitly wait for the listener to have fired — see comment at the
181
+ // listener registration above for why resultFuture.get() alone is racy.
182
+ assertTrue(
183
+ "completer.set listener must fire within 5s of resultFuture completing",
184
+ completerListenerRan.await(5, TimeUnit.SECONDS));
185
+
186
+ long deleteNanos = timingRepo.firstDeleteCompletionNanos.get();
187
+ long completerNanos = completerSetNanos.get();
188
+
189
+ assertTrue("delete DAO call must actually have completed", deleteNanos > 0);
190
+ assertTrue("completer.set must actually have fired", completerNanos > 0);
191
+ assertTrue(
192
+ "delete must complete strictly before completer.set — otherwise "
193
+ + "WorkManager could start a new work instance that reads the stale row "
194
+ + "(pre-#549 behavior). deleteNanos="
195
+ + deleteNanos
196
+ + " completerNanos="
197
+ + completerNanos
198
+ + " deltaNanos="
199
+ + (completerNanos - deleteNanos),
200
+ deleteNanos < completerNanos);
201
+
202
+ // Side-effect assertion: the row must be gone.
203
+ assertNull(
204
+ "row must be deleted from Room after doScheduledWork completes",
205
+ timingRepo.getWorkDataById(TEST_ID).get(5, TimeUnit.SECONDS));
206
+ assertTrue(
207
+ "Room must contain no entries after doScheduledWork completes",
208
+ timingRepo.getAll().get(5, TimeUnit.SECONDS).isEmpty());
209
+ }
210
+
211
+ private static WorkDataEntity buildSeedEntity(String id) {
212
+ Bundle notificationBundle = new Bundle();
213
+ notificationBundle.putString("id", id);
214
+ notificationBundle.putString("title", "DoScheduledWorkOrderingTest");
215
+ Bundle androidBundle = new Bundle();
216
+ androidBundle.putString("channelId", TEST_CHANNEL_ID);
217
+ // A smallIcon is required by Android's NotificationManager.fixNotification —
218
+ // a notification without one throws IllegalArgumentException. The
219
+ // androidTest res/drawable/test_icon.xml is a transparent placeholder that
220
+ // exists only in the test APK.
221
+ androidBundle.putString("smallIcon", "test_icon");
222
+ notificationBundle.putBundle("android", androidBundle);
223
+
224
+ Bundle triggerBundle = new Bundle();
225
+ triggerBundle.putInt("type", 0); // TIMESTAMP
226
+ triggerBundle.putLong("timestamp", System.currentTimeMillis());
227
+
228
+ return new WorkDataEntity(
229
+ id,
230
+ ObjectUtils.bundleToBytes(notificationBundle),
231
+ ObjectUtils.bundleToBytes(triggerBundle),
232
+ false /* withAlarmManager — WorkManager path */);
233
+ }
234
+ }
@@ -0,0 +1,204 @@
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
+ * ---
13
+ *
14
+ * NOT RUN IN CI.
15
+ *
16
+ * Instrumented regression test for caller #8 (reboot recovery anchor
17
+ * persistence) from pre-fix-549-audit.md. Seeds five recurring trigger rows
18
+ * with timestamps in the past, invokes NotifeeAlarmManager.rescheduleNotifications
19
+ * via its public entry point, and asserts that every row has an advanced
20
+ * timestamp once the reschedule completes — proving that the per-entity
21
+ * WorkDataRepository.update(...) futures are awaited before the
22
+ * BroadcastReceiver finish path, which is the #549 fix commit
23
+ * 71fa20e ("fix(android): persist reboot recovery anchor updates before
24
+ * finishing receiver").
25
+ *
26
+ * Run manually:
27
+ * cd apps/smoke/android
28
+ * ./gradlew :react-native-notify-kit:connectedDebugAndroidTest \
29
+ * --tests app.notifee.core.RebootRecoveryTest
30
+ */
31
+
32
+ import static org.junit.Assert.assertEquals;
33
+ import static org.junit.Assert.assertTrue;
34
+ import static org.junit.Assert.fail;
35
+
36
+ import android.content.Context;
37
+ import android.os.Bundle;
38
+ import androidx.test.ext.junit.runners.AndroidJUnit4;
39
+ import androidx.test.platform.app.InstrumentationRegistry;
40
+ import app.notifee.core.database.WorkDataEntity;
41
+ import app.notifee.core.database.WorkDataRepository;
42
+ import app.notifee.core.utility.ObjectUtils;
43
+ import java.util.List;
44
+ import java.util.concurrent.TimeUnit;
45
+ import org.junit.After;
46
+ import org.junit.Before;
47
+ import org.junit.Test;
48
+ import org.junit.runner.RunWith;
49
+
50
+ /**
51
+ * ⚠️ WARNING — DESTRUCTIVE INSTRUMENTED TEST ⚠️
52
+ *
53
+ * <p>This test calls {@code WorkDataRepository.getInstance(context)}, which returns the production
54
+ * singleton backed by the on-disk {@code notifee_core_database}. The {@code @Before} and
55
+ * {@code @After} hooks call {@code deleteAll()} on that database.
56
+ *
57
+ * <p>If you run this test on a device that has REAL scheduled notifee notifications (your personal
58
+ * phone, a shared QA device, a customer's device), THOSE NOTIFICATIONS WILL BE SILENTLY DELETED.
59
+ * There is no undo.
60
+ *
61
+ * <p>Run this test only on:
62
+ *
63
+ * <ul>
64
+ * <li>Throwaway emulators
65
+ * <li>Dedicated test devices
66
+ * <li>Devices where you have explicitly verified that no production notifee state exists
67
+ * </ul>
68
+ *
69
+ * <p>A structural fix (migrate to an in-memory Room database for tests) is the correct long-term
70
+ * solution but is out of scope for the #549 fix PR.
71
+ */
72
+ @RunWith(AndroidJUnit4.class)
73
+ public class RebootRecoveryTest {
74
+
75
+ private static final int SEED_COUNT = 5;
76
+ private static final long HOUR_IN_MS = 60L * 60 * 1000;
77
+
78
+ /** 5 hours before "now" — well in the past so setNextTimestamp must advance. */
79
+ private static final long OLD_ANCHOR_OFFSET_MS = 5 * HOUR_IN_MS;
80
+
81
+ private static final long POLL_DEADLINE_MS = 15_000;
82
+ private static final long POLL_INTERVAL_MS = 100;
83
+
84
+ private Context context;
85
+ private WorkDataRepository repo;
86
+
87
+ @Before
88
+ public void setUp() throws Exception {
89
+ context = InstrumentationRegistry.getInstrumentation().getTargetContext();
90
+ // Ensure ContextHolder is populated — production code paths inside
91
+ // NotifeeAlarmManager call ContextHolder.getApplicationContext() and
92
+ // WorkDataRepository.getInstance(getApplicationContext()).
93
+ ContextHolder.setApplicationContext(context.getApplicationContext());
94
+ repo = WorkDataRepository.getInstance(context);
95
+ repo.deleteAll().get(5, TimeUnit.SECONDS);
96
+ }
97
+
98
+ @After
99
+ public void tearDown() throws Exception {
100
+ // Cancel every real alarm the test scheduled so the device is left clean.
101
+ for (int i = 0; i < SEED_COUNT; i++) {
102
+ NotifeeAlarmManager.cancelNotification(testId(i));
103
+ }
104
+ repo.deleteAll().get(5, TimeUnit.SECONDS);
105
+ }
106
+
107
+ @Test
108
+ public void rescheduleNotifications_advancesEveryAnchorBeforeCompleting() throws Exception {
109
+ long now = System.currentTimeMillis();
110
+ long oldAnchor = now - OLD_ANCHOR_OFFSET_MS;
111
+ long minExpectedAnchor = now; // every anchor must move strictly into the future
112
+
113
+ // Seed SEED_COUNT entities, each a recurring hourly alarm with the old anchor.
114
+ for (int i = 0; i < SEED_COUNT; i++) {
115
+ repo.insert(buildEntity(testId(i), oldAnchor)).get(5, TimeUnit.SECONDS);
116
+ }
117
+ assertEquals(SEED_COUNT, repo.getAll().get(5, TimeUnit.SECONDS).size());
118
+
119
+ // Invoke the real reboot-recovery entry point. PendingResult is null because
120
+ // this test is not driven by a BroadcastReceiver goAsync() scope; the fix
121
+ // guards every finish() call with a null-check, so passing null exercises
122
+ // the same future chain without actually broadcasting anything.
123
+ new NotifeeAlarmManager().rescheduleNotifications(null);
124
+
125
+ // Poll for completion. The reschedule fires several async stages
126
+ // (getScheduledNotifications → per-entity update → allAsList → withTimeout),
127
+ // so we cannot directly latch on "done". Instead we observe the side
128
+ // effect: every row's timestamp must have been moved into the future.
129
+ List<WorkDataEntity> rows = awaitAllAnchorsAdvanced(minExpectedAnchor);
130
+
131
+ assertEquals("no rows lost during reboot recovery", SEED_COUNT, rows.size());
132
+ for (WorkDataEntity row : rows) {
133
+ Bundle triggerBundle = ObjectUtils.bytesToBundle(row.getTrigger());
134
+ long newAnchor = ObjectUtils.getLong(triggerBundle.get("timestamp"));
135
+ assertTrue(
136
+ "row "
137
+ + row.getId()
138
+ + " must have an advanced anchor: was="
139
+ + oldAnchor
140
+ + " now="
141
+ + newAnchor,
142
+ newAnchor >= oldAnchor + HOUR_IN_MS);
143
+ assertTrue(
144
+ "row " + row.getId() + " anchor must be in the future: " + newAnchor,
145
+ newAnchor >= minExpectedAnchor);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Polls {@link WorkDataRepository#getAll()} until every row has a timestamp at or after {@code
151
+ * minExpectedAnchor}, or fails on timeout.
152
+ */
153
+ private List<WorkDataEntity> awaitAllAnchorsAdvanced(long minExpectedAnchor) throws Exception {
154
+ long deadline = System.currentTimeMillis() + POLL_DEADLINE_MS;
155
+ while (System.currentTimeMillis() < deadline) {
156
+ List<WorkDataEntity> rows = repo.getAll().get(5, TimeUnit.SECONDS);
157
+ if (rows.size() == SEED_COUNT && allAdvanced(rows, minExpectedAnchor)) {
158
+ return rows;
159
+ }
160
+ Thread.sleep(POLL_INTERVAL_MS);
161
+ }
162
+ fail(
163
+ "reboot recovery did not advance every anchor within "
164
+ + POLL_DEADLINE_MS
165
+ + "ms — the fix in commit 71fa20e may be broken");
166
+ return null; // unreachable
167
+ }
168
+
169
+ private static boolean allAdvanced(List<WorkDataEntity> rows, long minExpectedAnchor) {
170
+ for (WorkDataEntity row : rows) {
171
+ Bundle trigger = ObjectUtils.bytesToBundle(row.getTrigger());
172
+ long ts = ObjectUtils.getLong(trigger.get("timestamp"));
173
+ if (ts < minExpectedAnchor) {
174
+ return false;
175
+ }
176
+ }
177
+ return true;
178
+ }
179
+
180
+ /** Build a recurring-hourly trigger entity with the given id and initial anchor. */
181
+ private static WorkDataEntity buildEntity(String id, long anchorMs) {
182
+ Bundle notificationBundle = new Bundle();
183
+ notificationBundle.putString("id", id);
184
+ notificationBundle.putString("title", "RebootRecoveryTest " + id);
185
+
186
+ Bundle triggerBundle = new Bundle();
187
+ triggerBundle.putInt("type", 0); // TIMESTAMP
188
+ triggerBundle.putLong("timestamp", anchorMs);
189
+ triggerBundle.putInt("repeatFrequency", 0); // HOURLY
190
+ Bundle alarmManagerBundle = new Bundle();
191
+ alarmManagerBundle.putInt("type", 3); // SET_EXACT_AND_ALLOW_WHILE_IDLE
192
+ triggerBundle.putBundle("alarmManager", alarmManagerBundle);
193
+
194
+ return new WorkDataEntity(
195
+ id,
196
+ ObjectUtils.bundleToBytes(notificationBundle),
197
+ ObjectUtils.bundleToBytes(triggerBundle),
198
+ true /* withAlarmManager */);
199
+ }
200
+
201
+ private static String testId(int i) {
202
+ return "reboot-recovery-test-" + i;
203
+ }
204
+ }
@@ -0,0 +1,56 @@
1
+ package app.notifee.core.database;
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
+ * ---
13
+ *
14
+ * Test-only WorkDataRepository subclass that captures the wall-clock nanoTime
15
+ * at which deleteById(id)'s underlying DAO call completes. Lives in the
16
+ * database package so it can call the @VisibleForTesting package-private
17
+ * WorkDataRepository(WorkDataDao, ListeningExecutorService) constructor.
18
+ * Consumers (e.g. DoScheduledWorkOrderingTest) instantiate via the public
19
+ * constructor below and inject this instance into the production singleton
20
+ * via reflection.
21
+ */
22
+
23
+ import android.content.Context;
24
+ import com.google.common.util.concurrent.ListenableFuture;
25
+ import com.google.common.util.concurrent.ListeningExecutorService;
26
+ import com.google.common.util.concurrent.MoreExecutors;
27
+ import java.util.concurrent.atomic.AtomicLong;
28
+
29
+ public class TimingWorkDataRepository extends WorkDataRepository {
30
+
31
+ /** nanoTime when the FIRST deleteById future completed. 0 if never called. */
32
+ public final AtomicLong firstDeleteCompletionNanos = new AtomicLong();
33
+
34
+ public TimingWorkDataRepository(WorkDataDao dao, ListeningExecutorService executor) {
35
+ super(dao, executor);
36
+ }
37
+
38
+ /**
39
+ * Factory that wraps the production {@link NotifeeCoreDatabase} DAO and executor, so that reads
40
+ * and writes share the same underlying Room tables as the production singleton.
41
+ */
42
+ public static TimingWorkDataRepository createForProductionDb(Context context) {
43
+ return new TimingWorkDataRepository(
44
+ NotifeeCoreDatabase.getDatabase(context).workDao(),
45
+ NotifeeCoreDatabase.databaseWriteListeningExecutor);
46
+ }
47
+
48
+ @Override
49
+ public ListenableFuture<Void> deleteById(String id) {
50
+ ListenableFuture<Void> f = super.deleteById(id);
51
+ f.addListener(
52
+ () -> firstDeleteCompletionNanos.compareAndSet(0, System.nanoTime()),
53
+ MoreExecutors.directExecutor());
54
+ return f;
55
+ }
56
+ }