move files from 'docs' to existing 'doc' directory

This commit is contained in:
2026-03-14 20:02:01 -06:00
parent 5ae0d6ba2c
commit fa1c639a8b
21 changed files with 32 additions and 32 deletions

View File

@@ -0,0 +1,122 @@
# Daily Notification Bugs — Diagnosis (Plugin + App)
**Context:** Fixes were applied in both the plugin and the app, but "reset doesn't fire" and "notification text defaults to fallback" still occur. This doc summarizes what was checked and what to do next.
---
## What Was Verified
### App integration (correct)
- **NativeNotificationService.ts**
- Pre-cancel is gated: only iOS calls `cancelDailyReminder()` before scheduling (lines 289305). Android skips it.
- Schedules with `id: this.reminderId` (`"daily_timesafari_reminder"`), plus `time`, `title`, `body`.
- Calls `DailyNotification.scheduleDailyNotification(scheduleOptions)` (not `scheduleDailyReminder`).
- **AccountViewView.vue**
- `editReminderNotification()` only calls `cancelDailyNotification()` when **not** Android (lines 13031305). On Android it only calls `scheduleDailyNotification()`.
So the app is not double-cancelling on Android and is passing the expected options.
### Plugin in apps node_modules (fixed code present)
- **node_modules/@timesafari/daily-notification-plugin** is at **version 1.1.4** and contains:
- **NotifyReceiver.kt:** DB idempotence is skipped when `skipPendingIntentIdempotence=true` (wrapped in `if (!skipPendingIntentIdempotence)`).
- **DailyNotificationWorker.java:** `preserveStaticReminder` read from input, stable `scheduleId` for static reminders, and `scheduleExactNotification(..., preserveStaticReminder, ...)`.
- **DailyNotificationPlugin.kt:** `cancelDailyReminder(call)` implemented.
So the **source** the app uses (from its dependency) already has the fixes.
### Plugin schedule path (correct)
- App calls `scheduleDailyNotification` → plugins `scheduleDailyNotification(call)``ScheduleHelper.scheduleDailyNotification(...)`.
- That helper calls `NotifyReceiver.cancelNotification(context, scheduleId)` then `scheduleExactNotification(..., skipPendingIntentIdempotence = true)`.
- So the “re-set” path does set `skipPendingIntentIdempotence = true` and the DB idempotence skip should apply.
---
## Likely Causes Why Bugs Still Appear
### 1. Stale Android build / old APK
The Android app compiles the plugin from:
`android/capacitor.settings.gradle`
`project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')`
If the app was not fully rebuilt after the plugin in node_modules was updated, the running APK may still contain old plugin code.
**Do this:**
- In the **app** repo (`crowd-funder-for-time-pwa`):
- `./gradlew clean` (or Android Studio → Build → Clean Project)
- Build and reinstall the app (e.g. Run on device/emulator).
- Confirm youre not installing an older APK from somewhere else.
### 2. Dependency not actually updated after plugin changes
The app depends on:
```json
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master"
```
If the fixes were only made in a **different** clone (e.g. `daily-notification-plugin_test`) and never pushed to that gitea `master`, then:
- `npm install` / `npm update` in the app would not pull the fixes.
- The apps `node_modules` would only have the fixes if they were copied/linked from the fixed repo.
**Do this:**
- If the fixes live in another clone: either **push** the fixed plugin to gitea `master` and run `npm update @timesafari/daily-notification-plugin` (then `npx cap sync android`, then clean build), **or** point the app at the fixed plugin locally, e.g. in **app** `package.json`:
- `"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin"`
(adjust path to your fixed plugin repo), then `npm install`, `npx cap sync android`, clean build and reinstall.
### 3. Fallback text from native fetcher (Bug 2 only)
**TimeSafariNativeFetcher.java** in the app is still a placeholder: it always returns:
- Title: `"TimeSafari Update"`
- Body: `"Check your starred projects for updates!"`
That only affects flows that **fetch** content (e.g. prefetch or any path that uses the fetcher for display). The **static** daily reminder path does not use the fetcher for display: title/body come from the schedule Intent and WorkManager input. So if you only use the “daily reminder” (one time + custom title/body), the fetcher placeholder should not be the cause. If you have any flow that relies on **fetched** content for the text, youll see that placeholder until the fetcher is implemented and wired (and optionally token persistence).
---
## Verification Steps (after clean build + reinstall)
1. **Reset / “re-set” (Bug 1)**
- Set reminder for 23 minutes from now.
- Edit and save **without changing the time**.
- Wait for the time; the notification should fire.
- In logcat, filter by the plugins tags and look for:
- `Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=...`
- `Scheduling next daily alarm: id=daily_timesafari_reminder ...`
If you see these, the fixed path is running.
2. **Static text on rollover (Bug 2)**
- Set a custom title/body, let the notification fire once.
- In logcat look for:
- `DN|ROLLOVER next=... scheduleId=daily_timesafari_reminder static=true`
If you see `static=true` and the same `scheduleId`, the next occurrence should keep your custom text.
3. **Plugin version at build time**
- In the apps `node_modules/@timesafari/daily-notification-plugin/package.json`, confirm `"version": "1.1.4"` (or the version that includes the fixes).
- After that, a clean build ensures that version is whats in the APK.
---
## Summary
| Check | Status |
|-------|--------|
| App gates cancel on Android | OK |
| App calls scheduleDailyNotification with id/title/body | OK |
| Plugin in app node_modules has DB idempotence skip | OK (1.1.4) |
| Plugin in app node_modules has static rollover fix | OK |
| Plugin in app node_modules has cancelDailyReminder | OK |
| Schedule path passes skipPendingIntentIdempotence = true | OK |
**See also:** `doc/plugin-feedback-android-rollover-double-fire-and-user-content.md` — when two notifications fire (e.g. one ~3 min early, one on the dot) and neither shows user-set content.
Most likely the app is still running an **old Android build**. Do a **clean build and reinstall**, and ensure the plugin dependency in the app really points at the fixed code (gitea master or local path). Then re-test and check logcat for the lines above. If the bugs persist after that, the next step is to capture a full logcat from “edit reminder (same time)” through the next fire and from “first fire” through “next day” to see which path runs.

View File

@@ -0,0 +1,169 @@
# 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 `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`).

View File

@@ -0,0 +1,82 @@
# TimeSafari — Daily notifications troubleshooting (iOS & Android)
**Last updated:** 2026-03-06 17:08 PST
**Audience:** End-users
**Applies to:** TimeSafari iOS/Android native app (daily notifications scheduled on-device)
If your **Daily Reminder** or notification doesnt show up, follow the steps below.
## Before you start
- These notifications are **scheduled on your device** (no browser/web push).
- If you previously followed an older “web notifications” guide, those steps no longer apply for iOS/Android builds.
## 1) Check your in-app notification settings
- Tap **Profile** in the bottom bar
- Under **Notifications**, confirm:
- **Daily Reminder** is **enabled**
- The **time** is set correctly
- The message looks correct
- If its already enabled, try to:
- Turn it **off**
- Turn it **on** again
- Re-set the time and message
## 2) iOS troubleshooting
### Allow notifications for TimeSafari
1. Open **Settings****Notifications**
2. Tap **TimeSafari**
3. Turn **Allow Notifications** on
4. Enable at least one delivery style (recommended):
- **Lock Screen**
- **Notification Center**
- **Banners**
5. Optional but helpful:
- **Sounds** on (if you want an audible reminder)
### Focus / Do Not Disturb
If youre using **Focus** or **Do Not Disturb**, notifications may be silenced or hidden.
- Open **Settings****Focus**
- Check the active Focus mode and ensure **TimeSafari** is allowed (or temporarily disable Focus to test)
### After restarting your phone
If you recently restarted iOS and dont see the notification, open **TimeSafari** once. (You dont need to change anything.)
## 3) Android troubleshooting
### Allow notifications for TimeSafari
1. Open **Settings****Apps**
2. Tap **TimeSafari****Manage notifications** (wording varies)
3. Turn notifications **on**
4. If Android shows notification categories/channels for the app, ensure the relevant channel is allowed.
### Battery / background restrictions
Battery optimization can delay or block scheduled notifications.
- Open **Settings****Apps****TimeSafari****Battery usage** (wording varies)
- If available:
- Set **Battery usage** to **Unrestricted**
- Turn **Allow background usage** on
- Disable optimization for TimeSafari
- If your device has lists like **Sleeping apps** / **Restricted apps**, remove TimeSafari from them
### After restarting your phone
Depending on the device manufacturer, Android can clear scheduled notifications during a reboot. If you restarted recently:
- Open **TimeSafari** once (you dont need to change anything)
## 4) If it still doesnt work
- Ensure youre on the latest TimeSafari app version.
- If you denied permission earlier, re-enable notifications in system settings (above).
- As a last resort, uninstall/reinstall the app (youll need to enable notifications again and reconfigure the daily reminder). **Important:** Before uninstalling, back up your identifier seed so you can import it back later: **Profile → Data Management → Backup Identifier Seed**.

