Widgets: wake at midnight instead of waiting to be poked (#228)
Both home-screen widgets kept highlighting yesterday as "today" after the date changed — and the agenda kept dimming events against yesterday — until the user paged the month arrows or removed and re-added the widget. The data layer was never the problem: the month cache guard already drops its window as soon as the anchor date differs, which is exactly why an arrow tap fixed it instantly. What was missing was anything to *trigger* a redraw at the day boundary. The manifest asked for DATE_CHANGED and the receiver's KDoc 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 — this has never worked on any device the app supports. That left only updatePeriodMillis, which the system defers in doze and OxygenOS-style skins throttle harder still; the reporter's is a 30-minute backstop that in practice never ran. So the app now holds its own wake-up. WidgetRolloverScheduler arms a single alarm for just after the next local midnight and every firing re-arms the next, the same shape as ReminderAlarmScheduler. It is deliberately inexact: setAndAllowWhileIdle needs no permission and survives doze, and a rollover a few minutes late is invisible on a sleeping screen — exact alarms stay reserved for reminder snooze. The target is the *actual* start of the day rather than a literal 00:00, so it stays right in Havana, where DST means midnight does not happen, and it walks on past a whole date skipped by a date-line move. The alarm is only armed while a widget is actually placed: onEnabled/onDisabled on both Glance receivers re-sync it, and sync() cancels only when neither kind is left, so removing the month widget never stops the agenda one rolling over. WidgetUpdateReceiver re-arms on everything that can invalidate the alarm — boot and package-replace wipe it, a clock or timezone change moves the boundary it was aimed at — and CalendulaApp does the same on start, which is what arms existing installs that upgrade into this without re-adding their widget. It also gained an action allowlist, matching ReminderScheduleReceiver: it is exported and the broadcasts it takes are protected. DATE_CHANGED stays in the filter as a free extra for any OEM that does deliver it, but the docs no longer claim anything depends on it. Adding the two overrides makes the receivers as structurally alike as the widgets they wrap, which is how #89 collapsed Glance's provider map, so the keep rule now covers GlanceAppWidgetReceiver as well. Verified in the releaseTest mapping: all four classes keep their real names. Tests cover the arithmetic that decides when to wake — ordinary days, the re-arming instant itself, both DST transitions, a zone whose midnight does not exist, a half-hour offset, and the same instant seen from two zones. Closes #228.
This commit is contained in:
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [2.19.3] — 2026-08-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1471,3 +1479,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||||
|
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||||
|
|||||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@@ -50,3 +50,10 @@
|
|||||||
# the real names also survives app updates, which would otherwise renumber the
|
# the real names also survives app updates, which would otherwise renumber the
|
||||||
# obfuscated name and orphan the stored mapping.
|
# obfuscated name and orphan the stored mapping.
|
||||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
-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
|
||||||
|
|||||||
@@ -331,8 +331,13 @@
|
|||||||
|
|
||||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||||
PROVIDER_CHANGED on any data change (our writes and external sync),
|
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||||
and the system broadcasts the date/time ones at midnight / clock
|
and the day boundary arrives as the app's own ROLLOVER alarm (#228),
|
||||||
changes so "today" highlighting rolls over. -->
|
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. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.WidgetUpdateReceiver"
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -346,6 +351,8 @@
|
|||||||
<action android:name="android.intent.action.DATE_CHANGED" />
|
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||||
<action android:name="android.intent.action.TIME_SET" />
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -44,6 +45,11 @@ class CalendulaApp : Application() {
|
|||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
reconcileCalendarVisibility()
|
reconcileCalendarVisibility()
|
||||||
startReminderDelivery()
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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<AlarmManager>() ?: 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
|
||||||
|
}
|
||||||
@@ -12,19 +12,40 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.launch
|
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,
|
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
||||||
* so it covers both the app's own writes and external sync.
|
* so it covers both the app's own writes and external sync.
|
||||||
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
|
||||||
* and the upcoming window roll over at midnight / on a clock change.
|
* 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
|
* `DATE_CHANGED` is still in the manifest filter as a free extra, but nothing
|
||||||
* XML, and the month widget's refresh button forces an immediate redraw.
|
* 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() {
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
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 pending = goAsync()
|
||||||
val appContext = context.applicationContext
|
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
|
// Calendar data may have changed (sync / our own write) — drop the cached
|
||||||
// month window so the widgets reload fresh. Month paging does NOT call
|
// month window so the widgets reload fresh. Month paging does NOT call
|
||||||
// this, so arrow taps stay instant.
|
// 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
* 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() {
|
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host-facing receiver for the month widget. Declared in the manifest with the
|
* 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() {
|
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user