Files
crowd-funder-for-time-pwa/doc/plugin-feedback-android-post-reboot-fallback-text.md

12 KiB
Raw Blame History

Plugin feedback: Android daily notification shows fallback text after device restart

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

Summary

When the user sets a daily reminder (custom title/message) and then restarts the device, the notification still fires at the scheduled time but displays fallback text instead of the users message. If the device is not restarted, the same flow (app active, background, or closed) shows the correct user-set text.

So the regression is specific to post-reboot: the alarm survives reboot (good), but the content used for display is wrong (fallback).


Evidence from Logcat

Filter: DNP-SCHEDULE, DailyNotificationWorker, DailyNotificationReceiver.

After reboot (boot recovery):

02-23 16:28:44.489 W/DNP-SCHEDULE: Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-02-24 16:28:44, source=BOOT_RECOVERY
02-23 16:28:44.489 W/DNP-SCHEDULE: Existing PendingIntent found for requestCode=53438 - alarm already scheduled

So boot recovery does not replace the alarm; the existing PendingIntent is kept.

When the notification fires (after reboot):

02-23 16:32:00.601 D/DailyNotificationReceiver: DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION
02-23 16:32:00.650 D/DailyNotificationReceiver: DN|WORK_ENQUEUE display=notify_1771835520000 work_name=display_notify_1771835520000
02-23 16:32:00.847 D/DailyNotificationWorker: DN|WORK_START id=notify_1771835520000 action=display ...
02-23 16:32:00.912 D/DailyNotificationWorker: DN|DISPLAY_START id=notify_1771835520000
02-23 16:32:00.982 D/DailyNotificationWorker: DN|JIT_FRESH skip=true ageMin=0 id=notify_1771835520000
02-23 16:32:00.982 D/DailyNotificationWorker: DN|DISPLAY_NOTIF_START id=notify_1771835520000
02-23 16:32:01.018 I/DailyNotificationWorker: DN|DISPLAY_NOTIF_OK id=notify_1771835520000

Important detail: the worker logs DN|JIT_FRESH skip=true, and there is no DN|DISPLAY_STATIC_REMINDER. So the static reminder path (title/body from Intent extras) is not used; the worker is using the path that loads content from Room/legacy and runs the JIT freshness check. That path is used when is_static_reminder is false or when title/body are missing from the WorkManager input.

Conclusion: when the alarm fires after reboot, the receiver either gets an Intent without (or with cleared) title, body, and is_static_reminder, or the WorkManager input is built without them, so the worker falls back to Room/legacy (and possibly to NativeFetcher), which produces fallback text.


Root cause (plugin side)

1. PendingIntent extras may not survive reboot

On Android, when an alarm is scheduled, the system stores the PendingIntent. After a device reboot, the alarm is restored from persisted state, but it is possible that Intent extras (e.g. title, body, is_static_reminder) are not persisted or are stripped when the broadcast is delivered. So when DailyNotificationReceiver.onReceive runs after reboot, intent.getStringExtra("title") and intent.getStringExtra("body") may be null, and intent.getBooleanExtra("is_static_reminder", false) may be false. The receiver still has notification_id (so the work is enqueued with that id), but the Worker input has no static reminder data, so the worker correctly takes the “load from Room / JIT” path. If the content then comes from Room with wrong/fallback data, or from the apps NativeFetcher (which returns placeholder text), the user sees fallback text.

2. Boot/force-stop recovery uses hardcoded title/body

In ReactivationManager.rescheduleAlarmForBoot and rescheduleAlarm (and similarly in BootReceiver if it ever reschedules), the config used for rescheduling is:

val config = UserNotificationConfig(
    ...
    title = "Daily Notification",
    body = "Your daily update is ready",
    ...
)

So whenever recovery does reschedule (e.g. after force-stop or in a code path that replaces the alarm), the new Intent carries this fallback text. In the current log, boot recovery skips rescheduling (duplicate found), so this is not the path that ran. But if in other builds or OEMs recovery does reschedule, or if a future change replaces the PendingIntent after reboot, the same bug would appear. So recovery should not use hardcoded strings when the schedule has known title/body.

3. Schedule entity does not store title/body

Schedule in DatabaseSchema.kt has no title or body fields. So after reboot there is no way to recover the users message from the plugin DB when:

  • The Intent extras are missing (post-reboot delivery), or
  • Recovery needs to reschedule and should use the same title/body as before.

The plugin does store a NotificationContentEntity (with title/body) when scheduling in NotifyReceiver, keyed by notificationId. So in principle the worker could get the right text by loading that entity when the Intent lacks title/body. That only works if:

  • The worker is given the same notification_id that was used when storing the entity, and
  • The entity was actually written and not overwritten by another path (e.g. prefetch/fallback).

If after reboot the delivered Intent has a different or missing notification_id, or the Room lookup fails (e.g. different id convention, DB not ready), the worker would fall back to legacy storage or fetcher, hence fallback text.


