diff --git a/CHANGELOG.md b/CHANGELOG.md index 5435a0a..a13eff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,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 ([#228]). + ## [2.19.3] — 2026-08-22 ### Added @@ -1471,3 +1479,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..840b866 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -50,3 +50,10 @@ # 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 + +# 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. +-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1db01ea..a5a1c4a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -331,8 +331,13 @@ + and the day boundary arrives as the app's own ROLLOVER alarm (#228), + delivered by an explicit PendingIntent so it needs no filter here. + DATE_CHANGED is kept as a free extra only — it is not an exempted + implicit broadcast, so a manifest-declared receiver is not given it + on Android 8+. TIME_SET / TIMEZONE_CHANGED move the day boundary, + and boot / package-replace wipe the alarm, so all four re-arm it. + Exported: the system broadcasts arrive from outside the app. --> @@ -346,6 +351,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..07d0d13 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,11 @@ 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) } /** 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..a60af92 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt @@ -0,0 +1,113 @@ +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.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`, but that broadcast is not + * on the implicit-broadcast exemption list, so a manifest-declared receiver has + * never been given it since Android 8 — leaving only `updatePeriodMillis`, which + * the system defers in doze and OEM skins throttle harder still. The result was + * yesterday staying highlighted (and the agenda's past-event dimming staying + * anchored to yesterday) until something else forced a redraw. + * + * Exactly one alarm exists at a time and every firing re-arms the next one, the + * same shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler]. + * It is deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and + * survives doze (which plain `set` does not), and a rollover that lands a few + * minutes late is invisible on a sleeping screen. Exact alarms stay reserved for + * reminder snooze. + */ +object WidgetRolloverScheduler { + + /** + * Fire just *after* midnight, never exactly on it. An alarm delivered a few + * milliseconds early would still read the old date and re-arm for an instant + * later; the offset makes "the day has changed" unambiguous. + */ + 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]. + * + * Uses the *actual* start of the day rather than 00:00, so it stays correct + * 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. + */ + 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 real zone; better a late redraw than no alarm. + return now + ROLLOVER_SLACK + } + + 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..2693f8f 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` — a clock or zone change moves the day + * boundary relative to the armed alarm, so both redraw *and* re-arm. + * - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms. The + * package-replaced one is also what arms existing installs that upgrade into + * the fix without re-adding their widget. * - * 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 still in the manifest filter as a free extra, but nothing + * depends on it: it is not an exempted implicit broadcast, so a manifest-declared + * receiver has not actually been given it since Android 8. The widgets also carry + * an `updatePeriodMillis` backstop in their provider XML, and the month widget's + * refresh button forces an immediate redraw. + * + * Exported for the system broadcasts; an extra redraw triggered by another app + * is harmless. */ 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. + 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) // 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,19 @@ class WidgetUpdateReceiver : BroadcastReceiver() { } } } + + companion object { + /** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */ + const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" + + private val HANDLED_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 4e1b088..0629d39 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,9 @@ package de.jeanlucmakiola.calendula.widget.agenda +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 +12,20 @@ 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] only cancels the + * alarm if no month widget is left either, so removing one kind never stops + * the other from rolling over. + */ + override fun onDisabled(context: Context) { + super.onDisabled(context) + WidgetRolloverScheduler.sync(context) + } } 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..5919d58 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,9 @@ package de.jeanlucmakiola.calendula.widget.month +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 +11,20 @@ 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] only cancels the + * alarm if no agenda widget is left either, so removing one kind never stops + * the other from rolling over. + */ + override fun onDisabled(context: Context) { + super.onDisabled(context) + 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..23459c9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt @@ -0,0 +1,141 @@ +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.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`() { + // The alarm has just fired and is re-arming: it must move a whole day on, + // otherwise the widget would wake 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 + var probe = LocalDateTime.parse("2026-03-28T00:00:00").toInstant(zone) + val end = LocalDateTime.parse("2026-03-31T00: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 skips 02:00-03:00 on 29 March 2026, so that day is 23h long. + val now = at("2026-03-28T12: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) + } + + @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, + ) + } + + @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. + // 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 local = next.toLocalDateTime(havana) + assertThat(local.date.toString()).isEqualTo("2026-03-08") + assertThat(local.hour).isEqualTo(1) + assertThat(local.minute).isEqualTo(0) + } + + // --- timezone changes ---------------------------------------------------- + + @Test + fun `the same instant rolls over at different times in different zones`() { + // Flying east and getting 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) + } + + @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) + } +}