Compare commits
5 Commits
fix/stale-
...
2359efaef6
| Author | SHA1 | Date | |
|---|---|---|---|
| 2359efaef6 | |||
|
|
398cfc8906 | ||
| c0b7ad29ef | |||
| bc49730ea5 | |||
| a1d1894f84 |
31
CHANGELOG.md
31
CHANGELOG.md
@@ -8,16 +8,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **A deleted occurrence no longer comes back when the series is re-timed.**
|
||||
Delete a single occurrence of a repeating event, then change the time of the
|
||||
whole series, and the occurrence you had removed reappeared. Doing it the
|
||||
other way round — "This and all following events" — lost every removal in the
|
||||
part of the series being changed. Removals now travel with the event: they
|
||||
keep their place in the series across a time change, a move to a different
|
||||
day, a timezone change and a switch to or from an all-day event, and they
|
||||
carry over when a series is split. Repeating events on your device's own
|
||||
calendars are affected, including the birthday and anniversary calendars
|
||||
Calendula creates from your contacts ([#248]).
|
||||
- **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 —
|
||||
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
|
||||
|
||||
@@ -1483,4 +1491,5 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||
|
||||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@@ -50,3 +50,9 @@
|
||||
# the real names also survives app updates, which would otherwise renumber the
|
||||
# obfuscated name and orphan the stored mapping.
|
||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||
|
||||
# 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
|
||||
|
||||
@@ -330,9 +330,13 @@
|
||||
</receiver>
|
||||
|
||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||
and the system broadcasts the date/time ones at midnight / clock
|
||||
changes so "today" highlighting rolls over. -->
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync).
|
||||
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
||||
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
|
||||
android:name=".widget.WidgetUpdateReceiver"
|
||||
android:exported="true">
|
||||
@@ -346,6 +350,8 @@
|
||||
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -44,6 +45,19 @@ class CalendulaApp : Application() {
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -232,10 +232,15 @@ interface CalendarDataSource {
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event by inserting a
|
||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
* Change a single occurrence of a recurring event at [beginMillis] (the
|
||||
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
|
||||
* `Events._ID` of the row now holding them.
|
||||
*
|
||||
* 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(
|
||||
eventId: Long,
|
||||
@@ -976,12 +981,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
) {
|
||||
val row = querySeriesRow(eventId)
|
||||
val values = buildEventUpdateValues(
|
||||
original = original,
|
||||
updated = updated,
|
||||
seriesDtStartMillis = row.dtStartMillis,
|
||||
seriesExdate = row.exdate,
|
||||
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
|
||||
zone = ZoneId.systemDefault(),
|
||||
)
|
||||
if (values.isNotEmpty()) {
|
||||
@@ -1196,6 +1199,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
form: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): 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.
|
||||
val values = buildOccurrenceExceptionValues(
|
||||
form = form,
|
||||
@@ -1214,6 +1224,89 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
@@ -1230,66 +1323,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
// Insert the new series first: if it fails, the original is untouched.
|
||||
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
||||
carrySplitExdate(newEventId, row, beginMillis, original, updated)
|
||||
truncateSeries(eventId, row, beginMillis)
|
||||
return newEventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry the [parent]'s exclusions for occurrences past [beginMillis] onto the
|
||||
* series [newEventId] that now owns them, re-timed by the shift the split
|
||||
* applied ([shiftedExdate]/[exdateAfter]). [insertEvent] builds the new row
|
||||
* from the form, which knows nothing about them, so without this every
|
||||
* occurrence the user had deleted from the tail of the series comes back.
|
||||
*
|
||||
* The whole time/recurrence set rides along with EXDATE for the reason
|
||||
* [buildOccurrenceExdateValues] documents: on its own the provider does not
|
||||
* read an EXDATE write as a recurrence change, and leaves the instances it
|
||||
* expanded on insert standing.
|
||||
*
|
||||
* A failure rolls the new series back and throws, so the split fails whole
|
||||
* rather than landing with the exclusions quietly dropped — the parent is
|
||||
* still untruncated at this point, so the event is left exactly as it was.
|
||||
*/
|
||||
private fun carrySplitExdate(
|
||||
newEventId: Long,
|
||||
parent: SeriesRow,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
) {
|
||||
// Dropping the recurrence in the same save leaves a one-off tail, and an
|
||||
// exclusion means nothing on a row that doesn't recur.
|
||||
if (updated.rrule.isNullOrBlank()) return
|
||||
val carried = shiftedExdate(
|
||||
existingExdate = exdateAfter(parent.exdate, beginMillis, original.isAllDay),
|
||||
original = original,
|
||||
updated = updated,
|
||||
zone = ZoneId.systemDefault(),
|
||||
) ?: return
|
||||
val row = querySeriesRow(newEventId)
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.EXDATE, carried)
|
||||
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
||||
put(CalendarContract.Events.RRULE, row.rrule)
|
||||
put(CalendarContract.Events.DURATION, row.duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
||||
}
|
||||
try {
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
|
||||
values, null, null,
|
||||
)
|
||||
if (rows == 0) {
|
||||
throw WriteFailedException("carry exdate onto split series id=$newEventId")
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
runCatching { deleteEvent(newEventId) }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||
val row = querySeriesRow(eventId)
|
||||
// From the first occurrence on = the whole series; also the fallback
|
||||
|
||||
@@ -10,9 +10,6 @@ import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.ResolverStyle
|
||||
import java.time.LocalDate as JavaLocalDate
|
||||
import java.time.LocalDateTime as JavaLocalDateTime
|
||||
|
||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||
@@ -137,21 +134,18 @@ internal fun buildEventInsertValues(
|
||||
*
|
||||
* Time fields travel together (the provider validates them as a unit):
|
||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||
* replaces DTEND, RRULE is written, and the row's exclusions
|
||||
* ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past
|
||||
* occurrences intact when someone edits a later occurrence's time, and keeps
|
||||
* the anchor's time-of-day stable across a DST boundary or a zone change
|
||||
* between the two.
|
||||
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
||||
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
||||
* stable across a DST boundary or a zone change between the two.
|
||||
*/
|
||||
internal fun buildEventUpdateValues(
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
seriesDtStartMillis: Long,
|
||||
seriesExdate: String?,
|
||||
zone: ZoneId,
|
||||
): Map<String, Any?> = buildMap {
|
||||
if (updated.title.trim() != original.title.trim()) {
|
||||
@@ -191,10 +185,6 @@ internal fun buildEventUpdateValues(
|
||||
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
||||
put(CalendarContract.Events.RRULE, null)
|
||||
put(CalendarContract.Events.DURATION, null)
|
||||
// The exclusions named occurrences of a series that no longer exists.
|
||||
// Left behind they are dormant rather than harmless: adding a recurrence
|
||||
// back later would punch the old holes into the new one.
|
||||
put(CalendarContract.Events.EXDATE, null)
|
||||
} else {
|
||||
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||
@@ -216,14 +206,6 @@ internal fun buildEventUpdateValues(
|
||||
put(CalendarContract.Events.DTEND, null)
|
||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||
// An EXDATE stamp is an absolute instant, so it excludes an occurrence
|
||||
// only while the series keeps generating one at exactly that instant. The
|
||||
// anchor has just moved, so every occurrence regenerates elsewhere and a
|
||||
// stamp left behind matches none of them — the occurrence the user deleted
|
||||
// comes back. Move the stamps the same way the anchor moved.
|
||||
shiftedExdate(seriesExdate, original, updated, zone)
|
||||
?.takeIf { it != seriesExdate }
|
||||
?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +243,21 @@ internal fun buildOccurrenceExceptionValues(
|
||||
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
|
||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||
@@ -438,7 +435,11 @@ internal fun buildOccurrenceExdateValues(
|
||||
allDay: Int,
|
||||
): Map<String, Any?> {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||
val merged = (exdateStamps(existingExdate) + stamp).distinct().joinToString(",")
|
||||
val existing = existingExdate?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
val merged = (existing + stamp).distinct().joinToString(",")
|
||||
return mapOf(
|
||||
CalendarContract.Events.EXDATE to merged,
|
||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||
@@ -450,121 +451,38 @@ internal fun buildOccurrenceExdateValues(
|
||||
}
|
||||
|
||||
/**
|
||||
* [existingExdate] with every stamp re-timed by the same **wall-clock** shift the
|
||||
* series anchor takes from [original] to [updated] — the exclusions' half of the
|
||||
* anchor move [buildEventUpdateValues] performs, and what carries them onto the
|
||||
* new series when "this and following" splits one.
|
||||
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
||||
* — i.e. it has already been dropped from the series, deleted or detached.
|
||||
*
|
||||
* A stamp is an absolute instant, so it keeps naming its occurrence only if it
|
||||
* moves exactly as the occurrence does: shifted in wall clock and re-resolved in
|
||||
* the event's (possibly new) zone. A millisecond delta would instead bake in the
|
||||
* offset that happened to apply at the edited occurrence — an hour off for any
|
||||
* exclusion on the far side of a DST boundary. All-day-ness is read from
|
||||
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
|
||||
* convert into each other when the event switches.
|
||||
*
|
||||
* Null when there is nothing to carry: no stamps, or a stamp in a form Calendula
|
||||
* never writes (a `TZID=`-parameterised or floating one from a sync adapter). Those
|
||||
* are left exactly as they are rather than guessed at — a stale stamp excludes
|
||||
* nothing, but a mangled one could exclude the wrong occurrence.
|
||||
* 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 shiftedExdate(
|
||||
internal fun exdateContains(
|
||||
existingExdate: String?,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
zone: ZoneId,
|
||||
): String? {
|
||||
val stamps = exdateStamps(existingExdate)
|
||||
if (stamps.isEmpty()) return null
|
||||
val fromZone = original.writeZone(zone)
|
||||
val toZone = updated.writeZone(zone)
|
||||
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||
return stamps
|
||||
.map { stamp ->
|
||||
val local = parseExdateStamp(stamp, original.isAllDay, fromZone) ?: return null
|
||||
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
|
||||
}
|
||||
.distinct()
|
||||
.joinToString(",")
|
||||
occurrenceMillis: Long,
|
||||
isAllDay: Boolean,
|
||||
): Boolean {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
||||
return existingExdate?.split(',')?.any { it.trim() == stamp } == true
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones
|
||||
* that belong to the *new* series once "this and following" splits a recurring
|
||||
* event there. The parent keeps the full list; its stamps past the split point are
|
||||
* simply inert once it stops generating those occurrences.
|
||||
*
|
||||
* The occurrence at [beginMillis] itself is never carried. It is the one the user
|
||||
* is editing, so it exists by definition, and honouring a stale exclusion for it
|
||||
* would swallow the edit whole.
|
||||
*
|
||||
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
|
||||
*/
|
||||
internal fun exdateAfter(existingExdate: String?, beginMillis: Long, isAllDay: Boolean): String? {
|
||||
val stamps = exdateStamps(existingExdate)
|
||||
if (stamps.isEmpty()) return null
|
||||
return stamps
|
||||
.filter { stamp ->
|
||||
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC) ?: return null
|
||||
utc.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis
|
||||
}
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.joinToString(",")
|
||||
}
|
||||
|
||||
/** The individual stamps of an EXDATE column value; it is a comma-separated list. */
|
||||
private fun exdateStamps(exdate: String?): List<String> = exdate
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
* 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
|
||||
* reads off the UTC calendar day.
|
||||
*/
|
||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
|
||||
formatExdateStamp(
|
||||
local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(),
|
||||
isAllDay = isAllDay,
|
||||
zone = ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
/**
|
||||
* One EXDATE entry for the occurrence whose wall clock in [zone] is [local]. An
|
||||
* all-day entry keeps only the date (its time-of-day is the anchor's, not the
|
||||
* occurrence's); a timed one is resolved in [zone] and written as a UTC instant.
|
||||
*/
|
||||
private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone: ZoneId): String =
|
||||
if (isAllDay) {
|
||||
local.toLocalDate().format(ALL_DAY_EXDATE)
|
||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
||||
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
||||
return if (isAllDay) {
|
||||
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
||||
} else {
|
||||
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE)
|
||||
"%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
utc.year, utc.monthValue, utc.dayOfMonth,
|
||||
utc.hour, utc.minute, utc.second,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [stamp] as the wall clock it names in [zone] — the inverse of
|
||||
* [formatExdateStamp]. Null for anything but the two forms Calendula writes, so a
|
||||
* caller can tell "not ours, leave it alone" from a value it may safely re-time.
|
||||
*/
|
||||
private fun parseExdateStamp(stamp: String, isAllDay: Boolean, zone: ZoneId): JavaLocalDateTime? =
|
||||
runCatching {
|
||||
if (isAllDay) {
|
||||
JavaLocalDate.parse(stamp, ALL_DAY_EXDATE).atStartOfDay()
|
||||
} else {
|
||||
JavaLocalDateTime.parse(stamp, TIMED_EXDATE)
|
||||
.atZone(ZoneOffset.UTC).withZoneSameInstant(zone).toLocalDateTime()
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
/** `VALUE=DATE` EXDATE form, for an all-day series. */
|
||||
private val ALL_DAY_EXDATE: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
|
||||
|
||||
/** UTC date-time EXDATE form, for a timed series. */
|
||||
private val TIMED_EXDATE: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||
|
||||
@@ -64,6 +64,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -264,13 +265,16 @@ fun EventEditScreen(
|
||||
viewModel.reset()
|
||||
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 -> {
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(saveFailedMessage)
|
||||
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
||||
}
|
||||
SaveUiState.NeedsPermission -> {
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long)
|
||||
}
|
||||
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
||||
else -> Unit
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -56,6 +57,8 @@ import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val TAG = "EventEdit"
|
||||
|
||||
/**
|
||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||
* want different reminder handling (#49), and differ in whether they own the
|
||||
@@ -751,7 +754,15 @@ class EventEditViewModel @Inject constructor(
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
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) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -12,19 +12,40 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Redraws both home-screen widgets when their data goes stale. Triggered by:
|
||||
* Redraws both home-screen widgets when their data goes stale, and keeps the
|
||||
* midnight rollover alarm armed. Triggered by:
|
||||
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
||||
* so it covers both the app's own writes and external sync.
|
||||
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
||||
* and the upcoming window roll over at midnight / on a clock change.
|
||||
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
|
||||
* the day boundary, so "today" highlighting and the agenda's past-event
|
||||
* dimming move on (#228).
|
||||
* - `TIME_SET` / `TIMEZONE_CHANGED` — 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
|
||||
* XML, and the month widget's refresh button forces an immediate redraw.
|
||||
* `DATE_CHANGED` is a free extra in the filter that nothing depends on — see
|
||||
* [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() {
|
||||
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
|
||||
// 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
|
||||
// month window so the widgets reload fresh. Month paging does NOT call
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
|
||||
/**
|
||||
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
||||
@@ -10,4 +13,32 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
*/
|
||||
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||
|
||||
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Last agenda widget removed. [WidgetRolloverScheduler.sync] 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,17 @@ class ShiftMonthAction : ActionCallback {
|
||||
val delta = parameters[deltaKey] ?: 0
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
|
||||
/**
|
||||
* Host-facing receiver for the month widget. Declared in the manifest with the
|
||||
@@ -9,4 +12,35 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
*/
|
||||
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
||||
|
||||
/** First month widget placed — start rolling "today" over at midnight (#228). */
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Last month widget removed. [WidgetRolloverScheduler.sync] 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,8 +128,7 @@ class EventWriteMapperTest {
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
series: Long = seriesStart,
|
||||
exdate: String? = null,
|
||||
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, exdate, berlin)
|
||||
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
|
||||
|
||||
/** The instant [local] names in [zoneId], as the provider would store it. */
|
||||
private fun instantAt(local: String, zoneId: String): Long =
|
||||
@@ -559,237 +558,172 @@ class EventWriteMapperTest {
|
||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||
}
|
||||
|
||||
// --- EXDATE follows the series when its times change (Codeberg #248) ---
|
||||
|
||||
/** A weekly series whose displayed occurrence runs 15 July 2026, 09:00–10:00. */
|
||||
private fun julySeries(): EventForm = form(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
|
||||
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
|
||||
private fun EventForm.atHour(hour: Int): EventForm = copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)),
|
||||
)
|
||||
// --- toDetachedOccurrence ("edit only this event", no _sync_id) ---
|
||||
|
||||
@Test
|
||||
fun `a series time edit moves its exclusions with the anchor`() {
|
||||
// The bug: the stamp stayed at the old instant, which the moved series no
|
||||
// longer generates, so the occurrence the user deleted came back.
|
||||
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
|
||||
val original = julySeries()
|
||||
// 8 July 09:00 Berlin (CEST, +2) == 07:00Z; at 10:00 it must read 08:00Z.
|
||||
val values = update(original, original.atHour(10), series, "20260708T070000Z")
|
||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260708T080000Z")
|
||||
}
|
||||
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()
|
||||
|
||||
@Test
|
||||
fun `an exclusion keeps its wall clock across a DST boundary`() {
|
||||
// Anchor and edited occurrence are in July (CEST, +2); the excluded
|
||||
// occurrence sits in January (CET, +1). Shifting by the millisecond delta
|
||||
// measured at the edited occurrence would leave it an hour off.
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = julySeries()
|
||||
// 14 January 09:00 Berlin == 08:00Z; at 10:00 it must read 09:00Z.
|
||||
val values = update(original, original.atHour(10), series, "20260114T080000Z")
|
||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260114T090000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinning a series to another zone re-resolves its exclusions there`() {
|
||||
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
|
||||
val original = julySeries()
|
||||
val values = update(
|
||||
original,
|
||||
original.copy(timezone = "Asia/Tokyo"),
|
||||
series,
|
||||
"20260716T070000Z",
|
||||
val values = buildEventInsertValues(
|
||||
form = detached,
|
||||
uid = "uid@calendula",
|
||||
times = detached.toWriteTimes(berlin),
|
||||
)
|
||||
// The exclusion still reads 09:00 — now 09:00 in Tokyo (+9) == 00:00Z.
|
||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260716T000000Z")
|
||||
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 `an all-day series move shifts its exclusions by whole days`() {
|
||||
val series = instantAt("2026-07-01T00:00", "UTC")
|
||||
val original = form(
|
||||
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, 7, 15), LocalTime(0, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||
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(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
|
||||
.isEqualTo("20260724")
|
||||
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 `switching a series to all-day rewrites its exclusions as dates`() {
|
||||
// The two forms aren't interchangeable: a date-time stamp on an all-day
|
||||
// series matches no occurrence, so the exclusion would be lost.
|
||||
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
|
||||
val original = julySeries()
|
||||
val values = update(original, original.copy(isAllDay = true), series, "20260722T070000Z")
|
||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722")
|
||||
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 `switching a series back to timed rewrites its exclusions as instants`() {
|
||||
val series = instantAt("2026-07-01T00:00", "UTC")
|
||||
val original = form(
|
||||
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, 7, 15), LocalTime(0, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val timed = original.copy(
|
||||
isAllDay = false,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
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),
|
||||
)
|
||||
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
|
||||
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
|
||||
.isEqualTo("20260722T070000Z")
|
||||
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611")
|
||||
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a text-only edit leaves the exclusions alone`() {
|
||||
val original = julySeries()
|
||||
val values = update(original, original.copy(title = "Renamed"), exdate = "20260722T070000Z")
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||
}
|
||||
// --- exdateContains (guards a second detach of the same occurrence) ---
|
||||
|
||||
@Test
|
||||
fun `changing only the rule rewrites no exclusions`() {
|
||||
// The times are untouched, so every surviving occurrence keeps its instant
|
||||
// and the stamps still name the right ones.
|
||||
val original = julySeries()
|
||||
val values = update(original, original.copy(rrule = "FREQ=DAILY"), exdate = "20260722T070000Z")
|
||||
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY")
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exdate form we do not write is left untouched`() {
|
||||
// A sync adapter may store a TZID-parameterised or floating stamp. A stale
|
||||
// stamp excludes nothing; a mangled one could exclude the wrong occurrence.
|
||||
val original = julySeries()
|
||||
val values = update(
|
||||
original,
|
||||
original.atHour(10),
|
||||
instantAt("2026-07-01T09:00", "Europe/Berlin"),
|
||||
"TZID=Europe/Berlin;20260722T090000",
|
||||
)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing the recurrence clears the exclusions with it`() {
|
||||
// Dormant, not harmless: adding a recurrence back later would punch the old
|
||||
// holes into the new one.
|
||||
val original = julySeries()
|
||||
val values = update(original, original.copy(rrule = null), exdate = "20260722T070000Z")
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EXDATE, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undoing a series move lands the exclusions back where they started`() {
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = julySeries()
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
|
||||
)
|
||||
val exdate = "20260722T070000Z"
|
||||
|
||||
val forward = update(original, moved, series, exdate)
|
||||
val movedExdate = forward[CalendarContract.Events.EXDATE] as String
|
||||
assertThat(movedExdate).isNotEqualTo(exdate)
|
||||
|
||||
val back = update(
|
||||
moved,
|
||||
original,
|
||||
forward[CalendarContract.Events.DTSTART] as Long,
|
||||
movedExdate,
|
||||
)
|
||||
assertThat(back[CalendarContract.Events.EXDATE]).isEqualTo(exdate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a series with no exclusions writes no exdate column`() {
|
||||
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
|
||||
val original = julySeries()
|
||||
assertThat(update(original, original.atHour(10), series, exdate = null))
|
||||
.doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||
}
|
||||
|
||||
// --- exdateAfter (the exclusions a "this and following" split inherits) ---
|
||||
|
||||
@Test
|
||||
fun `a split carries only the exclusions past the split point`() {
|
||||
// The occurrence at the split point is the one being edited, so it exists
|
||||
// by definition — a stale exclusion for it would swallow the edit whole.
|
||||
fun `an occurrence already excluded is recognised, timed and all-day`() {
|
||||
// Detaching twice would leave a second standalone copy.
|
||||
assertThat(
|
||||
exdateAfter(
|
||||
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
|
||||
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
|
||||
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,
|
||||
),
|
||||
).isEqualTo("20260722T080000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a split carries nothing when every exclusion is behind it`() {
|
||||
assertThat(
|
||||
exdateAfter("20260708T080000Z", instantAt("2026-07-15T08:00", "UTC"), isAllDay = false),
|
||||
).isNull()
|
||||
assertThat(exdateAfter(null, 0L, isAllDay = false)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day exclusions split on their UTC date`() {
|
||||
assertThat(
|
||||
exdateAfter("20260708,20260722", instantAt("2026-07-15T00:00", "UTC"), isAllDay = true),
|
||||
).isEqualTo("20260722")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable exclusion carries nothing across a split`() {
|
||||
assertThat(
|
||||
exdateAfter(
|
||||
"20260722T080000Z,TZID=Europe/Berlin;20260729T100000",
|
||||
instantAt("2026-07-15T08:00", "UTC"),
|
||||
isAllDay = false,
|
||||
),
|
||||
).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `split exclusions move by the same shift as the new series start`() {
|
||||
// What the split path composes: filter to the tail, then re-time it by the
|
||||
// shift the user applied to the occurrence they split at.
|
||||
val original = julySeries()
|
||||
val carried = shiftedExdate(
|
||||
existingExdate = exdateAfter(
|
||||
"20260716T070000Z",
|
||||
instantAt("2026-07-15T07:00", "UTC"),
|
||||
isAllDay = false,
|
||||
),
|
||||
original = original,
|
||||
updated = original.atHour(11),
|
||||
zone = berlin,
|
||||
)
|
||||
// 16 July 09:00 Berlin, pushed two hours, is 11:00 Berlin == 09:00Z.
|
||||
assertThat(carried).isEqualTo("20260716T090000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing to shift yields no exdate`() {
|
||||
assertThat(shiftedExdate(null, julySeries(), julySeries().atHour(10), berlin)).isNull()
|
||||
assertThat(shiftedExdate(" ", julySeries(), julySeries().atHour(10), berlin)).isNull()
|
||||
).isTrue()
|
||||
}
|
||||
|
||||
// --- per-event colour ---
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user