mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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.
This commit is contained in:
parent
d5afe62016
commit
c18055a341
11 changed files with 602 additions and 17 deletions
|
|
@ -399,7 +399,7 @@ class JavaScriptInterface(
|
|||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun updateWidget() {
|
||||
TaskListWidgetProvider.notifyDataChanged(activity)
|
||||
TaskListWidgetProvider.refreshAll(activity)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
android:contentDescription="" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_header_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@
|
|||
<!-- Home screen widget -->
|
||||
<string name="widget_title">Today\'s Tasks</string>
|
||||
<string name="widget_header_title">Today</string>
|
||||
<!-- Header when the snapshot predates the current day; %1$s is that day's date -->
|
||||
<string name="widget_header_outdated">%1$s (outdated)</string>
|
||||
<!-- Same, but the snapshot's own date could not be read — must still not say "Today" -->
|
||||
<string name="widget_header_outdated_unknown">Outdated</string>
|
||||
<string name="widget_description">Shows today\'s tasks with quick done action</string>
|
||||
<string name="widget_empty">No tasks for today</string>
|
||||
<string name="widget_task_done">Toggle task done</string>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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"}}',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ export class WidgetDataService {
|
|||
async pushCurrent(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue