Widgets: close the rest of the rollover gaps (#228)

Review follow-up on the midnight alarm.

The month widget could still show the wrong month after the rollover, and by way
of the exact workaround #228's reporter described. ShiftMonthAction stored an
absolute index on every tap, so paging forward and back — which looks like a
no-op and was how people forced a redraw — silently pinned the widget to the
month that was current at the time. It kept redrawing correctly and kept drawing
August into September, today's circle nowhere on the grid. Landing back on the
current month now clears the key instead of writing it, so the widget goes back
to following the date. Paging somewhere else and staying there is left alone;
that one is a choice, and the today button undoes it.

The alarm also had no way back once something dropped it without telling us — a
force-stop, a battery-restricted transition, an OEM freeze — short of the user
opening the app. onUpdate is the one wake-up the system still owns through
updatePeriodMillis, so both receivers re-arm from it. That doubles as a window
narrower: setAndAllowWhileIdle's delivery slack scales with how far out the alarm
was set, so re-arming every half hour keeps midnight's window at minutes rather
than hours.

Boot and package-replace now re-arm and stop there. The host sends
APPWIDGET_UPDATE after both, so the redraw was a second pair of wide provider
reads and RemoteViews serialisations in a cold process, exactly when the device
is busiest.

Smaller things: the unreachable lookahead fallback armed 5 seconds out, which
would have been a wake-loop rather than the late redraw the comment claimed —
an hour now. The action guard's comment was copied from ReminderScheduleReceiver
along with its claim that the broadcasts are protected; PROVIDER_CHANGED is not,
and the real reason the guard is worth having is that the receiver must stay
exported. Application start does its sync off the main thread now.

Tests moved onto frozen historical transitions — Berlin 2024 both ways, Havana
2018 — since the 2026 dates they used depend on DST rules that can still change
under a tzdata bump. The Berlin "fall back" case never repeated midnight, so it
now says what it actually checks (a 25-hour day must not overshoot) and a real
repeated-midnight zone, pre-2019 Sao Paulo, pins the known limitation instead of
implying it is handled. Dropped the two assertions that restated the code, and
added the one that was missing: that the receiver's action guard admits the
action the alarm is sent with, which is the single point where the whole thing
would fail silently.
This commit is contained in:
2026-08-27 20:17:22 +02:00
parent 809013997d
commit 7e7079df00
9 changed files with 146 additions and 43 deletions

View File

@@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
greying out the wrong events as already past — until you paged the month back greying out the wrong events as already past — until you paged the month back
and forth or removed and re-added the widget. Calendula now wakes itself at the and forth or removed and re-added the widget. Calendula now wakes itself at the
day boundary and redraws, and re-arms after a reboot, a clock change or a day boundary and redraws, and re-arms after a reboot, a clock change or a
flight into another timezone ([#228]). flight into another timezone. Paging the month widget forward and back also
stops quietly pinning it to that month, so it follows the date again instead of
being stranded on the month you happened to be looking at ([#228]).
## [2.19.3] — 2026-08-22 ## [2.19.3] — 2026-08-22

View File

@@ -51,9 +51,11 @@
# obfuscated name and orphan the stored mapping. # obfuscated name and orphan the stored mapping.
-keep class * extends androidx.glance.appwidget.GlanceAppWidget -keep class * extends androidx.glance.appwidget.GlanceAppWidget
# Same hazard one level up: MonthWidgetReceiver and AgendaWidgetReceiver are # Belt and braces one level up: MonthWidgetReceiver and AgendaWidgetReceiver are
# also structurally identical (same supertype, same overrides, only a differing # nearly as alike (same supertype, same overrides, only a differing property
# property initializer), and Glance's provider map is keyed off the receiver # initializer), and Glance's provider map is keyed off the receiver component
# component too. Manifest-declared components are normally kept anyway, but this # too. AGP's manifest-derived keep rules already cover them, and the rule above
# is cheap and makes the invariant explicit rather than incidental. # keeps the two widgets distinct enough that the receivers' constructors differ
# so this is redundant today. It is here because #89 cost a release to diagnose
# and the guarantee should not rest on a component staying in the manifest.
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver -keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver

View File

@@ -45,11 +45,21 @@ class CalendulaApp : Application() {
reconcileSpecialDates() reconcileSpecialDates()
reconcileCalendarVisibility() reconcileCalendarVisibility()
startReminderDelivery() startReminderDelivery()
// Re-arm the widgets' midnight rollover from whatever is actually placed reconcileWidgetRollover()
// (#228). Cheap and idempotent, and it covers the cases the broadcasts }
// miss — an install upgrading into the fix, or an alarm dropped by a
// force-stop, is armed again the next time the app is opened. /**
WidgetRolloverScheduler.sync(this) * Re-arm the widgets' midnight rollover from whatever is actually placed
* (#228). Idempotent, and it covers the cases no broadcast reaches — an
* install upgrading into the fix, or an alarm dropped by a force-stop, is
* armed again the next time the app is opened. Off the main thread because
* it makes a handful of binder calls and every process start runs it,
* including ones a worker or a receiver triggered.
*/
private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
WidgetRolloverScheduler.sync(this@CalendulaApp)
}
} }
/** /**

View File

@@ -15,6 +15,7 @@ import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant import kotlin.time.Instant
@@ -71,6 +72,12 @@ object WidgetRolloverScheduler {
* where a DST jump means midnight never happens (Havana springs from 00:00 to * where a DST jump means midnight never happens (Havana springs from 00:00 to
* 01:00) and where a whole local date is skipped by a date-line move (Apia * 01:00) and where a whole local date is skipped by a date-line move (Apia
* had no 30 December 2011) — the loop then walks on to the next real day. * had no 30 December 2011) — the loop then walks on to the next real day.
*
* The mirror case, a zone that rewinds *across* midnight so the day starts
* twice, resolves to the earlier start; the widget would then run an hour
* ahead of the clock. No entry in the current tz database does that (Brazil,
* which used to, dropped DST in 2019), and `updatePeriodMillis` covers it,
* so it is not worth carrying state to detect.
*/ */
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant { fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date val date = now.toLocalDateTime(zone).date
@@ -80,8 +87,10 @@ object WidgetRolloverScheduler {
if (candidate > now) return candidate if (candidate > now) return candidate
days++ days++
} }
// Unreachable for any real zone; better a late redraw than no alarm. // Unreachable for any zone in the tz database. Deliberately an hour and
return now + ROLLOVER_SLACK // not the slack: a 5-second retry would just hit this branch again and
// wake the device in a loop.
return now + 1.hours
} }
private fun hasPlacedWidgets(context: Context): Boolean { private fun hasPlacedWidgets(context: Context): Boolean {

View File

@@ -36,16 +36,23 @@ import kotlinx.coroutines.launch
*/ */
class WidgetUpdateReceiver : BroadcastReceiver() { class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
// Checked despite every action doing the same thing: the receiver is // The receiver has to stay exported for the system broadcasts, so an
// exported and the broadcasts it takes are protected, so any other // explicit intent can reach it with anything in it. Nothing here reads
// action did not come from where it claims to. // the intent's data and nothing crosses a trust boundary, but narrowing
// to the actions we actually asked for keeps a stray broadcast from
// costing two wide provider reads.
if (intent.action !in HANDLED_ACTIONS) return if (intent.action !in HANDLED_ACTIONS) return
val pending = goAsync()
val appContext = context.applicationContext val appContext = context.applicationContext
// Re-arm first: whatever happens to the redraw, the next day boundary is // Re-arm first: whatever happens to the redraw, the next day boundary is
// covered. Boot and package-replace dropped the alarm outright; a // covered. Boot and package-replace dropped the alarm outright; a
// rollover just consumed it; a clock change invalidated it. // rollover just consumed it; a clock change invalidated it.
WidgetRolloverScheduler.sync(appContext) WidgetRolloverScheduler.sync(appContext)
// Boot and package-replace only cost us the alarm. The host sends
// APPWIDGET_UPDATE after both anyway, so redrawing here would just repeat
// two wide provider reads and two RemoteViews serialisations in a cold
// process, at the moment the device is most contended.
if (intent.action in REARM_ONLY_ACTIONS) return
val pending = goAsync()
// Calendar data may have changed (sync / our own write) — drop the cached // Calendar data may have changed (sync / our own write) — drop the cached
// month window so the widgets reload fresh. Month paging does NOT call // month window so the widgets reload fresh. Month paging does NOT call
// this, so arrow taps stay instant. // this, so arrow taps stay instant.
@@ -64,14 +71,18 @@ class WidgetUpdateReceiver : BroadcastReceiver() {
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */ /** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
private val HANDLED_ACTIONS = setOf( /** Both wipe pending alarms, and the host redraws the widgets itself after them. */
private val REARM_ONLY_ACTIONS = setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
internal val HANDLED_ACTIONS = REARM_ONLY_ACTIONS + setOf(
ACTION_ROLLOVER, ACTION_ROLLOVER,
Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_PROVIDER_CHANGED,
Intent.ACTION_DATE_CHANGED, Intent.ACTION_DATE_CHANGED,
Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
) )
} }
} }

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import android.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
@@ -28,4 +29,17 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
super.onDisabled(context) super.onDisabled(context)
WidgetRolloverScheduler.sync(context) WidgetRolloverScheduler.sync(context)
} }
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
} }

