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:
151
docs/plugin-feedback-android-post-reboot-fallback-text.md
Normal file
151
docs/plugin-feedback-android-post-reboot-fallback-text.md
Normal 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 user’s 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 app’s 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 user’s 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 doesn’t 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 user’s 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 user’s 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 plugin’s boot recovery and alarm delivery are entirely on the plugin side.
|
||||
- The app’s `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 user’s 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 1–2 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
8
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "timesafari",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.2",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.1",
|
||||
"@capacitor-community/sqlite": "6.0.2",
|
||||
@@ -9452,8 +9452,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@timesafari/daily-notification-plugin": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#c2b1a608045abd403fd25732a10dff08f0a00014",
|
||||
"version": "1.1.8",
|
||||
"resolved": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#7188d32ae6cfd19f2120bd8e2026b557792b902a",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
||||
Reference in New Issue
Block a user