diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt index 305198053c..93db9170b5 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt @@ -399,7 +399,7 @@ class JavaScriptInterface( @Suppress("unused") @JavascriptInterface fun updateWidget() { - TaskListWidgetProvider.notifyDataChanged(activity) + TaskListWidgetProvider.refreshAll(activity) } /** diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt index 620c8cae81..c812817db9 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt @@ -7,9 +7,11 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.net.Uri +import android.text.format.DateUtils import android.util.Log import android.widget.RemoteViews import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.superproductivity.superproductivity.App import com.superproductivity.superproductivity.CapacitorMainActivity import com.superproductivity.superproductivity.R @@ -26,9 +28,7 @@ class TaskListWidgetProvider : AppWidgetProvider() { appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { - for (appWidgetId in appWidgetIds) { - updateWidget(context, appWidgetManager, appWidgetId) - } + updateAll(context, appWidgetManager, appWidgetIds) } override fun onReceive(context: Context, intent: Intent) { @@ -46,8 +46,12 @@ class TaskListWidgetProvider : AppWidgetProvider() { val setDone = intent.getBooleanExtra(EXTRA_SET_DONE, true) Log.d(TAG, "Toggle done from widget: taskId=$taskId setDone=$setDone") WidgetDoneQueue.setTarget(context, taskId, setDone) - // Re-render so the pending-done overlay shows the checked box - notifyDataChanged(context) + // Re-render so the pending-done overlay shows the checked box. Full + // refresh, not rows-only: the tap cannot change the blob, but the + // verdict is a function of *now*, and this is the one interaction that + // reaches our code while the app process is dead — so a tap on a new + // day must not redraw rows under a header still claiming "Today". + refreshAll(context) // Contentless "drain now" signal for a live app; Angular always // pulls the IDs from the queue itself (single delivery path). LocalBroadcastManager.getInstance(context) @@ -81,21 +85,104 @@ class TaskListWidgetProvider : AppWidgetProvider() { const val EXTRA_SET_DONE = "WIDGET_SET_DONE" const val EXTRA_OPEN_APP = "WIDGET_OPEN_APP" - fun notifyDataChanged(context: Context) { - val appWidgetManager = AppWidgetManager.getInstance(context) - val widgetIds = appWidgetManager.getAppWidgetIds( + private fun widgetIds(context: Context, appWidgetManager: AppWidgetManager): IntArray = + appWidgetManager.getAppWidgetIds( ComponentName(context, TaskListWidgetProvider::class.java) ) - appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, R.id.widget_task_list) + + /** + * "Today" while the snapshot still describes the current logical day, + * otherwise the snapshot's own date. Only Angular can compute today's list — + * today's repeat instances do not exist as entities until its day-change + * effects have run, and overdue tasks are carried over there too — so a + * process that stayed dead across midnight leaves yesterday's blob in place. + * Name the day actually on screen rather than mislabelling it "Today" (#9098). + * + * Reads the blob itself: the header lives in the provider's RemoteViews while + * the rows are built in a separate RemoteViewsFactory, with no shared lifetime + * to hand it down. Call once per refresh — the result is the same for every + * widget id. + */ + private fun headerTitle(context: Context): CharSequence { + val meta = try { + WidgetData.parseMeta( + (context.applicationContext as App).keyValStore + .get(WidgetData.KEYVAL_KEY, "{}") + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to read widget data for header", e) + // Unknown stamp: keep the pre-#9098 behaviour rather than cry stale. + return context.getString(R.string.widget_header_title) + } + // The verdict lives in WidgetData.headerFor (pure, tested); this only renders it. + return when (val header = WidgetData.headerFor(meta, System.currentTimeMillis())) { + is WidgetHeader.Today -> context.getString(R.string.widget_header_title) + is WidgetHeader.Outdated -> header.dayMs?.let { dayMs -> + context.getString( + R.string.widget_header_outdated, + DateUtils.formatDateTime( + context, + dayMs, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_ABBREV_MONTH or DateUtils.FORMAT_ABBREV_WEEKDAY + ) + ) + } ?: context.getString(R.string.widget_header_outdated_unknown) + } + } + + /** + * Refreshes rows and header — every caller needs both. A push can change the day + * the blob describes (it is how a widget stops being outdated), and a tap, though + * it cannot change the blob, still re-renders at a later *now* than the last + * verdict was computed at. The header is not part of the collection, so neither + * can be a rows-only reload. + * + * A full update is deliberate, not lazy. It costs a few PendingIntents on a + * debounced-and-deduped path, and it does NOT cost scroll position: the host + * reapplies onto the recycled view (same layout id) and AbsListView keeps the + * bound adapter when the adapter intent is unchanged, which it always is here. + * The obvious "cheaper" partiallyUpdateAppWidget is a trap — despite its docs it + * does not ignore a widget with no cached views, it *replaces* them, so it would + * install a header with no adapter and no click targets on any widget whose views + * the system has dropped (an app upgrade clears them explicitly). + */ + fun refreshAll(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val ids = widgetIds(context, appWidgetManager) + // Every push reaches here; without a widget there is nothing to read for. + if (ids.isEmpty()) { + return + } + updateAll(context, appWidgetManager, ids) + } + + /** Rebuilds each passed widget, reading the header once for all of them. */ + private fun updateAll( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + val header = headerTitle(context) + for (appWidgetId in appWidgetIds) { + updateWidget(context, appWidgetManager, appWidgetId, header) + } + // setRemoteAdapter alone does not re-invoke the factory's onDataSetChanged() + // when the adapter intent is unchanged (it always is — same widget id, same + // Uri), so the rows would otherwise be whatever the adapter last built. + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_task_list) } private fun updateWidget( context: Context, appWidgetManager: AppWidgetManager, - appWidgetId: Int + appWidgetId: Int, + header: CharSequence ) { val views = RemoteViews(context.packageName, R.layout.widget_task_list) + views.setTextViewText(R.id.widget_header_title, header) + val serviceIntent = Intent(context, TaskListWidgetService::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt index 1d3abc6f4b..0ae2eca3d9 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt @@ -1,6 +1,8 @@ package com.superproductivity.superproductivity.widget import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Locale data class WidgetTask( val id: String, @@ -9,6 +11,40 @@ data class WidgetTask( val projectColor: String? ) +/** + * When the snapshot stops describing "today", and which day it describes. + * + * Both are null for blobs written before the stamp existed (an install that upgraded + * without opening the app yet) and for unsupported versions. They are deliberately + * independent: a [validUntil] we cannot trust must never be paired with a [dayStr] we + * can, or vice versa — [WidgetData.isSnapshotStale] returns false without a + * [validUntil], and callers keep the pre-#9098 behaviour of trusting the list, which + * self-heals on the next push. + */ +data class WidgetMeta( + val dayStr: String?, + val validUntil: Long? +) + +/** + * What the header should say. Decided purely from the stamp by [WidgetData.headerFor] so + * the #9098 verdict is unit-testable — rendering it needs a Context, and this project has + * no Robolectric, so a decision left inside the provider could ship inverted and green. + */ +sealed interface WidgetHeader { + /** The snapshot still describes the current day. */ + data object Today : WidgetHeader + + /** + * The snapshot has expired. [dayMs] is an instant on the day it describes (local + * midnight, or 01:00 in zones where that midnight does not exist — either way the + * right calendar day, which is all the label needs), or null when that day is + * missing or unparseable: the header must still not say "Today", so it says only + * that it is outdated. + */ + data class Outdated(val dayMs: Long?) : WidgetHeader +} + /** * Native end of the `widget_data` KeyValStore contract. The writer is Angular's * WidgetDataService; the blob shape is defined by AndroidWidgetData in @@ -18,6 +54,7 @@ data class WidgetTask( object WidgetData { const val KEYVAL_KEY = "widget_data" private const val SUPPORTED_VERSION = 1 + private const val DAY_STR_PATTERN = "yyyy-MM-dd" /** * @param pendingDoneTargets per-task done-state targets queued via @@ -55,4 +92,71 @@ object WidgetData { } return result } + + /** + * Reads only the staleness stamp — the task list is loaded separately, in the + * RemoteViewsFactory, while the header lives in the provider's RemoteViews. + * + * Every field degrades independently to null rather than to a plausible-looking + * default: a defaulted stamp is indistinguishable from a real one and would make + * the widget assert a verdict it cannot support. + */ + fun parseMeta(json: String): WidgetMeta { + return try { + val root = JSONObject(json) + if (root.optInt("v", -1) != SUPPORTED_VERSION) { + WidgetMeta(null, null) + } else { + WidgetMeta( + // isNull guard because Android's optString maps JSON null to the + // string "null" (the reference org.json this is tested against does + // not, so no test can see the difference). Belt-and-braces: without + // it "null" would simply fail dayStrToMs and still yield Outdated. + dayStr = if (root.isNull("dayStr")) null + else root.optString("dayStr").takeIf { it.isNotEmpty() }, + // takeIf, not optLong's default: absent and JSON-null both yield 0L, + // which is a real instant (1970) and would read as "expired long ago". + // Missing must mean unknown. + validUntil = root.optLong("validUntil", 0L).takeIf { it > 0L } + ) + } + } catch (e: Exception) { + WidgetMeta(null, null) + } + } + + /** + * The entire staleness verdict: has the snapshot outlived the day it describes? + * + * Angular ships the boundary instant, so no calendar rules are mirrored here — see + * AndroidWidgetData.validUntil. Without a usable stamp we cannot know, and an + * unknown day must not be reported as expired: the rows are still the best we have. + */ + fun isSnapshotStale(meta: WidgetMeta, nowMs: Long): Boolean = + meta.validUntil != null && nowMs >= meta.validUntil + + /** + * The whole #9098 decision, Context-free so it can be tested: which header does this + * stamp justify? A stale snapshot whose day we cannot read still must not be called + * "Today" — losing the label is not a licence to lie. + */ + fun headerFor(meta: WidgetMeta, nowMs: Long): WidgetHeader = + if (!isSnapshotStale(meta, nowMs)) WidgetHeader.Today + else WidgetHeader.Outdated(meta.dayStr?.let { dayStrToMs(it) }) + + /** + * Local-midnight epoch millis for a YYYY-MM-DD day stamp, or null if it is not + * exactly that. Feeds the header LABEL only, never the verdict. + * + * Strict on purpose: SimpleDateFormat is lenient by default, so "2026-02-30" would + * roll into March and "2026-07-17garbage" would parse as a prefix — both would show + * a confidently wrong day. Locale.US pins the Gregorian calendar and ASCII digits; + * the device default could be a Buddhist or Persian calendar and misread the stamp. + */ + fun dayStrToMs(dayStr: String): Long? = try { + val fmt = SimpleDateFormat(DAY_STR_PATTERN, Locale.US).apply { isLenient = false } + fmt.parse(dayStr)?.takeIf { fmt.format(it) == dayStr }?.time + } catch (e: Exception) { + null + } } diff --git a/android/app/src/main/res/layout/widget_task_list.xml b/android/app/src/main/res/layout/widget_task_list.xml index ac5ae8a2e3..7436f1f8cc 100644 --- a/android/app/src/main/res/layout/widget_task_list.xml +++ b/android/app/src/main/res/layout/widget_task_list.xml @@ -27,6 +27,7 @@ android:contentDescription="" /> Today\'s Tasks Today + + %1$s (outdated) + + Outdated Shows today\'s tasks with quick done action No tasks for today Toggle task done diff --git a/android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt b/android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt index 111edf80ae..a8ca3a8320 100644 --- a/android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt +++ b/android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt @@ -1,9 +1,14 @@ package com.superproductivity.superproductivity.widget import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale /** * Locks the native end of the `widget_data` v:1 contract. The writer-side shape is @@ -11,10 +16,15 @@ import org.junit.Test */ class WidgetDataTest { + // validUntil is what the writer really emits for dayStr 2026-07-17 with a 0 offset in + // Europe/Berlin: 2026-07-17T22:00Z == local midnight starting the 18th. Deriving it + // rather than inventing one keeps the fixture a blob the app could actually produce. private val blob = """ { "v": 1, + "dayStr": "2026-07-17", + "validUntil": 1784325600000, "tasks": [ {"id": "t1", "title": "Task one", "isDone": false, "projectId": "p1"}, {"id": "t2", "title": "Task two", "isDone": true}, @@ -72,4 +82,183 @@ class WidgetDataTest { """{"v":1,"tasks":[{"id":"a","title":"A","isDone":false,"projectId":"px"}],"projectColors":{}}""" assertNull(WidgetData.parse(json)[0].projectColor) } + + @Test + fun parsesStalenessStamp() { + val meta = WidgetData.parseMeta(blob) + assertEquals("2026-07-17", meta.dayStr) + assertEquals(1784325600000L, meta.validUntil) + } + + // --- the verdict itself: this IS the #9098 fix --- + + @Test + fun snapshotIsFreshBeforeItsBoundary() { + val meta = WidgetMeta(dayStr = "2026-07-17", validUntil = 1_000L) + assertEquals(false, WidgetData.isSnapshotStale(meta, 999L)) + } + + @Test + fun snapshotIsStaleAtAndAfterItsBoundary() { + val meta = WidgetMeta(dayStr = "2026-07-17", validUntil = 1_000L) + // boundary is inclusive: validUntil is the first instant of the NEXT day + assertTrue(WidgetData.isSnapshotStale(meta, 1_000L)) + assertTrue(WidgetData.isSnapshotStale(meta, 1_001L)) + } + + @Test + fun snapshotWithoutBoundaryIsNeverStale() { + // Legacy blob (pre-#9098) or a rejected stamp: we cannot know, so we must not + // claim expiry — the rows are still the best we have, as before the fix. + assertEquals( + false, + WidgetData.isSnapshotStale(WidgetMeta("2026-07-17", null), Long.MAX_VALUE) + ) + } + + // --- headerFor: the decision the whole fix exists to make --- + + @Test + fun freshSnapshotHeaderIsToday() { + val header = WidgetData.headerFor(WidgetMeta("2026-07-17", 1_000L), 999L) + assertEquals(WidgetHeader.Today, header) + } + + @Test + fun staleSnapshotHeaderIsOutdatedWithItsOwnDay() { + // Inverting the staleness check would make this the Today branch and vice versa. + // The expected day is built independently of dayStrToMs (the function under test), + // so a globally-shifted dayStrToMs cannot drag both sides along with it. + val expectedDay = Calendar.getInstance().apply { + clear() + set(2026, Calendar.JULY, 17) + }.timeInMillis + val header = WidgetData.headerFor(WidgetMeta("2026-07-17", 1_000L), 1_000L) + assertEquals(WidgetHeader.Outdated(expectedDay), header) + } + + @Test + fun staleSnapshotWithoutDayIsStillOutdated() { + // Losing the label is not a licence to say "Today" — the snapshot IS expired. + assertEquals( + WidgetHeader.Outdated(null), + WidgetData.headerFor(WidgetMeta(null, 1_000L), 1_000L) + ) + } + + @Test + fun staleSnapshotWithUnparseableDayIsStillOutdated() { + assertEquals( + WidgetHeader.Outdated(null), + WidgetData.headerFor(WidgetMeta("2026-02-30", 1_000L), 1_000L) + ) + } + + @Test + fun legacyBlobHeaderIsTodayNotOutdated() { + // A pre-#9098 blob carries no boundary: keep the old behaviour rather than + // label every widget outdated on upgrade. + assertEquals( + WidgetHeader.Today, + WidgetData.headerFor(WidgetMeta("2026-07-17", null), Long.MAX_VALUE) + ) + } + + // --- parseMeta degrades to null, never to a plausible default --- + + @Test + fun blobWithoutStampHasNullFields() { + // Written before the stamp existed: an install that upgraded but has not + // re-pushed yet. + val meta = WidgetData.parseMeta("""{"v":1,"tasks":[]}""") + assertNull(meta.dayStr) + assertNull(meta.validUntil) + } + + @Test + fun keyValStoreDefaultBlobHasNullFields() { + // "{}" is KeyValStore.get's default when no blob was ever written. + val meta = WidgetData.parseMeta("{}") + assertNull(meta.dayStr) + assertNull(meta.validUntil) + } + + @Test + fun unparseableBlobDegradesInsteadOfThrowing() { + // parseMeta runs while building the header RemoteViews — a throw there would + // take down the whole widget render. + assertNull(WidgetData.parseMeta("not json").validUntil) + } + + @Test + fun unsupportedVersionYieldsNoStamp() { + // Assert dayStr too: a v:2 fixture carries no validUntil anyway, so asserting + // only that would pass with the version guard deleted. + val meta = WidgetData.parseMeta("""{"v":2,"dayStr":"2026-07-17","validUntil":1784325600000}""") + assertNull(meta.dayStr) + assertNull(meta.validUntil) + } + + @Test + fun jsonNullStampFieldsBecomeNullNotDefaults() { + // Pins the validUntil side: drop the `takeIf { it > 0L }` and this fails, because + // a JSON-null stamp would parse to 0L — a 1970 instant reading as "expired". + // + // CAVEAT (measured, not assumed): it does NOT pin the dayStr isNull guard. This + // classpath has the REFERENCE org.json, whose optString returns the default for + // JSON null; Android's returns the literal string "null". Deleting that guard + // leaves this green. It stays because Android needs it — not because a test + // proves it. + val meta = WidgetData.parseMeta("""{"v":1,"dayStr":null,"validUntil":null,"tasks":[]}""") + assertNull(meta.dayStr) + assertNull(meta.validUntil) + assertEquals(false, WidgetData.isSnapshotStale(meta, Long.MAX_VALUE)) + } + + @Test + fun nonPositiveValidUntilIsRejected() { + // 0L is both optLong's default and a real instant; treat it as absent. + assertNull(WidgetData.parseMeta("""{"v":1,"validUntil":0,"tasks":[]}""").validUntil) + } + + // --- dayStrToMs feeds the LABEL only, and is strict --- + + @Test + fun dayStrToMsIgnoresANonGregorianDeviceCalendar() { + // The round-trip guard CANNOT catch this: a Buddhist-calendar default locale + // reads "2026" as a Buddhist year (Gregorian 1483) and formats it back to the + // identical string, so only the pinned Locale.US keeps the day right. Without it + // the header reads "17 Jul 1483 (outdated)". + val prev = Locale.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("th-TH-u-ca-buddhist")) + val ms = WidgetData.dayStrToMs("2026-07-17") + assertNotNull(ms) + assertEquals( + "2026-07-17", + SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(ms!!)) + ) + } finally { + Locale.setDefault(prev) + } + } + + @Test + fun dayStrToMsRoundTripsToTheSameLocalDay() { + val ms = WidgetData.dayStrToMs("2026-07-16") + assertNotNull(ms) + assertEquals("2026-07-16", SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(ms!!))) + } + + @Test + fun dayStrToMsRejectsGarbageInsteadOfRollingOver() { + assertNull(WidgetData.dayStrToMs("nope")) + assertNull(WidgetData.dayStrToMs("")) + // Both of these are caught by the format round-trip, which is the load-bearing + // guard; isLenient=false is belt-and-braces. Without the round-trip a lenient + // parse rolls "2026-02-30" into March and accepts the "2026-07-17" prefix of + // the last case — either way labelling a confidently wrong day. + assertNull(WidgetData.dayStrToMs("2026-02-30")) + assertNull(WidgetData.dayStrToMs("2026-07-17garbage")) + } } diff --git a/docs/plans/2026-07-03-android-home-screen-widget.md b/docs/plans/2026-07-03-android-home-screen-widget.md index c025c59885..df0a90d1d7 100644 --- a/docs/plans/2026-07-03-android-home-screen-widget.md +++ b/docs/plans/2026-07-03-android-home-screen-widget.md @@ -59,8 +59,55 @@ punch-list. Re-implemented on current master (the PR's April base has drifted he - 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 shows yesterday's list until next open - (native can't recompute "today"; selector handles rollover whenever JS is alive). +- 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. diff --git a/src/app/features/android/android-widget.model.ts b/src/app/features/android/android-widget.model.ts index 3cbe8484e9..00a3683cc3 100644 --- a/src/app/features/android/android-widget.model.ts +++ b/src/app/features/android/android-widget.model.ts @@ -21,6 +21,36 @@ export interface AndroidWidgetTask { export interface AndroidWidgetData { v: 1; + /** + * DISPLAY ONLY — the logical day (YYYY-MM-DD) this snapshot was computed for, shown + * in the header once the snapshot expires. Never compare it natively to derive + * staleness; that is `validUntil`'s job. Parsing it wrong yields a wrong label; the + * verdict stays correct. + */ + dayStr: string; + /** + * THE VERDICT — epoch ms at which this snapshot stops describing "today", i.e. the + * end of `dayStr` including the user's start-of-next-day offset. Native's whole + * staleness check is `now >= validUntil`; no platform re-derives the app's calendar + * rules. + * + * Why the app ships the decision rather than its inputs: native cannot recompute + * today's list anyway — it only exists once Angular has run its day-change effects + * (repeat instances get materialized, overdue carried over), so with the process + * dead across midnight the blob is simply yesterday's, and the widget's only honest + * move is to say so. Shipping the boundary instant keeps that judgement in one + * language instead of mirroring `getDbDateStr` semantics into Kotlin, then Swift. + * + * Tradeoff: this freezes the writer's timezone, because the boundary is resolved in + * whatever zone the device was in at push time. Fly west and the snapshot expires + * early — a false "outdated", the fail-safe direction; fly east and it expires late, + * briefly reproducing #9098 for the delta. Note the device timezone is NOT a selector + * input, so a bare push after landing reuses the memoized boundary: it is recomputed + * only when one of the selector's own inputs changes (today's task ids, a task, a + * project, todayStr, or the offset) — or on a restart. Travelling alone does not do + * it, and neither does unrelated state churn. (#9098) + */ + validUntil: number; tasks: AndroidWidgetTask[]; projectColors: { [projectId: string]: string }; } diff --git a/src/app/features/android/store/android-widget.selectors.spec.ts b/src/app/features/android/store/android-widget.selectors.spec.ts index 8f64cfa4c8..33349c27e1 100644 --- a/src/app/features/android/store/android-widget.selectors.spec.ts +++ b/src/app/features/android/store/android-widget.selectors.spec.ts @@ -1,8 +1,61 @@ -import { selectAndroidWidgetData } from './android-widget.selectors'; +import { getWidgetValidUntil, selectAndroidWidgetData } from './android-widget.selectors'; +import { getDbDateStr } from '../../../util/get-db-date-str'; import { Task } from '../../tasks/task.model'; import { Project } from '../../project/project.model'; +describe('getWidgetValidUntil', () => { + const HOUR = 60 * 60 * 1000; + const FOUR_HOURS = 4 * HOUR; + + it('should return local midnight after the given day', () => { + // built the same way the impl does, so the expectation holds in every TZ the + // suite runs under rather than pinning one zone's epoch + expect(getWidgetValidUntil('2026-07-17', 0)).toBe(new Date(2026, 6, 18).getTime()); + }); + + it('should push the boundary out by the start-of-next-day offset', () => { + // 4am start-of-next-day: the 17th's snapshot stays valid until 04:00 on the 18th + expect(getWidgetValidUntil('2026-07-17', FOUR_HOURS)).toBe( + new Date(2026, 6, 18).getTime() + FOUR_HOURS, + ); + }); + + it('should roll over month and year boundaries', () => { + expect(getWidgetValidUntil('2026-07-31', 0)).toBe(new Date(2026, 7, 1).getTime()); + expect(getWidgetValidUntil('2026-12-31', 0)).toBe(new Date(2027, 0, 1).getTime()); + expect(getWidgetValidUntil('2028-02-28', 0)).toBe(new Date(2028, 1, 29).getTime()); + }); + + // Both transitions for each TZ the suite runs under (Berlin, then Los_Angeles), so + // neither variant passes these vacuously. + const DST_DAYS = ['2026-03-29', '2026-10-25', '2026-03-08', '2026-11-01']; + + // THE property, and the reason native needs no calendar rules of its own: validUntil + // is exactly the instant the app's own logical day rolls over. Asserted against the + // REAL getDbDateStr — a copy would keep passing if the app's day rule ever changed, + // which is precisely the drift this test exists to catch. + // + // Deliberately not asserting "validUntil is local midnight": that is true in Berlin + // and Los_Angeles but false for correct code in zones where midnight does not exist + // (America/Santiago resolves it to 01:00). The rollover property holds everywhere. + it('should mark exactly the instant the logical day changes', () => { + const logicalToday = (nowMs: number, offset: number): string => + getDbDateStr(new Date(nowMs - offset)); + + for (const dayStr of ['2026-07-17', '2026-03-28', ...DST_DAYS]) { + for (const offset of [0, FOUR_HOURS]) { + const validUntil = getWidgetValidUntil(dayStr, offset); + expect(logicalToday(validUntil - 1, offset)).toBe(dayStr); + expect(logicalToday(validUntil, offset)).not.toBe(dayStr); + } + } + }); +}); + describe('selectAndroidWidgetData', () => { + const DAY = '2026-07-17'; + const VALID_UNTIL = new Date(2026, 6, 18).getTime(); + const task = (id: string, partial: Partial = {}): Task => ({ id, @@ -32,9 +85,13 @@ describe('selectAndroidWidgetData', () => { t2: task('t2', { title: 'Task two', isDone: true }), }, projectState([project('p1', '#ff0000')]), + DAY, + 0, ); expect(result).toEqual({ v: 1, + dayStr: DAY, + validUntil: VALID_UNTIL, tasks: [ { id: 't1', title: 'Task one', isDone: false, projectId: 'p1' }, { id: 't2', title: 'Task two', isDone: true }, @@ -48,6 +105,8 @@ describe('selectAndroidWidgetData', () => { ['missing', 't1'], { t1: task('t1') }, projectState([]), + DAY, + 0, ); expect(result.tasks.length).toBe(1); expect(result.tasks[0].id).toBe('t1'); @@ -58,6 +117,8 @@ describe('selectAndroidWidgetData', () => { ['t1'], { t1: task('t1', { projectId: undefined }) }, projectState([]), + DAY, + 0, ); expect('projectId' in result.tasks[0]).toBe(false); }); @@ -67,11 +128,28 @@ describe('selectAndroidWidgetData', () => { ['t1'], { t1: task('t1', { projectId: 'p1' }) }, projectState([project('p1')]), + DAY, + 0, ); expect(result.projectColors).toEqual({}); expect(result.tasks[0].projectId).toBe('p1'); }); + // Native judges staleness purely by `now >= validUntil`, so the boundary — not the + // raw offset — is what has to cross the wire. dayStr rides along for the label only. + it('should stamp the boundary including a custom start-of-next-day', () => { + const fourAmOffset = 4 * 60 * 60 * 1000; + const result = selectAndroidWidgetData.projector( + ['t1'], + { t1: task('t1') }, + projectState([]), + '2026-07-16', + fourAmOffset, + ); + expect(result.dayStr).toBe('2026-07-16'); + expect(result.validUntil).toBe(new Date(2026, 6, 17).getTime() + fourAmOffset); + }); + it('should serialize to the exact v:1 blob shape consumed by WidgetData.kt (see WidgetDataTest.kt)', () => { const result = selectAndroidWidgetData.projector( ['t1', 't2'], @@ -80,9 +158,11 @@ describe('selectAndroidWidgetData', () => { t2: task('t2', { title: 'Task two', isDone: true }), }, projectState([project('p1', '#ff0000')]), + DAY, + 0, ); expect(JSON.stringify(result)).toBe( - '{"v":1,"tasks":[' + + `{"v":1,"dayStr":"${DAY}","validUntil":${VALID_UNTIL},"tasks":[` + '{"id":"t1","title":"Task one","isDone":false,"projectId":"p1"},' + '{"id":"t2","title":"Task two","isDone":true}],' + '"projectColors":{"p1":"#ff0000"}}', diff --git a/src/app/features/android/store/android-widget.selectors.ts b/src/app/features/android/store/android-widget.selectors.ts index e2ae443a8f..9c90e18921 100644 --- a/src/app/features/android/store/android-widget.selectors.ts +++ b/src/app/features/android/store/android-widget.selectors.ts @@ -2,8 +2,31 @@ import { createSelector } from '@ngrx/store'; import { selectTodayTaskIds } from '../../work-context/store/work-context.selectors'; import { selectTaskEntities } from '../../tasks/store/task.selectors'; import { selectProjectFeatureState } from '../../project/store/project.selectors'; +import { + selectStartOfNextDayDiffMs, + selectTodayStr, +} from '../../../root-store/app-state/app-state.selectors'; import { AndroidWidgetData, AndroidWidgetTask } from '../android-widget.model'; +/** + * The instant the logical day `dayStr` stops being "today": local midnight after it, + * plus the user's start-of-next-day offset. This is the whole of what native needs to + * judge staleness (`now >= validUntil`), so the app's day rules never get mirrored + * into Kotlin/Swift — see AndroidWidgetData.validUntil. + * + * Pure in its arguments — deliberately no Date.now(), so the selector stays + * replay-deterministic. `new Date(y, m, d)` normalizes month/year overflow and lands + * on LOCAL midnight, which keeps the boundary right across DST where a naive + * +24h would drift by an hour. + */ +export const getWidgetValidUntil = ( + dayStr: string, + startOfNextDayDiffMs: number, +): number => { + const [year, month, day] = dayStr.split('-').map(Number); + return new Date(year, month - 1, day + 1).getTime() + startOfNextDayDiffMs; +}; + /** * Projects today's tasks into the exact `widget_data` blob shape, so downstream * consumers get referential stability from the selector memoization and cheap @@ -13,7 +36,15 @@ export const selectAndroidWidgetData = createSelector( selectTodayTaskIds, selectTaskEntities, selectProjectFeatureState, - (todayTaskIds, taskEntities, projectState): AndroidWidgetData => { + selectTodayStr, + selectStartOfNextDayDiffMs, + ( + todayTaskIds, + taskEntities, + projectState, + dayStr, + startOfNextDayDiffMs, + ): AndroidWidgetData => { const tasks: AndroidWidgetTask[] = []; const projectColors: { [projectId: string]: string } = {}; @@ -37,6 +68,12 @@ export const selectAndroidWidgetData = createSelector( tasks.push(widgetTask); } - return { v: 1, tasks, projectColors }; + return { + v: 1, + dayStr, + validUntil: getWidgetValidUntil(dayStr, startOfNextDayDiffMs), + tasks, + projectColors, + }; }, ); diff --git a/src/app/features/android/widget-data.service.ts b/src/app/features/android/widget-data.service.ts index 19305e491e..34353bb498 100644 --- a/src/app/features/android/widget-data.service.ts +++ b/src/app/features/android/widget-data.service.ts @@ -19,6 +19,12 @@ export class WidgetDataService { async pushCurrent(): Promise { const data = await firstValueFrom(this._store.select(selectAndroidWidgetData)); const json = JSON.stringify(data); + // Compare the WHOLE blob, not just the tasks: at day rollover the list is often + // byte-identical and only the staleness stamp moves, and that push is the single + // thing that un-outdates the widget. Narrowing this key would silently restore + // #9098. (Not unit-tested — androidInterface is a module-level window capture, so + // this method is unreachable from Karma; see android-widget.effects.ts for the + // codebase's export-the-pure-logic pattern.) if (json === this._lastPushedJson) { return; }