tauri-apps / tauri-apps/plugins-workspace
[notification] Android: scheduled notifications are persisted as the string "null", breaking boot restore, getPending() and cancelAll()
- Dominant language
- Rust
- Stars
- 1.8k
- Forks
- 602
- Avg merge
- 4d 14h
- Merged PRs (30d)
- 9
Description
### Summary
On Android, a notification scheduled through the `batch` command is written
to `NotificationStorage` with the correct id but a **payload serialized as
the literal string `null`**. Everything that later reads that store fails:
1. `LocalNotificationRestoreReceiver` runs on boot, finds the saved ids,
deserializes nothing, and restores **no alarms** — scheduled
notifications silently disappear across a reboot.
2. `getPending()` rejects.
3. `cancelAll()` rejects.
`cancel([id])` is unaffected (it takes ids as arguments and never reads the
stored payload), which is a useful workaround but not a fix.
### Environment
- `tauri-plugin-notification` **2.3.3**, `@tauri-apps/plugin-notification` 2.3.3
- Tauri 2, `targetSdk` 36, `minSdk` 28
- Reproduced on **two** devices, both Android 16 / API 36:
- emulator `sdk_gphone64_arm64` (AOSP)
- OnePlus 15 (CPH2749), OxygenOS `CPH2749_16.0.9.400`
### Steps to reproduce
1. Schedule a notification through the Android `batch` command with an
interval schedule, e.g.
```ts
await invoke("plugin:notification|batch", {
notifications: [
{
id: 7039373,
channelId: "reminders",
title: "Test",
body: "Test",
schedule: Schedule.interval({ hour: 20, minute: 0, second: 0 }, true),
},
],
});
```
(Invoked directly because guest-js 2.3.3 exposes no `batch` wrapper and
the Rust plugin registers only `notify` / `request_permission` /
`is_permission_granted` — see "Additional notes".)
2. Confirm the alarm was registered:
```
adb shell dumpsys alarm | grep -A3 TimedNotificationPublisher
```
The alarm is scheduled correctly, so the scheduling half works.
3. Inspect the plugin's own store:
```
adb shell run-as cat shared_prefs/NOTIFICATION_STORE.xml
```
Observed:
```xml
null
```
The id is stored; the notification is not.
4. Call `pending()` → rejects. Call `cancelAll()` → rejects.
5. Reboot the device **without launching the app**, then check:
```
adb shell dumpsys alarm | grep -c TimedNotificationPublisher
```
Zero alarms are registered, and the scheduled notification never fires.
Logcat confirms the receiver itself *did* run:
```
ActivityManager: Start proc : for broadcast
{/app.tauri.notification.LocalNotificationRestoreReceiver}
```
So the boot path is wired correctly — it simply has nothing usable to
restore.
### Expected
- The stored payload round-trips, so the boot receiver can re-register the
schedule and the notification fires after a reboot.
- `getPending()` returns the pending notifications.
- `cancelAll()` cancels them.
### Actual
The stored payload is the string `null`; boot restore is a no-op and both
store-reading commands reject.
### Impact
Any app using the plugin for reminders loses **all** scheduled notifications
on reboot, with no error at schedule time and nothing in the logs to explain
it — the failure is invisible until a user notices a reminder never arrived.
### Notes on scope (what we did *not* verify)
- We captured that `getPending()`/`cancelAll()` reject, but not the exact
exception text (our test harness stringified the error poorly). The
connection to the `null` payload is inference from the stored file plus
the code path, not from a captured stack trace.
- `sendNotification()` with a `schedule` never persists at all — it goes
through the `window.Notification` polyfill → `notify` → `show`, which does
not call `NotificationStorage`. That may be intended, but the JS API does
expose `schedule` on `sendNotification`, so a caller can reasonably expect
a scheduled notification to survive a reboot. Worth clarifying in either
code or docs.
---
## Secondary report (same issue or its own — maintainers' call)
### The exact→inexact fallback is silent
`setExactIfPossible` checks `canScheduleExactAlarms()` and, when it returns
false, falls back to `setAndAllowWhileIdle`/`set` — **with no log line and no
signal to the caller**. An app that asked for an exact alarm gets an inexact
one and cannot tell.
This matters more than it sounds: on a device where the fallback triggers, a
reminder scheduled for 20:00 can be delivered minutes to hours late, and
there is nothing in the app's logs, the plugin's logs, or the JS return value
to indicate a downgrade happened. Diagnosing it requires reading
`dumpsys alarm` and knowing what `window=` means.
A single `Log.d` on the fallback branch — or better, surfacing the
`canScheduleExactAlarms()` result through the JS API so an app can warn the
user — would turn an invisible failure into a diagnosable one.
Measured contrast, same APK and same granted `USE_EXACT_ALARM`:
| device | scheduled alarm |
|---|---|
| AOSP emulator, API 36 | `window=0 … flags=0x5` (exact) |
| OnePlus 15, OxygenOS, API 36 | `window=+9m20s … flags=0x4` (inexact) |
---
### Possibly related
- #1897 (`active()` returns an empty object on Android) — that reporter also calls `pending()`; if their `pending()` was rejecting too, it may share this root cause.
- #1898 (`active()` / `pending()` / `cancel()` throw "Command not found" on macOS) — separate issue, but consistent with what we saw: the desktop plugin registers only `notify`, `request_permission` and `is_permission_granted`.
Contributor guide
Research direction
Start by tracing the Android batch command into NotificationStorage and LocalNotificationRestoreReceiver, then inspect how the notification payload becomes the string "null" in NOTIFICATION_STORE.xml. Reproduce the schedule, reboot, getPending(), and cancelAll() behavior, and verify that stored notifications round-trip and restore after boot. Treat the setExactIfPossible fallback as a separate scope unless maintainers combine it.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, rust
- Domain
- mobile
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100