Document PLUGIN_NOTIFICATION_FIX_ANDROID diagnosis and recommended changes in the daily-notification-plugin repo, verified against plugin 3.0.0.
7.8 KiB
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<NotificationContent> 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:
-
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,notificationContentsToDualPayloadBytesreplaces 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 whenisDual && nextNotifyAt > 0L— so a notification is still scheduled for the notify window.Reference (plugin):
// FetchWorker.kt — notificationContentsToDualPayloadBytes (~371–374 in v3.0.0) if (contents.isEmpty()) { return """{"title":"No updates","body":"No new content"}""".toByteArray(Charsets.UTF_8) }// FetchWorker.kt — doWork(), tail of success path (~306–309 in v3.0.0) if (isDual && nextNotifyAt > 0L) { DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt) DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext) } -
DualScheduleHelper.kt—fallbackBehavior: "show_default"usesuserNotificationdefaults
At display time, if there is no fresh dual-scope cache withinrelationship.contentTimeout, the helper falls back to the persisteduserNotification.title/userNotification.bodywhenfallbackBehavioris"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):
// 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):
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 teachNotifyReceiver/ worker that resolves dual content to not post when that sentinel is present; or - B) On empty list, do not call
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarmfor this cycle (and optionally persist “last fetch had no content” for the helper); or - C) Store an empty/marker cache row that
DualScheduleHelper.resolveDualContentBlockinginterprets as “return null” (no notification).
- A) Return a dedicated sentinel payload (e.g.
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:
fallbackBehaviormatrix (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):
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
datais 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-plugin3.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.