Files
crowd-funder-for-time-pwa/doc/plugin-feedback-android-rollover-after-reboot.md

16 KiB
Raw Blame History

Plugin feedback: Android rollover notification may not fire after device restart (app not launched)

Date: 2026-02-24 18:24
Target repo: daily-notification-plugin
Consuming app: crowd-funder-for-time-pwa (TimeSafari)
Platform: Android

Summary

Boot recovery can skip rescheduling after a device restart (it sees an “existing PendingIntent” and skips). Whether the next notification fails to fire as a result depends on whether the alarm survived the reboot: Androids documented behavior is that AlarmManager alarms do not persist across reboot, but on some devices/builds they do (implementation-dependent). So: (1) Set schedule → restart shortly after → wait for first notification: On at least one device, the initial notification fired even though boot recovery skipped (alarm had survived reboot). On devices where alarms are cleared, that initial notification would not fire. (2) Set schedule → first notification fires → restart → wait for rollover: Same logic—if the rollover alarm is cleared and boot recovery skips, the rollover wont fire. The fix (always reschedule in the boot path, skip idempotence there) remains correct: it makes behavior reliable regardless of alarm persistence. See Scenario 1: observed behavior and Two distinct scenarios.


Definitions

  • Rollover (in this doc): The next occurrence of the daily notification. Concretely: when todays alarm fires, the plugin runs scheduleNextNotification() and sets an alarm for the same time the next day. That “next day” alarm is the rollover.
  • Boot recovery: When the device boots, the plugins BootReceiver receives BOOT_COMPLETED (and/or LOCKED_BOOT_COMPLETED) and calls into the plugin to reschedule alarms from persisted schedule data.

Android behavior: alarm persistence across reboot is implementation-dependent

  • Documented behavior: AlarmManager alarms are not guaranteed to persist across a full device reboot; the platform may clear them when the device is turned off and rebooted. Apps are expected to reschedule on BOOT_COMPLETED.
  • Observed behavior: On some devices or Android builds, alarms (e.g. from setAlarmClock()) do survive reboot. So whether the next notification fires after a reboot when boot recovery skips depends on the device: if the alarm survived, it can still fire; if it was cleared, it will not fire until the app is opened and reschedules.

So the reliable way to guarantee the next notification fires after reboot is for boot recovery to always call AlarmManager.setAlarmClock() (or equivalent) again, and not to skip based on “existing PendingIntent.”


Scenario 1: observed behavior (schedule → restart → wait for first notification)

Logcat from a real test (schedule set, device restarted shortly after, app not launched):

Before reboot (initial schedule):

02-24 18:56:36 ... Scheduling next daily alarm: id=daily_timesafari_reminder, nextRun=2026-02-24 19:00:00, source=INITIAL_SETUP
02-24 18:56:36 ... Scheduling OS alarm: ... requestCode=53438, scheduleId=daily_timesafari_reminder ...

After reboot (boot recovery):

02-24 18:56:48 ... Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-02-24 19:00:00, source=BOOT_RECOVERY
02-24 18:56:48 ... Existing PendingIntent found for requestCode=53438 - alarm already scheduled

At scheduled time (19:00:00):

02-24 19:00:00 ... DailyNotificationReceiver: DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION
02-24 19:00:00 ... DailyNotificationWorker: DN|DISPLAY_NOTIF_OK ...
02-24 19:00:01 ... DN|ROLLOVER next=1772017200000 scheduleId=daily_rollover_1771930801007 ...

So in this run, boot recovery skipped (duplicate + existing PendingIntent), but the initial notification still fired at 19:00. That implies the alarm survived the reboot on this device. On devices where alarms are cleared on reboot, the same skip would mean the initial notification would not fire. Conclusion: scenario 1 failure is device-dependent; the fix (always reschedule on boot) removes that dependence.


Two distinct scenarios (same bug, different “victim” notification)

The same boot-recovery skip can affect either the initial notification or the rollover notification, depending on when the user restarts and whether the alarm survived reboot:

# User sequence What is lost on reboot (if alarms cleared) What fails if boot recovery skips and alarm was cleared
1 Set schedule → restart shortly after → wait for first notification Alarm for the first occurrence (e.g. 19:00 same day). Initial notification never fires. (Observed on one device: alarm survived, so notification fired despite skip.)
2 Set schedule → first notification fires (rollover set) → restart → wait for next day Alarm for the rollover (next day, e.g. daily_rollover_*). Rollover notification never fires.
  • Scenario 1: User configures a daily reminder, then reboots before the first fire. If the alarm is cleared on reboot and boot recovery skips, the first notification never fires. If the alarm survives (as in the logcat above), it can still fire.
  • Scenario 2: After the first fire, the plugin creates a new schedule (e.g. daily_rollover_1771930801007) and sets an alarm for the next day. If the device reboots, that rollover alarm may or may not persist. If it is cleared and boot recovery only reschedules the primary daily_timesafari_reminder (and skips), or does not reschedule the rollover, the rollover notification may not fire.

