diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7735c23..37658c0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,14 @@ 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]).
- **"Only this event" now actually saves your edit.** On some calendars —
including Google ones that still show as on-device, and any local calendar —
editing a single occurrence of a repeating event did nothing at all: the scope
@@ -1483,4 +1491,5 @@ 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
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
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)
+ }
+}