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) + } +}