forked from trent_larson/crowd-funder-for-time-pwa
170 lines
15 KiB
Markdown
170 lines
15 KiB
Markdown
# Daily Notification: Why Extra Notifications With Fallback / "Starred Projects" Still Fire
|
||
|
||
**Date:** 2026-03-02
|
||
**Context:** After previous fixes (see `DAILY_NOTIFICATION_BUG_DIAGNOSIS.md` and `plugin-feedback-android-rollover-double-fire-and-user-content.md`), duplicate notifications and fallback/"starred projects" text still occur. This doc explains root causes and where fixes must happen.
|
||
|
||
---
|
||
|
||
## Summary of What’s Happening
|
||
|
||
1. **Extra notification(s)** fire at a different time (e.g. ~3 min early) or at the same time as the user-set one.
|
||
2. **Wrong text** appears: either generic fallback ("Daily Update" / "Good morning! Ready to make today amazing?") or the app’s placeholder ("TimeSafari Update" / "Check your starred projects for updates!").
|
||
3. The **correct** notification (user-set time and message) can still fire as well, so the user sees both correct and wrong notifications.
|
||
|
||
---
|
||
|
||
## Root Causes
|
||
|
||
### 1. Second alarm from prefetch (UUID / fallback)
|
||
|
||
**Mechanism**
|
||
|
||
- The plugin has two scheduling paths:
|
||
- **NotifyReceiver** (AlarmManager): used for the app’s single daily reminder; uses `scheduleId` (e.g. `daily_timesafari_reminder`) and carries title/body in the Intent.
|
||
- **DailyNotificationScheduler** (legacy): used by **DailyNotificationFetchWorker** when prefetch runs and then calls `scheduleNotificationIfNeeded(fallbackContent)`. That creates a **second** alarm with `notification_id` = **UUID** (from `createEmergencyFallbackContent()` or from fetcher placeholder).
|
||
|
||
- **ScheduleHelper** correctly **does not** enqueue prefetch for static reminders (see comment in `DailyNotificationPlugin.kt` ~2686: "Do not enqueue prefetch for static reminders"). So **new** schedules from the app no longer create a prefetch job.
|
||
|
||
- However:
|
||
- **Existing** WorkManager prefetch jobs (tag `daily_notification_fetch`) that were enqueued **before** that fix (or by an older build) are still pending. When they run, fetch fails or returns placeholder → `useFallbackContent()` → `scheduleNotificationIfNeeded(fallbackContent)` → **second alarm with UUID**.
|
||
- That UUID alarm is **not** stored in the Schedule table. So when the user later calls `scheduleDailyNotification`, **cleanupExistingNotificationSchedules** only cancels alarms for schedule IDs that exist in the DB (e.g. `daily_timesafari_reminder`, `daily_rollover_*`). The **UUID alarm is never cancelled**.
|
||
|
||
- **Result:** You can have two alarms: one for `daily_timesafari_reminder` (correct) and one for a UUID (fallback text). If the UUID alarm was set for a slightly different time (e.g. from an old rollover), you get two notifications at two times.
|
||
|
||
**Where the fallback text comes from (plugin)**
|
||
|
||
- **DailyNotificationFetchWorker** (in both app’s `node_modules` plugin and the standalone repo):
|
||
- On failed fetch after max retries: `useFallbackContent(scheduledTime)` → `createEmergencyFallbackContent(scheduledTime)` → title "Daily Update", body "🌅 Good morning! Ready to make today amazing?".
|
||
- That content is saved and then **scheduled** via `scheduleNotificationIfNeeded(fallbackContent)`, which uses **DailyNotificationScheduler** (legacy) and assigns a **new UUID** to the content. So the second alarm fires with that UUID and shows that fallback text.
|
||
|
||
### 2. Prefetch WorkManager jobs not cancelled when user reschedules
|
||
|
||
- **scheduleDailyNotification** (plugin) calls:
|
||
- `ScheduleHelper.cleanupExistingNotificationSchedules(...)` → cancels **alarms** for all DB schedules (except current `scheduleId`).
|
||
- `ScheduleHelper.scheduleDailyNotification(...)` → cancels alarm for current `scheduleId`, schedules NotifyReceiver alarm, **does not** enqueue prefetch.
|
||
|
||
- It does **not** cancel **WorkManager** jobs. So any already-enqueued prefetch work (tag `daily_notification_fetch`) remains. When that work runs, it creates the second (UUID) alarm as above.
|
||
|
||
- **ScheduleHelper** has `cancelAllWorkManagerJobs(context)` (cancels tags `prefetch`, `daily_notification_fetch`, etc.), but **nothing calls it** in the schedule path. So pending prefetch jobs are left in place.
|
||
|
||
**Fix (plugin):** When the app calls `scheduleDailyNotification`, **cancel all fetch-related WorkManager work** (e.g. call `ScheduleHelper.cancelAllWorkManagerJobs(context)` or a helper that only cancels `daily_notification_fetch` and `prefetch`) **before** or **right after** `cleanupExistingNotificationSchedules`. That prevents any pending prefetch from running and creating a UUID alarm later.
|
||
|
||
### 3. "Starred projects" message from the app’s native fetcher
|
||
|
||
- **TimeSafariNativeFetcher** (`android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`) is still a **placeholder**: it always returns:
|
||
- Title: `"TimeSafari Update"`
|
||
- Body: `"Check your starred projects for updates!"`
|
||
|
||
- That text is used whenever the plugin **fetches** content and then displays it:
|
||
- **DailyNotificationFetchWorker**: on “successful” fetch it saves and schedules the fetcher’s result; for your app that result is the placeholder, so any notification created from that path shows “starred projects”.
|
||
- **DailyNotificationWorker** (JIT path): when `is_static_reminder` is false and content is loaded from Room by `notification_id`, if the worker then does a JIT refresh (e.g. content stale), it calls `DailyNotificationFetcher.fetchContentImmediately()` which can use the app’s native fetcher and **overwrite** title/body with the placeholder.
|
||
|
||
- So “starred projects” appears on any notification that goes through a **fetch** path (prefetch success or JIT) instead of the **static reminder** path (Intent title/body or Room by canonical `schedule_id`).
|
||
|
||
**Fix (app):** For a static-reminder-only flow, the plugin should not run prefetch (already done) and should not overwrite with fetcher in JIT for static reminders. Reducing duplicate/out-of-schedule alarms (fixes above) ensures the main run is the static one. Optionally, implement **TimeSafariNativeFetcher** to return real content if you ever want “fetch-based” notifications; until then, the only path that should show user text is the NotifyReceiver alarm with `daily_timesafari_reminder` and title/body from Intent or from Room by `schedule_id`.
|
||
|
||
### 4. Rollover / Room content keyed by run-specific id
|
||
|
||
- When an alarm fires with `notification_id` = **UUID** or **notify_<timestamp>** (and no or missing title/body in the Intent), the Worker treats it as **non-static**. It loads content from Room by that `notification_id`. The entity for `daily_timesafari_reminder` (user title/body) is stored under a **different** id, so the Worker either finds nothing or finds content written by prefetch/fallback for that run → wrong text.
|
||
|
||
- When the alarm is the **correct** one (`daily_timesafari_reminder`) and Intent has title/body (or `schedule_id`), the Worker uses static reminder or resolves by `schedule_id` and shows user text. So the main fix is to **avoid creating the UUID/notify_* run in the first place** (cancel prefetch work; no second alarm). Rollover for the static reminder already passes `scheduleId` and title/body in the Intent (NotifyReceiver puts them in the PendingIntent), so once there’s only one alarm, rollover should keep user text.
|
||
|
||
---
|
||
|
||
## Where Fixes Must Happen
|
||
|
||
### Plugin (daily-notification-plugin)
|
||
|
||
**1. Cancel prefetch (and related) WorkManager jobs when scheduling**
|
||
|
||
- **File:** `DailyNotificationPlugin.kt` (or wherever `scheduleDailyNotification` is implemented).
|
||
- **Change:** When handling `scheduleDailyNotification`, after `cleanupExistingNotificationSchedules` and before (or after) `ScheduleHelper.scheduleDailyNotification`, call a method that cancels all WorkManager work that can create a second alarm. Prefer reusing **ScheduleHelper.cancelAllWorkManagerJobs(context)** or adding a small helper that cancels only fetch-related tags (e.g. `daily_notification_fetch`, `prefetch`) so you don’t cancel display/dismiss work unnecessarily.
|
||
- **Effect:** Pending prefetch jobs from older builds or previous flows will not run, so no new UUID alarm is created and no extra notification with fallback text.
|
||
|
||
**2. (Already done) Do not enqueue prefetch for static reminders**
|
||
|
||
- **ScheduleHelper.scheduleDailyNotification** already does **not** enqueue FetchWorker for static reminders. No change needed here; just ensure no other code path enqueues prefetch for the app’s single daily reminder.
|
||
|
||
**3. (Optional) DailyNotificationFetchWorker: skip scheduling second alarm for static-reminder schedules**
|
||
|
||
- If you ever enqueue prefetch with an explicit “static reminder” flag, in **DailyNotificationFetchWorker** inside `useFallbackContent` / `scheduleNotificationIfNeeded`, skip calling `scheduleNotificationIfNeeded` when that flag is set. For your current setup (no prefetch for static), this is redundant but makes the contract clear and future-proof.
|
||
|
||
**4. Receiver: no DB on main thread**
|
||
|
||
- Your **DailyNotificationReceiver** in the app’s plugin only reads Intent extras and enqueues work; it does not read Room on the main thread. If you still see `db_fallback_failed` in logcat, the failing DB access is elsewhere (e.g. another receiver or an old build). Ensure no BroadcastReceiver does Room/DB access on the main thread; resolve title/body in the Worker from `schedule_id` if Intent lacks them.
|
||
|
||
### App (crowd-funder-for-time-pwa)
|
||
|
||
**Scope: static reminders only.** For fixing static reminders, **no app code changes are required.** Real fetch-based content can be added later.
|
||
|
||
**1. TimeSafariNativeFetcher**
|
||
|
||
- **File:** `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`
|
||
- **Current behavior:** Placeholder that returns `"TimeSafari Update"` / `"Check your starred projects for updates!"` (expected).
|
||
- **For static reminders now:** Leave as-is. The plugin fix (cancel prefetch work when scheduling) ensures the only notification path is the static one; the fetcher is never used for display in that flow. No change needed.
|
||
- **Later (optional):** When you implement real-world content fetching, replace the placeholder here so any future fetch-driven notifications show real content.
|
||
|
||
**2. Build and dependency**
|
||
|
||
- After plugin changes, ensure the app uses the updated plugin (point `package.json` at the fixed repo or publish and bump version), then **clean build** Android (`./gradlew clean`, rebuild, reinstall). Confirming the APK contains the plugin version that cancels prefetch work and does not enqueue prefetch for static reminders avoids stale behavior from old builds.
|
||
|
||
---
|
||
|
||
## Verification After Fixes
|
||
|
||
1. **Single notification, user text**
|
||
- Set daily reminder with a **distinct** title/body and a time 2–3 minutes ahead. Wait until that time.
|
||
- **Expect:** Exactly **one** notification at that time with your text. No second notification (no UUID, no “Daily Update” or “starred projects”).
|
||
|
||
2. **No out-of-schedule notification**
|
||
- Change reminder time (e.g. from 21:53 to 21:56) and save. Wait past 21:53 and until 21:56.
|
||
- **Expect:** No notification at 21:53; one at 21:56 with your text.
|
||
|
||
3. **Rollover**
|
||
- Let the correct notification fire once so rollover runs. Next day (or next occurrence) you should see **one** notification with the same user text.
|
||
|
||
4. **Logcat**
|
||
- No `display=<uuid>` at the same time as `static_reminder id=daily_timesafari_reminder`.
|
||
- After scheduling (e.g. edit and save), you should see prefetch/fetch work being cancelled if you add a log in the cancel path.
|
||
|
||
---
|
||
|
||
## Short Summary
|
||
|
||
| Issue | Cause | Fix location |
|
||
|-------|--------|--------------|
|
||
| Extra notification at same or different time | Prefetch WorkManager job still runs and creates second (UUID) alarm via legacy scheduler; that alarm is never cancelled on reschedule | **Plugin:** Cancel fetch-related WorkManager jobs when `scheduleDailyNotification` is called |
|
||
| Fallback text ("Daily Update" / "Good morning!") | FetchWorker’s `useFallbackContent` → `scheduleNotificationIfNeeded` creates alarm with that content | **Plugin:** Same as above (no prefetch run → no fallback alarm); optionally FetchWorker skips scheduling when static-reminder flag set |
|
||
| "Starred projects" text | TimeSafariNativeFetcher placeholder used when a fetch path runs | **Plugin:** Same as above (no prefetch → no fetch path). **App:** No change for static reminders; leave fetcher as placeholder until real fetch is implemented. |
|
||
| Wrong content on rollover | Rollover run keyed by UUID or notify_* and no title/body in Intent → Worker loads from Room by that id → wrong/empty content | **Plugin:** Avoid creating UUID/notify_* run (cancel prefetch). Static rollover already passes schedule_id and title/body. |
|
||
|
||
The critical missing step is **cancelling prefetch (and fetch) WorkManager work when the user schedules or reschedules** the daily notification. That prevents any pending prefetch from running and creating the second alarm with fallback or “starred projects” text.
|
||
|
||
---
|
||
|
||
## For Cursor (plugin repo) — actionable handoff
|
||
|
||
Use this section when applying the fix in the **daily-notification-plugin** repo (e.g. with Cursor). Paste or @-mention this doc as context.
|
||
|
||
**Goal:** For static reminders, only one notification at the user's chosen time with user-set title/body. No extra notification from pending prefetch (UUID alarm with fallback or "starred projects" text).
|
||
|
||
**Root cause:** `scheduleDailyNotification` cleans up DB schedules and alarms but **does not cancel WorkManager prefetch jobs**. Any previously enqueued job (tag `daily_notification_fetch`) still runs, then creates a second alarm via `DailyNotificationScheduler` (UUID). That alarm is never cancelled on reschedule. Fix: cancel fetch-related WorkManager work when the user schedules.
|
||
|
||
**Change (required):**
|
||
|
||
1. **Cancel fetch-related WorkManager jobs when handling `scheduleDailyNotification`**
|
||
- **File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`
|
||
- **Where:** In `scheduleDailyNotification(call)`, inside the `CoroutineScope(Dispatchers.IO).launch { ... }` block, **after** `ScheduleHelper.cleanupExistingNotificationSchedules(...)` and **before** `ScheduleHelper.scheduleDailyNotification(...)`.
|
||
- **What:** Call a method that cancels WorkManager work that can create a second alarm. Reuse **ScheduleHelper.cancelAllWorkManagerJobs(context)** (it already cancels `prefetch`, `daily_notification_fetch`, etc.). If you prefer not to cancel display/dismiss work, add a helper that only cancels `daily_notification_fetch` and `prefetch` and call that instead.
|
||
- **Example (using existing helper):**
|
||
```kotlin
|
||
ScheduleHelper.cancelAllWorkManagerJobs(context)
|
||
```
|
||
(If `cancelAllWorkManagerJobs` is suspend, call it with `runBlocking { }` or from the same coroutine scope.)
|
||
|
||
**No other plugin changes needed for this fix:** ScheduleHelper already does not enqueue prefetch for static reminders; the only missing step is cancelling **pending** prefetch work when the user schedules or reschedules.
|
||
|
||
**Files to look at (plugin Android):**
|
||
- `DailyNotificationPlugin.kt` — `scheduleDailyNotification(call)` (add cancel call after cleanup, before ScheduleHelper.scheduleDailyNotification).
|
||
- `ScheduleHelper` (in same file or separate) — `cancelAllWorkManagerJobs(context)` (already exists; ensure it cancels at least `daily_notification_fetch` and `prefetch`).
|