13 KiB
Plugin feedback: Android rollover — two notifications, neither with user content
Date: 2026-02-26 18:03
Target repo: daily-notification-plugin
Consuming app: crowd-funder-for-time-pwa (TimeSafari)
Platform: Android
Summary
When waiting for the rollover notification at the scheduled time:
- Two different notifications fired — one ~3 minutes before the schedule (21:53), one on the dot (21:56). They showed different text (neither the user’s).
- Neither notification contained the user-set content — both used Room/fallback content (
DN|DISPLAY_USE_ROOM_CONTENT,skip JIT), not the static reminder path. - Main-thread DB access — receiver logged
db_fallback_failedwith "Cannot access database on the main thread".
Fixes are required in the daily-notification-plugin (and optionally one app-side improvement). This doc gives the diagnosis and recommended changes.
Evidence from Logcat
Filter: DNP-SCHEDULE, DailyNotificationWorker, DailyNotificationReceiver.
First notification (21:53 — 3 minutes before schedule)
display=68ea176c-c9c0-4ef3-bd0c-61b67c8a3982(UUID-style id)DN|WORK_ENQUEUE db_fallback_failed— DB access on main thread when building work inputDN|DISPLAY_USE_ROOM_CONTENT id=68ea176c-... (skip JIT)— content from Room, not static reminder- After display:
DN|ROLLOVER next=1772113980000 scheduleId=daily_rollover_1772027581028 static=false - New schedule created for next day at 21:53
Second notification (21:56 — on the dot)
display=notify_1772027760000(time-based id; 1772027760000 = 2026-02-25 21:56)DN|DISPLAY_USE_ROOM_CONTENT id=notify_1772027760000 (skip JIT)— again Room content, not user text- After display:
DN|ROLLOVER next=1772114160000 scheduleId=daily_rollover_1772027760210 static=false - New schedule created for next day at 21:56
So the user’s chosen time was 21:56. The 21:53 alarm was a separate schedule (from a previous rollover or prefetch that used 21:53).
Root causes
1. Two alarms for two different times
- 21:53 — Alarm with
notification_id= UUID (68ea176c-...). This matches the “prefetch fallback” or “legacy scheduler” path: when prefetch fails or a rollover is created with a time that doesn’t match the current user schedule, the plugin can schedule an alarm with a random UUID and default content (seedoc/plugin-feedback-android-duplicate-reminder-notification.md). So at some point an alarm was set for 21:53 (e.g. a previous day’s rollover for 21:53, or a prefetch that scheduled fallback for 21:53). - 21:56 — Alarm with
notification_id=notify_1772027760000. This is the “real” schedule (user chose 21:56). The id is time-based, not the app’s static reminder iddaily_timesafari_reminder.
So there are two logical schedules active: one for 21:53 (stale or from prefetch) and one for 21:56. When the user reschedules to 21:56, the plugin must cancel all previous alarms for this reminder, including any rollover or prefetch-created alarm for 21:53 (and any other daily_rollover_* or UUID-based alarms that belong to the same logical reminder). Otherwise both fire and the user sees two notifications with different text.
Plugin fix: When the app calls scheduleDailyNotification with a given scheduleId (e.g. daily_timesafari_reminder):
- Cancel every alarm that belongs to this reminder: the main schedule, all rollover schedules (
daily_rollover_*that map to this reminder), and any prefetch-scheduled display alarm (UUID) that was created for this reminder. - For static reminders, do not enqueue prefetch work that will create a second alarm (see duplicate-reminder doc). If prefetch is already disabled for static reminders, then the 21:53 UUID alarm likely came from an old rollover (previous day’s fire at 21:53). So rollover must either (a) use a stable schedule id that gets cancelled when the user reschedules (e.g. same
scheduleIdor a known prefix), or (b) the plugin must cancel by “logical reminder” (e.g. all schedules whose next run is for this reminder) when the user sets a new time.
2. User content not used (USE_ROOM_CONTENT, skip JIT)
- There is no
DN|DISPLAY_STATIC_REMINDERin the logs. So the worker did not receive (or use) static reminder title/body. - Both runs show
DN|DISPLAY_USE_ROOM_CONTENT ... (skip JIT): content is loaded from Room bynotification_idand JIT/fetcher is skipped. So the worker is using Room content keyed by the run’s notification_id (UUID ornotify_*), not by the app’s reminder iddaily_timesafari_reminder.
The app stores title/body when it calls scheduleDailyNotification; the plugin should store that in a way that survives rollover and is used when the alarm fires. If the Intent carries notification_id = notify_1772027760000 (or a UUID) and no title/body (e.g. after reboot or when the rollover PendingIntent doesn’t carry extras), the worker looks up Room by that id. The entity for daily_timesafari_reminder (user title/body) is a different key, so the worker either finds nothing or finds fallback content written by prefetch for that run.
Plugin fix (see also doc/plugin-feedback-android-post-reboot-fallback-text.md):
- Receiver: When the Intent has
notification_idbut missing title/body (oris_static_reminderis false), resolve the “logical” reminder id (e.g. fromschedule_idextra, or from a mapping: rollover schedule id →daily_timesafari_reminder, or from NotificationContentEntity by schedule id). Load title/body from DB (Schedule or NotificationContentEntity) for that reminder and pass them into the Worker withis_static_reminder = true. - Worker: When displaying, if input has static reminder title/body, use them and do not overwrite with Room content keyed by run-specific id. When loading from Room by
notification_id, if the run’s id is a rollover or time-based id, also look up the canonical reminder id (e.g.daily_timesafari_reminder) and prefer that entity’s title/body if present, so rollover displays user text. - Rollover scheduling: When scheduling the next day’s alarm (ROLLOVER_ON_FIRE), pass title/body (or a stable reminder id) so the next fire’s Intent or Worker input can resolve user content. Optionally store title/body on the Schedule entity so boot recovery and rollover can always load them.
3. Main-thread database access
DN|WORK_ENQUEUE db_fallback_failed id=68ea176c-... err=Cannot access database on the main thread...
The receiver is trying to read from the DB (e.g. to fill in title/body when extras are missing) on the main thread. Room disallows this.
Plugin fix: In DailyNotificationReceiver.enqueueNotificationWork, do not call Room/DB on the main thread. Either (a) enqueue the work with the Intent extras only and let the Worker load title/body from DB on a background thread, or (b) use a coroutine/background executor in the receiver to load from DB and then enqueue work with the result. Prefer (a) unless the receiver must decide work parameters synchronously.
Relation to existing docs
- Duplicate reminder (
doc/plugin-feedback-android-duplicate-reminder-notification.md): Prefetch should not schedule a second alarm for static reminders. That would prevent a second alarm at the same time. Here we also have a second alarm at a different time (21:53 vs 21:56), so in addition the plugin must cancel all alarms for the reminder when the user reschedules (including old rollover times). - Post-reboot fallback text (
doc/plugin-feedback-android-post-reboot-fallback-text.md): Same idea — resolve title/body from DB when Intent lacks them; use canonical reminder id / NotificationContentEntity so rollover and post-reboot show user text. - Rollover after reboot (
doc/plugin-feedback-android-rollover-after-reboot.md): Boot recovery should always re-register alarms. Not the direct cause of “two notifications at two times” but relevant for consistency.
App-side behavior
- The app calls
scheduleDailyNotificationonce withid: "daily_timesafari_reminder",time,title, andbody. It does not manage rollover or prefetch; that is all plugin-side. - Optional app-side mitigation: When the user changes the reminder time (or turns the reminder off then on with a new time), the app could call a plugin API to “cancel all daily notification alarms for this app” before calling
scheduleDailyNotificationagain, if the plugin exposes such a method. That would reduce the chance of leftover 21:53 alarms. The correct fix is still plugin-side: when scheduling fordaily_timesafari_reminder, cancel every existing alarm that belongs to that reminder (including rollover and prefetch-created ones).
Verification after plugin fixes
- Set a daily reminder for 21:56 with distinct custom title/body.
- Wait for the notification (or set it 1–2 minutes ahead). One notification at 21:56 with your custom text.
- Let it fire once so rollover is scheduled for next day 21:56. Optionally reboot; next day one notification at 21:56 with your custom text.
- Change time to 21:58 and save. Wait until 21:56 and 21:58: no notification at 21:56; one at 21:58 with your text.
- Logcat: no
db_fallback_failed; for the display that shows user text, eitherDN|DISPLAY_STATIC_REMINDERor Room lookup by canonical id with user title/body.
Short summary for plugin maintainers
- Two notifications: Two different alarms were active (21:53 and 21:56). When the user sets 21:56, the plugin must cancel all alarms for this reminder (main + rollover + any prefetch-created), not only the “primary” schedule. For static reminders, prefetch must not schedule a second alarm (see duplicate-reminder doc).
- Wrong content: Worker used Room content keyed by run id (UUID /
notify_*), not the app’s reminder id. Resolve canonical reminder id and load title/body from DB in receiver or worker; pass static reminder data into Worker when Intent lacks it; when scheduling rollover, preserve title/body (or stable reminder id) so the next fire shows user text. - Main-thread DB: Receiver must not access Room on the main thread; move DB read to Worker or background in receiver.
For Cursor (plugin repo) — actionable handoff
Use this section when applying fixes in the daily-notification-plugin repo (e.g. with Cursor). You can paste or @-mention this doc as context.
Goal: (1) Only one notification at the user’s chosen time, with user-set title/body. (2) No main-thread DB access in the receiver.
Changes:
-
Cancel all alarms for the reminder when the app reschedules
WhenscheduleDailyNotificationis called with a givenscheduleId(e.g.daily_timesafari_reminder), cancel every alarm that belongs to this reminder: the main schedule, all rollover schedules (daily_rollover_*that correspond to this reminder), and any prefetch-created display alarm (UUID). That prevents a second notification at a stale time (e.g. 21:53 when the user set 21:56). -
Static reminder: no second alarm from prefetch
For static reminders, do not enqueue prefetch work that schedules a second alarm (see duplicate-reminder doc). Prefetch is for “fetch content then display”; for static reminders the single NotifyReceiver alarm is enough. -
Use user title/body when displaying (receiver + worker)
When the Intent hasnotification_idbut missing title/body (oris_static_reminderfalse), resolve the canonical reminder id (e.g. fromschedule_id, or rollover id → reminder id, or NotificationContentEntity by schedule). Load title/body from DB and pass into Worker withis_static_reminder = true. In the worker, when displaying rollover or time-based runs, prefer content for the canonical reminder id so user text is shown. When scheduling rollover (ROLLOVER_ON_FIRE), pass or persist title/body (or stable reminder id) so the next day’s fire can resolve them. -
No DB on main thread in receiver
InDailyNotificationReceiver.enqueueNotificationWork, do not call Room/DB on the main thread. Either enqueue work with Intent extras only and let the Worker load title/body on a background thread, or use a coroutine/background executor in the receiver before enqueueing.
Files to look at (plugin Android): ScheduleHelper / NotifyReceiver (cancel all alarms for reminder; schedule with correct id); DailyNotificationReceiver (no main-thread DB; optionally pass static reminder data from DB on background thread); DailyNotificationWorker (use static reminder input; resolve canonical id from Room when run id is rollover/notify_*); DailyNotificationFetchWorker (do not schedule second alarm for static reminders).