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

7.8 KiB
Raw Blame History

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):

    // FetchWorker.kt — notificationContentsToDualPayloadBytes (~371374 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 (~306309 in v3.0.0)
    if (isDual && nextNotifyAt > 0L) {
        DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
        DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext)
    }
    
  2. DualScheduleHelper.ktfallbackBehavior: "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):

    // 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):

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.


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 listno 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):

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.