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:
2026-08-27 19:58:49 +02:00
parent a2172dce12
commit 809013997d
9 changed files with 362 additions and 7 deletions

View File

@@ -331,8 +331,13 @@
<!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync),
and the system broadcasts the date/time ones at midnight / clock
changes so "today" highlighting rolls over. -->
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. -->
<receiver
android:name=".widget.WidgetUpdateReceiver"
android:exported="true">
@@ -346,6 +351,8 @@
<action android:name="android.intent.action.DATE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" />
<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>
</receiver>