In both cases the fix is the same: in the boot recovery path, skip the “existing PendingIntent” idempotence check so the plugin always re-registers the alarm(s) after reboot, making behavior reliable regardless of whether the OEM clears alarms.


Daily notification flow (relevant parts)

  1. Initial schedule (app): User sets a daily time (e.g. 09:00). App calls scheduleDailyNotification({ time, title, body, id }). Plugin stores schedule and sets an alarm for the next occurrence (e.g. tomorrow 09:00 if today 09:00 has passed).
  2. When the alarm fires: DailyNotificationReceiver runs, shows the notification, and the plugin calls scheduleNextNotification() (rollover), which schedules the next day at the same time via NotifyReceiver.scheduleExactNotification(..., ScheduleSource.ROLLOVER_ON_FIRE).
  3. After reboot: No alarm exists. BootReceiver runs (without the app being launched). It should load the schedule from the DB, compute the next run time, and call the same scheduling path to re-register the alarm with AlarmManager.

If step 3 does not actually register an alarm (because boot recovery skips), and the device cleared alarms on reboot, the next notification will not fire until the user opens the app. If the alarm survived reboot (device-dependent), it can still fire despite the skip.


Evidence that boot recovery can skip rescheduling

Boot recovery repeatedly logs that it is skipping reschedule (see also doc/plugin-feedback-android-post-reboot-fallback-text.md and the Scenario 1 logcat above):

Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=..., source=BOOT_RECOVERY
Existing PendingIntent found for requestCode=53438 - alarm already scheduled

So boot recovery does not call AlarmManager.setAlarmClock() in those runs; it relies on “existing PendingIntent” and skips. The “existing PendingIntent” comes from the plugins idempotence check (e.g. PendingIntent.getBroadcast(..., FLAG_NO_CREATE)), which can still return non-null after reboot (e.g. cached by package/requestCode/Intent identity). That does not prove an alarm is still registered: on some devices alarms are cleared on reboot, so after a skip there would be no alarm and the next notification would not fire. On other devices (as in the Scenario 1 test above) the alarm can survive, so the notification still fires despite the skip. So the risk of a missed notification after reboot is device-dependent; the fix (always reschedule on boot) removes that dependence.


Root cause (plugin side)

  1. Idempotence in scheduleExactNotification: Before scheduling, the plugin checks for an “existing” PendingIntent (and possibly DB state). If found, it skips scheduling to avoid duplicates.
  2. Boot recovery uses the same path: When BootReceiver runs, it calls into the same scheduling logic with source = BOOT_RECOVERY and without skipping the idempotence check (default skipPendingIntentIdempotence = false or equivalent).
  3. After reboot: The “existing PendingIntent” check can still succeed (e.g. cached), so boot recovery skips and does not call AlarmManager.setAlarmClock(). On devices where alarms are cleared on reboot, no alarm is re-registered and the next notification will not fire until the app is opened. On devices where alarms survive (as in the Scenario 1 test), the notification can still fire.

So the reliable behavior is: boot recovery should always re-register the alarm after reboot (e.g. by skipping the PendingIntent idempotence check in the boot path), so that the app does not depend on implementation-dependent alarm persistence.


Idea: In the boot recovery path only, force a real reschedule and avoid the “existing PendingIntent” skip. After reboot there is no alarm; treating it as “already scheduled” is wrong.

Concrete options:

  1. Skip PendingIntent idempotence when source is BOOT_RECOVERY
    When calling NotifyReceiver.scheduleExactNotification from boot recovery (e.g. from ReactivationManager.rescheduleAlarmForBoot or from BootReceiver), pass a flag so that the “existing PendingIntent” check is skipped (e.g. skipPendingIntentIdempotence = true or a dedicated forceRescheduleAfterBoot = true).
    That way, boot recovery always calls AlarmManager.setAlarmClock() (or equivalent) and re-registers the alarm, even if PendingIntent.getBroadcast(..., FLAG_NO_CREATE) still returns non-null from a pre-reboot cache.

  2. Separate boot path that never skips
    Alternatively, implement a dedicated “reschedule for boot” path that does not go through the same idempotence branch as user/manual reschedule. That path should always compute the next run time from the persisted schedule and call AlarmManager to set the alarm, without checking for an “existing” PendingIntent.

  3. Do not rely on PendingIntent existence as “alarm is set” after reboot
    If the plugin currently infers “alarm already scheduled” from “PendingIntent exists,” that inference is wrong after reboot. Either skip that check when the call is from boot recovery, or after reboot always re-register and only use idempotence for in-process duplicate prevention (e.g. when the user taps “Save” twice in a short time).

