The widget renders a pre-computed snapshot Angular pushes into `widget_data`; the blob carried no expiry, so a process that stayed dead across midnight kept rendering yesterday's tasks under a hardcoded "Today" header, indefinitely. Native cannot recompute the list. TODAY_TAG membership is virtual, today's repeat instances do not exist as entities until TaskDueEffects materializes them on a day change, and overdue carry-over runs there too — 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. So make the widget honest instead of wrong: Angular stamps the snapshot with `validUntil`, the instant it stops being today (start-of-next-day offset included), and `dayStr` for the label. Native's whole verdict is `WidgetData.headerFor` → `now >= validUntil`; when stale it renders the snapshot's own date instead of "Today". The list stays visible and useful, it just stops claiming to be today's. A stale snapshot whose day cannot be read says "Outdated" rather than falling back to the very lie this fixes. Shipping the boundary rather than its inputs keeps the app's calendar rules in one language. The iOS port (#8950) can consume `validUntil` unchanged instead of mirroring getDbDateStr semantics into Swift, where a non-Gregorian default locale would silently misread the day. The verdict is a pure function so it is unit-testable: this project has no Robolectric, and a decision left inside the provider could ship inverted and green. Both refresh paths rebuild the header. A push can change the day the blob describes; a tap cannot, but it re-renders at a later `now` than the last verdict was computed at — and it is the one interaction that reaches our code while the app is dead, so a tap on a new day must not redraw rows under a "Today" header. Also fixed: onUpdate re-registered the adapter with an unchanged intent, which does not re-invoke the factory's onDataSetChanged(), so the periodic update rendered whatever rows the adapter last built. `v` stays 1: the fields are additive, and parse() returns empty for any other version, so a bump would blank the widget of every install until next opened. A pre-#9098 blob has no `validUntil` and is never reported stale — unknown must not read as expired. Known bounds, documented in the plan rather than papered over: the label flips only on a push, a tap, or the inexact Doze-deferred 30-min periodic update, and the launcher paints cached views on unlock — so the lie is bounded, not eliminated. Force-stop is not a gap: the system masks a stopped package's widget entirely, so the reported symptom can only occur in the Doze band. An exact alarm at `validUntil` would close the rest; deferred on cost/scope, not because it would not work. Verified: 25/25 Kotlin across 6 timezones incl. midnight-gap zones (Santiago, Apia); 203/203 Angular in both timezones CI runs. Boundary math asserted against the real getDbDateStr rollover. Sabotage-measured, not assumed: inverting the verdict fails 5 tests, >=→> fails 4, dropping the validUntil guard fails 3, Locale.US→getDefault fails 1, dropping the day round-trip fails 1.
8.8 KiB
Android home screen widget (revival of PR #7124)
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).
Architecture (unchanged from POC — confirmed sound)
- Snapshot bridge: Angular pushes a compact JSON snapshot of today's tasks into the
existing native
KeyValStore(SQLite) under keywidget_data. The nativeRemoteViewsFactoryreads it. No new storage mechanism. - Done actions:
SharedPreferences-backedWidgetDoneQueue(mirrorsWidgetTaskQueue/ReminderDoneQueuepatterns), drained by Angular.
Improvements over the POC (mapped to the punch-list)
- Title tap opens app (bug fix). A collection has one
setPendingIntentTemplate; the POC's second fill-in intent silently fell into the done-template. Fix: one broadcast template, fill-ins carry eitherEXTRA_TASK_ID(→ done) orEXTRA_OPEN_APP(→startActivity), branch inonReceive. - Manifest hardening. Custom actions removed from the exported receiver's
intent-filter (explicit-component PendingIntents don't need filter entries; listing
them let any app mark tasks done / launch us). Only
APPWIDGET_UPDATEremains. - Queue-only done path. Widget tap → enqueue + widget refresh + contentless
"drain now" LocalBroadcast. Angular always reads via
getWidgetDoneQueue()(get-and-clear). Single delivery path; no doublesetDone(); retires the'$sanitizedId'string-interpolated JS call. - Single-writer blob (replaces the proposed ts-guard). Native never writes
widget_data. For instant checkbox feedback while the app is dead, the factory overlaysisDone=truefor IDs currently inWidgetDoneQueueat render time (peek(), non-clearing). The Angular-vs-native write race disappears structurally;markDoneInWidgetDatais deleted. - Memoized snapshot selector.
selectAndroidWidgetDataprojects 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 inWidgetDataServicevia a last-pushed-JSON cache, so all trigger paths share it. - Post-sync freshness push (new — gap found in review). The hydration-guard filter
drops all emissions during remote-op apply and nothing re-emits after, so the widget
would miss synced changes until the next local edit. Added push on the
isSyncInProgress$falling edge. (Push-on-pause kept as belt-and-braces.) - Typed contract.
AndroidWidgetDatainterface (TS) +WidgetData.ktparser as the two named ends of thev:1contract, 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.) - Drain hygiene. Dedupe IDs, skip missing/already-done tasks (no duplicate ops, no
op-log noise from stale queue entries), gate on initial data load. One aggregated
translated snack (
T/en.json) instead of one hardcoded snack per task. - KeyValStore per-call
db.close()removal (correct SQLiteOpenHelper pattern, avoids churn from widget reads) — redone against the current chunked-read code. All access is@Synchronizedon theApp-level singleton, so widget-vs-bridge concurrency stays serialized. Backup-ring round trip must be manually verified on device (no Robolectric in the project). - Kotlin JSON parsing extracted to
WidgetData.kt(single parse site, JVM-testable viaorg.json:jsontest dep).optString("projectId", null)JSON-NULL→"null" footgun avoided viaisNull()checks; Angular omitsprojectIdwhen absent.
Known limitations (deliberate, documented)
- Widget reflects the app's last known state; cross-client freshness while the app is dead is phase 2 (WorkManager + SuperSync).
- Day rollover while the app process is dead still shows yesterday's list until next
open (native can't recompute "today"; selector handles rollover whenever JS is
alive). Since #9098 the blob carries
validUntil(the instant the snapshot stops being today, offset included) plusdayStrfor the label, so native can at least detect staleness vianow >= validUntiland 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, andTODAY_TAGmembership 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 inheritsvalidUntilunchanged). Residual gaps, all bounded and deliberate:- The label flips only on an Angular push, a widget tap, or the 30-min
updatePeriodMillis. That alarm isELAPSED_REALTIME_WAKEUPbut 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); loweringupdatePeriodMillisdoes nothing.ACTION_DATE_CHANGEDcannot close it either: it is not on the API 26 implicit-broadcast exemption list, so a manifest receiver never fires attargetSdk 36— and it fires at calendar midnight, not the user's logical boundary. - Force-stop is not a gap in the above, contrary to an earlier reading of it. A
stopped package's widget is masked by the system (
maskWidgetsViewsLockedswaps inwork_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. - An exact alarm at
validUntilwould close the remaining gap, and the parts exist (SCHEDULE_EXACT_ALARM;BootReceiveron boot +MY_PACKAGE_REPLACED; thecanScheduleExactAlarms()/setAndAllowWhileIdlepattern inReminderNotificationHelper, which is Doze-exempt wheresetInexactRepeatingis 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) callingrefreshAllis a cheaper variant, though it is gated on sync credentials being configured. validUntilfreezes 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.- A blob written before #9098 has no
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 theOutdated(day-unknown) header, which fires only when the snapshot is known stale but its day is unreadable.
- The label flips only on an Angular push, a widget tap, or the 30-min
- Hardcoded dark styling; Jetpack Glance / Material You is a follow-up view-layer swap.
- Widget chrome strings are native
strings.xml(English) — accepted for v1. - No task creation / undo from widget.
Files
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).