From 398cfc890681ac30fdcc10a77448d4ba202e36ff Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 27 Aug 2026 20:39:18 +0200 Subject: [PATCH] Roll the home-screen widgets over at midnight (#228) (#246) The month and agenda widgets did not roll over at midnight. They kept highlighting yesterday as "today", and the agenda kept dimming events against yesterday, until the user paged the month arrows or removed and re-added the widget. **What was wrong** The manifest asked for `DATE_CHANGED` and `WidgetUpdateReceiver`'s docs presented it as the rollover mechanism, but `DATE_CHANGED` is not on the implicit-broadcast exemption list, so a manifest-declared receiver has not been given it since Android 8. That left only `updatePeriodMillis`, which the system defers in doze and OEM skins throttle harder still. The data layer was fine all along - the month cache guard already drops its window when the anchor date changes, which is why an arrow tap fixed it instantly. **What changed** - `WidgetRolloverScheduler` arms a single alarm for just after the next local midnight and re-arms on every firing, the same shape as `ReminderAlarmScheduler`. Inexact (`setAndAllowWhileIdle`): no permission, survives doze, and exact alarms stay reserved for reminder snooze. It targets the actual start of day, not a literal 00:00, so it holds where DST means midnight never happens. - Armed only while a widget is placed, via `onEnabled`/`onDisabled` on both Glance receivers; `sync()` cancels only when neither kind is left. Re-armed from boot, package-replace, time and timezone changes, app start (which is what arms existing installs upgrading into this), and from `onUpdate`, so an alarm dropped by a force-stop or an OEM freeze heals itself. - Paging the month widget forward and back no longer pins it to that month. `ShiftMonthAction` stored an absolute index on every tap, so the workaround people used to force a redraw quietly stranded the widget on whatever month was current at the time. - `DATE_CHANGED` stays in the filter as a free extra, but nothing depends on it and the docs no longer claim otherwise. - Keep rule extended to `GlanceAppWidgetReceiver`, since the two receivers are now as structurally alike as the widgets that #89 collapsed. **Verification** `testDebugUnitTest` (775 tests, 0 failures), `lintDebug` and `assembleDebug` all pass. `assembleReleaseTest` builds and the R8 mapping confirms `MonthWidget`, `AgendaWidget` and both receivers keep their real names. 12 new unit tests cover the next-midnight arithmetic: ordinary days, the re-arming instant itself, both DST directions on frozen historical transitions, a zone whose midnight does not exist, a half-hour offset, two zones seeing the same instant, and that the receiver's action guard admits the alarm's action. Not verified on a device - the overnight rollover on an OxygenOS-class device is the one thing that needs a real test before release. Closes #228. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/246 --- CHANGELOG.md | 11 ++ app/proguard-rules.pro | 6 + app/src/main/AndroidManifest.xml | 12 +- .../jeanlucmakiola/calendula/CalendulaApp.kt | 14 ++ .../widget/WidgetRolloverScheduler.kt | 115 ++++++++++++ .../calendula/widget/WidgetUpdateReceiver.kt | 52 +++++- .../widget/agenda/AgendaWidgetReceiver.kt | 31 ++++ .../calendula/widget/month/MonthWidget.kt | 12 +- .../widget/month/MonthWidgetReceiver.kt | 34 ++++ .../widget/WidgetRolloverSchedulerTest.kt | 164 ++++++++++++++++++ 10 files changed, 441 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5435a0a..a67ac18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Home-screen widgets now turn the page at midnight.** Both the month and the + agenda widget kept highlighting yesterday as "today" — and the agenda kept + 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. 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 ### Added @@ -1471,3 +1481,4 @@ automatically, with zero telemetry and no internet permission. [#192]: https://codeberg.org/jlmakiola/calendula/issues/192 [#196]: https://codeberg.org/jlmakiola/calendula/issues/196 [#214]: https://codeberg.org/jlmakiola/calendula/issues/214 +[#228]: https://codeberg.org/jlmakiola/calendula/issues/228 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ef98b3a..6181354 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -50,3 +50,9 @@ # the real names also survives app updates, which would otherwise renumber the # obfuscated name and orphan the stored mapping. -keep class * extends androidx.glance.appwidget.GlanceAppWidget + +# Belt and braces one level up: the two receivers are nearly as alike, and the +# provider map is keyed off the receiver component too. Redundant today (AGP's +# manifest-derived rules cover them), but #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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1db01ea..a7e97b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -330,9 +330,13 @@ + PROVIDER_CHANGED on any data change (our writes and external sync). + The day boundary arrives as the app's own ROLLOVER alarm (#228), by + explicit PendingIntent, so it needs no filter here; DATE_CHANGED is + a free extra only, since Android 8+ withholds it from manifest + receivers. The four below re-arm that alarm: TIME_SET / + TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it. + Exported: the system broadcasts arrive from outside the app. --> @@ -346,6 +350,8 @@ + + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index c11701e..e705be3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker +import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope @@ -44,6 +45,19 @@ class CalendulaApp : Application() { reconcileSpecialDates() reconcileCalendarVisibility() startReminderDelivery() + reconcileWidgetRollover() + } + + /** + * Re-arm the widgets' midnight rollover from whatever is actually placed + * (#228). Idempotent, and it covers what no broadcast reaches — an alarm + * dropped by a force-stop is armed again the next time the app is opened. + * Off the main thread: a handful of binder calls on every process start. + */ + 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 new file mode 100644 index 0000000..b08c987 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt @@ -0,0 +1,115 @@ +package de.jeanlucmakiola.calendula.widget + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.core.content.getSystemService +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +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 + +/** + * Holds the app's own wake-up for the next local midnight, so the home-screen + * widgets roll "today" over on the day boundary (#228). + * + * The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted + * implicit broadcast — a manifest-declared receiver has not been given it since + * Android 8, leaving only the throttled `updatePeriodMillis`. + * + * Exactly one alarm exists at a time and every firing re-arms the next, the same + * shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler]. + * Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and + * survives doze (plain `set` does not), a rollover a few minutes late is + * invisible on a sleeping screen, and exact alarms stay reserved for snooze. + */ +object WidgetRolloverScheduler { + + /** + * Fire just *after* midnight: an alarm delivered a few milliseconds early + * would still read the old date and re-arm for an instant later. + */ + internal val ROLLOVER_SLACK = 5.seconds + + /** + * Arm the next rollover, or cancel a pending one when no widget is placed. + * Idempotent, so every trigger (boot, app start, widget added/removed, the + * rollover itself, a clock or timezone change) can just call it. + */ + fun sync(context: Context) { + val appContext = context.applicationContext + val alarmManager = appContext.getSystemService() ?: return + val pendingIntent = rolloverPendingIntent(appContext) + if (!hasPlacedWidgets(appContext)) { + alarmManager.cancel(pendingIntent) + return + } + val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault()) + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent, + ) + } + + /** + * The instant just after the next local midnight following [now] in [zone]. + * + * The *actual* start of day, not 00:00, so it holds where a DST jump means + * midnight never happens (Havana) and where a date-line move skips a whole + * local date (Apia, December 2011) — the loop walks on to the next real day. + * + * The mirror case — a zone rewinding *across* midnight, so the day starts + * twice — resolves to the earlier start and runs an hour ahead of the clock. + * No live tz entry does that (Brazil dropped DST in 2019) and + * `updatePeriodMillis` covers it, so it isn't worth state to detect. + */ + fun nextRolloverAt(now: Instant, zone: TimeZone): Instant { + val date = now.toLocalDateTime(zone).date + var days = 1 + while (days <= MAX_LOOKAHEAD_DAYS) { + val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK + if (candidate > now) return candidate + days++ + } + // 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 { + val manager = AppWidgetManager.getInstance(context) ?: return false + return PROVIDERS.any { + manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty() + } + } + + private fun rolloverPendingIntent(context: Context): PendingIntent = + PendingIntent.getBroadcast( + context, + ROLLOVER_REQUEST_CODE, + Intent(context, WidgetUpdateReceiver::class.java) + .setAction(WidgetUpdateReceiver.ACTION_ROLLOVER), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + private val PROVIDERS = listOf( + MonthWidgetReceiver::class.java, + AgendaWidgetReceiver::class.java, + ) + + /** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */ + private const val ROLLOVER_REQUEST_CODE = 0x0DA1 + + /** A gap of more than a couple of days does not exist in any tz database entry. */ + private const val MAX_LOOKAHEAD_DAYS = 3 +} 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 18d5e81..2760edf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt @@ -12,19 +12,40 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch /** - * Redraws both home-screen widgets when their data goes stale. Triggered by: + * Redraws both home-screen widgets when their data goes stale, and keeps the + * midnight rollover alarm armed. Triggered by: * - `PROVIDER_CHANGED` from the calendar provider — fires on any data change, * so it covers both the app's own writes and external sync. - * - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting - * and the upcoming window roll over at midnight / on a clock change. + * - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] — + * the day boundary, so "today" highlighting and the agenda's past-event + * dimming move on (#228). + * - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and* + * re-arm. + * - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the + * latter is also what arms installs upgrading into the fix. * - * Both widgets also carry an `updatePeriodMillis` backstop in their provider - * XML, and the month widget's refresh button forces an immediate redraw. + * `DATE_CHANGED` is a free extra in the filter that nothing depends on — see + * [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the + * provider XML and the month widget's refresh button. + * + * Exported for the system broadcasts; an extra redraw from another app is + * harmless. */ class WidgetUpdateReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - val pending = goAsync() + // Exported, so anything can reach it with an explicit intent. Nothing + // here crosses a trust boundary, but narrowing to the actions we asked + // for keeps a stray broadcast from costing two wide provider reads. + if (intent.action !in HANDLED_ACTIONS) return val appContext = context.applicationContext + // Re-arm first, so the next day boundary is covered whatever the redraw + // does. Every handled action either dropped, consumed or invalidated it. + WidgetRolloverScheduler.sync(appContext) + // The host sends APPWIDGET_UPDATE after both of these anyway, so + // redrawing here would only repeat the work 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. @@ -38,4 +59,23 @@ class WidgetUpdateReceiver : BroadcastReceiver() { } } } + + companion object { + /** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */ + const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" + + /** 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, + ) + } } 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 4e1b088..736da00 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,7 +1,10 @@ package de.jeanlucmakiola.calendula.widget.agenda +import android.appwidget.AppWidgetManager +import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver +import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler /** * Host-facing receiver for the agenda widget. Declared in the manifest with the @@ -10,4 +13,32 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver */ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = AgendaWidget() + + /** First agenda widget placed — start rolling "today" over at midnight (#228). */ + override fun onEnabled(context: Context) { + super.onEnabled(context) + WidgetRolloverScheduler.sync(context) + } + + /** + * Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if + * no month widget is left either. + */ + override fun onDisabled(context: Context) { + 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..353db0c 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,17 @@ 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, so the widget + // goes back to *following* the date rather than being pinned to + // whichever month was current at the tap. Paging out and back is the + // workaround #228's reporter used, and pinning it there would have + // stuck them on that month 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 e450f0c..08e4a3e 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,7 +1,10 @@ package de.jeanlucmakiola.calendula.widget.month +import android.appwidget.AppWidgetManager +import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver +import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler /** * Host-facing receiver for the month widget. Declared in the manifest with the @@ -9,4 +12,35 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver */ class MonthWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = MonthWidget() + + /** First month widget placed — start rolling "today" over at midnight (#228). */ + override fun onEnabled(context: Context) { + super.onEnabled(context) + WidgetRolloverScheduler.sync(context) + } + + /** + * Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if + * no agenda widget is left either. + */ + override fun onDisabled(context: Context) { + super.onDisabled(context) + WidgetRolloverScheduler.sync(context) + } + + /** + * The `updatePeriodMillis` backstop is the one wake-up the *system* still + * owns, so it doubles as the alarm's self-heal: anything that drops a + * pending alarm without a broadcast (force-stop, battery restriction, an OEM + * freeze) is repaired here rather than on the next app open. Re-arming + * closer to midnight also narrows the inexact delivery window. + */ + 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 new file mode 100644 index 0000000..da9edee --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt @@ -0,0 +1,164 @@ +package de.jeanlucmakiola.calendula.widget + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +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 + +/** + * The rollover alarm is what makes the widgets stop highlighting yesterday + * (#228), so the "when is the next local midnight" arithmetic is the one piece + * worth pinning down — especially where midnight is not 00:00. + */ +class WidgetRolloverSchedulerTest { + + private val berlin = TimeZone.of("Europe/Berlin") + + private fun at(local: String, zone: TimeZone): Instant = + LocalDateTime.parse(local).toInstant(zone) + + private fun nextRollover(local: String, zone: TimeZone = berlin): Instant = + WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone) + + // --- the ordinary day ---------------------------------------------------- + + @Test + fun `midday rolls over at the coming midnight`() { + val next = nextRollover("2026-08-27T12:00:00") + assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) + } + + @Test + fun `a second before midnight still targets tonight, not tomorrow night`() { + val next = nextRollover("2026-08-27T23:59:59") + assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) + } + + @Test + fun `at midnight exactly the target is the next day, never the current instant`() { + // Re-arming after a firing must move a whole day on, or the widget wakes + // itself in a tight loop. + val now = at("2026-08-28T00:00:00", berlin) + val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) + assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin)) + assertThat(next - now).isGreaterThan(Duration.ZERO) + } + + @Test + fun `re-arming from the slack instant itself moves a full day on`() { + // What actually happens in practice: the receiver runs at midnight + slack. + val now = at("2026-08-28T00:00:05", berlin) + assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin)) + .isEqualTo(at("2026-08-29T00:00:05", berlin)) + } + + @Test + fun `the result is always in the future for every minute of a day`() { + val zone = berlin + // Spans Berlin's 2024 spring-forward: the likeliest source of a target + // in the past, i.e. 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 + } + } + + // --- daylight saving ----------------------------------------------------- + + @Test + fun `spring forward keeps the rollover one day away, not one hour short`() { + // 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("2024-03-31T00:00:05", berlin)) + assertThat(next - now).isLessThan(24.hours) + } + + @Test + fun `fall back does not overshoot into the repeated hour`() { + // Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so + // "now + 24h" would land at 23:00 on the 26th and never roll over. + 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 as the accepted trade: the widget runs an hour + // ahead until the next redraw. No live zone does this since 2019. + 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 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("2018-03-10T12:00:00", havana), havana) + val local = next.toLocalDateTime(havana) + 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 ---------------------------------------------------- + + @Test + fun `the same instant rolls over at different times in different zones`() { + // TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic + // follows the zone, not a cached offset. + val instant = at("2026-08-27T12:00:00", berlin) + val tokyo = TimeZone.of("Asia/Tokyo") + val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin) + val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo) + assertThat(tokyoNext).isNotEqualTo(berlinNext) + assertThat(tokyoNext).isLessThan(berlinNext) + assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0) + } + + @Test + fun `a half-hour offset zone still lands on its own midnight`() { + val kathmandu = TimeZone.of("Asia/Kathmandu") + val next = WidgetRolloverScheduler.nextRolloverAt( + at("2026-08-27T12:00:00", kathmandu), kathmandu, + ) + val local = next.toLocalDateTime(kathmandu) + assertThat(local.date.toString()).isEqualTo("2026-08-28") + assertThat(local.hour).isEqualTo(0) + } + + // --- the wiring ---------------------------------------------------------- + + @Test + 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) + } +}