Recommendation: Option 1 is the smallest change: in the boot recovery call site(s), pass skipPendingIntentIdempotence = true (or the equivalent flag) so that scheduling is not skipped and the alarm is always re-registered after reboot.

Will this cause duplicate alarms when the alarm survived reboot? No. When boot recovery calls setAlarmClock() (or equivalent), it uses the same scheduleId and thus the same requestCode and same Intent (and hence the same logical PendingIntent) as the existing alarm. On Android, setting an alarm with a PendingIntent that matches one already registered replaces that alarm; it does not add a second one. So you end up with one alarm either way—either the one that survived reboot (now effectively “confirmed” by the second call) or the one just set if the previous one had been cleared. No duplicate notifications.


Verification after fix

  1. Schedule a daily notification for a time a few minutes in the future (or use a test build that allows short intervals).
  2. Let it fire once so the plugin schedules the rollover (next day).
  3. Restart the device and do not open the app.
  4. Wait until the next scheduled time (next day, or the same day if testing with a second alarm a few minutes later).
  5. Confirm that the notification does fire.
  6. In logcat after reboot, you should see boot recovery not logging “Skipping duplicate schedule” / “Existing PendingIntent found” for this schedule, and you should see the alarm being set (e.g. “Scheduling OS alarm” or similar).

App-side behavior

No change is required in the consuming app for this bug. The app does not reschedule after reboot; that is the plugins responsibility via BootReceiver and boot recovery. Fixing the plugin so that boot recovery always re-registers the alarm (and does not skip due to PendingIntent idempotence) is sufficient.


Short summary for plugin maintainers

Issue: After an Android device restart, boot recovery skips rescheduling when it finds an “existing PendingIntent.” On devices where AlarmManager clears alarms on reboot, that skip means the next daily notification (initial or rollover) will not fire until the app is opened. On devices where alarms survive reboot, the notification can still fire (as observed in a Scenario 1 test). So the failure is device-dependent; the plugin should not rely on alarm persistence.

Fix: In the boot recovery path, when calling scheduleExactNotification (or the equivalent), pass a flag to skip the “existing PendingIntent” idempotence check (e.g. skipPendingIntentIdempotence = true), so that the alarm is always re-registered after reboot and behavior is reliable on all devices.


For Cursor (plugin repo) — actionable fix

Use this section when applying the fix in the daily-notification-plugin repo (e.g. with Cursor).

Goal: When rescheduling after boot, always register the alarm with AlarmManager. Do not skip because “existing PendingIntent” was found (that check can be true after reboot even though the alarm was cleared).

Change: At every call site where the plugin invokes NotifyReceiver.scheduleExactNotification (or the Kotlin equivalent) for boot recovery (i.e. when the schedule source is BOOT_RECOVERY or the call is from BootReceiver / ReactivationManager.rescheduleAlarmForBoot), pass skipPendingIntentIdempotence = true so that the idempotence check is skipped and the alarm is always set.

Files to look at (plugin Android code):

  • ReactivationManager.kt — Find rescheduleAlarmForBoot (or similar). It likely calls NotifyReceiver.scheduleExactNotification(...). Ensure that call passes skipPendingIntentIdempotence = true (and source = ScheduleSource.BOOT_RECOVERY if applicable).
  • BootReceiver.kt — If it calls scheduleExactNotification or invokes ReactivationManager for boot, ensure that path passes skipPendingIntentIdempotence = true.

Method signature (for reference):
NotifyReceiver.scheduleExactNotification(context, triggerAtMillis, config, isStaticReminder, reminderId, scheduleId, source, skipPendingIntentIdempotence). The last parameter is what must be true for boot recovery.

Verification: After the change, trigger a device reboot (app not launched), then inspect logcat. You should not see “Skipping duplicate schedule” / “Existing PendingIntent found” for source=BOOT_RECOVERY; you should see “Scheduling OS alarm” (or equivalent) so the alarm is re-registered.