A. Persist title/body for static reminders and use when extras are missing

  1. Persist title/body (and optionally sound/vibration/priority) for static reminders

    • Either extend the Schedule entity with title, body (and optionally other display fields), or ensure there is a single, authoritative NotificationContentEntity per schedule/notification id that is written at schedule time and not overwritten by prefetch/fallback.
    • When the app calls scheduleDailyNotification with a static reminder, store these values (already done for NotificationContentEntity in NotifyReceiver; ensure the same id is used for lookup after reboot).
  2. In DailyNotificationReceiver.enqueueNotificationWork

    • If the Intent has notification_id but missing title/body (or they are empty), or is_static_reminder is false but the schedule is known to be a static reminder:
      • Resolve the schedule/notification id (e.g. from schedule_id extra if present, or from notification_id if it matches a known pattern).
      • Load title/body (and other display fields) from the plugin DB (Schedule or NotificationContentEntity).
      • If found, pass them into the Worker input and set is_static_reminder = true so the worker uses the static reminder path with the correct text.
  3. In DailyNotificationWorker.handleDisplayNotification

    • When loading content from Room by notification_id, if the entity exists and has title/body, use it as-is for display and skip replacing it with JIT/fetcher content for that run (or treat it as static for this display so JIT doesnt overwrite user text with fetcher fallback).

This way, even if the broadcast Intent loses extras after reboot, the receiver or worker can still show the users message from persisted storage.

B. Use persisted title/body in boot/force-stop recovery

  • In ReactivationManager.rescheduleAlarmForBoot, rescheduleAlarm, and any similar recovery path that builds a UserNotificationConfig:
    • Load the schedule (and associated title/body) from the DB (e.g. from Schedule if extended, or from NotificationContentEntity by schedule/notification id).
    • If title/body exist, use them in the config instead of "Daily Notification" / "Your daily update is ready".
    • Only use the hardcoded fallback when no persisted title/body exist (e.g. legacy schedules).

This ensures that any time recovery reschedules an alarm, the users custom message is preserved.

C. Ensure one canonical content record per static reminder

  • Ensure that for a given static reminder schedule, the NotificationContentEntity written at schedule time (in NotifyReceiver) is the one used for display when the alarm fires (including after reboot), and that prefetch/fallback paths do not overwrite that entity for the same logical notification (e.g. same schedule id or same notification id). If the worker currently loads by notification_id, ensure that id is stable and matches what was stored at schedule time.

App-side behavior (no change required for this bug)

  • The app calls scheduleDailyNotification once with id: "daily_timesafari_reminder", title, and body. It does not reschedule after reboot; the plugins boot recovery and alarm delivery are entirely on the plugin side.
  • The apps TimeSafariNativeFetcher returns placeholder text; that is only used when the plugin takes the “fetch content” path. Fixing the plugin so that after reboot the static reminder path (or Room content with user title/body) is used will prevent that placeholder from appearing for the users reminder.

Verification after fix

  1. Set a daily reminder with a distinct custom message (e.g. “My custom reminder text”).
  2. Restart the device (full reboot).
  3. Wait until the scheduled time (or set it 12 minutes ahead for a quick test).
  4. Confirm that the notification shows “My custom reminder text” (or the chosen title), not “Daily Notification” / “Your daily update is ready” or the NativeFetcher placeholder.
  5. In logcat, after the notification fires, you should see either:
    • DN|DISPLAY_STATIC_REMINDER with the correct title, or
    • A path that loads content from Room and displays it without overwriting with fetcher fallback.

Files to consider in the plugin

  • NotifyReceiver.kt Already stores NotificationContentEntity at schedule time; ensure the same notificationId used in the PendingIntent is the one used for this entity so post-reboot lookup by notification_id finds it.
  • DailyNotificationReceiver.java In enqueueNotificationWork, add a fallback: if Intent has notification_id but no (or empty) title/body, look up title/body from DB (by schedule_id or notification_id) and pass them into Worker input with is_static_reminder = true.
  • DailyNotificationWorker.java When loading from Room for a given notification_id, prefer that entity for display and avoid overwriting with JIT/fetcher content when the content is for a static reminder (e.g. same id as a schedule that was created as static).
  • ReactivationManager.kt In rescheduleAlarmForBoot and rescheduleAlarm, load title/body from Schedule or NotificationContentEntity and use them in UserNotificationConfig instead of hardcoded strings.
  • DatabaseSchema.kt (optional) If you prefer to keep title/body on the schedule, add title and body (and optionally other display fields) to the Schedule entity and persist them when the app calls scheduleDailyNotification.

Short summary for Cursor (plugin-side)

Bug: After Android device restart, the daily notification still fires but shows fallback text instead of the user-set message. Logs show the worker uses the non-static path (JIT_FRESH, no DISPLAY_STATIC_REMINDER), so Intent extras (title/body/is_static_reminder) are likely missing after reboot.

Fix: (1) When the receiver has notification_id but missing title/body, look up title/body from the plugin DB (Schedule or NotificationContentEntity) and pass them into the Worker as static reminder data. (2) In boot/force-stop recovery, load title/body from DB and use them when rescheduling instead of hardcoded “Daily Notification” / “Your daily update is ready”. (3) Ensure the NotificationContentEntity written at schedule time is the one used for display after reboot (same id, not overwritten by prefetch/fallback).