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:
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -51,9 +51,11 @@
|
||||
# obfuscated name and orphan the stored mapping.
|
||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||
|
||||
# Same hazard one level up: MonthWidgetReceiver and AgendaWidgetReceiver are
|
||||
# also structurally identical (same supertype, same overrides, only a differing
|
||||
# property initializer), and Glance's provider map is keyed off the receiver
|
||||
# component too. Manifest-declared components are normally kept anyway, but this
|
||||
# is cheap and makes the invariant explicit rather than incidental.
|
||||
# Belt and braces one level up: MonthWidgetReceiver and AgendaWidgetReceiver are
|
||||
# nearly as alike (same supertype, same overrides, only a differing property
|
||||
# initializer), and Glance's provider map is keyed off the receiver component
|
||||
# too. AGP's manifest-derived keep rules already cover them, and the rule above
|
||||
# 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
|
||||
|
||||
@@ -45,11 +45,21 @@ class CalendulaApp : Application() {
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
startReminderDelivery()
|
||||
// Re-arm the widgets' midnight rollover from whatever is actually placed
|
||||
// (#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)
|
||||
reconcileWidgetRollover()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -71,6 +72,12 @@ object WidgetRolloverScheduler {
|
||||
* 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
|
||||
* 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 {
|
||||
val date = now.toLocalDateTime(zone).date
|
||||
@@ -80,8 +87,10 @@ object WidgetRolloverScheduler {
|
||||
if (candidate > now) return candidate
|
||||
days++
|
||||
}
|
||||
// Unreachable for any real zone; better a late redraw than no alarm.
|
||||
return now + ROLLOVER_SLACK
|
||||
// Unreachable for any zone in the tz database. Deliberately an hour and
|
||||
// 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 {
|
||||
|
||||
@@ -36,16 +36,23 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// Checked despite every action doing the same thing: the receiver is
|
||||
// exported and the broadcasts it takes are protected, so any other
|
||||
// action did not come from where it claims to.
|
||||
// The receiver has to stay exported for the system broadcasts, so an
|
||||
// explicit intent can reach it with anything in it. Nothing here reads
|
||||
// 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
|
||||
val pending = goAsync()
|
||||
val appContext = context.applicationContext
|
||||
// Re-arm first: whatever happens to the redraw, the next day boundary is
|
||||
// covered. Boot and package-replace dropped the alarm outright; a
|
||||
// rollover just consumed it; a clock change invalidated it.
|
||||
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
|
||||
// month window so the widgets reload fresh. Month paging does NOT call
|
||||
// this, so arrow taps stay instant.
|
||||
@@ -64,14 +71,18 @@ class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
|
||||
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,
|
||||
Intent.ACTION_PROVIDER_CHANGED,
|
||||
Intent.ACTION_DATE_CHANGED,
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
@@ -28,4 +29,17 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
super.onDisabled(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,19 @@ class ShiftMonthAction : ActionCallback {
|
||||
val delta = parameters[deltaKey] ?: 0
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
@@ -27,4 +28,21 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
super.onDisabled(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -62,8 +63,10 @@ class WidgetRolloverSchedulerTest {
|
||||
@Test
|
||||
fun `the result is always in the future for every minute of a day`() {
|
||||
val zone = berlin
|
||||
var probe = LocalDateTime.parse("2026-03-28T00:00:00").toInstant(zone)
|
||||
val end = LocalDateTime.parse("2026-03-31T00:00:00").toInstant(zone)
|
||||
// Spans Berlin's 2024 spring-forward, the case most likely to produce a
|
||||
// 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) {
|
||||
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
|
||||
probe += 1.minutes
|
||||
@@ -74,35 +77,55 @@ class WidgetRolloverSchedulerTest {
|
||||
|
||||
@Test
|
||||
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.
|
||||
val now = at("2026-03-28T12:00:00", berlin)
|
||||
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
|
||||
// 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)
|
||||
assertThat(next).isEqualTo(at("2026-03-29T00:00:05", berlin))
|
||||
assertThat(next.toLocalDateTime(berlin).hour).isEqualTo(0)
|
||||
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
|
||||
assertThat(next - now).isLessThan(24.hours)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fall back lands on the first of the two midnights`() {
|
||||
// Berlin repeats 02:00-03:00 on 25 October 2026; midnight itself is
|
||||
// unambiguous, but the day is 25h long.
|
||||
val next = nextRollover("2026-10-24T12:00:00")
|
||||
assertThat(next.toLocalDateTime(berlin).hour).isEqualTo(0)
|
||||
assertThat(next).isEqualTo(
|
||||
LocalDate.parse("2026-10-25").atStartOfDayIn(berlin) +
|
||||
WidgetRolloverScheduler.ROLLOVER_SLACK,
|
||||
fun `fall back does not overshoot into the repeated hour`() {
|
||||
// Berlin repeated 02:00-03:00 on 27 October 2024: midnight itself is
|
||||
// unambiguous, but the day is 25h long, so "now + 24h" would land at
|
||||
// 23:00 on the 26th and never roll the date over at all.
|
||||
val now = at("2024-10-26T12:00:00", berlin)
|
||||
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
|
||||
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
|
||||
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.
|
||||
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)
|
||||
assertThat(local.date.toString()).isEqualTo("2026-03-08")
|
||||
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
|
||||
assertThat(local.hour).isEqualTo(1)
|
||||
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 ----------------------------------------------------
|
||||
@@ -131,11 +154,13 @@ class WidgetRolloverSchedulerTest {
|
||||
assertThat(local.hour).isEqualTo(0)
|
||||
}
|
||||
|
||||
// --- the wiring ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the slack is small enough to be invisible and large enough to clear the boundary`() {
|
||||
// Guards the constant: too small and a slightly early alarm still reads
|
||||
// yesterday's date; minutes of it and the widget visibly lags midnight.
|
||||
assertThat(WidgetRolloverScheduler.ROLLOVER_SLACK.inWholeSeconds).isAtLeast(1)
|
||||
assertThat(WidgetRolloverScheduler.ROLLOVER_SLACK.inWholeSeconds).isAtMost(60)
|
||||
fun `the receiver actually handles the action the alarm is sent with`() {
|
||||
// The single point where the whole fix would die silently: the alarm
|
||||
// fires, the receiver drops it on the action guard, nothing redraws.
|
||||
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
||||
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user