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

134 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).