docs/plans/2026-07-03-android-home-screen-widget.md
Implements #3818. Based on ilvez's POC (PR #7124, closed) plus the 2026-06-08 review punch-list. Re-implemented on current master (the PR's April base has drifted heavily).
KeyValStore (SQLite) under key widget_data. The native
RemoteViewsFactory reads it. No new storage mechanism.SharedPreferences-backed WidgetDoneQueue (mirrors
WidgetTaskQueue / ReminderDoneQueue patterns), drained by Angular.setPendingIntentTemplate;
the POC's second fill-in intent silently fell into the done-template. Fix: one
broadcast template, fill-ins carry either EXTRA_TASK_ID (→ done) or
EXTRA_OPEN_APP (→ startActivity), branch in onReceive.APPWIDGET_UPDATE remains.getWidgetDoneQueue()
(get-and-clear). Single delivery path; no double setDone(); retires the
'$sanitizedId' string-interpolated JS call.widget_data. For instant checkbox feedback while the app is dead, the factory
overlays isDone=true for IDs currently in WidgetDoneQueue at render time
(peek(), non-clearing). The Angular-vs-native write race disappears structurally;
markDoneInWidgetData is deleted.selectAndroidWidgetData projects
todayIds + task entities + project colors into the exact blob shape. Project color
changes now propagate (POC pulled colors in but excluded them from the distinct key).
Dedupe happens in WidgetDataService via a last-pushed-JSON cache, so all trigger
paths share it.isSyncInProgress$ falling edge. (Push-on-pause kept as belt-and-braces.)AndroidWidgetData interface (TS) + WidgetData.kt parser as
the two named ends of the v:1 contract, locked by a golden-shape unit test on the
serializer and a JVM parse test on the Kotlin side. (typia writer-side assert was
considered and skipped: the object is constructed from typed state, so an assert is
tautological, and a throw would leave the widget stale — worse than pushing.)T/en.json) instead of one hardcoded snack per task.db.close() removal (correct SQLiteOpenHelper pattern,
avoids churn from widget reads) — redone against the current chunked-read code.
All access is @Synchronized on the App-level singleton, so widget-vs-bridge
concurrency stays serialized. Backup-ring round trip must be manually verified on
device (no Robolectric in the project).WidgetData.kt (single parse site, JVM-testable
via org.json:json test dep). optString("projectId", null) JSON-NULL→"null"
footgun avoided via isNull() checks; Angular omits projectId when absent.validUntil (the instant the snapshot stops
being today, offset included) plus dayStr for the label, so native can at least
detect staleness via now >= validUntil and name the day it is actually showing
instead of claiming "Today". It cannot fix the list. Filtering natively would not
help: today's repeat instances do not exist as entities until Angular's day-change
effects materialize them, overdue carry-over runs there too, and TODAY_TAG
membership is virtual — so there is no persisted field a native filter could read
that would give the right answer even in principle. Only running the app can
produce today's list. Angular ships the verdict, never its inputs, so no platform
mirrors the app's calendar rules (iOS inherits validUntil unchanged).
Residual gaps, all bounded and deliberate:
updatePeriodMillis. That alarm is ELAPSED_REALTIME_WAKEUP but inexact
(setInexactRepeating), so Doze defers it to a maintenance window — and the launcher
paints the system-cached RemoteViews the instant you unlock, so first glance on a new
morning can still read "Today". The lie is bounded, not eliminated. 30 min is the
platform floor (MIN_UPDATE_PERIOD); lowering updatePeriodMillis does nothing.
ACTION_DATE_CHANGED cannot close it either: it is not on the API 26
implicit-broadcast exemption list, so a manifest receiver never fires at
targetSdk 36 — and it fires at calendar midnight, not the user's logical boundary.maskWidgetsViewsLocked swaps in
work_widget_mask_view — a dimmed icon, tap-to-unstop) as well as having its
broadcasts cancelled, so it shows no task list at all and the header question is
moot. It follows that #9098's reported symptom — a stale list under "Today" — can
only occur in the Doze/app-standby band, which is exactly the band this fix works in.validUntil would close the remaining gap, and the parts exist
(SCHEDULE_EXACT_ALARM; BootReceiver on boot + MY_PACKAGE_REPLACED; the
canScheduleExactAlarms()/setAndAllowWhileIdle pattern in
ReminderNotificationHelper, which is Doze-exempt where setInexactRepeating is
not). Deferred on cost/scope — it needs rescheduling on boot, package replace,
timezone change, start-of-next-day change and widget add/remove — not because it
would not work. SyncReminderWorker (already running every 15 min while the app is
dead) calling refreshAll is a cheaper variant, though it is gated on sync
credentials being configured.validUntil freezes the writer's timezone: the boundary resolves in whatever zone
the device was in at push time. West-travel expires it early (a false "outdated" —
the fail-safe direction); east-travel expires it late. The device timezone is not a
selector input, so it is recomputed only when one of the selector's own inputs
changes (today's task ids, a task, a project, todayStr, the offset) or on restart —
not by travelling, and not by unrelated state churn.validUntil, so an install that auto-updates and
is never opened keeps showing "Today" over an old list — indefinitely, not for a
bounded window. Accepted: without a boundary the widget genuinely cannot know, and
an unopened app's list is stale regardless; it self-heals on the first push. Note
this is distinct from the Outdated (day-unknown) header, which fires only when the
snapshot is known stale but its day is unreadable.strings.xml (English) — accepted for v1.Native: widget/{TaskListWidgetProvider,TaskListWidgetService,WidgetData,WidgetDoneQueue}.kt,
CapacitorMainActivity.kt (drain receiver), webview/JavaScriptInterface.kt
(getWidgetDoneQueue, updateWidget), app/KeyValStore.kt, manifest, layouts,
xml/appwidget_info.xml, values/strings.xml, build.gradle (test dep),
test/.../widget/WidgetDataTest.kt.
Angular: features/android/android-widget.model.ts,
features/android/store/android-widget.selectors.ts (+spec),
features/android/widget-data.service.ts (+spec),
features/android/store/android-widget.effects.ts (+spec),
android-interface.ts, root-store/feature-stores.module.ts, en.json (+npm run int).