diff --git a/doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md b/doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md new file mode 100644 index 0000000..9aebb17 --- /dev/null +++ b/doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md @@ -0,0 +1,113 @@ +# Android dual schedule: native fetch, WorkManager timing, and scoped content cache + +**Status:** Draft — implementation plan (pre-code) +**Date:** 2026-03-25 +**Scope:** `daily-notification-plugin` Android (Kotlin/Java), dual / “New Activity” schedule (`scheduleDualNotification`) + +--- + +## 1. Problem summary + +On Android, the dual schedule path today has three related gaps: + +1. **Prefetch does not use the native fetcher.** `FetchWorker` treats a missing `contentFetch.url` as mock JSON and never calls `NativeNotificationContentFetcher`, so host apps that omit `url` on purpose never see `TimeSafariNativeFetcher`-style behavior. + +2. **Prefetch runs at setup, not at `contentFetch.schedule`.** Dual scheduling calls `FetchWorker.enqueueFetch` with **no** initial delay, while the notify alarm is set for the next `userNotification.schedule` occurrence. The DB may record `dual_fetch_*` with `nextRunAt` for the fetch cron, but work is not aligned to that instant. + +3. **Cache is global.** `DualScheduleHelper` uses `contentCacheDao().getLatest()`, so any other feature (e.g. daily reminder prefetch writing the same table) can **overwrite** the row dual notify expects, or dual can overwrite data other features rely on. + +--- + +## 2. Decisions (approved direction) + +| Area | Decision | +|------|----------| +| **Fetch timing** | **Option B:** Use **WorkManager** with **`setInitialDelay`** to the next `calculateNextRunTime(contentFetch.schedule)` (not an additional exact alarm for fetch in the first iteration). Keep **unique work name** `FetchWorker.WORK_NAME_DUAL` (`fetch_dual`) so `cancelDualSchedule` and daily reminder prefetch (`fetch_content`) stay isolated. | +| **Exact alarms** | Accept **best-effort** timing under Doze/OEM; document that prefetch may run late. Revisit with a dedicated alarm only if field data requires stricter wall-clock behavior. | +| **Native fetch** | When `url` is null/blank and `DailyNotificationPlugin.getNativeFetcherStatic()` is non-null, invoke **`NativeNotificationContentFetcher.fetchContent(FetchContext)`** with semantics aligned to `DailyNotificationFetchWorker` (timeout, metadata). Persist results into the **dual-scoped** cache (below). If `url` is set, preserve **HTTP GET** behavior for that path. | +| **Cache sharing** | Add an explicit **`cacheScope`** (string) column on the content cache entity. Use well-known values (e.g. **`dual`**, **`daily`**, **`legacy`**). **Dual** reads/writes only **`dual`**; daily reminder writes **`daily`**; migration defaults old rows to **`legacy`** with documented read behavior. | + +--- + +## 3. Implementation outline + +### 3.1 Dual prefetch scheduling (WorkManager + delay) + +- Compute `nextFetchAt = calculateNextRunTime(contentFetch.schedule)` using the same cron resolution as today for notify. +- Compute `delayMs = max(0, nextFetchAt - now)`. +- Build `OneTimeWorkRequest` for `FetchWorker` (or shared worker) with **`setInitialDelay(delayMs, MILLISECONDS)`**, existing constraints/backoff as appropriate, and **`InputData`** that identifies this run as **dual** (and passes `url`, timeouts, etc.). +- **Reschedule:** After a successful run (or as part of the dual rollover story), enqueue the **next** dual prefetch for the following occurrence of `contentFetch.schedule` (parallel to notify rollover). **Boot recovery** must recompute delay from persisted dual config and re-enqueue `fetch_dual` if needed. + +### 3.2 `FetchWorker` / native path + +- When handling **dual** work with **no** `url`, call the **native fetcher** if registered; otherwise fall back policy should be explicit (mock only for dev, or failure — product choice). +- Serialize native results to the same **payload** shape `DualScheduleHelper` already expects (JSON with `title` / `body` or `content`). +- **Write** using **`cacheScope = dual`** (see §3.3). + +### 3.3 Room: `cacheScope` column + +- Add **`cacheScope: String`** to the `ContentCache` entity (name may match existing naming in the codebase). +- Room migration: new column **NOT NULL** with default **`legacy`** for existing rows. +- DAO: + - **`getLatestByScope(scope: String)`** (or equivalent) for dual and daily. + - Deprecate or narrow use of unscoped **`getLatest()`**; document which call sites must pass scope. + +### 3.4 `DualScheduleHelper` + +- Replace **`getLatest()`** with **`getLatestByScope("dual")`** (constant for the dual scope string). +- Keep **`contentTimeout` / `fallbackBehavior`** logic unchanged; only the **source row** changes. + +### 3.5 Daily reminder and other writers + +- Any code path that populates `ContentCache` for the **daily** reminder should set **`cacheScope = daily`**. +- Verify **all** `upsert` paths; avoid writing **`dual`** except from dual prefetch. + +### 3.6 Cancellation and isolation + +- **`cancelDualSchedule`:** Continue canceling **`fetch_dual`** only; do not cancel **`fetch_content`**. +- No change to notify alarm IDs (`dual_notify_*` vs daily `notify_*` / app-specific ids) beyond what already exists. + +### 3.7 Tests + +- Delay math: next fetch in the future; past instant triggers immediate or “run now” branch per policy. +- Native path invoked when `url` absent and fetcher registered; HTTP path unchanged when `url` set. +- **Scope isolation:** Write **`daily`**, then **`dual`**, assert **`getLatestByScope("dual")`** is not the daily payload and **`DualScheduleHelper`** resolves dual copy correctly. +- Boot / reschedule path re-enqueues **`fetch_dual`** with correct delay when dual config exists. + +### 3.8 Documentation + +- **CHANGELOG.md** and **README** (Android section): dual prefetch uses delayed WorkManager; content cache is **scoped**; integrators must not assume a single global cache row for all features. + +--- + +## 4. Risks and mitigations + +| Risk | Mitigation | +|------|------------| +| WorkManager runs **after** notify time | Document; consider retry or follow-up work item for stricter scheduling if needed. | +| Missed migration for a writer | Grep for `ContentCache` / `upsert` / `getLatest` and audit. | +| Old **`legacy`** rows | Dual helper reads **`dual`** only; legacy readers, if any, keep using **`legacy`** or explicit migration to **`daily`**. | + +--- + +## 5. Out of scope (this iteration) + +- iOS parity for dual native fetch / timing (track separately if needed). +- Replacing WorkManager with exact alarms for fetch (optional future enhancement). + +--- + +## 6. References + +- Prior analysis: consuming app `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` (external repo). +- Plugin: `FetchWorker.kt`, `ScheduleHelper.scheduleDualNotification`, `DualScheduleHelper.kt`, `DailyNotificationFetchWorker.java`, `ContentCache` / DAO definitions. + +--- + +## 7. Review checklist (before merge) + +- [ ] All `ContentCache` writes specify **`cacheScope`** intentionally. +- [ ] `DualScheduleHelper` uses **scoped** read for **`dual`**. +- [ ] `cancelDualSchedule` does not cancel **`fetch_content`**. +- [ ] Boot path reschedules dual prefetch when appropriate. +- [ ] CHANGELOG / README updated.