Files
crowd-funder-for-time-pwa/doc/plugin-feedback-android-rollover-double-fire-and-user-content.md

13 KiB
Raw Blame History

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:

  1. Two different notifications fired — one ~3 minutes before the schedule (21:53), one on the dot (21:56). They showed different text (neither the users).
  2. Neither notification contained the user-set content — both used Room/fallback content (DN|DISPLAY_USE_ROOM_CONTENT, skip JIT), not the static reminder path.
  3. Main-thread DB access — receiver logged db_fallback_failed with "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 input
  • DN|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 users 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 doesnt match the current user schedule, the plugin can schedule an alarm with a random UUID and default content (see doc/plugin-feedback-android-duplicate-reminder-notification.md). So at some point an alarm was set for 21:53 (e.g. a previous days 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 apps static reminder id daily_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 days fire at 21:53). So rollover must either (a) use a stable schedule id that gets cancelled when the user reschedules (e.g. same scheduleId or 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_REMINDER in 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 by notification_id and JIT/fetcher is skipped. So the worker is using Room content keyed by the runs notification_id (UUID or notify_*), not by the apps reminder id daily_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 doesnt 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_id but missing title/body (or is_static_reminder is false), resolve the “logical” reminder id (e.g. from schedule_id extra, 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 with is_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 runs id is a rollover or time-based id, also look up the canonical reminder id (e.g. daily_timesafari_reminder) and prefer that entitys title/body if present, so rollover displays user text.
  • Rollover scheduling: When scheduling the next days alarm (ROLLOVER_ON_FIRE), pass title/body (or a stable reminder id) so the next fires 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 scheduleDailyNotification once with id: "daily_timesafari_reminder", time, title, and body. 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 scheduleDailyNotification again, 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 for daily_timesafari_reminder, cancel every existing alarm that belongs to that reminder (including rollover and prefetch-created ones).

Verification after plugin fixes

  1. Set a daily reminder for 21:56 with distinct custom title/body.
  2. Wait for the notification (or set it 12 minutes ahead). One notification at 21:56 with your custom text.
  3. 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.
  4. 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.
  5. Logcat: no db_fallback_failed; for the display that shows user text, either DN|DISPLAY_STATIC_REMINDER or 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 apps 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 users chosen time, with user-set title/body. (2) No main-thread DB access in the receiver.

Changes:

  1. Cancel all alarms for the reminder when the app reschedules
    When scheduleDailyNotification is called 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 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).

  2. 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.

  3. Use user title/body when displaying (receiver + worker)
    When the Intent has notification_id but missing title/body (or is_static_reminder false), resolve the canonical reminder id (e.g. from schedule_id, or rollover id → reminder id, or NotificationContentEntity by schedule). Load title/body from DB and pass into Worker with is_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 days fire can resolve them.

  4. No DB on main thread in receiver
    In DailyNotificationReceiver.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).