# 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 host’s native fetcher (`TimeSafariNativeFetcher`) returns an **empty** `List` when the API’s `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 (~371–374 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 (~306–309 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 doesn’t supply something else. Reference (plugin): ```kotlin // DualScheduleHelper.kt — resolveDualContentBlocking (simplified; ~31–57 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.