Skip to content

[notification] Android: scheduled notifications are persisted as the string "null", breaking boot restore, getPending() and cancelAll() #3550

Description

@bahamapapa127

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.

    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 <app-id> cat shared_prefs/NOTIFICATION_STORE.xml
    

    Observed:

    <?xml version='1.0' encoding='utf-8' standalone='yes' ?>
    <map>
        <string name="7039373">null</string>
    </map>

    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 <pid>:<app-id> for broadcast
      {<app-id>/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 → notifyshow, 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/setwith 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions