Files
crowd-funder-from-jason/doc/DAILY_NOTIFICATION_DUPLICATE_FALLBACK_ANALYSIS.md

15 KiB
Raw Blame History

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 Whats 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 apps 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 apps 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 apps 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 apps 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 fetchers 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 apps 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 theres 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 dont 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 apps 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 apps 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 23 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!") FetchWorkers useFallbackContentscheduleNotificationIfNeeded 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):
      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.ktscheduleDailyNotification(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).