View File

@@ -152,7 +152,19 @@ class ShiftMonthAction : ActionCallback {
val delta = parameters[deltaKey] ?: 0 val delta = parameters[deltaKey] ?: 0
updateAppWidgetState(context, glanceId) { prefs -> updateAppWidgetState(context, glanceId) { prefs ->
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone()) val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
prefs[MONTH_INDEX_KEY] = cur + delta val next = cur + delta
// Landing back on the current month clears the key rather than
// storing today's index, so the widget goes back to *following* the
// date instead of being pinned to the month that happened to be
// current when it was tapped. Paging forward and back is the very
// workaround #228's reporter used to force a redraw; storing the
// index there would have left them stuck on that month for good once
// it stopped being the current one.
if (next == currentMonthIndex(systemZone())) {
prefs.remove(MONTH_INDEX_KEY)
} else {
prefs[MONTH_INDEX_KEY] = next
}
} }
MonthWidget().update(context.applicationContext, glanceId) MonthWidget().update(context.applicationContext, glanceId)
} }

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import android.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
@@ -27,4 +28,21 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
super.onDisabled(context) super.onDisabled(context)
WidgetRolloverScheduler.sync(context) WidgetRolloverScheduler.sync(context)
} }
/**
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
* owns, so it doubles as the rollover alarm's self-heal: anything that drops
* a pending alarm without a broadcast — a force-stop, a battery-restricted
* transition, an OEM freeze — is repaired here rather than waiting for the
* app to be opened. Re-arming closer to midnight also narrows the inexact
* alarm's delivery window, which scales with how far out it was set.
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
} }

View File

@@ -9,6 +9,7 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant import kotlin.time.Instant
@@ -62,8 +63,10 @@ class WidgetRolloverSchedulerTest {
@Test @Test
fun `the result is always in the future for every minute of a day`() { fun `the result is always in the future for every minute of a day`() {
val zone = berlin val zone = berlin
var probe = LocalDateTime.parse("2026-03-28T00:00:00").toInstant(zone) // Spans Berlin's 2024 spring-forward, the case most likely to produce a
val end = LocalDateTime.parse("2026-03-31T00:00:00").toInstant(zone) // target in the past and so an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) { while (probe < end) {
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe) assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
probe += 1.minutes probe += 1.minutes
@@ -74,35 +77,55 @@ class WidgetRolloverSchedulerTest {
@Test @Test
fun `spring forward keeps the rollover one day away, not one hour short`() { fun `spring forward keeps the rollover one day away, not one hour short`() {
// Berlin skips 02:00-03:00 on 29 March 2026, so that day is 23h long. // Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
val now = at("2026-03-28T12:00:00", berlin) // A rollover computed as "now + 24h" would land at 01:00 on 1 April.
val now = at("2024-03-30T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-03-29T00:00:05", berlin)) assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
assertThat(next.toLocalDateTime(berlin).hour).isEqualTo(0) assertThat(next - now).isLessThan(24.hours)
} }
@Test @Test
fun `fall back lands on the first of the two midnights`() { fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeats 02:00-03:00 on 25 October 2026; midnight itself is // Berlin repeated 02:00-03:00 on 27 October 2024: midnight itself is
// unambiguous, but the day is 25h long. // unambiguous, but the day is 25h long, so "now + 24h" would land at
val next = nextRollover("2026-10-24T12:00:00") // 23:00 on the 26th and never roll the date over at all.
assertThat(next.toLocalDateTime(berlin).hour).isEqualTo(0) val now = at("2024-10-26T12:00:00", berlin)
assertThat(next).isEqualTo( val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
LocalDate.parse("2026-10-25").atStartOfDayIn(berlin) + assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
WidgetRolloverScheduler.ROLLOVER_SLACK, assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
}
@Test
fun `a zone that repeats midnight takes the first start of day`() {
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
// began twice. Pinned deliberately: the widget then runs an hour ahead
// of the clock until the next redraw, which is the accepted trade
// (Brazil dropped DST in 2019, so no live zone does this).
val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
) )
val local = next.toLocalDateTime(saoPaulo)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
assertThat(local.hour).isEqualTo(0)
} }
@Test @Test
fun `a zone where midnight does not exist rolls over at the real start of day`() { fun `a zone where midnight does not exist rolls over at the real start of day`() {
// Cuba starts DST at 00:00, so 8 March 2026 begins at 01:00 in Havana. // Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
// Targeting a literal 00:00 there would arm an instant on the wrong day. // Targeting a literal 00:00 there would arm an instant on the wrong day.
val havana = TimeZone.of("America/Havana") val havana = TimeZone.of("America/Havana")
val next = WidgetRolloverScheduler.nextRolloverAt(at("2026-03-07T12:00:00", havana), havana) val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
val local = next.toLocalDateTime(havana) val local = next.toLocalDateTime(havana)
assertThat(local.date.toString()).isEqualTo("2026-03-08") assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
assertThat(local.hour).isEqualTo(1) assertThat(local.hour).isEqualTo(1)
assertThat(local.minute).isEqualTo(0) assertThat(local.minute).isEqualTo(0)
// And it is genuinely the first instant of that date, not a guess.
assertThat(next).isEqualTo(
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
WidgetRolloverScheduler.ROLLOVER_SLACK,
)
} }
// --- timezone changes ---------------------------------------------------- // --- timezone changes ----------------------------------------------------
@@ -131,11 +154,13 @@ class WidgetRolloverSchedulerTest {
assertThat(local.hour).isEqualTo(0) assertThat(local.hour).isEqualTo(0)
} }
// --- the wiring ----------------------------------------------------------
@Test @Test
fun `the slack is small enough to be invisible and large enough to clear the boundary`() { fun `the receiver actually handles the action the alarm is sent with`() {
// Guards the constant: too small and a slightly early alarm still reads // The single point where the whole fix would die silently: the alarm
// yesterday's date; minutes of it and the widget visibly lags midnight. // fires, the receiver drops it on the action guard, nothing redraws.
assertThat(WidgetRolloverScheduler.ROLLOVER_SLACK.inWholeSeconds).isAtLeast(1) assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
assertThat(WidgetRolloverScheduler.ROLLOVER_SLACK.inWholeSeconds).isAtMost(60) .contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
} }
} }