Compare commits

..

2 Commits

Author SHA1 Message Date
Jean-Luc Makiola
cf72dce0e7 fix: editing a single occurrence does nothing on unsynced calendars (#234) (#245)
## What was wrong

"Edit only this event" wrote a modified-occurrence exception unconditionally. That is the right shape for a synced series and the wrong one for everything else: an exception attaches to its parent through `ORIGINAL_SYNC_ID`, so on a series row with no `_sync_id` the link never forms — the insert fails or lands an orphan, the generic catch in `EventEditViewModel.performSave` turns it into a snackbar, and the scope dialog just closes again. To the reporter that read as "nothing happens, ever", with a stray copy of the event the one time the insert did land.

This is the same constraint `deleteOccurrence` has documented since #47, and the same calendars: a local calendar, Calendula's own contact special-date calendars, and — the reporter's case — a Google calendar whose rows the sync adapter has not stamped yet, which is exactly why it "shows as on-device". `deleteOccurrence` got the `_sync_id` guard in 4fea176; `updateOccurrence` never did.

## What changed

`updateOccurrence` now branches on `_sync_id` the way `deleteOccurrence` does.

- **Synced series**: the exception path, untouched. Its write shape is load-bearing and on-device verified (#16, #47).
- **No `_sync_id`**: the occurrence is excluded from the parent via EXDATE and the edited values are inserted as a standalone event on the same calendar — a detached instance, minus the `RECURRENCE-ID` the provider has no way to store here.

Two things shape the write. The parent update reuses `buildOccurrenceExdateValues` unchanged, so it keeps carrying the whole time/recurrence set — an EXDATE-only update is not a recurrence change to the provider and leaves the expanded instances standing (#47's first quirk). And the form's RRULE is stripped before the insert: the exception path gets an inherited rule cleared for free by DTSTART + DURATION, but nothing clears one here, so leaving it would insert a second *series* overlapping the first.

Ordering is chosen for the failure cases. The insert runs first, so a failure there leaves the series completely untouched — the discipline `updateEventFromOccurrence` already follows. If the EXDATE update then fails, the new row is a visible duplicate of an occurrence still in the series, so it is rolled back (best effort) before the failure surfaces. The reverse order could strand an occurrence excluded from its series with nothing standing in for it, turning an edit into a silent delete.

A second detach of the same occurrence is refused rather than silently making a second copy (reachable from a stale detail screen: the EXDATE merge folds the repeat away and the update still reports a changed row). `NoSuchEventException` from a write now maps to the same "no longer exists" state the pre-check already gives.

Smaller, in the same area: a failed save was invisible precisely because this bug was — the failure snackbar gets the long duration instead of a flash, and the catch logs the scope and event id (never the form's content) so a failure leaves something to report.

## What this costs

The detached row has no stored link back to its series — that is the whole reason the path exists — and the KDoc now says so plainly. A whole-series delete leaves it standing where an exception row would have gone with the parent; a calendar move leaves it behind; a series-wide *time* edit moves the generated instances but not the absolute-instant EXDATE hole, so the occurrence returns alongside the copy; and building the row from the form rather than cloning the parent drops `ORGANIZER`, `STATUS` and the organizer/resource attendee rows, exactly as `moveEvent` does.

The EXDATE staleness is not new — a #47 delete resurrects the same way after a series time edit — but a duplicate is a louder symptom than a resurrection. It wants fixing at the series-update end (re-stamping EXDATE alongside the DTSTART shift in `buildEventUpdateValues`, and carrying surviving stamps into the split series in `updateEventFromOccurrence`), which is a change to the "all events" path for *every* calendar type and does not belong in a targeted fix. Worth its own issue.

## How it was verified

- `./gradlew :app:testDebugUnitTest` — BUILD SUCCESSFUL, 62 suites, 0 failures.
- `./gradlew :app:lintDebug` — BUILD SUCCESSFUL, no new findings.
- `./gradlew :app:assembleDebug` — BUILD SUCCESSFUL.
- Independent adversarial code review, whose findings drove the second commit (the double-detach guard, the corrected rollback claim, and the cost documentation above). It confirmed no interleaving loses an occurrence, and cleared the drag-to-reschedule path: `RescheduleViewModel.undoFor` returns null for a recurring single-occurrence move, so the changed return value (a new event id rather than an exception id) never reaches the undo machinery.

New JVM tests cover the pure halves: the rule is dropped and the row becomes a one-off with DTEND, every edited field survives onto the inserted columns, all-day stays on UTC midnights, the detached row's DTSTART agrees with the EXDATE stamp that removes it from the parent (timed and all-day), and `exdateContains` recognises an already-excluded occurrence without matching a neighbouring one.

## What still needs a device

The provider behaviour itself cannot be confirmed on the JVM — `AndroidCalendarDataSource` has no fake-resolver harness, so `detachOccurrence`'s branch, its insert-then-EXDATE ordering and its rollback have no unit coverage. On a device, on a **local or unsynced** calendar:

1. The reported case end to end: recurring series, edit one occurrence's title, "Only this event" — the edit sticks, that occurrence alone changes, and the rest of the series survives (the #47 collapse must not reappear).
2. The same for an **all-day** yearly series (a contact birthday calendar is the natural subject) — the date-only EXDATE form excludes the right day, not the one before it.
3. A series pinned to a **non-device timezone**, and an occurrence across a **DST boundary** — the hole and the detached row must land on the same instant.
4. Editing the occurrence's **time**, not just its title, and editing the **first** occurrence of a series (DTSTART then points at an excluded instant — expected to be fine, same property the #47 delete path already has, but untested).
5. Reminders and guests on the detached row, and a colour from an account palette.
6. Regression on a **DAVx5 / Google synced** calendar: "Only this event" must still go down the exception path and behave exactly as before.
7. Drag-to-reschedule a single occurrence on an unsynced series — same path, different caller.

Closes #234

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/245
2026-08-27 20:43:42 +02:00
Jean-Luc Makiola
398cfc8906 Roll the home-screen widgets over at midnight (#228) (#246)
The month and agenda widgets did not roll over at midnight. They kept highlighting yesterday as "today", and the agenda kept dimming events against yesterday, until the user paged the month arrows or removed and re-added the widget.

**What was wrong**

The manifest asked for `DATE_CHANGED` and `WidgetUpdateReceiver`'s docs 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. That left only `updatePeriodMillis`, which the system defers in doze and OEM skins throttle harder still. The data layer was fine all along - the month cache guard already drops its window when the anchor date changes, which is why an arrow tap fixed it instantly.

**What changed**

- `WidgetRolloverScheduler` arms a single alarm for just after the next local midnight and re-arms on every firing, the same shape as `ReminderAlarmScheduler`. Inexact (`setAndAllowWhileIdle`): no permission, survives doze, and exact alarms stay reserved for reminder snooze. It targets the actual start of day, not a literal 00:00, so it holds where DST means midnight never happens.
- Armed only while a widget is placed, via `onEnabled`/`onDisabled` on both Glance receivers; `sync()` cancels only when neither kind is left. Re-armed from boot, package-replace, time and timezone changes, app start (which is what arms existing installs upgrading into this), and from `onUpdate`, so an alarm dropped by a force-stop or an OEM freeze heals itself.
- Paging the month widget forward and back no longer pins it to that month. `ShiftMonthAction` stored an absolute index on every tap, so the workaround people used to force a redraw quietly stranded the widget on whatever month was current at the time.
- `DATE_CHANGED` stays in the filter as a free extra, but nothing depends on it and the docs no longer claim otherwise.
- Keep rule extended to `GlanceAppWidgetReceiver`, since the two receivers are now as structurally alike as the widgets that #89 collapsed.

**Verification**

`testDebugUnitTest` (775 tests, 0 failures), `lintDebug` and `assembleDebug` all pass. `assembleReleaseTest` builds and the R8 mapping confirms `MonthWidget`, `AgendaWidget` and both receivers keep their real names. 12 new unit tests cover the next-midnight arithmetic: ordinary days, the re-arming instant itself, both DST directions on frozen historical transitions, a zone whose midnight does not exist, a half-hour offset, two zones seeing the same instant, and that the receiver's action guard admits the alarm's action.

Not verified on a device - the overnight rollover on an OxygenOS-class device is the one thing that needs a real test before release.

Closes #228.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/246
2026-08-27 20:39:18 +02:00
15 changed files with 497 additions and 117 deletions

View File

@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed ### Fixed
- **Home-screen widgets now turn the page at midnight.** Both the month and the
agenda widget kept highlighting yesterday as "today" — and the agenda kept
greying out the wrong events as already past — until you paged the month back
and forth or removed and re-added the widget. Calendula now wakes itself at the
day boundary and redraws, and re-arms after a reboot, a clock change or a
flight into another timezone. Paging the month widget forward and back also
stops quietly pinning it to that month, so it follows the date again instead of
being stranded on the month you happened to be looking at ([#228]).
- **"Only this event" now actually saves your edit.** On some calendars — - **"Only this event" now actually saves your edit.** On some calendars —
including Google ones that still show as on-device, and any local calendar — 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 editing a single occurrence of a repeating event did nothing at all: the scope
@@ -1483,4 +1491,5 @@ 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
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234 [#234]: https://codeberg.org/jlmakiola/calendula/issues/234

View File

@@ -50,3 +50,9 @@
# 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
# Belt and braces one level up: the two receivers are nearly as alike, and the
# provider map is keyed off the receiver component too. Redundant today (AGP's
# manifest-derived rules cover them), but #89 cost a release to diagnose and the
# guarantee should not rest on a component staying in the manifest.
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver

View File

@@ -330,9 +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).
and the system broadcasts the date/time ones at midnight / clock The day boundary arrives as the app's own ROLLOVER alarm (#228), by
changes so "today" highlighting rolls over. --> explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
a free extra only, since Android 8+ withholds it from manifest
receivers. The four below re-arm that alarm: TIME_SET /
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
Exported: the system broadcasts arrive from outside the app. -->
<receiver <receiver
android:name=".widget.WidgetUpdateReceiver" android:name=".widget.WidgetUpdateReceiver"
android:exported="true"> android:exported="true">
@@ -346,6 +350,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>

View File

@@ -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,19 @@ class CalendulaApp : Application() {
reconcileSpecialDates() reconcileSpecialDates()
reconcileCalendarVisibility() reconcileCalendarVisibility()
startReminderDelivery() startReminderDelivery()
reconcileWidgetRollover()
}
/**
* Re-arm the widgets' midnight rollover from whatever is actually placed
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
* dropped by a force-stop is armed again the next time the app is opened.
* Off the main thread: a handful of binder calls on every process start.
*/
private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
WidgetRolloverScheduler.sync(this@CalendulaApp)
}
} }
/** /**

View File

@@ -1200,12 +1200,9 @@ class AndroidCalendarDataSource @Inject constructor(
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
): Long { ): Long {
val row = querySeriesRow(eventId) val row = querySeriesRow(eventId)
// Deliberately stricter than deleteOccurrence's bare _sync_id check: the // Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means
// detach path drops the occurrence with EXDATE, which only means anything // something on a row that recurs, so a non-recurring one keeps the
// on a row that actually recurs. Without an RRULE there is nothing to // exception path rather than getting a recurrence set written onto it.
// exclude from, so such a row keeps the existing path rather than getting
// a recurrence set written onto a one-off event. The UI only offers the
// scope choice for a recurring event, so neither case is reachable today.
if (row.syncId == null && !row.rrule.isNullOrBlank()) { if (row.syncId == null && !row.rrule.isNullOrBlank()) {
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes) return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
} }
@@ -1233,46 +1230,24 @@ class AndroidCalendarDataSource @Inject constructor(
* standalone event on the same calendar. * standalone event on the same calendar.
* *
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`, * A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
* exactly like the cancelled one [deleteOccurrence] documents. With no * exactly like the cancelled one [deleteOccurrence] documents; with no
* `_sync_id` the link never forms: the insert either fails outright or lands * `_sync_id` the link never forms and the edit is lost (Codeberg #234).
* an orphan row, and the parent's expansion collapses — which is why editing * EXDATE plus a standalone row needs no link — what a detached instance
* one occurrence of such a series did nothing at all, and occasionally left a * degrades to without a `RECURRENCE-ID` to carry it.
* stray copy behind (Codeberg #234). The reporter's calendar was a Google one
* that "shows as on-device", i.e. rows the sync adapter had not stamped yet;
* Calendula's own local and contact special-date calendars are permanently in
* this shape.
* *
* EXDATE + a standalone row needs no parent link, and is what a detached * The detached row keeps no stored link back to its series, so: it no longer
* instance degrades to when there is no `RECURRENCE-ID` to carry it: the user * travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
* sees one edited event where the occurrence was, and the rest of the series * children, and this is neither); its EXDATE hole is an absolute instant, so
* untouched. * 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].
* *
* What that costs, plainly, because none of it is recoverable later — the * Insert first, so a failure leaves the series untouched
* detached row has *no* stored link back to its series, which is the whole * ([updateEventFromOccurrence]'s discipline); roll the new row back if the
* reason this path exists: * EXDATE update then fails, since it would be a visible duplicate. The
* - It stops travelling with the series. Moving the series to another * reverse order risks the worse outcome — an excluded occurrence with no
* calendar leaves it behind ([moveEvent] copies the master and its * replacement, i.e. an edit that quietly deletes.
* `ORIGINAL_ID` children; this is neither), and deleting the whole series
* leaves it standing where an exception row would have gone with the parent.
* - Its EXDATE hole is an absolute instant. Editing the series' *time* for all
* events moves the generated instances but not the hole, so the occurrence
* comes back alongside the detached copy. That staleness predates this path
* — a #47 delete resurrects the same way — but a duplicate is a louder
* symptom than a resurrection, and it wants fixing at the series-update end.
* - It is built from the form, not cloned from the parent, so columns the form
* doesn't model are dropped rather than inherited: `ORGANIZER`, `STATUS`,
* and the organizer/resource attendee rows [reconcileAttendees] otherwise
* preserves. Same limitation as [moveEvent]. Rare on the calendars that
* reach this path, which are locally authored, but not impossible.
*
* Order is deliberate. The insert goes first, so a failure there leaves the
* series completely untouched (the same discipline as
* [updateEventFromOccurrence]). If the EXDATE update then fails, the new row
* is a visible duplicate of an occurrence that is still in the series, so it
* is rolled back before the failure surfaces — better a save the user can
* retry than a silent duplicate. The reverse order would risk the opposite:
* an occurrence excluded from the series with no replacement, i.e. an edit
* that quietly deletes.
*/ */
private fun detachOccurrence( private fun detachOccurrence(
eventId: Long, eventId: Long,
@@ -1281,17 +1256,15 @@ class AndroidCalendarDataSource @Inject constructor(
form: EventForm, form: EventForm,
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
): Long { ): Long {
// Already detached (or deleted): the series no longer contains this // Already detached (or deleted) from a stale screen still pointing at the
// occurrence, so there is nothing here to edit. Reached from a stale // parent: the EXDATE merge would fold the repeat away and still report a
// screen still pointing at the parent — without this the EXDATE merge // changed row, quietly leaving a *second* standalone copy.
// would fold the repeat away, the update would still report a changed
// row, and the save would quietly leave a *second* standalone copy.
if (exdateContains(row.exdate, beginMillis, isAllDay = row.allDay != 0)) { if (exdateContains(row.exdate, beginMillis, isAllDay = row.allDay != 0)) {
throw NoSuchEventException(eventId) throw NoSuchEventException(eventId)
} }
// Carries the form's reminders, guests and colour like any new event // Reminders, guests and colour come along like any new event, and so does
// and a fresh UID, because the detached row really is a separate event // a fresh UID the detached row is a separate event now, and sharing the
// now: sharing the parent's would collide with it in .ics restore dedup. // parent's would collide with it in .ics restore dedup.
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes) val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
val values = buildOccurrenceExdateValues( val values = buildOccurrenceExdateValues(
existingExdate = row.exdate, existingExdate = row.exdate,
@@ -1302,10 +1275,9 @@ class AndroidCalendarDataSource @Inject constructor(
timezone = row.timezone, timezone = row.timezone,
allDay = row.allDay, allDay = row.allDay,
) )
// Rows touched, not occurrences excluded: this is 1 whenever the series // Rows touched, not occurrences excluded 1 whenever the series row still
// row still exists. It catches the row disappearing under us, not an // exists. It catches the row disappearing under us, not an EXDATE the
// EXDATE the provider's expansion fails to match — that would report // provider's expansion fails to match.
// success and leave the duplicate. Same rollback idiom as moveEvent.
val updatedRows = try { val updatedRows = try {
resolver.update( resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
@@ -1326,10 +1298,8 @@ class AndroidCalendarDataSource @Inject constructor(
/** /**
* Undo the standalone row [detachOccurrence] inserted before its EXDATE * Undo the standalone row [detachOccurrence] inserted before its EXDATE
* update failed. Best effort: the caller is already throwing, and a rollback * update failed. Best effort: the caller is already throwing, and the worst
* that itself fails must not replace the real failure with a confusing one. * case is the duplicate we were avoiding — never a lost occurrence.
* The worst case is the duplicate we were trying to avoid, which the user can
* see and delete — never a lost occurrence.
*/ */
private fun rollBackDetached(detachedId: Long) { private fun rollBackDetached(detachedId: Long) {
runCatching { deleteEvent(detachedId) }.onFailure { runCatching { deleteEvent(detachedId) }.onFailure {

View File

@@ -248,18 +248,13 @@ internal fun buildOccurrenceExceptionValues(
* series rule dropped so [buildEventInsertValues] writes a standalone one-off * series rule dropped so [buildEventInsertValues] writes a standalone one-off
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times. * row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
* *
* This is the "edit only this event" shape for a series with **no `_sync_id`**, * The "edit only this event" shape for a series with **no `_sync_id`**, where an
* where an exception row can't be used at all — see [buildOccurrenceExdateValues] * exception row can't attach to its parent at all (Codeberg #234).
* for why the parent link never forms. The occurrence is dropped from the parent
* with EXDATE and re-created as its own event, which is what a detached instance
* degrades to without a `RECURRENCE-ID` to carry it.
* *
* Dropping the rule mirrors what the exception path gets for free: the provider * The exception path gets the rule dropped for free the provider clears the
* clears the RRULE it cloned from the parent when an exception carries * RRULE it cloned when an exception carries DTSTART + DURATION
* DTSTART + DURATION ([buildOccurrenceExceptionValues]). Here nothing is cloned, * ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
* so the rule has to be stripped by hand leaving it on would insert a second * by hand; leaving it on would insert a second *series* overlapping the first.
* *series* overlapping the first, which is the duplication this path exists to
* avoid.
*/ */
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null) internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
@@ -457,14 +452,11 @@ internal fun buildOccurrenceExdateValues(
/** /**
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis] * Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
* — i.e. this occurrence has already been dropped from the series (deleted, or * — i.e. it has already been dropped from the series, deleted or detached.
* detached into its own event).
* *
* Guards the detach path against running twice for the same occurrence, which a * Guards the detach path against running twice from a stale screen: the EXDATE
* stale detail screen still pointing at the parent can otherwise reach. The * merge folds the repeat away silently and the update still reports one row
* EXDATE merge folds the repeat away silently and the parent update still * changed, so a second save would leave a second standalone copy.
* reports one row changed, so without this check the second save would leave a
* *second* standalone copy and call it a success.
*/ */
internal fun exdateContains( internal fun exdateContains(
existingExdate: String?, existingExdate: String?,

View File

@@ -265,10 +265,9 @@ fun EventEditScreen(
viewModel.reset() viewModel.reset()
onSaved() onSaved()
} }
// A failed save leaves the user on a form that looks exactly as it // A failed save leaves the form looking unchanged, so the snackbar is
// did, so the snackbar is the only sign anything happened — it gets // the only sign anything happened — long rather than the default
// the long duration rather than the default flash (Codeberg #234: // flash (Codeberg #234: it read as "nothing happens at all").
// the failure read as "nothing happens at all").
SaveUiState.Failed -> { SaveUiState.Failed -> {
viewModel.consumeSaveResult() viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long) snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)

View File

@@ -755,14 +755,13 @@ class EventEditViewModel @Inject constructor(
} catch (e: SecurityException) { } catch (e: SecurityException) {
SaveUiState.NeedsPermission SaveUiState.NeedsPermission
} catch (e: NoSuchEventException) { } catch (e: NoSuchEventException) {
// The write found the event (or the occurrence) already gone // The event or occurrence is already gone: the same answer the
// the same answer the pre-check gives, and a far better one than // pre-check gives, and better than a bare "couldn't save".
// a bare "couldn't save" for something that no longer exists.
SaveUiState.Gone SaveUiState.Gone
} catch (e: Exception) { } catch (e: Exception) {
// The user only gets a generic snackbar, so without this a // The user only gets a generic snackbar, so without this a failed
// failed write leaves no trace at all to report (Codeberg #234). // write leaves nothing to report (Codeberg #234). Scope and event
// Scope and event id only — never the form's content. // id only — never the form's content.
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e) Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
SaveUiState.Failed SaveUiState.Failed
} }

View File

@@ -0,0 +1,115 @@
package de.jeanlucmakiola.calendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.getSystemService
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Holds the app's own wake-up for the next local midnight, so the home-screen
* widgets roll "today" over on the day boundary (#228).
*
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
* implicit broadcast — a manifest-declared receiver has not been given it since
* Android 8, leaving only the throttled `updatePeriodMillis`.
*
* Exactly one alarm exists at a time and every firing re-arms the next, the same
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
* survives doze (plain `set` does not), a rollover a few minutes late is
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
*/
object WidgetRolloverScheduler {
/**
* Fire just *after* midnight: an alarm delivered a few milliseconds early
* would still read the old date and re-arm for an instant later.
*/
internal val ROLLOVER_SLACK = 5.seconds
/**
* Arm the next rollover, or cancel a pending one when no widget is placed.
* Idempotent, so every trigger (boot, app start, widget added/removed, the
* rollover itself, a clock or timezone change) can just call it.
*/
fun sync(context: Context) {
val appContext = context.applicationContext
val alarmManager = appContext.getSystemService<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].
*
* The *actual* start of day, not 00:00, so it holds where a DST jump means
* midnight never happens (Havana) and where a date-line move skips a whole
* local date (Apia, December 2011) — the loop walks on to the next real day.
*
* The mirror case — a zone rewinding *across* midnight, so the day starts
* twice — resolves to the earlier start and runs an hour ahead of the clock.
* No live tz entry does that (Brazil dropped DST in 2019) and
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
*/
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date
var days = 1
while (days <= MAX_LOOKAHEAD_DAYS) {
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
if (candidate > now) return candidate
days++
}
// Unreachable for any zone in the tz database. Deliberately an hour and
// not the slack: a 5-second retry would just hit this branch again and
// wake the device in a loop.
return now + 1.hours
}
private fun hasPlacedWidgets(context: Context): Boolean {
val manager = AppWidgetManager.getInstance(context) ?: return false
return PROVIDERS.any {
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
}
}
private fun rolloverPendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java)
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private val PROVIDERS = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
/** A gap of more than a couple of days does not exist in any tz database entry. */
private const val MAX_LOOKAHEAD_DAYS = 3
}

View File

@@ -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` — the day boundary moved, so redraw *and*
* re-arm.
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
* latter is also what arms installs upgrading into the fix.
* *
* Both widgets also carry an `updatePeriodMillis` backstop in their provider * `DATE_CHANGED` is a free extra in the filter that nothing depends on — see
* XML, and the month widget's refresh button forces an immediate redraw. * [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
* provider XML and the month widget's refresh button.
*
* Exported for the system broadcasts; an extra redraw from another app is
* harmless.
*/ */
class WidgetUpdateReceiver : BroadcastReceiver() { class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync() // Exported, so anything can reach it with an explicit intent. Nothing
// here crosses a trust boundary, but narrowing to the actions we asked
// for keeps a stray broadcast from costing two wide provider reads.
if (intent.action !in HANDLED_ACTIONS) return
val appContext = context.applicationContext val appContext = context.applicationContext
// Re-arm first, so the next day boundary is covered whatever the redraw
// does. Every handled action either dropped, consumed or invalidated it.
WidgetRolloverScheduler.sync(appContext)
// The host sends APPWIDGET_UPDATE after both of these anyway, so
// redrawing here would only repeat the work in a cold process, at the
// moment the device is most contended.
if (intent.action in REARM_ONLY_ACTIONS) return
val pending = goAsync()
// Calendar data may have changed (sync / our own write) — drop the cached // 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,23 @@ class WidgetUpdateReceiver : BroadcastReceiver() {
} }
} }
} }
companion object {
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
private val REARM_ONLY_ACTIONS = setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
internal val HANDLED_ACTIONS = REARM_ONLY_ACTIONS + setOf(
ACTION_ROLLOVER,
Intent.ACTION_PROVIDER_CHANGED,
Intent.ACTION_DATE_CHANGED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
)
}
} }

View File

@@ -1,7 +1,10 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import android.appwidget.AppWidgetManager
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 +13,32 @@ 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] cancels only if
* no month widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
} }

View File

@@ -152,7 +152,17 @@ class ShiftMonthAction : ActionCallback {
val delta = parameters[deltaKey] ?: 0 val delta = parameters[deltaKey] ?: 0
updateAppWidgetState(context, glanceId) { prefs -> updateAppWidgetState(context, glanceId) { prefs ->
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone()) val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
prefs[MONTH_INDEX_KEY] = cur + delta val next = cur + delta
// Landing back on the current month clears the key, so the widget
// goes back to *following* the date rather than being pinned to
// whichever month was current at the tap. Paging out and back is the
// workaround #228's reporter used, and pinning it there would have
// stuck them on that month once it stopped being the current one.
if (next == currentMonthIndex(systemZone())) {
prefs.remove(MONTH_INDEX_KEY)
} else {
prefs[MONTH_INDEX_KEY] = next
}
} }
MonthWidget().update(context.applicationContext, glanceId) MonthWidget().update(context.applicationContext, glanceId)
} }

View File

@@ -1,7 +1,10 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import android.appwidget.AppWidgetManager
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 +12,35 @@ 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] cancels only if
* no agenda widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
* owns, so it doubles as the alarm's self-heal: anything that drops a
* pending alarm without a broadcast (force-stop, battery restriction, an OEM
* freeze) is repaired here rather than on the next app open. Re-arming
* closer to midnight also narrows the inexact delivery window.
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
} }

View File

@@ -571,14 +571,11 @@ class EventWriteMapperTest {
times = detached.toWriteTimes(berlin), times = detached.toWriteTimes(berlin),
) )
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved") assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
// The rule must not survive: without a _sync_id nothing clears an // A surviving rule would insert a second *series* overlapping the first
// inherited RRULE the way the exception path's DTSTART + DURATION does,
// so keeping it would insert a second *series* overlapping the first
// (Codeberg #234's stray duplicate). // (Codeberg #234's stray duplicate).
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION) assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
// A one-off row carries DTEND the invariant buildEventInsertValues // A one-off row carries DTEND rather than a duration.
// holds for every non-recurring event.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) 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.DTEND]).isEqualTo(1_781_170_200_000L)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin") assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
@@ -586,9 +583,8 @@ class EventWriteMapperTest {
@Test @Test
fun `a detached occurrence carries every edited field onto the new row`() { fun `a detached occurrence carries every edited field onto the new row`() {
// The detached row is built from the form rather than cloned from the // Built from the form, not cloned from the parent: a field dropped here
// parent, so anything the user edited has to survive the trip — a field // is an edit silently lost.
// dropped here is an edit silently lost.
val edited = form(timezone = "America/New_York").copy( val edited = form(timezone = "America/New_York").copy(
title = " Standup ", title = " Standup ",
location = "Room 2", location = "Room 2",
@@ -612,12 +608,10 @@ class EventWriteMapperTest {
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE) .isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
assertThat(values[CalendarContract.Events.ACCESS_LEVEL]) assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE) .isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
// The pinned zone survives too — a detached occurrence must not be // The pinned zone survives — never re-anchored to the device.
// silently re-anchored to the device.
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York") assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula") assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
// Reminders and guests aren't columns the insert path seeds them from // Reminders aren't columns; the insert path seeds them from the form.
// the form, so they only have to survive on the form itself.
assertThat(detached.reminders).containsExactly(10) assertThat(detached.reminders).containsExactly(10)
} }
@@ -645,9 +639,8 @@ class EventWriteMapperTest {
@Test @Test
fun `the detached row lands exactly where the parent's exdate removes it`() { fun `the detached row lands exactly where the parent's exdate removes it`() {
// The two halves of the no-_sync_id edit have to agree, or the user sees // The two halves must agree on the instant, or the user sees the
// the occurrence twice or not at all. With the time left untouched, the // occurrence twice or not at all.
// EXDATE stamp and the detached row's DTSTART describe the same instant.
val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY") val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY")
val occurrenceMillis = 1_781_164_800_000L val occurrenceMillis = 1_781_164_800_000L
@@ -702,8 +695,7 @@ class EventWriteMapperTest {
@Test @Test
fun `an occurrence already excluded is recognised, timed and all-day`() { fun `an occurrence already excluded is recognised, timed and all-day`() {
// Detaching twice would fold the repeated EXDATE away and still report a // Detaching twice would leave a second standalone copy.
// changed row, leaving a second standalone copy of one occurrence.
assertThat( assertThat(
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false), exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false),
).isTrue() ).isTrue()
@@ -724,8 +716,7 @@ class EventWriteMapperTest {
@Test @Test
fun `an exclusion is found anywhere in a multi-entry exdate list`() { fun `an exclusion is found anywhere in a multi-entry exdate list`() {
// Whitespace after a comma is legal in the stored column and must not // Whitespace after a comma is legal in the stored column.
// hide an exclusion — that would let a duplicate through.
assertThat( assertThat(
exdateContains( exdateContains(
"20260604T080000Z, 20260611T080000Z,20260618T080000Z", "20260604T080000Z, 20260611T080000Z,20260618T080000Z",

View File

@@ -0,0 +1,164 @@
package de.jeanlucmakiola.calendula.widget
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* The rollover alarm is what makes the widgets stop highlighting yesterday
* (#228), so the "when is the next local midnight" arithmetic is the one piece
* worth pinning down — especially where midnight is not 00:00.
*/
class WidgetRolloverSchedulerTest {
private val berlin = TimeZone.of("Europe/Berlin")
private fun at(local: String, zone: TimeZone): Instant =
LocalDateTime.parse(local).toInstant(zone)
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
// --- the ordinary day ----------------------------------------------------
@Test
fun `midday rolls over at the coming midnight`() {
val next = nextRollover("2026-08-27T12:00:00")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `a second before midnight still targets tonight, not tomorrow night`() {
val next = nextRollover("2026-08-27T23:59:59")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `at midnight exactly the target is the next day, never the current instant`() {
// Re-arming after a firing must move a whole day on, or the widget wakes
// itself in a tight loop.
val now = at("2026-08-28T00:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
assertThat(next - now).isGreaterThan(Duration.ZERO)
}
@Test
fun `re-arming from the slack instant itself moves a full day on`() {
// What actually happens in practice: the receiver runs at midnight + slack.
val now = at("2026-08-28T00:00:05", berlin)
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
.isEqualTo(at("2026-08-29T00:00:05", berlin))
}
@Test
fun `the result is always in the future for every minute of a day`() {
val zone = berlin
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
// in the past, i.e. an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) {
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
probe += 1.minutes
}
}
// --- daylight saving -----------------------------------------------------
@Test
fun `spring forward keeps the rollover one day away, not one hour short`() {
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
val now = at("2024-03-30T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
assertThat(next - now).isLessThan(24.hours)
}
@Test
fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
// "now + 24h" would land at 23:00 on the 26th and never roll over.
val now = at("2024-10-26T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
}
@Test
fun `a zone that repeats midnight takes the first start of day`() {
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
// began twice. Pinned as the accepted trade: the widget runs an hour
// ahead until the next redraw. No live zone does this since 2019.
val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
)
val local = next.toLocalDateTime(saoPaulo)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
assertThat(local.hour).isEqualTo(0)
}
@Test
fun `a zone where midnight does not exist rolls over at the real start of day`() {
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
// Targeting a literal 00:00 there would arm an instant on the wrong day.
val havana = TimeZone.of("America/Havana")
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
val local = next.toLocalDateTime(havana)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
assertThat(local.hour).isEqualTo(1)
assertThat(local.minute).isEqualTo(0)
// And it is genuinely the first instant of that date, not a guess.
assertThat(next).isEqualTo(
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
WidgetRolloverScheduler.ROLLOVER_SLACK,
)
}
// --- timezone changes ----------------------------------------------------
@Test
fun `the same instant rolls over at different times in different zones`() {
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
// follows the zone, not a cached offset.
val instant = at("2026-08-27T12:00:00", berlin)
val tokyo = TimeZone.of("Asia/Tokyo")
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
assertThat(tokyoNext).isNotEqualTo(berlinNext)
assertThat(tokyoNext).isLessThan(berlinNext)
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
}
@Test
fun `a half-hour offset zone still lands on its own midnight`() {
val kathmandu = TimeZone.of("Asia/Kathmandu")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2026-08-27T12:00:00", kathmandu), kathmandu,
)
val local = next.toLocalDateTime(kathmandu)
assertThat(local.date.toString()).isEqualTo("2026-08-28")
assertThat(local.hour).isEqualTo(0)
}
// --- the wiring ----------------------------------------------------------
@Test
fun `the receiver actually handles the action the alarm is sent with`() {
// The single point where the whole fix would die silently: the alarm
// fires, the receiver drops it on the action guard, nothing redraws.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
}
}