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:
@@ -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.
|
||||||
Reference in New Issue
Block a user