View File

@@ -80,7 +80,7 @@ installed by each developer. They are not automatically active.
- Test files: `*.test.js`, `*.spec.ts`, `*.test.vue`
- Scripts: `scripts/` directory
- Test directories: `test-*` directories
- Documentation: `docs/`, `*.md`, `*.txt`
- Documentation: `doc/`, `*.md`, `*.txt`
- Config files: `*.json`, `*.yml`, `*.yaml`
- IDE files: `.cursor/` directory

View File

@@ -0,0 +1,114 @@
# Plugin feedback: Android duplicate reminder notification on first-time setup
**Date:** 2026-02-18
**Generated:** 2026-02-18 17:47:06 PST
**Target repo:** daily-notification-plugin (local copy at `daily-notification-plugin_test`)
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platform:** Android
## Summary
When the user sets a **Reminder Notification for the first time** (toggle on → set message and time in `PushNotificationPermission`), **two notifications** fire at the scheduled time:
1. **Correct one:** Users chosen title/message, from the static reminder alarm (`scheduleId` = `daily_timesafari_reminder`).
2. **Extra one:** Fallback message (“Daily Update” / “🌅 Good morning! Ready to make today amazing?”), from a second alarm that uses a **UUID** as `notification_id`.
When the user **edits** an existing reminder (Edit Notification Details), only one notification fires. The duplicate only happens on **initial** setup.
The app calls `scheduleDailyNotification` **once** per user action in both flows (first-time and edit). The duplicate is caused inside the plugin by the **prefetch worker** scheduling a second alarm via the legacy `DailyNotificationScheduler`.
---
## Evidence from Logcat
Filter: `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
- **17:42:34** Single call from app: plugin schedules the static reminder alarm (`scheduleId=daily_timesafari_reminder`, source=INITIAL_SETUP). One OS alarm is scheduled.
- **17:45:00** **Two** `RECEIVE_START` events:
- First: `display=5e373fd1-0f08-4e8f-b166-cfd46d694d82` (UUID).
- Second: `static_reminder id=daily_timesafari_reminder`.
- Both run in parallel: Worker for UUID shows `DN|JIT_FRESH skip=true` and displays; Worker for `daily_timesafari_reminder` shows `DN|DISPLAY_STATIC_REMINDER` and displays. So two notifications are shown.
Conclusion: two different PendingIntents fire at the same time: one with `notification_id` = UUID, one with `notification_id` = `daily_timesafari_reminder`.
---
## Root cause (plugin side)
1. **ScheduleHelper.scheduleDailyNotification** (e.g. in `DailyNotificationPlugin.kt`):
- Cancels existing alarm for `scheduleId`.
- Schedules **one** alarm via **NotifyReceiver.scheduleExactNotification** with `reminderId = scheduleId`, `scheduleId = scheduleId`, `isStaticReminder = true` (INITIAL_SETUP). That alarm carries title/body in the intent and is the “correct” notification.
- Enqueues **DailyNotificationFetchWorker** (prefetch) to run 2 minutes before the same time.
2. **DailyNotificationFetchWorker** runs ~2 minutes before the display time:
- Tries to fetch content (e.g. native fetcher). For a static-reminder-only app (no URL, no fetcher returning content), the fetch returns empty/null.
- Goes to **handleFailedFetch****useFallbackContent****getFallbackContent****createEmergencyFallbackContent(scheduledTime)**.
- **createEmergencyFallbackContent** builds a `NotificationContent()` (default constructor), which assigns a **random UUID** as `id`, and sets title “Daily Update” and body “🌅 Good morning! Ready to make today amazing?”.
- **useFallbackContent** then calls **scheduleNotificationIfNeeded(fallbackContent)**.
3. **scheduleNotificationIfNeeded** uses the **legacy DailyNotificationScheduler** (AlarmManager) to schedule **another** alarm at the **same** `scheduledTime`, with `notification_id` = that UUID.
So at fire time there are two alarms:
- NotifyReceivers alarm: `notification_id` = `daily_timesafari_reminder`, `is_static_reminder` = true → correct user message.
- DailyNotificationSchedulers alarm: `notification_id` = UUID → fallback message.
The prefetch path is intended for “fetch content then display” flows. For **static reminder** schedules, the display is already fully handled by the single NotifyReceiver alarm; the prefetch worker should not schedule a second alarm.
---
## Why edit doesnt show the duplicate (in observed behavior)
On edit, the app still calls the plugin once and the plugin again enqueues the prefetch worker. Possible reasons the duplicate is less obvious on edit:
- Different timing (e.g. user sets a time further out, or doesnt wait for the second notification).
- Or the first-time run leaves the prefetch/legacy path in a state where the duplicate only appears on first setup.
Regardless, the **correct fix** is to ensure that for static-reminder schedules the prefetch worker never schedules a second alarm.
---
## Recommended fix (in the plugin)
**Option A (recommended): Do not enqueue prefetch for static reminder schedules**
In **ScheduleHelper.scheduleDailyNotification** (or equivalent), when scheduling a **static reminder** (title/body from app, no URL, display already in the intent), **do not** enqueue `DailyNotificationFetchWorker` for that run. The prefetch is for “fetch content then show”; for static reminders there is nothing to fetch and the only alarm should be the one from NotifyReceiver.
- No new inputData flags needed.
- No change to DailyNotificationFetchWorker semantics for other flows.
**Option B: Prefetch worker skips scheduling when display is already scheduled**
- When enqueueing the prefetch work for a static-reminder schedule, pass an input flag (e.g. `display_already_scheduled` or `is_static_reminder_schedule` = true).
- In **DailyNotificationFetchWorker**, in **useFallbackContent** (and anywhere else that calls **scheduleNotificationIfNeeded** for this work item), if that flag is set, **do not** call **scheduleNotificationIfNeeded**.
- Ensures only the NotifyReceiver alarm fires for that time.
Option A is simpler and matches the semantics: static reminder = one alarm, no prefetch.
---
## App-side behavior (no change required)
- **First-time reminder:** Account view opens `PushNotificationPermission` without `skipSchedule`. User sets time/message and confirms. Dialogs `turnOnNativeNotifications` calls `NotificationService.scheduleDailyNotification(...)` **once** and then the callback saves settings. No second schedule from the app.
- **Edit reminder:** Account view opens the dialog with `skipSchedule: true`. Only the parents callback runs; it calls `cancelDailyNotification()` (on iOS) then `scheduleDailyNotification(...)` **once**. No double schedule from the app.
So the duplicate is entirely due to the plugins prefetch worker scheduling an extra alarm via the legacy scheduler; fixing it in the plugin as above will resolve the issue.
---
## Files to consider in the plugin
- **ScheduleHelper.scheduleDailyNotification** (e.g. in `DailyNotificationPlugin.kt`): where the single NotifyReceiver alarm and the prefetch work are enqueued. Either skip enqueueing prefetch for static reminder (Option A), or add inputData for “display already scheduled” (Option B).
- **DailyNotificationFetchWorker**: `useFallbackContent``scheduleNotificationIfNeeded`; if using Option B, skip `scheduleNotificationIfNeeded` when the new flag is set.
- **DailyNotificationScheduler** (legacy): used by `scheduleNotificationIfNeeded` to add the second (UUID) alarm; no change required if the worker simply stops calling it for static-reminder schedules.
---
## Verification
After the fix:
1. **First-time:** Turn on Reminder Notification, set message and time (e.g. 23 minutes ahead). Wait until the scheduled time. **Only one** notification should appear, with the users message.
2. Logcat should show a single `RECEIVE_START` at that time (e.g. `static_reminder id=daily_timesafari_reminder`), and no second `display=<uuid>` for the same time.
You can reuse the same Logcat filter as above to confirm a single receiver run per scheduled time.

View File

@@ -0,0 +1,74 @@
# Plugin feedback: Android exact alarm — stop opening Settings automatically
**Date:** 2026-03-09
**Target repo:** daily-notification-plugin
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platform:** Android
## Summary
When the consuming app calls `scheduleDailyNotification()` after the user has granted `POST_NOTIFICATIONS`, the plugin checks whether **exact alarms** can be scheduled (Android 12+). If not, it **opens the system Settings** (exact-alarm or app-details screen) and **rejects** the call. This is intrusive: the app prefers to schedule without forcing the user into Settings, and to inform the user about exact alarms in its own UI (e.g. a note in the success message).
**Requested change:** Remove the automatic opening of Settings for exact alarm permission from `scheduleDailyNotification()`. Either:
- **Option A (preferred):** Do not open Settings and do not reject when exact alarm is not granted. Proceed with scheduling (using inexact alarms if necessary when exact is unavailable), and let the consuming app handle any UX (e.g. optional hint to enable exact alarms).
- **Option B:** Do not open Settings, but still reject with a clear error code/message when exact alarm is required and not granted, so the app can show its own message or deep-link to Settings if desired.
---
## Where this lives in the plugin
**File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`
**Method:** `scheduleDailyNotification(call: PluginCall)`
**Lines:** ~10571109 (exact line numbers may shift with edits)
Current behavior:
1. At the start of `scheduleDailyNotification()`, the plugin calls `canScheduleExactAlarms(context)`.
2. If `false`:
- If Android S+ and `canRequestExactAlarmPermission(context)` is true: it builds `Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM`, calls `context.startActivity(intent)`, logs **"Exact alarm permission required. Opened Settings for user to grant permission."** (tag `DNP-PLUGIN`), and rejects with `EXACT_ALARM_PERMISSION_REQUIRED`.
- Else: it opens app details (`Settings.ACTION_APPLICATION_DETAILS_SETTINGS`), logs **"Exact alarm permission denied. Directing user to app settings."**, and rejects with `PERMISSION_DENIED`.
3. Only if exact alarms are allowed does the plugin continue to schedule.
So the **exact alarms** feature here is: **gate scheduling on exact alarm permission and, when not granted, open Settings and reject.**
---
## Evidence from consumer app (logcat)
Filter: `DNP-PLUGIN`, `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
Typical sequence when user enables daily notification:
1. `DNP-PLUGIN: Created pending permission request: ... type=POST_NOTIFICATIONS`
2. User grants notification permission.
3. `DNP-PLUGIN: Resolving pending POST_NOTIFICATIONS request on resume: granted=true`
4. App calls `scheduleDailyNotification(...)`.
5. `DNP-PLUGIN: Exact alarm permission required. Opened Settings for user to grant permission.`
The consumer app does **not** call any plugin API to “request exact alarm” or “open exact alarm settings”; it only calls `requestPermissions()` (POST_NOTIFICATIONS) and then `scheduleDailyNotification()`. The plugins own guard in `scheduleDailyNotification()` is what opens Settings.
---
## Consumer app context
- **Permission flow:** The app requests `POST_NOTIFICATIONS` via the plugins `requestPermissions()`, then calls `scheduleDailyNotification()`. It does not request exact alarm permission itself.
- **UX:** The app already shows an optional note when exact alarm is not granted (e.g. “If notifications dont appear, enable Exact alarms in Android Settings → Apps → TimeSafari → App settings”). It does not want the plugin to open Settings automatically.
- **Manifest:** The app declares `SCHEDULE_EXACT_ALARM` in its AndroidManifest; the issue is only the **automatic redirect to Settings** and the **reject** when exact alarm is not yet granted.
---
## Suggested plugin changes
1. **In `scheduleDailyNotification()`:** Remove the block that opens Settings and rejects when `!canScheduleExactAlarms(context)` (the block ~10571109). Do **not** call `startActivity` for `ACTION_REQUEST_SCHEDULE_EXACT_ALARM` or `ACTION_APPLICATION_DETAILS_SETTINGS` from this method.
2. **Scheduling when exact alarm is not granted:** Prefer Option A: continue and schedule even when exact alarms are not allowed (e.g. use inexact/alarm manager APIs that dont require exact alarm, or document that timing may be approximate). If the plugin must reject when exact is required, use Option B: reject with a specific error code/message and no `startActivity`.
3. **Leave other APIs unchanged:** Methods such as `openExactAlarmSettings()` or `requestExactAlarmPermission()` can remain for apps that explicitly want to send the user to Settings; the change is only to stop doing it automatically inside `scheduleDailyNotification()`.
---
## Relation to existing docs
- **Plugin:** `doc/daily-notification-plugin-android-receiver-issue.md` and `doc/daily-notification-plugin-checklist.md` describe use of `SCHEDULE_EXACT_ALARM` (not `USE_EXACT_ALARM`). This feedback does not change that; it only asks to stop auto-opening Settings in `scheduleDailyNotification()`.
- **Consumer app:** `doc/notification-permissions-and-rollovers.md` describes the permission flow; `doc/NOTIFICATION_TROUBLESHOOTING.md` mentions exact alarms for user guidance.
Use this document when implementing the change in the **daily-notification-plugin** repo (e.g. with Cursor). After changing the plugin, update the consuming apps dependency (e.g. `npm update @timesafari/daily-notification-plugin`), then `npx cap sync android` and rebuild.

View File

@@ -0,0 +1,151 @@
# Plugin feedback: Android daily notification shows fallback text after device restart
**Date:** 2026-02-23
**Target repo:** daily-notification-plugin
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platform:** Android
## Summary
When the user sets a daily reminder (custom title/message) and then **restarts the device**, the notification still fires at the scheduled time but displays **fallback text** instead of the users message. If the device is **not** restarted, the same flow (app active, background, or closed) shows the correct user-set text.
So the regression is specific to **post-reboot**: the alarm survives reboot (good), but the **content** used for display is wrong (fallback).
---
## Evidence from Logcat
Filter: `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
**After reboot (boot recovery):**
```
02-23 16:28:44.489 W/DNP-SCHEDULE: Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-02-24 16:28:44, source=BOOT_RECOVERY
02-23 16:28:44.489 W/DNP-SCHEDULE: Existing PendingIntent found for requestCode=53438 - alarm already scheduled
```
So boot recovery does **not** replace the alarm; the existing PendingIntent is kept.
**When the notification fires (after reboot):**
```
02-23 16:32:00.601 D/DailyNotificationReceiver: DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION
02-23 16:32:00.650 D/DailyNotificationReceiver: DN|WORK_ENQUEUE display=notify_1771835520000 work_name=display_notify_1771835520000
02-23 16:32:00.847 D/DailyNotificationWorker: DN|WORK_START id=notify_1771835520000 action=display ...
02-23 16:32:00.912 D/DailyNotificationWorker: DN|DISPLAY_START id=notify_1771835520000
02-23 16:32:00.982 D/DailyNotificationWorker: DN|JIT_FRESH skip=true ageMin=0 id=notify_1771835520000
02-23 16:32:00.982 D/DailyNotificationWorker: DN|DISPLAY_NOTIF_START id=notify_1771835520000
02-23 16:32:01.018 I/DailyNotificationWorker: DN|DISPLAY_NOTIF_OK id=notify_1771835520000
```
Important detail: the worker logs **`DN|JIT_FRESH skip=true`**, and there is **no** `DN|DISPLAY_STATIC_REMINDER`. So the **static reminder path** (title/body from Intent extras) is **not** used; the worker is using the path that loads content from Room/legacy and runs the JIT freshness check. That path is used when `is_static_reminder` is false or when `title`/`body` are missing from the WorkManager input.
Conclusion: when the alarm fires after reboot, the receiver either gets an Intent **without** (or with cleared) `title`, `body`, and `is_static_reminder`, or the WorkManager input is built without them, so the worker falls back to Room/legacy (and possibly to NativeFetcher), which produces fallback text.
---
## Root cause (plugin side)
### 1. PendingIntent extras may not survive reboot
On Android, when an alarm is scheduled, the system stores the PendingIntent. After a **device reboot**, the alarm is restored from persisted state, but it is possible that **Intent extras** (e.g. `title`, `body`, `is_static_reminder`) are **not** persisted or are stripped when the broadcast is delivered. So when `DailyNotificationReceiver.onReceive` runs after reboot, `intent.getStringExtra("title")` and `intent.getStringExtra("body")` may be null, and `intent.getBooleanExtra("is_static_reminder", false)` may be false. The receiver still has `notification_id` (so the work is enqueued with that id), but the Worker input has no static reminder data, so the worker correctly takes the “load from Room / JIT” path. If the content then comes from Room with wrong/fallback data, or from the apps NativeFetcher (which returns placeholder text), the user sees fallback text.
### 2. Boot/force-stop recovery uses hardcoded title/body
In `ReactivationManager.rescheduleAlarmForBoot` and `rescheduleAlarm` (and similarly in `BootReceiver` if it ever reschedules), the config used for rescheduling is:
```kotlin
val config = UserNotificationConfig(
...
title = "Daily Notification",
body = "Your daily update is ready",
...
)
```
So whenever recovery **does** reschedule (e.g. after force-stop or in a code path that replaces the alarm), the new Intent carries this fallback text. In the **current** log, boot recovery **skips** rescheduling (duplicate found), so this is not the path that ran. But if in other builds or OEMs recovery does reschedule, or if a future change replaces the PendingIntent after reboot, the same bug would appear. So recovery should not use hardcoded strings when the schedule has known title/body.
### 3. Schedule entity does not store title/body
`Schedule` in `DatabaseSchema.kt` has no `title` or `body` fields. So after reboot there is no way to recover the users message from the plugin DB when:
- The Intent extras are missing (post-reboot delivery), or
- Recovery needs to reschedule and should use the same title/body as before.
The plugin **does** store a `NotificationContentEntity` (with title/body) when scheduling in `NotifyReceiver`, keyed by `notificationId`. So in principle the worker could get the right text by loading that entity when the Intent lacks title/body. That only works if:
- The worker is given the same `notification_id` that was used when storing the entity, and
- The entity was actually written and not overwritten by another path (e.g. prefetch/fallback).
If after reboot the delivered Intent has a different or missing `notification_id`, or the Room lookup fails (e.g. different id convention, DB not ready), the worker would fall back to legacy storage or fetcher, hence fallback text.
---
## Recommended fix (in the plugin)
### A. Persist title/body for static reminders and use when extras are missing
1. **Persist title/body (and optionally sound/vibration/priority) for static reminders**
- Either extend the `Schedule` entity with `title`, `body` (and optionally other display fields), or ensure there is a single, authoritative `NotificationContentEntity` per schedule/notification id that is written at schedule time and not overwritten by prefetch/fallback.
- When the app calls `scheduleDailyNotification` with a static reminder, store these values (already done for `NotificationContentEntity` in `NotifyReceiver`; ensure the same id is used for lookup after reboot).
2. **In `DailyNotificationReceiver.enqueueNotificationWork`**
- If the Intent has `notification_id` but **missing** `title`/`body` (or they are empty), or `is_static_reminder` is false but the schedule is known to be a static reminder:
- Resolve the schedule/notification id (e.g. from `schedule_id` extra if present, or from `notification_id` if it matches a known pattern).
- Load title/body (and other display fields) from the plugin DB (Schedule or NotificationContentEntity).
- If found, pass them into the Worker input and set `is_static_reminder = true` so the worker uses the static reminder path with the correct text.
3. **In `DailyNotificationWorker.handleDisplayNotification`**
- When loading content from Room by `notification_id`, if the entity exists and has title/body, use it as-is for display and **skip** replacing it with JIT/fetcher content for that run (or treat it as static for this display so JIT doesnt overwrite user text with fetcher fallback).
This way, even if the broadcast Intent loses extras after reboot, the receiver or worker can still show the users message from persisted storage.
### B. Use persisted title/body in boot/force-stop recovery
- In `ReactivationManager.rescheduleAlarmForBoot`, `rescheduleAlarm`, and any similar recovery path that builds a `UserNotificationConfig`:
- Load the schedule (and associated title/body) from the DB (e.g. from `Schedule` if extended, or from `NotificationContentEntity` by schedule/notification id).
- If title/body exist, use them in the config instead of `"Daily Notification"` / `"Your daily update is ready"`.
- Only use the hardcoded fallback when no persisted title/body exist (e.g. legacy schedules).
This ensures that any time recovery reschedules an alarm, the users custom message is preserved.
### C. Ensure one canonical content record per static reminder
- Ensure that for a given static reminder schedule, the `NotificationContentEntity` written at schedule time (in `NotifyReceiver`) is the one used for display when the alarm fires (including after reboot), and that prefetch/fallback paths do not overwrite that entity for the same logical notification (e.g. same schedule id or same notification id). If the worker currently loads by `notification_id`, ensure that id is stable and matches what was stored at schedule time.
---
## App-side behavior (no change required for this bug)
- The app calls `scheduleDailyNotification` once with `id: "daily_timesafari_reminder"`, `title`, and `body`. It does not reschedule after reboot; the plugins boot recovery and alarm delivery are entirely on the plugin side.
- The apps `TimeSafariNativeFetcher` returns placeholder text; that is only used when the plugin takes the “fetch content” path. Fixing the plugin so that after reboot the static reminder path (or Room content with user title/body) is used will prevent that placeholder from appearing for the users reminder.
---
## Verification after fix
1. Set a daily reminder with a **distinct** custom message (e.g. “My custom reminder text”).
2. **Restart the device** (full reboot).
3. Wait until the scheduled time (or set it 12 minutes ahead for a quick test).
4. Confirm that the notification shows **“My custom reminder text”** (or the chosen title), not “Daily Notification” / “Your daily update is ready” or the NativeFetcher placeholder.
5. In logcat, after the notification fires, you should see either:
- `DN|DISPLAY_STATIC_REMINDER` with the correct title, or
- A path that loads content from Room and displays it without overwriting with fetcher fallback.
---
## Files to consider in the plugin
- **NotifyReceiver.kt** Already stores `NotificationContentEntity` at schedule time; ensure the same `notificationId` used in the PendingIntent is the one used for this entity so post-reboot lookup by `notification_id` finds it.
- **DailyNotificationReceiver.java** In `enqueueNotificationWork`, add a fallback: if Intent has `notification_id` but no (or empty) `title`/`body`, look up title/body from DB (by `schedule_id` or `notification_id`) and pass them into Worker input with `is_static_reminder = true`.
- **DailyNotificationWorker.java** When loading from Room for a given `notification_id`, prefer that entity for display and avoid overwriting with JIT/fetcher content when the content is for a static reminder (e.g. same id as a schedule that was created as static).
- **ReactivationManager.kt** In `rescheduleAlarmForBoot` and `rescheduleAlarm`, load title/body from Schedule or NotificationContentEntity and use them in `UserNotificationConfig` instead of hardcoded strings.
- **DatabaseSchema.kt** (optional) If you prefer to keep title/body on the schedule, add `title` and `body` (and optionally other display fields) to the `Schedule` entity and persist them when the app calls `scheduleDailyNotification`.
---
## Short summary for Cursor (plugin-side)
**Bug:** After Android device restart, the daily notification still fires but shows fallback text instead of the user-set message. Logs show the worker uses the non-static path (`JIT_FRESH`, no `DISPLAY_STATIC_REMINDER`), so Intent extras (title/body/is_static_reminder) are likely missing after reboot.
**Fix:** (1) When the receiver has `notification_id` but missing title/body, look up title/body from the plugin DB (Schedule or NotificationContentEntity) and pass them into the Worker as static reminder data. (2) In boot/force-stop recovery, load title/body from DB and use them when rescheduling instead of hardcoded “Daily Notification” / “Your daily update is ready”. (3) Ensure the NotificationContentEntity written at schedule time is the one used for display after reboot (same id, not overwritten by prefetch/fallback).

