react-native-notify-kit 9.4.0 → 9.6.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.
- package/README.md +200 -5
- package/android/build.gradle +1 -0
- package/android/src/androidTest/java/app/notifee/core/DoScheduledWorkOrderingTest.java +234 -0
- package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +569 -0
- package/android/src/androidTest/java/app/notifee/core/database/TimingWorkDataRepository.java +56 -0
- package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java +221 -0
- package/android/src/androidTest/res/drawable/test_icon.xml +14 -0
- package/android/src/main/java/app/notifee/core/AlarmPermissionBroadcastReceiver.java +17 -6
- package/android/src/main/java/app/notifee/core/InitProvider.java +109 -2
- package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +385 -93
- package/android/src/main/java/app/notifee/core/NotificationAlarmReceiver.java +16 -5
- package/android/src/main/java/app/notifee/core/NotificationManager.java +153 -96
- package/android/src/main/java/app/notifee/core/Preferences.java +9 -0
- package/android/src/main/java/app/notifee/core/RebootBroadcastReceiver.java +20 -5
- package/android/src/main/java/app/notifee/core/database/WorkDataRepository.java +38 -34
- package/android/src/main/kotlin/io/invertase/notifee/HeadlessTask.kt +3 -2
- package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +251 -4
- package/android/src/test/java/app/notifee/core/InitProviderBootCheckTest.java +81 -0
- package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerHandleStaleTest.java +242 -0
- package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerSetAlarmClockTest.java +264 -0
- package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java +279 -0
- package/android/src/test/java/app/notifee/core/model/TimestampTriggerModelTest.java +199 -3
- package/android/src/test/java/io/invertase/notifee/HeadlessTaskConfigTest.kt +34 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -263,7 +263,7 @@ This fork is a complete migration to React Native's **New Architecture**:
|
|
|
263
263
|
- **Toolchain**: Yarn 4, Node 22+, Java 17, compileSdk/targetSdk 35
|
|
264
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`.
|
|
265
265
|
- **Core notification logic (NotifeeCore) is unchanged** — the public API is fully compatible with the original Notifee
|
|
266
|
-
- **
|
|
266
|
+
- **30 upstream bugs fixed** — see [Bugs Fixed from Upstream Notifee](#bugs-fixed-from-upstream-notifee) below
|
|
267
267
|
- **Reliable trigger notifications** — AlarmManager is the default backend instead of WorkManager, with automatic fallback when exact alarm permission is not granted
|
|
268
268
|
- **New API: `setNotificationConfig()`** — opt-out flag to prevent Notifee from intercepting iOS remote notification handlers (see [New APIs](#new-apis) below)
|
|
269
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
|
|
@@ -297,7 +297,15 @@ This fork fixes the following bugs that were never resolved in the original Noti
|
|
|
297
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 |
|
|
298
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 |
|
|
299
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) |
|
|
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 |
|
|
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 |
|
|
301
309
|
|
|
302
310
|
> **Note for apps requiring guaranteed exact alarms (alarm clocks, timers, calendars):**
|
|
303
311
|
> Add `<uses-permission android:name="android.permission.USE_EXACT_ALARM" />` to your app's
|
|
@@ -308,6 +316,18 @@ This fork fixes the following bugs that were never resolved in the original Noti
|
|
|
308
316
|
|
|
309
317
|
As bugs are fixed, this table is updated. See [CHANGELOG.md](CHANGELOG.md) for full details.
|
|
310
318
|
|
|
319
|
+
## Documented Workarounds for Platform Limitations
|
|
320
|
+
|
|
321
|
+
Some upstream Notifee issues are not bugs in the library itself but platform-level limitations imposed by Android's Doze mode and vendor power management — no library code can make `AlarmManager` deliver an alarm to, or a foreground service survive inside, an app the OEM has explicitly paused. For these, the fork provides **documented mitigations**: user-facing helper APIs, code-level self-healing where possible, and decision guides that steer consumers toward the Android primitive most resilient to the specific vendor policy.
|
|
322
|
+
|
|
323
|
+
| Upstream issue | Symptom | Platform root cause | Fork mitigation |
|
|
324
|
+
| --- | --- | --- | --- |
|
|
325
|
+
| [invertase/notifee#410](https://github.com/invertase/notifee/issues/410) | Foreground service paused on screen lock (Samsung OneUI, ~6 seconds after screen off on battery) and killed immediately when the app is backgrounded (Xiaomi MIUI) | Vendor aggressive battery-saver and autostart policies suspend or terminate foreground services of apps not whitelisted in the OEM's protected-apps / autostart settings. Partially Doze-related on non-exempt `foregroundServiceType` values; mostly OEM-specific behavior catalogued at [dontkillmyapp.com](https://dontkillmyapp.com/). | **(1) Decision guide** — the [Timers: foreground service or `SET_ALARM_CLOCK`?](#timers-foreground-service-or-set_alarm_clock) section recommends the `SET_ALARM_CLOCK` trigger over a silent foreground service for rest, cooking, and recovery timer use cases. `setAlarmClock` is the same primitive the stock Clock app uses and is generally respected by vendor aggressive-kill policies. **(2) Foreground service use case matrix** — the [Foreground service use case guide](#foreground-service-use-case-guide) documents which `foregroundServiceType` values are Doze-CPU-exempt, which have type-specific timeouts, and the Google Play policy constraints that rule out misusing `mediaPlayback` for silent timers. **(3) `openPowerManagerSettings()` helper API** — deep-links the user to the correct vendor autostart / protected-apps screen on 16 manufacturers; whitelisting the app prevents both `BOOT_COMPLETED` suppression and background FGS kills. |
|
|
326
|
+
| [invertase/notifee#734](https://github.com/invertase/notifee/issues/734) | Scheduled trigger notifications silently lost across a device reboot on OEM devices (Xiaomi MIUI, OnePlus, Huawei EMUI, Oppo ColorOS, Vivo FuntouchOS) | The vendor OS suppresses the `BOOT_COMPLETED` broadcast to apps the user has not manually whitelisted, so the library's `RebootBroadcastReceiver` never runs and persisted `AlarmManager` triggers are never re-armed after reboot. | **(1) `BOOT_COUNT` cold-start self-heal (code)** — on every app init, `InitProvider` compares `Settings.Global.BOOT_COUNT` against the last-known value in `SharedPreferences` and re-arms every persisted trigger on a background thread if a boot delta is detected, even when `BOOT_COMPLETED` was never delivered. Paired with a process-wide `AtomicBoolean` race guard in `NotifeeAlarmManager.rescheduleNotifications` that prevents double-advancement when the reboot receiver and the cold-start path race. **(2) `openPowerManagerSettings()` helper API** — the same vendor-settings deep-link used by #410, pointing the user at the autostart whitelist for defense in depth. |
|
|
327
|
+
| [invertase/notifee#927](https://github.com/invertase/notifee/issues/927) | Custom sound passed via `displayNotification({ android: { sound, channelId }, ios: { sound } })` is ignored for **remote push notifications** (FCM/APNs) delivered while the app is in background or killed — the system default sound plays instead. Foreground delivery and **locally-scheduled notifications** (`displayNotification`, `createTriggerNotification`) are unaffected. | When a remote push arrives while the app is killed, the JavaScript layer never runs — the system tray item is drawn by the OS (Android system + Firebase SDK; iOS + APNs) before any Notifee code executes. On Android API 26+, the `NotificationChannel` sound is set once at channel creation and is immutable thereafter — `NotificationCompat.Builder.setSound()` is silently ignored when the builder has a `channelId`. On iOS, the Notification Service Extension only rewrites incoming push content when the payload contains a `notifee_options` key (see `NotifeeCoreExtensionHelper.m:43`); a plain APNs payload is delivered unmodified. | **Documentation only — the platform contract cannot be worked around at the library layer.** Recipes by platform: **(Android)** create the `NotificationChannel` with the desired sound at first-run (the channel sound is immutable; to change it the channel must be deleted and recreated under a new `channelId`), and configure `AndroidNotification.sound` in the FCM payload server-side so the system tray honors it for background pushes. As a heavier alternative, switch the backend to an FCM data-only payload and call `displayNotification()` from a headless task — the JS-side `android.sound` is then honored, but this trades simplicity for the cost of running JS on every push. **(iOS)** either set `aps.sound` directly in the APNs payload, or install the Notification Service Extension (see `docs/react-native/ios/remote-notification-support.mdx`) and ship the sound under `notifee_options.ios.sound` in the push payload. |
|
|
328
|
+
|
|
329
|
+
Both mitigations are intentionally additive to the existing reboot-recovery and foreground-service code paths and do not replace the consumer's responsibility to prompt the user for battery-optimization exemption when the use case warrants it. For a complete vendor-by-vendor reference of autostart, battery-saver, and background-restriction behavior, see [dontkillmyapp.com](https://dontkillmyapp.com/).
|
|
330
|
+
|
|
311
331
|
## Behavior changes from upstream
|
|
312
332
|
|
|
313
333
|
In addition to bug fixes, the fork makes a few opinionated default changes vs `@notifee/react-native` to improve reliability and reduce footguns. These are intentional behavioral differences that you should be aware of when migrating:
|
|
@@ -326,7 +346,7 @@ In addition to bug fixes, the fork makes a few opinionated default changes vs `@
|
|
|
326
346
|
|
|
327
347
|
- **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)).
|
|
328
348
|
|
|
329
|
-
- **Foreground service notifications use `FOREGROUND_SERVICE_IMMEDIATE` by default** (since
|
|
349
|
+
- **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`.
|
|
330
350
|
|
|
331
351
|
- **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.
|
|
332
352
|
|
|
@@ -359,6 +379,81 @@ Available types: `camera`, `connectedDevice`, `dataSync`, `health`, `location`,
|
|
|
359
379
|
|
|
360
380
|
> **Note:** `shortService` has a 3-minute timeout on Android 14+. If your foreground service needs to run longer, use a different type. The library's `onTimeout()` handler will gracefully stop the service if the timeout fires.
|
|
361
381
|
|
|
382
|
+
### Foreground service use case guide
|
|
383
|
+
|
|
384
|
+
Choosing the right `foregroundServiceType` matters — the wrong choice can cause Doze-driven CPU suspension with the screen off, Google Play policy rejection, or premature kills by the Android 14+ type-specific timeouts. This matrix maps common use cases to the recommended type and calls out the caveats you need to know before shipping:
|
|
385
|
+
|
|
386
|
+
| Use case | Recommended type | Doze CPU exempt? | Type timeout | Key caveat |
|
|
387
|
+
| --- | --- | --- | --- | --- |
|
|
388
|
+
| Silent rest / workout / cooking timer | **`SET_ALARM_CLOCK` trigger — not an FGS** | N/A | N/A | See the ["Timers: foreground service or `SET_ALARM_CLOCK`?"](#timers-foreground-service-or-set_alarm_clock) decision guide below. |
|
|
389
|
+
| Timer with audio cue (metronome, guided set) | `mediaPlayback` | Yes | None | Must actually play audio — silent `mediaPlayback` is a Play Store policy violation. |
|
|
390
|
+
| Short operation (< 3 min) | `shortService` | No | **3 min** | Library's `onTimeout()` stops cleanly and emits `TYPE_FG_TIMEOUT` to JS. |
|
|
391
|
+
| Long-running data sync | `dataSync` | No | 6 h (API 34); stricter on API 35+ | Pair with `openBatteryOptimizationSettings()` for reliability on OEM devices. |
|
|
392
|
+
| Location / navigation / fitness GPS | `location` | Yes | None | Requires `ACCESS_FINE_LOCATION` runtime permission. |
|
|
393
|
+
| Music / podcast / audiobook playback | `mediaPlayback` | Yes | None | Must be real playback — see policy callout below. |
|
|
394
|
+
| Bluetooth / USB device sync | `connectedDevice` | No | None | Requires companion-device or Bluetooth permission. |
|
|
395
|
+
| Enterprise / DPC / system-critical | `specialUse` or `systemExempted` | Varies | None | `specialUse` requires a `<property>` element and Play Store justification review. |
|
|
396
|
+
| Arbitrary deferrable background work | **None — use `WorkManager` directly, not an FGS.** | N/A | N/A | FGS is not the right abstraction for deferrable work. |
|
|
397
|
+
|
|
398
|
+
> **Warning:** **`mediaPlayback` requires active audio playback.** [Google Play's Foreground Service Types policy](https://support.google.com/googleplay/android-developer/answer/13392821) explicitly prohibits declaring `mediaPlayback` for services that do not play audio. A silent timer, stopwatch, or rest-timer declared as `mediaPlayback` will be rejected during Play Store review. For silent long-running timers, prefer the `SET_ALARM_CLOCK` trigger path (see the decision guide below).
|
|
399
|
+
|
|
400
|
+
### Android 15+ additional FGS restrictions
|
|
401
|
+
|
|
402
|
+
Android 15 (API 35) tightens foreground service restrictions further:
|
|
403
|
+
|
|
404
|
+
- **`dataSync` cumulative 6-hour limit per 24-hour window.** Apps that previously started a fresh `dataSync` FGS repeatedly will hit the new cap.
|
|
405
|
+
- **`mediaProcessing` is a new dedicated type** for short media transcode / processing operations, with its own timeout.
|
|
406
|
+
- **`specialUse` requires a `<property>` element** on the `<service>` tag with `android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"` and a user-visible justification string. Play Store review uses this property to evaluate the declaration.
|
|
407
|
+
- **Type-specific timeouts fire `onTimeout(int startId, int fgsType)`.** This fork already implements the API 35+ overload — at timeout the service stops cleanly and the library emits `TYPE_FG_TIMEOUT` to JS with both `startId` and `fgsType` in the event payload.
|
|
408
|
+
|
|
409
|
+
If you target API 35+, audit your `foregroundServiceType` choice against the matrix above before shipping. The canonical reference is the [Android 15 foreground service behavior changes](https://developer.android.com/about/versions/15/behavior-changes-15#fgs-changes) documentation.
|
|
410
|
+
|
|
411
|
+
### OEM Background Restrictions
|
|
412
|
+
|
|
413
|
+
Some Android vendors (Xiaomi/Redmi MIUI, Huawei/Honor EMUI, Oppo/Realme ColorOS, Vivo/iQOO FuntouchOS and OriginOS, Samsung OneUI) apply aggressive autostart and battery-saver restrictions that affect **both scheduled trigger notifications and running foreground services**:
|
|
414
|
+
|
|
415
|
+
- **Trigger notifications** — the vendor OS suppresses the `BOOT_COMPLETED` broadcast to apps the user has not explicitly whitelisted, so `AlarmManager`-backed triggers never re-arm after a device reboot until the user opens the app.
|
|
416
|
+
- **Foreground services** — the same vendor policy pauses or terminates a running foreground service as soon as the app is backgrounded. Symptoms reported in [invertase/notifee#410](https://github.com/invertase/notifee/issues/410) include the service pausing after ~6 seconds with the screen off on Samsung OneUI on battery, and immediate kill on Xiaomi MIUI when the app moves to the background.
|
|
417
|
+
|
|
418
|
+
This is platform-level behavior imposed by the vendor — no library can make `AlarmManager` deliver an alarm to, or a foreground service survive inside, an app the OEM has explicitly paused.
|
|
419
|
+
|
|
420
|
+
The fork mitigates this with two layers that work together:
|
|
421
|
+
|
|
422
|
+
**1. Automatic cold-start recovery.** On every app init, the library compares `Settings.Global.BOOT_COUNT` against the value recorded on the previous run. If a reboot has occurred since the last run — whether or not `BOOT_COMPLETED` was delivered to your app — the library re-arms every persisted trigger on a background thread. This means that on an OEM device where `BOOT_COMPLETED` was suppressed, simply opening your app (or having it cold-started by any other entry point: push notification, geofence, share intent) recovers all missed and upcoming alarms. Previously, opening the app alone did not recover them. This recovery runs unconditionally — it is not gated by the `notifee_init_warmup_enabled` metadata flag, because it is a correctness fix rather than a startup optimization.
|
|
423
|
+
|
|
424
|
+
**2. Vendor settings helper APIs.** The existing `getPowerManagerInfo()` and `openPowerManagerSettings()` APIs let your app guide the user directly to the correct vendor settings screen (Xiaomi Autostart, Huawei Protected Apps, Oppo Startup Manager, and 13 more vendors) to whitelist the app. Once whitelisted, `BOOT_COMPLETED` is delivered normally on every reboot and exact alarm timing is preserved without waiting for the next app cold-start. The same whitelist also prevents the OS from killing your foreground service when the app is backgrounded — so this helper is the primary mitigation path for **both** trigger-notification reliability and foreground-service reliability on OEM devices.
|
|
425
|
+
|
|
426
|
+
A typical integration that combines both layers looks like this:
|
|
427
|
+
|
|
428
|
+
```typescript
|
|
429
|
+
import notifee from 'react-native-notify-kit';
|
|
430
|
+
import { Alert, Platform } from 'react-native';
|
|
431
|
+
|
|
432
|
+
if (Platform.OS === 'android') {
|
|
433
|
+
const info = await notifee.getPowerManagerInfo();
|
|
434
|
+
if (info.activity) {
|
|
435
|
+
// The user is on a device with a known vendor autostart activity.
|
|
436
|
+
// Prompt them once (e.g. on first run, or after a scheduled notification
|
|
437
|
+
// fails to fire on time), explaining why exact timing depends on this
|
|
438
|
+
// permission.
|
|
439
|
+
Alert.alert(
|
|
440
|
+
'Allow background activity',
|
|
441
|
+
'Your device restricts background apps by default. To reliably receive scheduled notifications, please enable autostart for this app.',
|
|
442
|
+
[
|
|
443
|
+
{ text: 'Open settings', onPress: () => notifee.openPowerManagerSettings() },
|
|
444
|
+
{ text: 'Later', style: 'cancel' },
|
|
445
|
+
],
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
For the authoritative vendor-by-vendor matrix of autostart, battery optimization, and background-restriction behavior, see [dontkillmyapp.com](https://dontkillmyapp.com/).
|
|
452
|
+
|
|
453
|
+
> **Scope note:** the cold-start recovery path is best-effort. It runs as soon as Android invokes `InitProvider.onCreate` (before `Application.onCreate`), but may still be delayed by minutes or hours on a device where the user never opens your app after a reboot. For use cases that require guaranteed sub-second timing (alarm clocks, medication reminders, calendar events), also declare `USE_EXACT_ALARM` in your manifest (see the [note above](#bugs-fixed-from-upstream-notifee)) and prompt the user to whitelist your app via the vendor settings helper.
|
|
454
|
+
>
|
|
455
|
+
> **Defense in depth:** the cold-start BOOT_COUNT path and the traditional `RebootBroadcastReceiver` path both funnel into the same `NotifeeAlarmManager.rescheduleNotifications` entry point, which is guarded by a process-wide `AtomicBoolean` — whichever path runs first wins the reschedule cycle, and the second logs `Reschedule already in progress, skipping duplicate request` and exits cleanly. On real devices the two paths often *both* fire, for a subtle reason observed during Step 6 smoke testing: when the system force-stops your app (during an install, crash recovery, or a `pm clear` from a QA tool) and then Android re-delivers `BOOT_COMPLETED` as soon as the package is launched again, the reboot receiver runs at the same time as `InitProvider.onCreate`'s cold-start check. You get both paths for free — proof of the race guard's design. On an OEM device that suppresses `BOOT_COMPLETED` outright, only the cold-start path runs. Either way the zombie re-fire loop is broken.
|
|
456
|
+
|
|
362
457
|
### Trigger Notification Reliability
|
|
363
458
|
|
|
364
459
|
This fork defaults to AlarmManager for trigger notifications on Android, instead of WorkManager.
|
|
@@ -379,6 +474,89 @@ await notifee.createTriggerNotification(notification, {
|
|
|
379
474
|
});
|
|
380
475
|
```
|
|
381
476
|
|
|
477
|
+
#### AlarmType guide
|
|
478
|
+
|
|
479
|
+
When `alarmManager` is enabled (the default), the `alarmManager.type` field selects which
|
|
480
|
+
`android.app.AlarmManager` primitive is used to schedule the trigger. This fork supports all
|
|
481
|
+
five `AlarmType` values — including `SET_ALARM_CLOCK`, which upstream Notifee tracked in
|
|
482
|
+
[invertase/notifee#655](https://github.com/invertase/notifee/issues/655) and merged via
|
|
483
|
+
[#749](https://github.com/invertase/notifee/pull/749).
|
|
484
|
+
|
|
485
|
+
| AlarmType | Exact? | Wakes device? | Doze bypass? | Status bar icon | When to use |
|
|
486
|
+
| ---------------------------------- | ------ | ------------- | ------------ | --------------- | ------------------------------------------------------------------------------ |
|
|
487
|
+
| `SET` | No | Yes | No | No | Non-critical reminders that can slip by several minutes (daily digest). |
|
|
488
|
+
| `SET_AND_ALLOW_WHILE_IDLE` | No | Yes | Yes | No | Non-critical reminders that must still fire while the device is in Doze. |
|
|
489
|
+
| `SET_EXACT` | Yes | Yes | No | No | Time-sensitive reminders when the app is reasonably sure not to be in Doze. |
|
|
490
|
+
| `SET_EXACT_AND_ALLOW_WHILE_IDLE` | Yes | Yes | Yes | No | **Fork default.** Time-sensitive reminders that must fire even in Doze. |
|
|
491
|
+
| `SET_ALARM_CLOCK` | Yes | Yes | Yes | **Yes** | True alarm-clock / recovery-timer use cases — highest priority, OEM-resilient. |
|
|
492
|
+
|
|
493
|
+
`SET_ALARM_CLOCK` is the strongest Android guarantee available for a scheduled notification:
|
|
494
|
+
|
|
495
|
+
- **Status-bar alarm-clock icon.** The system renders the alarm-clock glyph in the status bar
|
|
496
|
+
until the trigger fires, signalling to the user that an alarm is pending.
|
|
497
|
+
- **Least susceptible to OEM power management.** Vendor aggressive-kill policies (Xiaomi MIUI,
|
|
498
|
+
Oppo ColorOS, Huawei EMUI, Vivo FuntouchOS — documented in the "OEM Background Restrictions"
|
|
499
|
+
section and on [dontkillmyapp.com](https://dontkillmyapp.com/)) generally respect
|
|
500
|
+
`setAlarmClock` even when they would otherwise drop `setExactAndAllowWhileIdle`. This is the
|
|
501
|
+
same mechanism the stock Clock app uses.
|
|
502
|
+
- **Intended for the same reliability problem as [invertase/notifee#734](https://github.com/invertase/notifee/issues/734).**
|
|
503
|
+
If your use case is a medication reminder, a rest-timer between gym sets, a cooking timer,
|
|
504
|
+
or any recovery-timer scenario where a missed notification is user-visible damage, prefer
|
|
505
|
+
`SET_ALARM_CLOCK` over the fork default.
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
import notifee, { AlarmType, TriggerType } from 'react-native-notify-kit';
|
|
509
|
+
|
|
510
|
+
await notifee.createTriggerNotification(
|
|
511
|
+
{
|
|
512
|
+
title: 'Rest complete',
|
|
513
|
+
body: 'Next set is ready.',
|
|
514
|
+
android: { channelId: 'timers' },
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
type: TriggerType.TIMESTAMP,
|
|
518
|
+
timestamp: Date.now() + 90_000,
|
|
519
|
+
alarmManager: {
|
|
520
|
+
type: AlarmType.SET_ALARM_CLOCK,
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
**Required permissions on Android 12+.** `SET_EXACT`, `SET_EXACT_AND_ALLOW_WHILE_IDLE`, and
|
|
527
|
+
`SET_ALARM_CLOCK` all require the `SCHEDULE_EXACT_ALARM` or `USE_EXACT_ALARM` permission.
|
|
528
|
+
If the permission is not granted, Notifee falls back to `setAndAllowWhileIdle` (inexact)
|
|
529
|
+
instead of crashing — see the `SecurityException` handling in `NotifeeAlarmManager`.
|
|
530
|
+
For use cases that must be exact on first install, declare `USE_EXACT_ALARM` in your manifest
|
|
531
|
+
and consider prompting the user with `openAlarmPermissionSettings()`.
|
|
532
|
+
|
|
533
|
+
### Timers: foreground service or `SET_ALARM_CLOCK`?
|
|
534
|
+
|
|
535
|
+
A common question for this fork: **should a rest / cooking / recovery timer be a foreground service, or a scheduled trigger notification?** For most timer use cases the answer is the trigger path — and specifically `SET_ALARM_CLOCK`.
|
|
536
|
+
|
|
537
|
+
| Timer characteristic | Recommended approach |
|
|
538
|
+
| --- | --- |
|
|
539
|
+
| Fires once at a known time, no live UI update while app is backgrounded | **`SET_ALARM_CLOCK` trigger** (see [AlarmType guide](#alarmtype-guide)) |
|
|
540
|
+
| Fires repeatedly at known intervals | **`SET_ALARM_CLOCK` trigger** with app-side scheduling of the next cycle |
|
|
541
|
+
| Needs a live ticking notification UI while app is backgrounded | Foreground service (`mediaPlayback` if audio, otherwise reconsider the UX) |
|
|
542
|
+
| Streams audio, music, or guided voice | Foreground service with `mediaPlayback` |
|
|
543
|
+
| Continuous background work (location, Bluetooth) | Foreground service with the matching type from the matrix above |
|
|
544
|
+
|
|
545
|
+
**Why `SET_ALARM_CLOCK` is usually the right choice for timers:**
|
|
546
|
+
|
|
547
|
+
- **OEM-resilient.** Vendor aggressive-kill policies (Xiaomi MIUI, Oppo ColorOS, Huawei EMUI, Vivo FuntouchOS, Samsung OneUI) generally respect `setAlarmClock` even when they drop `setExactAndAllowWhileIdle` and kill foreground services. This is the same primitive the stock Clock app uses.
|
|
548
|
+
- **No `foregroundServiceType` to pick.** You avoid the Doze / Play-policy / Android 15 timeout maze entirely.
|
|
549
|
+
- **No risk of Play Store rejection** for misusing `mediaPlayback` on a silent timer.
|
|
550
|
+
- **No wake lock to manage.** The library's foreground-service path does not acquire a wake lock on your behalf — under Doze on a non-exempt `foregroundServiceType`, the CPU can still suspend with the screen off. `SET_ALARM_CLOCK` wakes the device at fire time regardless of Doze state.
|
|
551
|
+
|
|
552
|
+
**When a foreground service *is* the right choice:**
|
|
553
|
+
|
|
554
|
+
- You need the notification to tick every second while the app is backgrounded (metronome with audio, VoIP call, active GPS track). A `SET_ALARM_CLOCK` trigger fires once at the scheduled time, not continuously.
|
|
555
|
+
- You need actual audio playback — use `mediaPlayback`.
|
|
556
|
+
- You need continuous location updates — use `location`.
|
|
557
|
+
|
|
558
|
+
For the specific use case in [invertase/notifee#410](https://github.com/invertase/notifee/issues/410) (rest timer between workout sets, screen off, OEM device), `SET_ALARM_CLOCK` is the recommended path. Pair it with `openPowerManagerSettings()` for defense in depth on OEM devices — see the [OEM Background Restrictions](#oem-background-restrictions) section above.
|
|
559
|
+
|
|
382
560
|
### Android: `pressAction` defaults to opening the app on tap
|
|
383
561
|
|
|
384
562
|
On Android, `pressAction` now defaults to `{ id: 'default', launchActivity: 'default' }` when omitted from the notification payload. This means tapping a notification opens the app's main activity by default — matching iOS behavior and eliminating a common footgun where trigger notifications appeared to work but tapping them did nothing after an app kill.
|
|
@@ -432,7 +610,24 @@ With `handleRemoteNotifications: false`:
|
|
|
432
610
|
|
|
433
611
|
Default is `true` (backward compatible — Notifee handles everything, same as original Notifee behavior).
|
|
434
612
|
|
|
435
|
-
## Advanced
|
|
613
|
+
## Advanced
|
|
614
|
+
|
|
615
|
+
### Troubleshooting
|
|
616
|
+
|
|
617
|
+
#### Custom sounds for push notifications in background or killed state
|
|
618
|
+
|
|
619
|
+
If you've set `android.sound` and `ios.sound` in `displayNotification(...)` and the custom sound plays only when the app is in foreground, this is expected platform behavior — not a library bug. When a **remote push** (FCM/APNs) arrives while the app is killed, your JavaScript code never runs, so anything you configured client-side is ignored.
|
|
620
|
+
|
|
621
|
+
The fix is to set the sound in the push payload **server-side**:
|
|
622
|
+
|
|
623
|
+
- **Android (FCM)**: set `AndroidNotification.sound` in the FCM payload to the name of a sound file bundled in `android/app/src/main/res/raw/`. Make sure the `NotificationChannel` was created with the same sound — the channel sound is immutable after creation, so changing the sound requires creating a channel under a new `channelId`.
|
|
624
|
+
- **iOS (APNs)**: either set `aps.sound` directly to the name of a sound file bundled in your app, or — if you need richer rewriting (image attachments, dynamic content) — install the Notification Service Extension and ship the sound under `notifee_options.ios.sound` in the push payload. See [`docs/react-native/ios/remote-notification-support.mdx`](docs/react-native/ios/remote-notification-support.mdx).
|
|
625
|
+
|
|
626
|
+
An advanced alternative on Android is to switch the backend to an FCM **data-only** payload and call `notifee.displayNotification()` from a headless task — this lets the JS-side `android.sound` win, at the cost of running JS on every push. Most apps should prefer the server-side payload approach.
|
|
627
|
+
|
|
628
|
+
**Local notifications are different.** This limitation only affects **remote pushes** delivered by FCM/APNs while the app is killed. Notifications scheduled locally via `notifee.displayNotification()` or `notifee.createTriggerNotification()` — for example, a timer firing after the user closed the app — *do* honor the JS-side `sound` parameter, because the library itself wakes up and presents the notification (via `AlarmManager` on Android or `UNUserNotificationCenter` on iOS). The usual platform rules still apply: on Android the `NotificationChannel` sound is immutable after creation and wins over the per-notification `sound`; on iOS the sound file must be bundled in the app (`.wav`/`.aiff`/`.caf`, under 30 seconds, in the main bundle). For reliable local timer notifications on OEM devices that aggressively kill background work, prefer `AlarmType.SET_ALARM_CLOCK` — see the [Timers: foreground service or `SET_ALARM_CLOCK`?](#timers-foreground-service-or-set_alarm_clock) section.
|
|
629
|
+
|
|
630
|
+
Reference: [invertase/notifee#927](https://github.com/invertase/notifee/issues/927).
|
|
436
631
|
|
|
437
632
|
### Manual warmup control
|
|
438
633
|
|
|
@@ -459,7 +654,7 @@ await notifee.prewarmForegroundService();
|
|
|
459
654
|
- **Does NOT start a foreground service** — it only performs class loading and Binder proxy warming. No Google Play policy risk.
|
|
460
655
|
- **Best-effort** — internal failures are logged and swallowed; the promise always resolves.
|
|
461
656
|
|
|
462
|
-
To verify whether calling this method provides a measurable benefit for your app, capture a Perfetto trace with `notifee:*` trace sections and compare the `notifee:displayNotification` duration with and without the prewarm call.
|
|
657
|
+
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.
|
|
463
658
|
|
|
464
659
|
### Regenerating the Baseline Profile
|
|
465
660
|
|
package/android/build.gradle
CHANGED
|
@@ -151,6 +151,7 @@ dependencies {
|
|
|
151
151
|
// --- Test dependencies ---
|
|
152
152
|
testImplementation 'junit:junit:4.13.2' // https://github.com/junit-team/junit4/releases
|
|
153
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
|
|
154
155
|
androidTestImplementation 'androidx.test.ext:junit:1.1.3' // https://developer.android.com/jetpack/androidx/releases/test
|
|
155
156
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' // see above
|
|
156
157
|
androidTestImplementation "androidx.room:room-testing:$room_version"
|
|
@@ -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
|
+
}
|