docs: add plugin feedback for Android post-reboot notification fallback text

Document bug where daily reminder shows fallback text after device restart
(Intent extras not surviving reboot). Includes root cause, recommended
plugin fixes (persist/restore title/body, use DB in recovery), and
verification steps for the daily-notification-plugin repo.
This commit is contained in:
Jose Olarte III
2026-02-23 21:24:29 +08:00
parent 8310152c34
commit cd5f9f5317
2 changed files with 155 additions and 4 deletions

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=com.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).

8
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.2.0", "version": "1.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "timesafari", "name": "timesafari",
"version": "1.2.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@capacitor-community/electron": "^5.0.1", "@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2", "@capacitor-community/sqlite": "6.0.2",
@@ -9452,8 +9452,8 @@
} }
}, },
"node_modules/@timesafari/daily-notification-plugin": { "node_modules/@timesafari/daily-notification-plugin": {
"version": "1.1.7", "version": "1.1.8",
"resolved": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#c2b1a608045abd403fd25732a10dff08f0a00014", "resolved": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#7188d32ae6cfd19f2120bd8e2026b557792b902a",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"