View File

@@ -0,0 +1,169 @@
# Plugin feedback: Android rollover notification may not fire after device restart (app not launched)
**Date:** 2026-02-24 18:24
**Target repo:** daily-notification-plugin
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platform:** Android
## Summary
Boot recovery can **skip** rescheduling after a device restart (it sees an “existing PendingIntent” and skips). Whether the next notification **fails to fire** as a result depends on **whether the alarm survived the reboot**: Androids documented behavior is that AlarmManager alarms do **not** persist across reboot, but on some devices/builds they **do** (implementation-dependent). So: **(1)** *Set schedule → restart shortly after → wait for first notification:* On at least one device, the initial notification **fired** even though boot recovery skipped (alarm had survived reboot). On devices where alarms are cleared, that initial notification would not fire. **(2)** *Set schedule → first notification fires → restart → wait for rollover:* Same logic—if the rollover alarm is cleared and boot recovery skips, the rollover wont fire. The **fix** (always reschedule in the boot path, skip idempotence there) remains correct: it makes behavior reliable regardless of alarm persistence. See [Scenario 1: observed behavior](#scenario-1-observed-behavior) and [Two distinct scenarios](#two-distinct-scenarios-same-bug-different-victim-notification).
---
## Definitions
- **Rollover (in this doc):** The next occurrence of the daily notification. Concretely: when todays alarm fires, the plugin runs `scheduleNextNotification()` and sets an alarm for the same time the next day. That “next day” alarm is the rollover.
- **Boot recovery:** When the device boots, the plugins `BootReceiver` receives `BOOT_COMPLETED` (and/or `LOCKED_BOOT_COMPLETED`) and calls into the plugin to reschedule alarms from persisted schedule data.
---
## Android behavior: alarm persistence across reboot is implementation-dependent
- **Documented behavior:** AlarmManager alarms are **not** guaranteed to persist across a full device reboot; the platform may clear them when the device is turned off and rebooted. Apps are expected to reschedule on `BOOT_COMPLETED`.
- **Observed behavior:** On some devices or Android builds, alarms (e.g. from `setAlarmClock()`) **do** survive reboot. So whether the next notification fires after a reboot when boot recovery **skips** depends on the device: if the alarm survived, it can still fire; if it was cleared, it will not fire until the app is opened and reschedules.
So the **reliable** way to guarantee the next notification fires after reboot is for boot recovery to **always** call `AlarmManager.setAlarmClock()` (or equivalent) again, and not to skip based on “existing PendingIntent.”
---
## Scenario 1: observed behavior (schedule → restart → wait for first notification)
Logcat from a real test (schedule set, device restarted shortly after, app not launched):
**Before reboot (initial schedule):**
```
02-24 18:56:36 ... Scheduling next daily alarm: id=daily_timesafari_reminder, nextRun=2026-02-24 19:00:00, source=INITIAL_SETUP
02-24 18:56:36 ... Scheduling OS alarm: ... requestCode=53438, scheduleId=daily_timesafari_reminder ...
```
**After reboot (boot recovery):**
```
02-24 18:56:48 ... Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-02-24 19:00:00, source=BOOT_RECOVERY
02-24 18:56:48 ... Existing PendingIntent found for requestCode=53438 - alarm already scheduled
```
**At scheduled time (19:00:00):**
```
02-24 19:00:00 ... DailyNotificationReceiver: DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION
02-24 19:00:00 ... DailyNotificationWorker: DN|DISPLAY_NOTIF_OK ...
02-24 19:00:01 ... DN|ROLLOVER next=1772017200000 scheduleId=daily_rollover_1771930801007 ...
```
So in this run, **boot recovery skipped** (duplicate + existing PendingIntent), but the **initial notification still fired** at 19:00. That implies the alarm **survived the reboot** on this device. On devices where alarms are cleared on reboot, the same skip would mean the initial notification would **not** fire. Conclusion: scenario 1 failure is **device-dependent**; the fix (always reschedule on boot) removes that dependence.
---
## Two distinct scenarios (same bug, different “victim” notification)
The same boot-recovery skip can affect either the **initial** notification or the **rollover** notification, depending on when the user restarts and whether the alarm survived reboot:
| # | User sequence | What is lost on reboot (if alarms cleared) | What fails if boot recovery skips **and** alarm was cleared |
|---|----------------|--------------------------------------------|--------------------------------------------------------------|
| **1** | Set schedule → **restart shortly after** → wait for first notification | Alarm for the **first** occurrence (e.g. 19:00 same day). | **Initial** notification never fires. *(Observed on one device: alarm survived, so notification fired despite skip.)* |
| **2** | Set schedule → **first notification fires** (rollover set) → restart → wait for next day | Alarm for the **rollover** (next day, e.g. `daily_rollover_*`). | **Rollover** notification never fires. |
- **Scenario 1:** User configures a daily reminder, then reboots before the first fire. If the alarm is cleared on reboot and boot recovery skips, the first notification never fires. If the alarm survives (as in the logcat above), it can still fire.
- **Scenario 2:** After the first fire, the plugin creates a **new** schedule (e.g. `daily_rollover_1771930801007`) and sets an alarm for the next day. If the device reboots, that rollover alarm may or may not persist. If it is cleared and boot recovery only reschedules the primary `daily_timesafari_reminder` (and skips), or does not reschedule the rollover, the rollover notification may not fire.
In both cases the **fix** is the same: in the boot recovery path, skip the “existing PendingIntent” idempotence check so the plugin always re-registers the alarm(s) after reboot, making behavior reliable regardless of whether the OEM clears alarms.
---
## Daily notification flow (relevant parts)
1. **Initial schedule (app):** User sets a daily time (e.g. 09:00). App calls `scheduleDailyNotification({ time, title, body, id })`. Plugin stores schedule and sets an alarm for the next occurrence (e.g. tomorrow 09:00 if today 09:00 has passed).
2. **When the alarm fires:** `DailyNotificationReceiver` runs, shows the notification, and the plugin calls `scheduleNextNotification()` (rollover), which schedules the **next day** at the same time via `NotifyReceiver.scheduleExactNotification(..., ScheduleSource.ROLLOVER_ON_FIRE)`.
3. **After reboot:** No alarm exists. `BootReceiver` runs (without the app being launched). It should load the schedule from the DB, compute the next run time, and call the same scheduling path to re-register the alarm with AlarmManager.
If step 3 does **not** actually register an alarm (because boot recovery skips), and the device **cleared** alarms on reboot, the next notification will not fire until the user opens the app. If the alarm survived reboot (device-dependent), it can still fire despite the skip.
---
## Evidence that boot recovery can skip rescheduling
Boot recovery repeatedly logs that it is **skipping** reschedule (see also `doc/plugin-feedback-android-post-reboot-fallback-text.md` and the Scenario 1 logcat above):
```
Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=..., source=BOOT_RECOVERY
Existing PendingIntent found for requestCode=53438 - alarm already scheduled
```
So boot recovery **does not** call `AlarmManager.setAlarmClock()` in those runs; it relies on “existing PendingIntent” and skips. The “existing PendingIntent” comes from the plugins idempotence check (e.g. `PendingIntent.getBroadcast(..., FLAG_NO_CREATE)`), which can still return non-null after reboot (e.g. cached by package/requestCode/Intent identity). That does **not** prove an alarm is still registered: on some devices alarms are cleared on reboot, so after a skip there would be no alarm and the next notification would not fire. On other devices (as in the Scenario 1 test above) the alarm can survive, so the notification still fires despite the skip. So the **risk** of a missed notification after reboot is **device-dependent**; the fix (always reschedule on boot) removes that dependence.
---
## Root cause (plugin side)
1. **Idempotence in `scheduleExactNotification`:** Before scheduling, the plugin checks for an “existing” PendingIntent (and possibly DB state). If found, it skips scheduling to avoid duplicates.
2. **Boot recovery uses the same path:** When `BootReceiver` runs, it calls into the same scheduling logic with `source = BOOT_RECOVERY` and **without** skipping the idempotence check (default `skipPendingIntentIdempotence = false` or equivalent).
3. **After reboot:** The “existing PendingIntent” check can still succeed (e.g. cached), so boot recovery skips and does not call `AlarmManager.setAlarmClock()`. On devices where alarms are cleared on reboot, no alarm is re-registered and the next notification will not fire until the app is opened. On devices where alarms survive (as in the Scenario 1 test), the notification can still fire.
So the **reliable** behavior is: **boot recovery should always re-register the alarm after reboot** (e.g. by skipping the PendingIntent idempotence check in the boot path), so that the app does not depend on implementation-dependent alarm persistence.
---
## Recommended fix (in the plugin)
**Idea:** In the **boot recovery** path only, force a real reschedule and avoid the “existing PendingIntent” skip. After reboot there is no alarm; treating it as “already scheduled” is wrong.
**Concrete options:**
1. **Skip PendingIntent idempotence when source is BOOT_RECOVERY**
When calling `NotifyReceiver.scheduleExactNotification` from boot recovery (e.g. from `ReactivationManager.rescheduleAlarmForBoot` or from `BootReceiver`), pass a flag so that the “existing PendingIntent” check is **skipped** (e.g. `skipPendingIntentIdempotence = true` or a dedicated `forceRescheduleAfterBoot = true`).
That way, boot recovery always calls `AlarmManager.setAlarmClock()` (or equivalent) and re-registers the alarm, even if `PendingIntent.getBroadcast(..., FLAG_NO_CREATE)` still returns non-null from a pre-reboot cache.
2. **Separate boot path that never skips**
Alternatively, implement a dedicated “reschedule for boot” path that does not go through the same idempotence branch as user/manual reschedule. That path should always compute the next run time from the persisted schedule and call AlarmManager to set the alarm, without checking for an “existing” PendingIntent.
3. **Do not rely on PendingIntent existence as “alarm is set” after reboot**
If the plugin currently infers “alarm already scheduled” from “PendingIntent exists,” that inference is wrong after reboot. Either skip that check when the call is from boot recovery, or after reboot always re-register and only use idempotence for in-process duplicate prevention (e.g. when the user taps “Save” twice in a short time).
**Recommendation:** Option 1 is the smallest change: in the boot recovery call site(s), pass `skipPendingIntentIdempotence = true` (or the equivalent flag) so that scheduling is not skipped and the alarm is always re-registered after reboot.
**Will this cause duplicate alarms when the alarm survived reboot?** No. When boot recovery calls `setAlarmClock()` (or equivalent), it uses the same `scheduleId` and thus the same `requestCode` and same Intent (and hence the same logical PendingIntent) as the existing alarm. On Android, setting an alarm with a PendingIntent that matches one already registered **replaces** that alarm; it does not add a second one. So you end up with one alarm either way—either the one that survived reboot (now effectively “confirmed” by the second call) or the one just set if the previous one had been cleared. No duplicate notifications.
---
## Verification after fix
1. Schedule a daily notification for a time a few minutes in the future (or use a test build that allows short intervals).
2. Let it fire once so the plugin schedules the rollover (next day).
3. **Restart the device** and do **not** open the app.
4. Wait until the next scheduled time (next day, or the same day if testing with a second alarm a few minutes later).
5. Confirm that the notification **does** fire.
6. In logcat after reboot, you should see boot recovery **not** logging “Skipping duplicate schedule” / “Existing PendingIntent found” for this schedule, and you should see the alarm being set (e.g. “Scheduling OS alarm” or similar).
---
## App-side behavior
No change is required in the consuming app for this bug. The app does not reschedule after reboot; that is the plugins responsibility via `BootReceiver` and boot recovery. Fixing the plugin so that boot recovery always re-registers the alarm (and does not skip due to PendingIntent idempotence) is sufficient.
---
## Short summary for plugin maintainers
**Issue:** After an Android device restart, boot recovery skips rescheduling when it finds an “existing PendingIntent.” On devices where AlarmManager clears alarms on reboot, that skip means the next daily notification (initial or rollover) will not fire until the app is opened. On devices where alarms survive reboot, the notification can still fire (as observed in a Scenario 1 test). So the failure is device-dependent; the plugin should not rely on alarm persistence.
**Fix:** In the boot recovery path, when calling `scheduleExactNotification` (or the equivalent), pass a flag to **skip** the “existing PendingIntent” idempotence check (e.g. `skipPendingIntentIdempotence = true`), so that the alarm is always re-registered after reboot and behavior is reliable on all devices.
---
## For Cursor (plugin repo) — actionable fix
Use this section when applying the fix in the **daily-notification-plugin** repo (e.g. with Cursor).
**Goal:** When rescheduling after boot, **always** register the alarm with AlarmManager. Do not skip because “existing PendingIntent” was found (that check can be true after reboot even though the alarm was cleared).
**Change:** At every call site where the plugin invokes `NotifyReceiver.scheduleExactNotification` (or the Kotlin equivalent) for **boot recovery** (i.e. when the schedule source is `BOOT_RECOVERY` or the call is from `BootReceiver` / `ReactivationManager.rescheduleAlarmForBoot`), pass **`skipPendingIntentIdempotence = true`** so that the idempotence check is skipped and the alarm is always set.
**Files to look at (plugin Android code):**
- **ReactivationManager.kt** — Find `rescheduleAlarmForBoot` (or similar). It likely calls `NotifyReceiver.scheduleExactNotification(...)`. Ensure that call passes `skipPendingIntentIdempotence = true` (and `source = ScheduleSource.BOOT_RECOVERY` if applicable).
- **BootReceiver.kt** — If it calls `scheduleExactNotification` or invokes ReactivationManager for boot, ensure that path passes `skipPendingIntentIdempotence = true`.
**Method signature (for reference):**
`NotifyReceiver.scheduleExactNotification(context, triggerAtMillis, config, isStaticReminder, reminderId, scheduleId, source, skipPendingIntentIdempotence)`. The last parameter is what must be `true` for boot recovery.
**Verification:** After the change, trigger a device reboot (app not launched), then inspect logcat. You should **not** see “Skipping duplicate schedule” / “Existing PendingIntent found” for `source=BOOT_RECOVERY`; you should see “Scheduling OS alarm” (or equivalent) so the alarm is re-registered.

View File

@@ -0,0 +1,133 @@
# 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).

View File

@@ -0,0 +1,104 @@
# Plugin feedback: Android rollover interval two bugs (logcat evidence)
**Date:** 2026-03-04
**Target:** daily-notification-plugin (Android)
**Feature:** `rolloverIntervalMinutes` (e.g. 10 minutes for testing)
**Result:** Two bugs prevent rollover notifications from firing every 10 minutes when the user does not open the app.
---
## Test setup
- Schedule set with **rolloverIntervalMinutes=10** (e.g. first run 20:05).
- Expected: notification at 20:05, 20:15, 20:25, 20:35, 20:40 (after user edit), 20:50, 21:00, 21:10, 21:20, …
- User did **not** open the app between 20:25 and 20:36, or between 21:10 and 21:20.
---
## Bug 1: Rollover interval not applied when the firing run is a rollover schedule
### Observed
- **20:25** Notification fired (room content; work id UUID, scheduleId `daily_rollover_1772540701872`).
- **20:35** **No notification.**
### Logcat evidence (20:25 fire)
There is **no** `DN|ROLLOVER_INTERVAL` or `DN|ROLLOVER_NEXT using_interval_minutes=10` in this block. Next run is set to **next day** at 20:25, not today 20:35:
```
03-03 20:25:01.844 D/DailyNotificationWorker: DN|RESCHEDULE_START id=29e1e984-d8b2-49ea-bb69-68b923fe4428
03-03 20:25:01.874 D/DailyNotificationWorker: DN|ROLLOVER next=1772627100000 scheduleId=daily_rollover_1772540701872 static=false
03-03 20:25:01.928 I/DNP-SCHEDULE: Scheduling next daily alarm: id=daily_rollover_1772540701872, nextRun=2026-03-04 20:25:00, source=ROLLOVER_ON_FIRE
```
Compare with a fire that **does** use the interval (e.g. 20:15):
```
03-03 20:15:01.860 D/DailyNotificationWorker: DN|ROLLOVER_INTERVAL scheduleId=daily_timesafari_reminder minutes=10
03-03 20:15:01.862 D/DailyNotificationWorker: DN|ROLLOVER_NEXT using_interval_minutes=10 next=1772540700870
03-03 20:15:01.870 D/DailyNotificationWorker: DN|ROLLOVER next=1772540700870 scheduleId=daily_timesafari_reminder static=false
```
### Root cause
When the notification that just fired was scheduled from a **previous** rollover (i.e. work id is UUID / scheduleId is `daily_rollover_*`), the rollover path appears to use **+24 hours** and never reads or applies the stored `rolloverIntervalMinutes`. The interval is only applied when the firing schedule is the main/canonical one (e.g. `daily_timesafari_reminder`).
### Required fix
When scheduling the next run after a notification fires (rollover path), **always** resolve the **logical** schedule (e.g. map `daily_rollover_*` back to the main schedule id) and read the stored `rolloverIntervalMinutes` for that reminder. If present and > 0, set next trigger = current trigger + that many minutes (using the same logic as the path that already logs `ROLLOVER_INTERVAL` / `ROLLOVER_NEXT`). Only use +24 hours when the interval is absent or 0.
---
## Bug 2: ROLLOVER_ON_FIRE reschedule skipped as “duplicate” so next alarm is never set
### Observed
- **21:10** Notification fired; worker correctly computes next = 21:20 (epoch 1772544000862).
- **21:20** **No notification.**
### Logcat evidence (21:10 fire)
Worker applies interval and requests next at 21:20; schedule layer skips and does **not** set the alarm:
```
03-03 21:10:01.281 D/DailyNotificationWorker: DN|ROLLOVER_INTERVAL scheduleId=daily_timesafari_reminder minutes=10
03-03 21:10:01.284 D/DailyNotificationWorker: DN|ROLLOVER_NEXT using_interval_minutes=10 next=1772544000862
03-03 21:10:01.294 D/DailyNotificationWorker: DN|ROLLOVER next=1772544000862 scheduleId=daily_timesafari_reminder static=false
03-03 21:10:01.313 W/DNP-SCHEDULE: Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-03-03 21:20:00, source=ROLLOVER_ON_FIRE
03-03 21:10:01.314 W/DNP-SCHEDULE: Existing PendingIntent found for requestCode=53438 - alarm already scheduled
03-03 21:10:01.332 I/DailyNotificationWorker: DN|RESCHEDULE_OK ...
```
So the worker reports RESCHEDULE_OK, but the scheduler did **not** call through to set the OS alarm for 21:20. The “existing” PendingIntent was for the alarm that **just fired** (21:10). Idempotence is preventing the **update** to the new trigger time.
### Root cause
Duplicate/idempotence logic (e.g. “Existing PendingIntent found for requestCode=53438”) is applied in a way that skips scheduling when the same schedule id is used with a **new** trigger time. For `source=ROLLOVER_ON_FIRE`, the same schedule id is **supposed** to be updated to a new trigger time every time a rollover fires. Skipping when only the trigger time changes breaks the rollover chain.
### Required fix
For `source=ROLLOVER_ON_FIRE`, do **not** skip scheduling when the only “match” is the same schedule id with a **different** `nextRun`/trigger time. Either:
- Treat “same schedule id, different trigger time” as an **update**: cancel the existing alarm (or PendingIntent) for that schedule and set the new one for the new trigger time, or
- In the idempotence check, require that the **existing** alarms trigger time equals the **requested** trigger time before skipping; if the requested time is different, proceed with cancel + set.
After the fix, when the 21:10 alarm fires and the worker requests next at 21:20, the schedule layer should cancel the 21:10 alarm and set a new alarm for 21:20 (same schedule id, new trigger).
---
## Desired behavior (for reference)
Once both bugs are fixed:
- Rollover notifications should keep being scheduled every `rolloverIntervalMinutes` (e.g. 10 minutes) **without the user opening the app** between fires.
- Flow: alarm fires → Receiver → Worker (display + reschedule) → schedule layer sets next alarm. All of this runs when the alarm fires; no app launch required.
---
## Summary table
| Time | Expected | Actual | Bug |
|--------|------------------------|---------------|-----|
| 20:35 | Rollover notification | No notification | **Bug 1:** Rollover from `daily_rollover_*` path uses +24h instead of `rolloverIntervalMinutes`. |
| 21:20 | Rollover notification | No notification | **Bug 2:** Schedule layer skips with “Skipping duplicate schedule” / “Existing PendingIntent found”; 21:20 alarm never set. |

View File

@@ -0,0 +1,148 @@
# Plugin spec: Configurable rollover interval (e.g. 10 minutes for testing)
**Date:** 2026-03-03
**Target repo:** daily-notification-plugin
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platforms:** iOS and Android
## Summary
The consuming app needs to support **rapid testing** of daily notification rollover on device. Today, after a notification fires, the plugin always schedules the next occurrence **24 hours** later. We need an **optional** parameter so the app can request a different interval (e.g. **10 minutes**) for dev/testing. When that parameter is present, the plugin must:
1. Use the given interval (in minutes) when scheduling the **next** occurrence after a notification fires (rollover).
2. **Persist** that interval with the schedule so that it survives **device reboot** and is used again when:
- Boot recovery reschedules alarms from stored data, and
- Any subsequent rollover runs (after the next notification fires).
If the interval is not persisted, then after a device restart the plugin would no longer know to use 10 minutes and would fall back to 24 hours; rapid testing after reboot would break. So persistence is a **required** part of this feature.
---
## API contract (app → plugin)
### Method: `scheduleDailyNotification` (or equivalent used for the apps daily reminder)
**Add an optional parameter:**
- **Name:** `rolloverIntervalMinutes` (or equivalent, e.g. `repeatIntervalMinutes`).
- **Type:** `number` (integer), optional.
- **Meaning:** When the scheduled notification fires, schedule the **next** occurrence this many **minutes** after the current trigger time (instead of 24 hours). When **absent** or not provided, behavior is unchanged: next occurrence is **24 hours** later (current behavior).
**Example (pseudocode):**
- App calls: `scheduleDailyNotification({ id, time, title, body, ..., rolloverIntervalMinutes: 10 })`.
- Plugin stores the schedule **including** `rolloverIntervalMinutes: 10`.
- When the notification fires, plugin computes next trigger = current trigger + 10 minutes (instead of + 24 hours), and schedules that.
- When the device reboots, boot recovery loads the schedule, sees `rolloverIntervalMinutes: 10`, and uses it when (a) computing the next run time for reschedule and (b) any future rollover after the next fire.
**Example (normal production, no param):**
- App calls: `scheduleDailyNotification({ id, time, title, body })` (no `rolloverIntervalMinutes`).
- Plugin stores the schedule with no interval (or default 24h).
- Rollover and boot recovery behave as today: next occurrence 24 hours later.
---
## Persistence requirement (critical for device restart)
The rollover interval must be **stored with the schedule** in the plugins persistent storage (e.g. Room on Android, UserDefaults/DB on iOS), not only kept in memory. Concretely:
1. **When the app calls `scheduleDailyNotification` with `rolloverIntervalMinutes`:**
- Persist that value in the same place you persist the rest of the schedule (e.g. Schedule entity, or equivalent table/row that is read on boot and on rollover).
2. **When computing the next occurrence (rollover path, after a notification fires):**
- Read the stored `rolloverIntervalMinutes` for that schedule.
- If present and > 0: next trigger = current trigger + `rolloverIntervalMinutes` minutes.
- If absent or 0: next trigger = current trigger + 24 hours (existing behavior).
3. **When boot recovery runs (after device restart):**
- Load schedules from persistent storage (including the stored `rolloverIntervalMinutes`).
- When rescheduling each alarm, use the stored interval to compute the next run time (same logic as rollover: if interval is set, use it; otherwise 24 hours).
- When the next notification fires after reboot, the rollover path will again read the same stored value, so the 10-minute (or whatever) interval continues to apply.
4. **When the app calls `scheduleDailyNotification` without `rolloverIntervalMinutes` (or to turn off fast rollover):**
- Overwrite the stored schedule so that the interval is cleared or set to default (24h). Subsequent rollovers and boot recovery then use 24 hours again.
**Why this matters:** Without persisting the interval, a device restart would lose the “10 minutes” setting; rollover and boot recovery would have no way to know to use 10 minutes and would default to 24 hours. Rapid testing after reboot would not work.
---
## Platform-specific notes
### Android
- **Storage:** Add `rollover_interval_minutes` (or equivalent) to the Schedule entity (or wherever the apps reminder schedule is stored) and persist it when handling `scheduleDailyNotification`. Use it in:
- Rollover path (e.g. when scheduling next alarm after notification fires).
- Boot recovery path (when rebuilding alarms from DB after `BOOT_COMPLETED`).
- **Next trigger:** Current trigger time + `rolloverIntervalMinutes` minutes (using `Calendar` or equivalent so DST/timezone is handled correctly; same care as for 24h rollover).
### iOS
- **Storage:** Add the same field to whatever persistent structure holds the schedule (e.g. the same place that stores time, title, body, id). Persist it when the app calls the schedule method.
- **Rollover:** In `scheduleNextNotification()` (or equivalent), read the stored interval; if set, use `Calendar.date(byAdding: .minute, value: rolloverIntervalMinutes, to: currentDate)` (or equivalent) instead of adding 24 hours.
- **App launch / recovery:** If the plugin has any path that restores or reschedules after app launch or system events, use the stored interval there as well so behavior is consistent.
---
## Edge cases and defaults
- **Parameter absent:** Do not change current behavior. Next occurrence = 24 hours later.
- **Parameter = 0 or negative:** Treat as “use default”; same as absent (24 hours).
- **Parameter > 0 (e.g. 10):** Next occurrence = current trigger + that many minutes.
- **Existing schedules (created before this feature):** No stored interval → treat as 24 hours. No migration required beyond “missing field = default”.
---
## App-side behavior (for context)
- The app will only pass `rolloverIntervalMinutes` when a **dev-only** setting is enabled (e.g. “Use 10-minute rollover for testing” in the Notifications section). Production users will not set it.
- The app will pass it on every `scheduleDailyNotification` call when the user has that setting on (first-time enable and edit). When the user turns the setting off, the app will call `scheduleDailyNotification` without the parameter (so the plugin can persist “no interval” / 24h).
---
## Verification (plugin repo)
1. **Rollover with interval:** Schedule with `rolloverIntervalMinutes: 10`. Trigger the notification (or wait). Confirm the **next** scheduled time is ~10 minutes after the current trigger (not 24 hours). Let it fire again; confirm the following occurrence is again ~10 minutes later.
2. **Persistence:** Schedule with `rolloverIntervalMinutes: 10`, then **restart the device** (do not open the app). After boot, confirm (via logs or next fire) that the rescheduled alarm uses the 10-minute interval (e.g. next fire is 10 minutes after the last stored trigger, not 24 hours). After that notification fires, confirm the **next** rollover is still 10 minutes later.
3. **Default:** Schedule without `rolloverIntervalMinutes`. Confirm next occurrence is 24 hours later. Reboot; confirm boot recovery still uses 24 hours.
4. **Turn off:** Schedule with 10 minutes, then have the app call `scheduleDailyNotification` again with the same id/time but **no** `rolloverIntervalMinutes`. Confirm stored interval is cleared and next rollover is 24 hours.
---
## Short summary for plugin maintainers
- **New optional parameter:** `rolloverIntervalMinutes?: number` on the schedule method used for the apps daily reminder.
- **When set (e.g. 10):** After a notification fires, schedule the next occurrence in that many **minutes** instead of 24 hours.
- **Must persist:** Store the value with the schedule in the plugins DB/storage. Use it in **rollover** and in **boot recovery** so that after a device restart the same interval is used. Without persistence, the feature would not work after reboot.
- **When absent:** Behavior unchanged (24-hour rollover). No migration needed for existing schedules.
---
## For Cursor (plugin repo) — actionable handoff
Use this section when implementing this feature in the **daily-notification-plugin** repo (e.g. with Cursor). You can paste or @-mention this doc as context.
**Goal:** Support an optional `rolloverIntervalMinutes` (or equivalent) on the daily reminder schedule API. When provided (e.g. `10`), schedule the next occurrence that many minutes after the current trigger instead of 24 hours. **Persist this value** with the schedule so that rollover and boot recovery both use it; after a device restart, the same interval must still apply.
**Concrete tasks:**
1. **API:** In the plugin interface used by the app (e.g. `scheduleDailyNotification`), add an optional parameter `rolloverIntervalMinutes?: number`. Document that when absent, next occurrence is 24 hours (current behavior).
2. **Storage (Android):** In the Schedule entity (or equivalent), add a column/field for the rollover interval (e.g. `rollover_interval_minutes` nullable Int). When handling `scheduleDailyNotification`, persist the value if present; if absent, store null or 0 to mean “24 hours”.
3. **Storage (iOS):** Add the same field to the persistent structure that holds the reminder schedule. Persist it when the app calls the schedule method.
4. **Rollover (both platforms):** In the code that runs when a scheduled notification fires and schedules the next occurrence:
- Read the stored `rolloverIntervalMinutes` for that schedule.
- If present and > 0: next trigger = current trigger + that many minutes (using Calendar/date APIs that respect timezone/DST).
- Else: next trigger = current trigger + 24 hours (existing behavior).
- Persist the same interval on the new schedule record so the next rollover still uses it.
5. **Boot recovery (both platforms):** In the path that runs after device reboot and reschedules from stored data:
- Load the stored `rolloverIntervalMinutes` with each schedule.
- When computing “next run time” for reschedule, use the same logic: if interval set, current trigger + that many minutes; else + 24 hours.
- Do not rely on in-memory state; always read from persisted storage so behavior is correct after restart.
6. **Clearing the interval:** When the app calls `scheduleDailyNotification` without `rolloverIntervalMinutes` (e.g. user turned off “fast rollover” in the app), overwrite the stored schedule so the interval field is null/0. Subsequent rollovers and boot recovery then use 24 hours.
7. **Tests:** Add or extend tests for: (a) rollover with 10 minutes, (b) boot recovery with stored 10-minute interval, (c) default 24h when parameter absent or cleared.