diff --git a/CHANGELOG.md b/CHANGELOG.md index a13eff6..a67ac18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 840b866..29c99c3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index 07d0d13..6d943a6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -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) + } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt index a60af92..bc2d518 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt index 2693f8f..117fa43 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt @@ -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, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt index 0629d39..1682fcd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt @@ -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) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 6778bda..a5d0532 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -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) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt index 5919d58..7a56afa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt @@ -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) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt index 23459c9..19370f1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt @@ -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) } }