15 KiB
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
- Extra notification(s) fire at a different time (e.g. ~3 min early) or at the same time as the user-set one.
- 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!").
- 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 withnotification_id= UUID (fromcreateEmergencyFallbackContent()or from fetcher placeholder).
- NotifyReceiver (AlarmManager): used for the app’s single daily reminder; uses
-
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.
- Existing WorkManager prefetch jobs (tag
-
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_modulesplugin 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.
- On failed fetch after max retries:
2. Prefetch WorkManager jobs not cancelled when user reschedules
-
scheduleDailyNotification (plugin) calls:
ScheduleHelper.cleanupExistingNotificationSchedules(...)→ cancels alarms for all DB schedules (except currentscheduleId).ScheduleHelper.scheduleDailyNotification(...)→ cancels alarm for currentscheduleId, 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 tagsprefetch,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!"
- Title:
-
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_reminderis false and content is loaded from Room bynotification_id, if the worker then does a JIT refresh (e.g. content stale), it callsDailyNotificationFetcher.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 thatnotification_id. The entity fordaily_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 (orschedule_id), the Worker uses static reminder or resolves byschedule_idand 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 passesscheduleIdand 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 whereverscheduleDailyNotificationis implemented). - Change: When handling
scheduleDailyNotification, aftercleanupExistingNotificationSchedulesand 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 callingscheduleNotificationIfNeededwhen 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_failedin 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 fromschedule_idif 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.jsonat 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
-
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”).
-
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.
-
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.
-
Logcat
- No
display=<uuid>at the same time asstatic_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.
- No
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):
- Cancel fetch-related WorkManager jobs when handling
scheduleDailyNotification- File:
android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt - Where: In
scheduleDailyNotification(call), inside theCoroutineScope(Dispatchers.IO).launch { ... }block, afterScheduleHelper.cleanupExistingNotificationSchedules(...)and beforeScheduleHelper.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 cancelsdaily_notification_fetchandprefetchand call that instead. - Example (using existing helper):
(If
ScheduleHelper.cancelAllWorkManagerJobs(context)cancelAllWorkManagerJobsis suspend, call it withrunBlocking { }or from the same coroutine scope.)
- File:
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 leastdaily_notification_fetchandprefetch).