Compare commits
1 Commits
7e7079df00
...
fix/stale-
| Author | SHA1 | Date | |
|---|---|---|---|
| 27a48b9eb4 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -8,14 +8,16 @@ 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
|
- **A deleted occurrence no longer comes back when the series is re-timed.**
|
||||||
agenda widget kept highlighting yesterday as "today" — and the agenda kept
|
Delete a single occurrence of a repeating event, then change the time of the
|
||||||
greying out the wrong events as already past — until you paged the month back
|
whole series, and the occurrence you had removed reappeared. Doing it the
|
||||||
and forth or removed and re-added the widget. Calendula now wakes itself at the
|
other way round — "This and all following events" — lost every removal in the
|
||||||
day boundary and redraws, and re-arms after a reboot, a clock change or a
|
part of the series being changed. Removals now travel with the event: they
|
||||||
flight into another timezone. Paging the month widget forward and back also
|
keep their place in the series across a time change, a move to a different
|
||||||
stops quietly pinning it to that month, so it follows the date again instead of
|
day, a timezone change and a switch to or from an all-day event, and they
|
||||||
being stranded on the month you happened to be looking at ([#228]).
|
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]).
|
||||||
|
|
||||||
## [2.19.3] — 2026-08-22
|
## [2.19.3] — 2026-08-22
|
||||||
|
|
||||||
@@ -1481,4 +1483,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||||
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||||
|
|||||||
9
app/proguard-rules.pro
vendored
9
app/proguard-rules.pro
vendored
@@ -50,12 +50,3 @@
|
|||||||
# 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: MonthWidgetReceiver and AgendaWidgetReceiver are
|
|
||||||
# nearly as alike (same supertype, same overrides, only a differing property
|
|
||||||
# initializer), and Glance's provider map is keyed off the receiver component
|
|
||||||
# too. AGP's manifest-derived keep rules already cover them, and the rule above
|
|
||||||
# keeps the two widgets distinct enough that the receivers' constructors differ —
|
|
||||||
# so this is redundant today. It is here because #89 cost a release to diagnose
|
|
||||||
# and the guarantee should not rest on a component staying in the manifest.
|
|
||||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver
|
|
||||||
|
|||||||
@@ -331,13 +331,8 @@
|
|||||||
|
|
||||||
<!-- 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 day boundary arrives as the app's own ROLLOVER alarm (#228),
|
and the system broadcasts the date/time ones at midnight / clock
|
||||||
delivered by an explicit PendingIntent so it needs no filter here.
|
changes so "today" highlighting rolls over. -->
|
||||||
DATE_CHANGED is kept as a free extra only — it is not an exempted
|
|
||||||
implicit broadcast, so a manifest-declared receiver is not given it
|
|
||||||
on Android 8+. TIME_SET / TIMEZONE_CHANGED move the day boundary,
|
|
||||||
and boot / package-replace wipe the alarm, so all four re-arm it.
|
|
||||||
Exported: the system broadcasts arrive from outside the app. -->
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.WidgetUpdateReceiver"
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -351,8 +346,6 @@
|
|||||||
<action android:name="android.intent.action.DATE_CHANGED" />
|
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||||
<action android:name="android.intent.action.TIME_SET" />
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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
|
||||||
@@ -45,21 +44,6 @@ 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 the cases no broadcast reaches — an
|
|
||||||
* install upgrading into the fix, or an alarm dropped by a force-stop, is
|
|
||||||
* armed again the next time the app is opened. Off the main thread because
|
|
||||||
* it makes a handful of binder calls and every process start runs it,
|
|
||||||
* including ones a worker or a receiver triggered.
|
|
||||||
*/
|
|
||||||
private fun reconcileWidgetRollover() {
|
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
|
||||||
WidgetRolloverScheduler.sync(this@CalendulaApp)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -976,10 +976,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
) {
|
) {
|
||||||
|
val row = querySeriesRow(eventId)
|
||||||
val values = buildEventUpdateValues(
|
val values = buildEventUpdateValues(
|
||||||
original = original,
|
original = original,
|
||||||
updated = updated,
|
updated = updated,
|
||||||
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
|
seriesDtStartMillis = row.dtStartMillis,
|
||||||
|
seriesExdate = row.exdate,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
)
|
)
|
||||||
if (values.isNotEmpty()) {
|
if (values.isNotEmpty()) {
|
||||||
@@ -1228,10 +1230,66 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
// Insert the new series first: if it fails, the original is untouched.
|
// Insert the new series first: if it fails, the original is untouched.
|
||||||
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
||||||
|
carrySplitExdate(newEventId, row, beginMillis, original, updated)
|
||||||
truncateSeries(eventId, row, beginMillis)
|
truncateSeries(eventId, row, beginMillis)
|
||||||
return newEventId
|
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) {
|
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
// From the first occurrence on = the whole series; also the fallback
|
// From the first occurrence on = the whole series; also the fallback
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import java.time.Duration
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
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
|
import java.time.LocalDateTime as JavaLocalDateTime
|
||||||
|
|
||||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
@@ -134,18 +137,21 @@ internal fun buildEventInsertValues(
|
|||||||
*
|
*
|
||||||
* Time fields travel together (the provider validates them as a unit):
|
* Time fields travel together (the provider validates them as a unit):
|
||||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
* - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared;
|
||||||
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
* - 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
|
* 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
|
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||||
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
* replaces DTEND, RRULE is written, and the row's exclusions
|
||||||
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
* ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past
|
||||||
* stable across a DST boundary or a zone change between the two.
|
* 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(
|
internal fun buildEventUpdateValues(
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
seriesDtStartMillis: Long,
|
seriesDtStartMillis: Long,
|
||||||
|
seriesExdate: String?,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
): Map<String, Any?> = buildMap {
|
): Map<String, Any?> = buildMap {
|
||||||
if (updated.title.trim() != original.title.trim()) {
|
if (updated.title.trim() != original.title.trim()) {
|
||||||
@@ -185,6 +191,10 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
||||||
put(CalendarContract.Events.RRULE, null)
|
put(CalendarContract.Events.RRULE, null)
|
||||||
put(CalendarContract.Events.DURATION, 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 {
|
} else {
|
||||||
// Move the series anchor by the *wall-clock* shift the user applied to the
|
// 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)
|
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||||
@@ -206,6 +216,14 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.DTEND, null)
|
put(CalendarContract.Events.DTEND, null)
|
||||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,11 +438,7 @@ internal fun buildOccurrenceExdateValues(
|
|||||||
allDay: Int,
|
allDay: Int,
|
||||||
): Map<String, Any?> {
|
): Map<String, Any?> {
|
||||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||||
val existing = existingExdate?.split(',')
|
val merged = (exdateStamps(existingExdate) + stamp).distinct().joinToString(",")
|
||||||
?.map { it.trim() }
|
|
||||||
?.filter { it.isNotEmpty() }
|
|
||||||
.orEmpty()
|
|
||||||
val merged = (existing + stamp).distinct().joinToString(",")
|
|
||||||
return mapOf(
|
return mapOf(
|
||||||
CalendarContract.Events.EXDATE to merged,
|
CalendarContract.Events.EXDATE to merged,
|
||||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||||
@@ -435,22 +449,122 @@ 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
internal fun shiftedExdate(
|
||||||
|
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(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||||
* reads off the UTC calendar day.
|
* reads off the UTC calendar day.
|
||||||
*/
|
*/
|
||||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
|
||||||
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
formatExdateStamp(
|
||||||
return if (isAllDay) {
|
local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(),
|
||||||
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
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)
|
||||||
} else {
|
} else {
|
||||||
"%04d%02d%02dT%02d%02d%02dZ".format(
|
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE)
|
||||||
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
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
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`, but that broadcast is not
|
|
||||||
* on the implicit-broadcast exemption list, so a manifest-declared receiver has
|
|
||||||
* never been given it since Android 8 — leaving only `updatePeriodMillis`, which
|
|
||||||
* the system defers in doze and OEM skins throttle harder still. The result was
|
|
||||||
* yesterday staying highlighted (and the agenda's past-event dimming staying
|
|
||||||
* anchored to yesterday) until something else forced a redraw.
|
|
||||||
*
|
|
||||||
* Exactly one alarm exists at a time and every firing re-arms the next one, the
|
|
||||||
* same shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
|
|
||||||
* It is deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
|
|
||||||
* survives doze (which plain `set` does not), and a rollover that lands a few
|
|
||||||
* minutes late is invisible on a sleeping screen. Exact alarms stay reserved for
|
|
||||||
* reminder snooze.
|
|
||||||
*/
|
|
||||||
object WidgetRolloverScheduler {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire just *after* midnight, never exactly on it. An alarm delivered a few
|
|
||||||
* milliseconds early would still read the old date and re-arm for an instant
|
|
||||||
* later; the offset makes "the day has changed" unambiguous.
|
|
||||||
*/
|
|
||||||
internal val ROLLOVER_SLACK = 5.seconds
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Arm the next rollover, or cancel a pending one when no widget is placed.
|
|
||||||
* Idempotent, so every trigger (boot, app start, widget added/removed, the
|
|
||||||
* rollover itself, a clock or timezone change) can just call it.
|
|
||||||
*/
|
|
||||||
fun sync(context: Context) {
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
|
|
||||||
val pendingIntent = rolloverPendingIntent(appContext)
|
|
||||||
if (!hasPlacedWidgets(appContext)) {
|
|
||||||
alarmManager.cancel(pendingIntent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
|
|
||||||
alarmManager.setAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The instant just after the next local midnight following [now] in [zone].
|
|
||||||
*
|
|
||||||
* Uses the *actual* start of the day rather than 00:00, so it stays correct
|
|
||||||
* where a DST jump means midnight never happens (Havana springs from 00:00 to
|
|
||||||
* 01:00) and where a whole local date is skipped by a date-line move (Apia
|
|
||||||
* had no 30 December 2011) — the loop then walks on to the next real day.
|
|
||||||
*
|
|
||||||
* The mirror case, a zone that rewinds *across* midnight so the day starts
|
|
||||||
* twice, resolves to the earlier start; the widget would then run an hour
|
|
||||||
* ahead of the clock. No entry in the current tz database does that (Brazil,
|
|
||||||
* which used to, dropped DST in 2019), and `updatePeriodMillis` covers it,
|
|
||||||
* so it is not worth carrying state to detect.
|
|
||||||
*/
|
|
||||||
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
|
|
||||||
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,47 +12,19 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redraws both home-screen widgets when their data goes stale, and keeps the
|
* Redraws both home-screen widgets when their data goes stale. Triggered by:
|
||||||
* 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.
|
||||||
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
|
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
||||||
* the day boundary, so "today" highlighting and the agenda's past-event
|
* and the upcoming window roll over at midnight / on a clock change.
|
||||||
* dimming move on (#228).
|
|
||||||
* - `TIME_SET` / `TIMEZONE_CHANGED` — a clock or zone change moves the day
|
|
||||||
* boundary relative to the armed alarm, so both redraw *and* re-arm.
|
|
||||||
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms. The
|
|
||||||
* package-replaced one is also what arms existing installs that upgrade into
|
|
||||||
* the fix without re-adding their widget.
|
|
||||||
*
|
*
|
||||||
* `DATE_CHANGED` is still in the manifest filter as a free extra, but nothing
|
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
|
||||||
* depends on it: it is not an exempted implicit broadcast, so a manifest-declared
|
* XML, and the month widget's refresh button forces an immediate redraw.
|
||||||
* receiver has not actually been given it since Android 8. The widgets also carry
|
|
||||||
* an `updatePeriodMillis` backstop in their provider XML, and the month widget's
|
|
||||||
* refresh button forces an immediate redraw.
|
|
||||||
*
|
|
||||||
* Exported for the system broadcasts; an extra redraw triggered by another app
|
|
||||||
* is harmless.
|
|
||||||
*/
|
*/
|
||||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
// The receiver has to stay exported for the system broadcasts, so an
|
|
||||||
// explicit intent can reach it with anything in it. Nothing here reads
|
|
||||||
// the intent's data and nothing crosses a trust boundary, but narrowing
|
|
||||||
// to the actions we actually asked for keeps a stray broadcast from
|
|
||||||
// costing two wide provider reads.
|
|
||||||
if (intent.action !in HANDLED_ACTIONS) return
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
// Re-arm first: whatever happens to the redraw, the next day boundary is
|
|
||||||
// covered. Boot and package-replace dropped the alarm outright; a
|
|
||||||
// rollover just consumed it; a clock change invalidated it.
|
|
||||||
WidgetRolloverScheduler.sync(appContext)
|
|
||||||
// Boot and package-replace only cost us the alarm. The host sends
|
|
||||||
// APPWIDGET_UPDATE after both anyway, so redrawing here would just repeat
|
|
||||||
// two wide provider reads and two RemoteViews serialisations in a cold
|
|
||||||
// process, at the moment the device is most contended.
|
|
||||||
if (intent.action in REARM_ONLY_ACTIONS) return
|
|
||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
|
val appContext = context.applicationContext
|
||||||
// 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.
|
||||||
@@ -66,23 +38,4 @@ 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,10 +1,7 @@
|
|||||||
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
|
||||||
@@ -13,33 +10,4 @@ import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
|||||||
*/
|
*/
|
||||||
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||||
|
|
||||||
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
|
|
||||||
override fun onEnabled(context: Context) {
|
|
||||||
super.onEnabled(context)
|
|
||||||
WidgetRolloverScheduler.sync(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last agenda widget removed. [WidgetRolloverScheduler.sync] only cancels the
|
|
||||||
* alarm if no month widget is left either, so removing one kind never stops
|
|
||||||
* the other from rolling over.
|
|
||||||
*/
|
|
||||||
override fun onDisabled(context: Context) {
|
|
||||||
super.onDisabled(context)
|
|
||||||
WidgetRolloverScheduler.sync(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,19 +152,7 @@ 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())
|
||||||
val next = cur + delta
|
prefs[MONTH_INDEX_KEY] = cur + delta
|
||||||
// Landing back on the current month clears the key rather than
|
|
||||||
// storing today's index, so the widget goes back to *following* the
|
|
||||||
// date instead of being pinned to the month that happened to be
|
|
||||||
// current when it was tapped. Paging forward and back is the very
|
|
||||||
// workaround #228's reporter used to force a redraw; storing the
|
|
||||||
// index there would have left them stuck on that month for good once
|
|
||||||
// it stopped being the current one.
|
|
||||||
if (next == currentMonthIndex(systemZone())) {
|
|
||||||
prefs.remove(MONTH_INDEX_KEY)
|
|
||||||
} else {
|
|
||||||
prefs[MONTH_INDEX_KEY] = next
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
MonthWidget().update(context.applicationContext, glanceId)
|
MonthWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
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
|
||||||
@@ -12,37 +9,4 @@ import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
|||||||
*/
|
*/
|
||||||
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
||||||
|
|
||||||
/** First month widget placed — start rolling "today" over at midnight (#228). */
|
|
||||||
override fun onEnabled(context: Context) {
|
|
||||||
super.onEnabled(context)
|
|
||||||
WidgetRolloverScheduler.sync(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last month widget removed. [WidgetRolloverScheduler.sync] only cancels the
|
|
||||||
* alarm if no agenda widget is left either, so removing one kind never stops
|
|
||||||
* the other from rolling over.
|
|
||||||
*/
|
|
||||||
override fun onDisabled(context: Context) {
|
|
||||||
super.onDisabled(context)
|
|
||||||
WidgetRolloverScheduler.sync(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
|
|
||||||
* owns, so it doubles as the rollover alarm's self-heal: anything that drops
|
|
||||||
* a pending alarm without a broadcast — a force-stop, a battery-restricted
|
|
||||||
* transition, an OEM freeze — is repaired here rather than waiting for the
|
|
||||||
* app to be opened. Re-arming closer to midnight also narrows the inexact
|
|
||||||
* alarm's delivery window, which scales with how far out it was set.
|
|
||||||
*/
|
|
||||||
override fun onUpdate(
|
|
||||||
context: Context,
|
|
||||||
appWidgetManager: AppWidgetManager,
|
|
||||||
appWidgetIds: IntArray,
|
|
||||||
) {
|
|
||||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
|
||||||
WidgetRolloverScheduler.sync(context)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ class EventWriteMapperTest {
|
|||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
series: Long = seriesStart,
|
series: Long = seriesStart,
|
||||||
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
|
exdate: String? = null,
|
||||||
|
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, exdate, berlin)
|
||||||
|
|
||||||
/** The instant [local] names in [zoneId], as the provider would store it. */
|
/** The instant [local] names in [zoneId], as the provider would store it. */
|
||||||
private fun instantAt(local: String, zoneId: String): Long =
|
private fun instantAt(local: String, zoneId: String): Long =
|
||||||
@@ -558,6 +559,239 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
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)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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")
|
||||||
|
}
|
||||||
|
|
||||||
|
@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",
|
||||||
|
)
|
||||||
|
// The exclusion still reads 09:00 — now 09:00 in Tokyo (+9) == 00:00Z.
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260716T000000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day series move shifts its exclusions by whole days`() {
|
||||||
|
val series = instantAt("2026-07-01T00:00", "UTC")
|
||||||
|
val original = 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)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
|
||||||
|
.isEqualTo("20260724")
|
||||||
|
}
|
||||||
|
|
||||||
|
@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")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switching a series back to timed rewrites its exclusions as instants`() {
|
||||||
|
val series = instantAt("2026-07-01T00:00", "UTC")
|
||||||
|
val original = 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)),
|
||||||
|
)
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.
|
||||||
|
assertThat(
|
||||||
|
exdateAfter(
|
||||||
|
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
|
||||||
|
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
// --- per-event colour ---
|
// --- per-event colour ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
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`() {
|
|
||||||
// The alarm has just fired and is re-arming: it must move a whole day on,
|
|
||||||
// otherwise the widget would wake itself in a tight loop.
|
|
||||||
val now = at("2026-08-28T00:00:00", berlin)
|
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
|
||||||
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
|
|
||||||
assertThat(next - now).isGreaterThan(Duration.ZERO)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `re-arming from the slack instant itself moves a full day on`() {
|
|
||||||
// What actually happens in practice: the receiver runs at midnight + slack.
|
|
||||||
val now = at("2026-08-28T00:00:05", berlin)
|
|
||||||
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
|
|
||||||
.isEqualTo(at("2026-08-29T00:00:05", berlin))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the result is always in the future for every minute of a day`() {
|
|
||||||
val zone = berlin
|
|
||||||
// Spans Berlin's 2024 spring-forward, the case most likely to produce a
|
|
||||||
// target in the past and so 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: midnight itself is
|
|
||||||
// unambiguous, but the day is 25h long, so "now + 24h" would land at
|
|
||||||
// 23:00 on the 26th and never roll the date over at all.
|
|
||||||
val now = at("2024-10-26T12:00:00", berlin)
|
|
||||||
val 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 deliberately: the widget then runs an hour ahead
|
|
||||||
// of the clock until the next redraw, which is the accepted trade
|
|
||||||
// (Brazil dropped DST in 2019, so no live zone does this).
|
|
||||||
val saoPaulo = TimeZone.of("America/Sao_Paulo")
|
|
||||||
val 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`() {
|
|
||||||
// Flying east and getting TIMEZONE_CHANGED must re-arm to the new local
|
|
||||||
// midnight — the arithmetic follows the zone, not a cached offset.
|
|
||||||
val instant = at("2026-08-27T12:00:00", berlin)
|
|
||||||
val tokyo = TimeZone.of("Asia/Tokyo")
|
|
||||||
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
|
|
||||||
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
|
|
||||||
assertThat(tokyoNext).isNotEqualTo(berlinNext)
|
|
||||||
assertThat(tokyoNext).isLessThan(berlinNext)
|
|
||||||
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a half-hour offset zone still lands on its own midnight`() {
|
|
||||||
val kathmandu = TimeZone.of("Asia/Kathmandu")
|
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(
|
|
||||||
at("2026-08-27T12:00:00", kathmandu), kathmandu,
|
|
||||||
)
|
|
||||||
val local = next.toLocalDateTime(kathmandu)
|
|
||||||
assertThat(local.date.toString()).isEqualTo("2026-08-28")
|
|
||||||
assertThat(local.hour).isEqualTo(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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