docs(android): add dual schedule native fetch and cache scope plan

Add ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md describing the
pre-implementation plan: WorkManager initial delay for dual prefetch,
NativeNotificationContentFetcher when URL is absent, and cacheScope on
ContentCache to separate dual vs daily reminder cache rows.
This commit is contained in:
Jose Olarte III
2026-03-25 16:05:20 +08:00
parent fc1cebd720
commit a5c5a7e74e

View File

@@ -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.