Files
crowd-funder-for-time-pwa/doc/PLUGIN_NOTIFICATION_FIX_ANDROID.md
Jose Olarte III 24957e0c6f docs(notifications): add Android plugin handout for empty-fetch dual schedule
Document PLUGIN_NOTIFICATION_FIX_ANDROID diagnosis and recommended changes in
the daily-notification-plugin repo, verified against plugin 3.0.0.
2026-04-10 21:12:11 +08:00

130 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Android plugin: New Activity notification when API has no activities
**Audience:** Maintainers of `@timesafari/daily-notification-plugin` (Android / Kotlin).
**Host app:** TimeSafari (`crowd-funder-for-time-pwa`) — this file lives in the **app** repo only as a handoff; apply changes in the **plugin** repo.
**Problem (product):** “New Activity” should notify only when the API reports new/updated activity. The hosts native fetcher (`TimeSafariNativeFetcher`) returns an **empty** `List<NotificationContent>` when the APIs `data` array is empty. Users still see a **daily** local notification.
**Version note:** This diagnosis was first written against older plugin builds (e.g. **2.1.x / 2.2.x**). After upgrading the host to **`@timesafari/daily-notification-plugin` 3.0.0**, the Android files below were **re-read** from `node_modules`. The relevant logic is **unchanged** in 3.0.0: the same two mechanisms still explain unwanted daily notifications when the API returns no rows. If you maintain the plugin, re-verify after each major release.
**Root cause (Android, confirmed in plugin v3.0.0 sources under `node_modules`):** Two mechanisms interact:
1. **`FetchWorker.kt` — empty native fetch is converted to synthetic JSON instead of “skip”**
When the dual prefetch runs with the native fetcher and the list is empty, `notificationContentsToDualPayloadBytes` **replaces** the empty list with a JSON payload `"No updates"` / `"No new content"`, and the work unit still completes successfully. The dual path then **always** arms the chained notify alarm when `isDual && nextNotifyAt > 0L` — so a notification is still scheduled for the notify window.
Reference (plugin):
```kotlin
// FetchWorker.kt — notificationContentsToDualPayloadBytes (~371374 in v3.0.0)
if (contents.isEmpty()) {
return """{"title":"No updates","body":"No new content"}""".toByteArray(Charsets.UTF_8)
}
```
```kotlin
// FetchWorker.kt — doWork(), tail of success path (~306309 in v3.0.0)
if (isDual && nextNotifyAt > 0L) {
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext)
}
```
2. **`DualScheduleHelper.kt` — `fallbackBehavior: "show_default"` uses `userNotification` defaults**
At display time, if there is **no** fresh dual-scope cache within `relationship.contentTimeout`, the helper falls back to the **persisted** `userNotification.title` / `userNotification.body` when `fallbackBehavior` is `"show_default"`. The host app sets those defaults to copy such as “New Activity” / “Check your starred projects…”, so the user sees that **even when the API had nothing**, if the cache path doesnt supply something else.
Reference (plugin):
```kotlin
// DualScheduleHelper.kt — resolveDualContentBlocking (simplified; ~3157 in v3.0.0)
val fallbackBehavior = relationship?.optString("fallbackBehavior", "show_default") ?: "show_default"
val defaultTitle = userNotification.optString("title", "Daily Notification")
val defaultBody = userNotification.optString("body", "Your daily update is ready")
// ...
} else {
if (fallbackBehavior != "show_default") return null
Pair(defaultTitle, defaultBody)
}
```
**TypeScript contract (plugin `src/definitions.ts` in v3.0.0 — `DualScheduleConfiguration.relationship`):**
```ts
relationship?: {
autoLink: boolean;
contentTimeout: number;
fallbackBehavior: 'skip' | 'show_default' | 'retry';
};
```
`skip` is only partially useful on Android **with the current fetch implementation**: it avoids the **default title/body** branch in `DualScheduleHelper` when cache is missing/stale, but it does **not** by itself stop a notification if the fetch path still materializes content (including the synthetic `"No updates"` payload) or if chained notify is already armed.
**3.0.0 vs 2.2.x:** Plugin **3.0.0** advertises broader features (e.g. TTL-at-fire, observability). Those do **not** replace the dual-fetch pipeline inspected here: `FetchWorker` still maps an empty native list to JSON and still schedules the chained notify on success; `DualScheduleHelper` still applies `show_default` vs defaults when cache is absent or outside `contentTimeout`. Revisit this doc if a future release changes `notificationContentsToDualPayloadBytes` or the dual notify gate.
---
## Recommended plugin changes (Android)
### 1) Treat empty native fetch as “no notification” (primary)
**File:** `android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt`
**Issue:** `notificationContentsToDualPayloadBytes` must not turn an empty list into a non-empty payload if the product contract is “no rows in API → no notification.”
**Direction:**
- **Before:** Empty list → JSON `No updates` / `No new content` → success → chained notify scheduled.
- **After (one of):**
- **A)** Return a dedicated sentinel payload (e.g. `{ "skipNotification": true }`) and teach **`NotifyReceiver` / worker** that resolves dual content to **not post** when that sentinel is present; **or**
- **B)** On empty list, **do not** call `DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm` for this cycle (and optionally persist “last fetch had no content” for the helper); **or**
- **C)** Store an empty/marker cache row that `DualScheduleHelper.resolveDualContentBlocking` interprets as “return null” (no notification).
Pick one strategy and keep behavior consistent with `relationship.fallbackBehavior`:
- If `fallbackBehavior == "skip"`: skip notification when fetch returns empty or when sentinel indicates skip.
- If `fallbackBehavior == "show_default"`: keep current default-title/body behavior **only** when the product intends it (may be wrong for TimeSafari).
### 2) Honor `relationship.fallbackBehavior` end-to-end
**Files:** `FetchWorker.kt`, `DualScheduleHelper.kt`, any worker/receiver that posts the dual notification.
**Issue:** `DualScheduleHelper` reads `fallbackBehavior`, but the fetch path does not use the same semantics for “empty API result.”
**Direction:** When persisting dual config, pass `fallbackBehavior` into the fetch success path so that **empty fetch + `skip`** never schedules or displays a notification.
### 3) Tests
- Dual fetch + native fetcher returns **empty list** → **no** notification posted (or no chained alarm), matching host expectation.
- Non-empty list → notification with fetcher-provided title/body.
- Optional: `fallbackBehavior` matrix (`skip` / `show_default`) with stale cache vs fresh cache.
---
## Host app follow-up (separate PR in `crowd-funder-for-time-pwa`)
After the plugin implements empty-fetch semantics, set in `buildDualScheduleConfig` (`src/services/notifications/dualScheduleConfig.ts`):
```ts
relationship: {
autoLink: true,
contentTimeout: 5 * 60 * 1000,
fallbackBehavior: "skip", // was "show_default"
},
```
Only do this once Android behavior matches the contract (otherwise users may get **no** notification even when you would want defaults on network failure — product decision).
---
## References in this repo (context only)
- Host native fetcher returns no content when API `data` is empty: `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` (`parseApiResponse`).
- Host dual config today uses `fallbackBehavior: "show_default"`: `src/services/notifications/dualScheduleConfig.ts`.
---
## Plugin version verification
- **Last verified against:** `@timesafari/daily-notification-plugin` **3.0.0** (`node_modules/.../package.json`).
- **Prior builds:** Behavior matched the earlier **2.1.x** analysis; **2.2.0 → 3.0.0** did not remove the empty-list → synthetic JSON mapping or the chained-notify success path in the inspected sources.
- Re-verify line numbers after rebasing or patching the plugin repo.