super-productivity/docs/plans/2026-07-03-android-home-screen-widget.md
Johannes Millan c18055a341
fix(android): stop the widget labelling a stale list as Today (#9098) (#9118)
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.
2026-07-17 16:06:06 +02:00

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 key widget_data. The native RemoteViewsFactory reads it. No new storage mechanism.
  • Done actions: SharedPreferences-backed WidgetDoneQueue (mirrors WidgetTaskQueue / ReminderDoneQueue patterns), drained by Angular.

Improvements over the POC (mapped to the punch-list)

  1. 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 either EXTRA_TASK_ID (→ done) or EXTRA_OPEN_APP (→ startActivity), branch in onReceive.
  2. 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_UPDATE remains.
  3. 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 double setDone(); retires the '$sanitizedId' string-interpolated JS call.
  4. Single-writer blob (replaces the proposed ts-guard). Native never writes 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.
  5. Memoized snapshot selector. 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.
  6. 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.)
  7. Typed contract. 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.)
  8. 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.
  9. KeyValStore per-call 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).
  10. Kotlin JSON parsing extracted to 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.

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) 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:
    • The label flips only on an Angular push, a widget tap, or the 30-min 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.
    • 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 (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.
    • An exact alarm at 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.
    • 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 the Outdated (day-unknown) header, which fires only when the snapshot is known stale but its day is unreadable.
  • 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).