Compare commits

..

2 Commits

Author SHA1 Message Date
7e7079df00 Widgets: close the rest of the rollover gaps (#228)
Review follow-up on the midnight alarm.

The month widget could still show the wrong month after the rollover, and by way
of the exact workaround #228's reporter described. ShiftMonthAction stored an
absolute index on every tap, so paging forward and back — which looks like a
no-op and was how people forced a redraw — silently pinned the widget to the
month that was current at the time. It kept redrawing correctly and kept drawing
August into September, today's circle nowhere on the grid. Landing back on the
current month now clears the key instead of writing it, so the widget goes back
to following the date. Paging somewhere else and staying there is left alone;
that one is a choice, and the today button undoes it.

The alarm also had no way back once something dropped it without telling us — a
force-stop, a battery-restricted transition, an OEM freeze — short of the user
opening the app. onUpdate is the one wake-up the system still owns through
updatePeriodMillis, so both receivers re-arm from it. That doubles as a window
narrower: setAndAllowWhileIdle's delivery slack scales with how far out the alarm
was set, so re-arming every half hour keeps midnight's window at minutes rather
than hours.

Boot and package-replace now re-arm and stop there. The host sends
APPWIDGET_UPDATE after both, so the redraw was a second pair of wide provider
reads and RemoteViews serialisations in a cold process, exactly when the device
is busiest.

Smaller things: the unreachable lookahead fallback armed 5 seconds out, which
would have been a wake-loop rather than the late redraw the comment claimed —
an hour now. The action guard's comment was copied from ReminderScheduleReceiver
along with its claim that the broadcasts are protected; PROVIDER_CHANGED is not,
and the real reason the guard is worth having is that the receiver must stay
exported. Application start does its sync off the main thread now.

Tests moved onto frozen historical transitions — Berlin 2024 both ways, Havana
2018 — since the 2026 dates they used depend on DST rules that can still change
under a tzdata bump. The Berlin "fall back" case never repeated midnight, so it
now says what it actually checks (a 25-hour day must not overshoot) and a real
repeated-midnight zone, pre-2019 Sao Paulo, pins the known limitation instead of
implying it is handled. Dropped the two assertions that restated the code, and
added the one that was missing: that the receiver's action guard admits the
action the alarm is sent with, which is the single point where the whole thing
would fail silently.
2026-08-27 20:17:22 +02:00
809013997d 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.
2026-08-27 19:58:49 +02:00
15 changed files with 103 additions and 397 deletions

View File

@@ -16,16 +16,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
flight into another timezone. Paging the month widget forward and back also 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 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]). 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
dialog closed, the edit screen stayed put, and saving again just repeated it.
Android can only attach a single-occurrence change to its series once the
calendar has been synced at least once, so on those calendars the change had
nowhere to go. Calendula now removes that one occurrence from the series and
saves the edit as its own event instead, which is what you see either way. A
save that does fail also says so for longer, rather than flashing past
([#234]).
## [2.19.3] — 2026-08-22 ## [2.19.3] — 2026-08-22
@@ -1492,4 +1482,3 @@ automatically, with zero telemetry and no internet permission.
[#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 [#228]: https://codeberg.org/jlmakiola/calendula/issues/228
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234

View File

@@ -51,8 +51,11 @@
# 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
# Belt and braces one level up: the two receivers are nearly as alike, and the # Belt and braces one level up: MonthWidgetReceiver and AgendaWidgetReceiver are
# provider map is keyed off the receiver component too. Redundant today (AGP's # nearly as alike (same supertype, same overrides, only a differing property
# manifest-derived rules cover them), but #89 cost a release to diagnose and the # initializer), and Glance's provider map is keyed off the receiver component
# guarantee should not rest on a component staying in the manifest. # too. AGP's manifest-derived keep rules already cover them, and the rule above
# keeps the two widgets distinct enough that the receivers' constructors differ
# so this is redundant today. It is here because #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 -keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver

View File

@@ -330,12 +330,13 @@
</receiver> </receiver>
<!-- 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),
The day boundary arrives as the app's own ROLLOVER alarm (#228), by and the day boundary arrives as the app's own ROLLOVER alarm (#228),
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is delivered by an explicit PendingIntent so it needs no filter here.
a free extra only, since Android 8+ withholds it from manifest DATE_CHANGED is kept as a free extra only — it is not an exempted
receivers. The four below re-arm that alarm: TIME_SET / implicit broadcast, so a manifest-declared receiver is not given it
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe 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. --> Exported: the system broadcasts arrive from outside the app. -->
<receiver <receiver
android:name=".widget.WidgetUpdateReceiver" android:name=".widget.WidgetUpdateReceiver"

View File

@@ -50,9 +50,11 @@ class CalendulaApp : Application() {
/** /**
* Re-arm the widgets' midnight rollover from whatever is actually placed * Re-arm the widgets' midnight rollover from whatever is actually placed
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm * (#228). Idempotent, and it covers the cases no broadcast reaches — an
* dropped by a force-stop is armed again the next time the app is opened. * install upgrading into the fix, or an alarm dropped by a force-stop, is
* Off the main thread: a handful of binder calls on every process start. * armed again the next time the app is opened. Off the main thread because
* it makes a handful of binder calls and every process start runs it,
* including ones a worker or a receiver triggered.
*/ */
private fun reconcileWidgetRollover() { private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {

View File

@@ -232,15 +232,10 @@ interface CalendarDataSource {
): Long ): Long
/** /**
* Change a single occurrence of a recurring event at [beginMillis] (the * Change a single occurrence of a recurring event by inserting a
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the * modified-occurrence exception at [beginMillis] (the occurrence's
* `Events._ID` of the row now holding them. * `Instances.BEGIN`) carrying [form]'s values; returns the exception
* * row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
* A series with a `_sync_id` gets a modified-occurrence exception. One
* without gets the occurrence excluded from the parent via EXDATE plus a
* standalone event carrying the edits — an exception cannot link to its
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
* [allDayReminderTimeMinutes]: see [insertEvent].
*/ */
fun updateOccurrence( fun updateOccurrence(
eventId: Long, eventId: Long,
@@ -1199,13 +1194,6 @@ class AndroidCalendarDataSource @Inject constructor(
form: EventForm, form: EventForm,
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
): Long { ): Long {
val row = querySeriesRow(eventId)
// Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means
// something on a row that recurs, so a non-recurring one keeps the
// exception path rather than getting a recurrence set written onto it.
if (row.syncId == null && !row.rrule.isNullOrBlank()) {
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
}
// The provider clones the series row and applies these values on top. // The provider clones the series row and applies these values on top.
val values = buildOccurrenceExceptionValues( val values = buildOccurrenceExceptionValues(
form = form, form = form,
@@ -1224,89 +1212,6 @@ class AndroidCalendarDataSource @Inject constructor(
return exceptionId return exceptionId
} }
/**
* "Edit only this event" on a series with **no `_sync_id`**: drop the
* occurrence from the parent with EXDATE and insert the edited values as a
* standalone event on the same calendar.
*
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
* exactly like the cancelled one [deleteOccurrence] documents; with no
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
* EXDATE plus a standalone row needs no link — what a detached instance
* degrades to without a `RECURRENCE-ID` to carry it.
*
* The detached row keeps no stored link back to its series, so: it no longer
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
* children, and this is neither); its EXDATE hole is an absolute instant, so
* re-timing the whole series brings the occurrence back beside the copy (a
* #47 delete resurrects the same way); and it is built from the form, not
* cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
* preserves are dropped — the same limitation as [moveEvent].
*
* Insert first, so a failure leaves the series untouched
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
* EXDATE update then fails, since it would be a visible duplicate. The
* reverse order risks the worse outcome — an excluded occurrence with no
* replacement, i.e. an edit that quietly deletes.
*/
private fun detachOccurrence(
eventId: Long,
beginMillis: Long,
row: SeriesRow,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
// Already detached (or deleted) from a stale screen still pointing at the
// parent: the EXDATE merge would fold the repeat away and still report a
// changed row, quietly leaving a *second* standalone copy.
if (exdateContains(row.exdate, beginMillis, isAllDay = row.allDay != 0)) {
throw NoSuchEventException(eventId)
}
// Reminders, guests and colour come along like any new event, and so does
// a fresh UID — the detached row is a separate event now, and sharing the
// parent's would collide with it in .ics restore dedup.
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
// Rows touched, not occurrences excluded — 1 whenever the series row still
// exists. It catches the row disappearing under us, not an EXDATE the
// provider's expansion fails to match.
val updatedRows = try {
resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
} catch (t: Throwable) {
rollBackDetached(detachedId)
throw t
}
if (updatedRows == 0) {
rollBackDetached(detachedId)
throw WriteFailedException(
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
)
}
return detachedId
}
/**
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
* update failed. Best effort: the caller is already throwing, and the worst
* case is the duplicate we were avoiding — never a lost occurrence.
*/
private fun rollBackDetached(detachedId: Long) {
runCatching { deleteEvent(detachedId) }.onFailure {
Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
}
}
override fun updateEventFromOccurrence( override fun updateEventFromOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,

View File

@@ -243,21 +243,6 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color)) putAll(eventColorColumns(form.colorKey, form.color))
} }
/**
* The form as a **detached occurrence**: the same edited values, with the
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
*
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
* exception row can't attach to its parent at all (Codeberg #234).
*
* The exception path gets the rule dropped for free — the provider clears the
* RRULE it cloned when an exception carries DTSTART + DURATION
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
* by hand; leaving it on would insert a second *series* overlapping the first.
*/
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
/** /**
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it * Raw provider snapshot of a master/one-off Events row, enough to re-insert it
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID` * verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
@@ -450,23 +435,6 @@ internal fun buildOccurrenceExdateValues(
) )
} }
/**
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
* — i.e. it has already been dropped from the series, deleted or detached.
*
* Guards the detach path against running twice from a stale screen: the EXDATE
* merge folds the repeat away silently and the update still reports one row
* changed, so a second save would leave a second standalone copy.
*/
internal fun exdateContains(
existingExdate: String?,
occurrenceMillis: Long,
isAllDay: Boolean,
): Boolean {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
return existingExdate?.split(',')?.any { it.trim() == stamp } == true
}
/** /**
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms * One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date * are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date

View File

@@ -64,7 +64,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -265,16 +264,13 @@ fun EventEditScreen(
viewModel.reset() viewModel.reset()
onSaved() onSaved()
} }
// A failed save leaves the form looking unchanged, so the snackbar is
// the only sign anything happened — long rather than the default
// flash (Codeberg #234: it read as "nothing happens at all").
SaveUiState.Failed -> { SaveUiState.Failed -> {
viewModel.consumeSaveResult() viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long) snackbarHostState.showSnackbar(saveFailedMessage)
} }
SaveUiState.NeedsPermission -> { SaveUiState.NeedsPermission -> {
viewModel.consumeSaveResult() viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long) snackbarHostState.showSnackbar(writeDeniedMessage)
} }
// AwaitingScope/AwaitingConflict/Gone render as dialogs below. // AwaitingScope/AwaitingConflict/Gone render as dialogs below.
else -> Unit else -> Unit

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.edit package de.jeanlucmakiola.calendula.ui.edit
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -57,8 +56,6 @@ import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "EventEdit"
/** /**
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources * Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the * want different reminder handling (#49), and differ in whether they own the
@@ -754,15 +751,7 @@ class EventEditViewModel @Inject constructor(
throw e throw e
} catch (e: SecurityException) { } catch (e: SecurityException) {
SaveUiState.NeedsPermission SaveUiState.NeedsPermission
} catch (e: NoSuchEventException) {
// The event or occurrence is already gone: the same answer the
// pre-check gives, and better than a bare "couldn't save".
SaveUiState.Gone
} catch (e: Exception) { } catch (e: Exception) {
// The user only gets a generic snackbar, so without this a failed
// write leaves nothing to report (Codeberg #234). Scope and event
// id only — never the form's content.
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
SaveUiState.Failed SaveUiState.Failed
} }
} }

View File

@@ -23,21 +23,26 @@ import kotlin.time.Instant
* Holds the app's own wake-up for the next local midnight, so the home-screen * 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). * widgets roll "today" over on the day boundary (#228).
* *
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted * The widgets used to lean on `ACTION_DATE_CHANGED`, but that broadcast is not
* implicit broadcast a manifest-declared receiver has not been given it since * on the implicit-broadcast exemption list, so a manifest-declared receiver has
* Android 8, leaving only the throttled `updatePeriodMillis`. * 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, the same * Exactly one alarm exists at a time and every firing re-arms the next one, the
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler]. * same shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and * It is deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
* survives doze (plain `set` does not), a rollover a few minutes late is * survives doze (which plain `set` does not), and a rollover that lands a few
* invisible on a sleeping screen, and exact alarms stay reserved for snooze. * minutes late is invisible on a sleeping screen. Exact alarms stay reserved for
* reminder snooze.
*/ */
object WidgetRolloverScheduler { object WidgetRolloverScheduler {
/** /**
* Fire just *after* midnight: an alarm delivered a few milliseconds early * Fire just *after* midnight, never exactly on it. An alarm delivered a few
* would still read the old date and re-arm for an instant later. * 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 internal val ROLLOVER_SLACK = 5.seconds
@@ -63,14 +68,16 @@ object WidgetRolloverScheduler {
/** /**
* The instant just after the next local midnight following [now] in [zone]. * 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 * Uses the *actual* start of the day rather than 00:00, so it stays correct
* midnight never happens (Havana) and where a date-line move skips a whole * where a DST jump means midnight never happens (Havana springs from 00:00 to
* local date (Apia, December 2011) — the loop walks on to the next real day. * 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.
* *
* The mirror case a zone rewinding *across* midnight, so the day starts * The mirror case, a zone that rewinds *across* midnight so the day starts
* twice resolves to the earlier start and runs an hour ahead of the clock. * twice, resolves to the earlier start; the widget would then run an hour
* No live tz entry does that (Brazil dropped DST in 2019) and * ahead of the clock. No entry in the current tz database does that (Brazil,
* `updatePeriodMillis` covers it, so it isn't worth state to detect. * which used to, dropped DST in 2019), and `updatePeriodMillis` covers it,
* so it is not worth carrying state to detect.
*/ */
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant { fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date val date = now.toLocalDateTime(zone).date

View File

@@ -19,31 +19,38 @@ import kotlinx.coroutines.launch
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] — * - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
* the day boundary, so "today" highlighting and the agenda's past-event * the day boundary, so "today" highlighting and the agenda's past-event
* dimming move on (#228). * dimming move on (#228).
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and* * - `TIME_SET` / `TIMEZONE_CHANGED` — a clock or zone change moves the day
* re-arm. * boundary relative to the armed alarm, so both redraw *and* re-arm.
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the * - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms. The
* latter is also what arms installs upgrading into the fix. * package-replaced one is also what arms existing installs that upgrade into
* the fix without re-adding their widget.
* *
* `DATE_CHANGED` is a free extra in the filter that nothing depends on — see * `DATE_CHANGED` is still in the manifest filter as a free extra, but nothing
* [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the * depends on it: it is not an exempted implicit broadcast, so a manifest-declared
* provider XML and the month widget's refresh button. * 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 from another app is * Exported for the system broadcasts; an extra redraw triggered by another app
* harmless. * is harmless.
*/ */
class WidgetUpdateReceiver : BroadcastReceiver() { class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
// Exported, so anything can reach it with an explicit intent. Nothing // The receiver has to stay exported for the system broadcasts, so an
// here crosses a trust boundary, but narrowing to the actions we asked // explicit intent can reach it with anything in it. Nothing here reads
// for keeps a stray broadcast from costing two wide provider reads. // the intent's data and nothing crosses a trust boundary, but narrowing
// to the actions we actually asked for keeps a stray broadcast from
// costing two wide provider reads.
if (intent.action !in HANDLED_ACTIONS) return if (intent.action !in HANDLED_ACTIONS) return
val appContext = context.applicationContext val appContext = context.applicationContext
// Re-arm first, so the next day boundary is covered whatever the redraw // Re-arm first: whatever happens to the redraw, the next day boundary is
// does. Every handled action either dropped, consumed or invalidated it. // covered. Boot and package-replace dropped the alarm outright; a
// rollover just consumed it; a clock change invalidated it.
WidgetRolloverScheduler.sync(appContext) WidgetRolloverScheduler.sync(appContext)
// The host sends APPWIDGET_UPDATE after both of these anyway, so // Boot and package-replace only cost us the alarm. The host sends
// redrawing here would only repeat the work in a cold process, at the // APPWIDGET_UPDATE after both anyway, so redrawing here would just repeat
// moment the device is most contended. // two wide provider reads and two RemoteViews serialisations in a cold
// process, at the moment the device is most contended.
if (intent.action in REARM_ONLY_ACTIONS) return if (intent.action in REARM_ONLY_ACTIONS) return
val pending = goAsync() val pending = goAsync()
// Calendar data may have changed (sync / our own write) — drop the cached // Calendar data may have changed (sync / our own write) — drop the cached

View File

@@ -21,8 +21,9 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
} }
/** /**
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if * Last agenda widget removed. [WidgetRolloverScheduler.sync] only cancels the
* no month widget is left either. * alarm if no month widget is left either, so removing one kind never stops
* the other from rolling over.
*/ */
override fun onDisabled(context: Context) { override fun onDisabled(context: Context) {
super.onDisabled(context) super.onDisabled(context)

View File

@@ -153,11 +153,13 @@ class ShiftMonthAction : ActionCallback {
updateAppWidgetState(context, glanceId) { prefs -> updateAppWidgetState(context, glanceId) { prefs ->
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone()) val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
val next = cur + delta val next = cur + delta
// Landing back on the current month clears the key, so the widget // Landing back on the current month clears the key rather than
// goes back to *following* the date rather than being pinned to // storing today's index, so the widget goes back to *following* the
// whichever month was current at the tap. Paging out and back is the // date instead of being pinned to the month that happened to be
// workaround #228's reporter used, and pinning it there would have // current when it was tapped. Paging forward and back is the very
// stuck them on that month once it stopped being the current one. // workaround #228's reporter used to force a redraw; storing the
// index there would have left them stuck on that month for good once
// it stopped being the current one.
if (next == currentMonthIndex(systemZone())) { if (next == currentMonthIndex(systemZone())) {
prefs.remove(MONTH_INDEX_KEY) prefs.remove(MONTH_INDEX_KEY)
} else { } else {

View File

@@ -20,8 +20,9 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
} }
/** /**
* Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if * Last month widget removed. [WidgetRolloverScheduler.sync] only cancels the
* no agenda widget is left either. * alarm if no agenda widget is left either, so removing one kind never stops
* the other from rolling over.
*/ */
override fun onDisabled(context: Context) { override fun onDisabled(context: Context) {
super.onDisabled(context) super.onDisabled(context)
@@ -30,10 +31,11 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
/** /**
* The `updatePeriodMillis` backstop is the one wake-up the *system* still * 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 * owns, so it doubles as the rollover alarm's self-heal: anything that drops
* pending alarm without a broadcast (force-stop, battery restriction, an OEM * a pending alarm without a broadcast — a force-stop, a battery-restricted
* freeze) is repaired here rather than on the next app open. Re-arming * transition, an OEM freeze is repaired here rather than waiting for the
* closer to midnight also narrows the inexact delivery window. * app to be opened. Re-arming closer to midnight also narrows the inexact
* alarm's delivery window, which scales with how far out it was set.
*/ */
override fun onUpdate( override fun onUpdate(
context: Context, context: Context,

View File

@@ -558,174 +558,6 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1) assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
} }
// --- toDetachedOccurrence ("edit only this event", no _sync_id) ---
@Test
fun `a detached occurrence drops the series rule and becomes a one-off row`() {
val edited = form().copy(title = "Moved", rrule = "FREQ=WEEKLY;BYDAY=TH")
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
// A surviving rule would insert a second *series* overlapping the first
// (Codeberg #234's stray duplicate).
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
// A one-off row carries DTEND rather than a duration.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
}
@Test
fun `a detached occurrence carries every edited field onto the new row`() {
// Built from the form, not cloned from the parent: a field dropped here
// is an edit silently lost.
val edited = form(timezone = "America/New_York").copy(
title = " Standup ",
location = "Room 2",
description = "notes",
reminders = listOf(10),
availability = Availability.Free,
accessLevel = AccessLevel.Private,
rrule = "FREQ=DAILY",
)
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Standup")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Room 2")
assertThat(values[CalendarContract.Events.DESCRIPTION]).isEqualTo("notes")
assertThat(values[CalendarContract.Events.AVAILABILITY])
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
// The pinned zone survives — never re-anchored to the device.
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
// Reminders aren't columns; the insert path seeds them from the form.
assertThat(detached.reminders).containsExactly(10)
}
@Test
fun `a detached all-day occurrence stays on UTC midnights`() {
val edited = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
).copy(title = "Birthday", rrule = "FREQ=YEARLY")
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
// Exclusive DTEND — the next UTC midnight.
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_222_400_000L)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
}
@Test
fun `the detached row lands exactly where the parent's exdate removes it`() {
// The two halves must agree on the instant, or the user sees the
// occurrence twice or not at all.
val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY")
val occurrenceMillis = 1_781_164_800_000L
val parent = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = occurrenceMillis,
dtStartMillis = 1_780_560_000_000L,
rrule = "FREQ=WEEKLY",
duration = "P5400S",
timezone = "Europe/Berlin",
allDay = 0,
)
val detached = edited.toDetachedOccurrence()
val inserted = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611T080000Z")
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(occurrenceMillis)
// The parent keeps its own anchor and rule — only this occurrence leaves.
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `a detached all-day occurrence matches its date-only exdate stamp`() {
val parent = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z
dtStartMillis = 1_749_600_000_000L,
rrule = "FREQ=YEARLY",
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
val detached = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
).copy(rrule = "FREQ=YEARLY").toDetachedOccurrence()
val inserted = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611")
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
}
// --- exdateContains (guards a second detach of the same occurrence) ---
@Test
fun `an occurrence already excluded is recognised, timed and all-day`() {
// Detaching twice would leave a second standalone copy.
assertThat(
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false),
).isTrue()
assertThat(
exdateContains("20260611", 1_781_136_000_000L, isAllDay = true),
).isTrue()
}
@Test
fun `an occurrence not in the exdate list is not mistaken for an excluded one`() {
assertThat(exdateContains(null, 1_781_164_800_000L, isAllDay = false)).isFalse()
assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false)).isFalse()
// A neighbouring occurrence must not match — the guard is per-instant.
assertThat(
exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false),
).isFalse()
}
@Test
fun `an exclusion is found anywhere in a multi-entry exdate list`() {
// Whitespace after a comma is legal in the stored column.
assertThat(
exdateContains(
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
1_781_164_800_000L,
isAllDay = false,
),
).isTrue()
}
// --- per-event colour --- // --- per-event colour ---
@Test @Test

View File

@@ -44,8 +44,8 @@ class WidgetRolloverSchedulerTest {
@Test @Test
fun `at midnight exactly the target is the next day, never the current instant`() { 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 // The alarm has just fired and is re-arming: it must move a whole day on,
// itself in a tight loop. // otherwise the widget would wake itself in a tight loop.
val now = at("2026-08-28T00:00:00", berlin) val now = at("2026-08-28T00:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin)) assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
@@ -63,8 +63,8 @@ class WidgetRolloverSchedulerTest {
@Test @Test
fun `the result is always in the future for every minute of a day`() { fun `the result is always in the future for every minute of a day`() {
val zone = berlin val zone = berlin
// Spans Berlin's 2024 spring-forward: the likeliest source of a target // Spans Berlin's 2024 spring-forward, the case most likely to produce a
// in the past, i.e. an alarm that fires immediately, forever. // target in the past and so an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone) var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone) val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) { while (probe < end) {
@@ -87,8 +87,9 @@ class WidgetRolloverSchedulerTest {
@Test @Test
fun `fall back does not overshoot into the repeated hour`() { fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so // Berlin repeated 02:00-03:00 on 27 October 2024: midnight itself is
// "now + 24h" would land at 23:00 on the 26th and never roll over. // unambiguous, but the day is 25h long, so "now + 24h" would land at
// 23:00 on the 26th and never roll the date over at all.
val now = at("2024-10-26T12:00:00", berlin) val now = at("2024-10-26T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin)) assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
@@ -98,8 +99,9 @@ class WidgetRolloverSchedulerTest {
@Test @Test
fun `a zone that repeats midnight takes the first start of day`() { 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 // 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 // began twice. Pinned deliberately: the widget then runs an hour ahead
// ahead until the next redraw. No live zone does this since 2019. // of the clock until the next redraw, which is the accepted trade
// (Brazil dropped DST in 2019, so no live zone does this).
val saoPaulo = TimeZone.of("America/Sao_Paulo") val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt( val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo, at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
@@ -130,8 +132,8 @@ class WidgetRolloverSchedulerTest {
@Test @Test
fun `the same instant rolls over at different times in different zones`() { fun `the same instant rolls over at different times in different zones`() {
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic // Flying east and getting TIMEZONE_CHANGED must re-arm to the new local
// follows the zone, not a cached offset. // midnight — the arithmetic follows the zone, not a cached offset.
val instant = at("2026-08-27T12:00:00", berlin) val instant = at("2026-08-27T12:00:00", berlin)
val tokyo = TimeZone.of("Asia/Tokyo") val tokyo = TimeZone.of("Asia/Tokyo")
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin) val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)