# Plugin feedback: Android dual schedule — native fetcher not used; fetch timing wrong **Date:** 2026-03-24 21:56 PST **Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin) **Consuming app:** crowd-funder-for-time-pwa (TimeSafari) **Platform:** Android (Kotlin / Java) **Related:** New Activity notifications (`scheduleDualNotification` / `cancelDualSchedule`) --- ## Summary On Android, the **dual (New Activity) schedule** path is **not** implementing the intended contract: 1. **Prefetch does not call `NativeNotificationContentFetcher`.** `ScheduleHelper.scheduleDualNotification` delegates fetch to `FetchWorker` (HTTP GET to optional `url`, or **mock JSON** when `url` is absent). The host app’s `TimeSafariNativeFetcher` is **never** invoked. Logcat shows `DNP-FETCH: Starting content fetch from: null, notificationTime=0` and **no** `TimeSafariNativeFetcher` `fetchContent` lines. 2. **Fetch is not scheduled at `contentFetch.schedule` (e.g. T−5 minutes).** `FetchWorker.enqueueFetch` enqueues **immediate** `OneTimeWorkRequest` work (no `setInitialDelay` aligned to the fetch cron). The **notify** alarm is scheduled correctly for `dual_notify_*`, but there is **no** corresponding alarm/work at the **fetch** cron time. A `dual_fetch_*` row may exist in the DB with `nextRunAt`, but the **actual** fetch runs at **enable/setup time**, not at T−5. 3. **Cache vs `DualScheduleHelper` / `contentTimeout`.** `DualScheduleHelper.resolveDualContentBlocking` only uses `contentCache` when the latest fetch is within `relationship.contentTimeout` (e.g. 5 minutes). If fetch runs **once at setup** and notify fires **~9+ minutes later**, cache is **stale** → `useCache=false` → default title/body from `userNotification`, even when mock payload was stored. **Recommended direction (plugin):** - For dual schedule when **no HTTP `url`** is configured (or when a flag indicates native mode), run **`NativeNotificationContentFetcher.fetchContent(FetchContext)`** (same path as `DailyNotificationFetchWorker` uses), persist results into the same `contentCache` / pipeline `DualScheduleHelper` expects. - **Schedule** that work (or an alarm that enqueues it) **at** `calculateNextRunTime(contentFetch.schedule)` — i.e. **before** the notify alarm, typically **5 minutes** earlier per app cron (see consuming app `timeToCronFiveMinutesBefore`). - Optionally align **one** scheduling mechanism: either exact alarm for fetch + notify, or WorkManager with **initial delay** to the next fetch instant (and reschedule after run). --- ## Symptoms (consuming app + logcat) - Notification shows **default** copy from `userNotification` (`title` / `body` from `buildDualScheduleConfig`), not API-derived or native “No updates” copy. - Logcat: `DNP-DUAL: Resolved dual content: useCache=false` at notify time. - Logcat: `DNP-FETCH: Starting content fetch from: null, notificationTime=0` followed by `Content fetch completed successfully` **at schedule/setup time**, not at T−5. - **No** `TimeSafariNativeFetcher` `fetchContent START` / `POST …/plansLastUpdatedBetween` during prefetch window (host registers `NativeNotificationContentFetcher` and logs on configure + fetch). - **No** activity at the **prefetch cron** time (e.g. 19:05 for notify at 19:10); only **notify** fires at T. --- ## What the consuming app sends (contract) **File:** `src/services/notifications/dualScheduleConfig.ts` - `contentFetch.enabled: true` - `contentFetch.schedule`: cron **5 minutes before** `userNotification.schedule` (e.g. `"25 19 * * *"` for notify `"30 19 * * *"`). - **No** `contentFetch.url` — intended to use **native** Endorser API via `configureNativeFetcher` + `NativeNotificationContentFetcher`. - `relationship.autoLink: true`, `relationship.contentTimeout: 5 * 60 * 1000`, `fallbackBehavior: "show_default"`. **Host app:** `android/.../TimeSafariNativeFetcher.java` implements `NativeNotificationContentFetcher` and calls `POST /api/v2/report/plansLastUpdatedBetween` with starred plan IDs from `updateStarredPlans`. --- ## Root cause (plugin code — paths to review) These paths are from a local clone of **daily-notification-plugin**; line numbers may drift. ### 1. `FetchWorker` is URL/mock-only; does not call native fetcher `android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt` - `enqueueFetch` passes `config.url` into `InputData`; `doWork` logs `Starting content fetch from: $url`. - `fetchContent(url, …)` when `url` is null/blank returns **`generateMockContent()`** — never calls `DailyNotificationPlugin.getNativeFetcherStatic().fetchContent(...)`. ### 2. `scheduleDualNotification` runs fetch work immediately, not at fetch cron `android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt` — `object ScheduleHelper`, `suspend fun scheduleDualNotification(...)` - Calls `scheduleFetch(context, contentFetchConfig)` which resolves to `FetchWorker.scheduleFetchForDual` → `enqueueFetch` **without** delay tied to `contentFetchConfig.schedule`. - Schedules **notify** via `NotifyReceiver.scheduleExactNotification` for `dual_notify_*` at `calculateNextRunTime(userNotificationConfig.schedule)`. - Persists `dual_fetch_*` with `nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)` but **no** matching alarm/work is scheduled for that instant in the current flow (as observed). ### 3. Native fetcher exists elsewhere `android/src/main/java/org/timesafari/dailynotification/DailyNotificationFetchWorker.java` - Contains logic to call `NativeNotificationContentFetcher.fetchContent(FetchContext)` (with timeout). Dual schedule **does not** enqueue this worker for the TimeSafari `contentFetch` payload. ### 4. `DualScheduleHelper` behavior is consistent with “wrong fetch time” `android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt` - Uses latest `contentCache` only if `(now - fetchedAt) <= contentTimeoutMs`. If fetch ran at setup and notify is **later** than `contentTimeout`, **cache is ignored** → `useCache=false` in logs. --- ## Acceptance criteria (plugin) After a fix, on a device with: - `configureNativeFetcher` + `updateStarredPlans` called (host app), - `scheduleDualNotification` with `contentFetch.enabled: true`, no `url`, cron 5 min before notify, then: 1. **At or before** the notify fire time, **within** `contentTimeout`, the cache used by `DualScheduleHelper` reflects **native** fetch results when the API returns data (or empty), not only mock JSON. 2. Logcat **includes** host tag `TimeSafariNativeFetcher` with `fetchContent START` (or equivalent) **when** prefetch runs, **or** plugin logs an explicit `NativeNotificationContentFetcher` invocation. 3. Prefetch **does not** run only at **INITIAL_SETUP**; it runs at the **next** occurrence of `contentFetch.schedule` (and reschedules for the following day after success, same as notify rollover). 4. **Optional:** If `url` is set, preserve HTTP GET behavior; if `url` is absent and native fetcher is registered, use native path. --- ## References in consuming app | Topic | Location | |--------|----------| | Dual config builder | `src/services/notifications/dualScheduleConfig.ts` | | `scheduleDualNotification` call | `src/views/AccountViewView.vue` (`scheduleNewActivityDualNotification`, `editNewActivityNotification`) | | Native fetcher | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` | | Registration | `MainActivity` / plugin init (host registers `DailyNotificationPlugin.setNativeFetcher`) | --- ## Notes for Cursor / implementers - **Do not** assume `contentFetch.url` is present; TimeSafari intentionally omits it for native API. - **Reuse** the same `FetchContext` / timeout semantics as `DailyNotificationFetchWorker` where possible to avoid two divergent native fetch implementations. - After changing timing, **verify** `WorkManager` unique work name `fetch_dual` / `cancelDualSchedule` still cancel only dual fetch and do not break daily reminder. --- ## Related docs in this repo - `doc/notification-from-api-call.md` — integration plan for API-driven New Activity. - `doc/plugin-feedback-android-scheduleDualNotification-contentFetch-json.md` — optional `timeout` / `retry*` JSON parsing (already addressed on the plugin side).