12 KiB
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 user’s 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 app’s 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 user’s 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_idthat 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.
Recommended fix (in the plugin)
A. Persist title/body for static reminders and use when extras are missing
-
Persist title/body (and optionally sound/vibration/priority) for static reminders
- Either extend the
Scheduleentity withtitle,body(and optionally other display fields), or ensure there is a single, authoritativeNotificationContentEntityper schedule/notification id that is written at schedule time and not overwritten by prefetch/fallback. - When the app calls
scheduleDailyNotificationwith a static reminder, store these values (already done forNotificationContentEntityinNotifyReceiver; ensure the same id is used for lookup after reboot).
- Either extend the
-
In
DailyNotificationReceiver.enqueueNotificationWork- If the Intent has
notification_idbut missingtitle/body(or they are empty), oris_static_reminderis false but the schedule is known to be a static reminder:- Resolve the schedule/notification id (e.g. from
schedule_idextra if present, or fromnotification_idif 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 = trueso the worker uses the static reminder path with the correct text.
- Resolve the schedule/notification id (e.g. from
- If the Intent has
-
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 doesn’t overwrite user text with fetcher fallback).
- When loading content from Room by
This way, even if the broadcast Intent loses extras after reboot, the receiver or worker can still show the user’s 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 aUserNotificationConfig:- Load the schedule (and associated title/body) from the DB (e.g. from
Scheduleif extended, or fromNotificationContentEntityby 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).
- Load the schedule (and associated title/body) from the DB (e.g. from
This ensures that any time recovery reschedules an alarm, the user’s custom message is preserved.
C. Ensure one canonical content record per static reminder
- Ensure that for a given static reminder schedule, the
NotificationContentEntitywritten at schedule time (inNotifyReceiver) 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 bynotification_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
scheduleDailyNotificationonce withid: "daily_timesafari_reminder",title, andbody. It does not reschedule after reboot; the plugin’s boot recovery and alarm delivery are entirely on the plugin side. - The app’s
TimeSafariNativeFetcherreturns 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 user’s reminder.
Verification after fix
- Set a daily reminder with a distinct custom message (e.g. “My custom reminder text”).
- Restart the device (full reboot).
- Wait until the scheduled time (or set it 1–2 minutes ahead for a quick test).
- 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.
- In logcat, after the notification fires, you should see either:
DN|DISPLAY_STATIC_REMINDERwith 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
NotificationContentEntityat schedule time; ensure the samenotificationIdused in the PendingIntent is the one used for this entity so post-reboot lookup bynotification_idfinds it. - DailyNotificationReceiver.java – In
enqueueNotificationWork, add a fallback: if Intent hasnotification_idbut no (or empty)title/body, look up title/body from DB (byschedule_idornotification_id) and pass them into Worker input withis_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
rescheduleAlarmForBootandrescheduleAlarm, load title/body from Schedule or NotificationContentEntity and use them inUserNotificationConfiginstead of hardcoded strings. - DatabaseSchema.kt (optional) – If you prefer to keep title/body on the schedule, add
titleandbody(and optionally other display fields) to theScheduleentity and persist them when the app callsscheduleDailyNotification.
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).