Compare commits
2 Commits
release/v2
...
bc49730ea5
| Author | SHA1 | Date | |
|---|---|---|---|
| bc49730ea5 | |||
| a1d1894f84 |
@@ -8,14 +8,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Home-screen widgets now turn the page at midnight.** Both the month and the
|
|
||||||
agenda widget kept highlighting yesterday as "today" — and the agenda kept
|
|
||||||
greying out the wrong events as already past — until you paged the month back
|
|
||||||
and forth or removed and re-added the widget. Calendula now wakes itself at the
|
|
||||||
day boundary and redraws, and re-arms after a reboot, a clock change or a
|
|
||||||
flight into another timezone. Paging the month widget forward and back also
|
|
||||||
stops quietly pinning it to that month, so it follows the date again instead of
|
|
||||||
being stranded on the month you happened to be looking at ([#228]).
|
|
||||||
- **"Only this event" now actually saves your edit.** On some calendars —
|
- **"Only this event" now actually saves your edit.** On some calendars —
|
||||||
including Google ones that still show as on-device, and any local calendar —
|
including Google ones that still show as on-device, and any local calendar —
|
||||||
editing a single occurrence of a repeating event did nothing at all: the scope
|
editing a single occurrence of a repeating event did nothing at all: the scope
|
||||||
@@ -1491,5 +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
|
|
||||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||||
|
|||||||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@@ -50,9 +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: 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,13 +330,9 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||||
PROVIDER_CHANGED on any data change (our writes and external sync).
|
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||||
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
and the system broadcasts the date/time ones at midnight / clock
|
||||||
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
changes so "today" highlighting rolls over. -->
|
||||||
a free extra only, since Android 8+ withholds it from manifest
|
|
||||||
receivers. The four below re-arm that alarm: TIME_SET /
|
|
||||||
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
|
||||||
Exported: the system broadcasts arrive from outside the app. -->
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.WidgetUpdateReceiver"
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -350,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,19 +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 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1200,9 +1200,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
// Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means
|
// Deliberately stricter than deleteOccurrence's bare _sync_id check: the
|
||||||
// something on a row that recurs, so a non-recurring one keeps the
|
// detach path drops the occurrence with EXDATE, which only means anything
|
||||||
// exception path rather than getting a recurrence set written onto it.
|
// on a row that actually recurs. Without an RRULE there is nothing to
|
||||||
|
// exclude from, so such a row keeps the existing path rather than getting
|
||||||
|
// a recurrence set written onto a one-off event. The UI only offers the
|
||||||
|
// scope choice for a recurring event, so neither case is reachable today.
|
||||||
if (row.syncId == null && !row.rrule.isNullOrBlank()) {
|
if (row.syncId == null && !row.rrule.isNullOrBlank()) {
|
||||||
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
|
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
|
||||||
}
|
}
|
||||||
@@ -1230,24 +1233,46 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
* standalone event on the same calendar.
|
* standalone event on the same calendar.
|
||||||
*
|
*
|
||||||
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
|
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
|
||||||
* exactly like the cancelled one [deleteOccurrence] documents; with no
|
* exactly like the cancelled one [deleteOccurrence] documents. With no
|
||||||
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
|
* `_sync_id` the link never forms: the insert either fails outright or lands
|
||||||
* EXDATE plus a standalone row needs no link — what a detached instance
|
* an orphan row, and the parent's expansion collapses — which is why editing
|
||||||
* degrades to without a `RECURRENCE-ID` to carry it.
|
* one occurrence of such a series did nothing at all, and occasionally left a
|
||||||
|
* stray copy behind (Codeberg #234). The reporter's calendar was a Google one
|
||||||
|
* that "shows as on-device", i.e. rows the sync adapter had not stamped yet;
|
||||||
|
* Calendula's own local and contact special-date calendars are permanently in
|
||||||
|
* this shape.
|
||||||
*
|
*
|
||||||
* The detached row keeps no stored link back to its series, so: it no longer
|
* EXDATE + a standalone row needs no parent link, and is what a detached
|
||||||
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
|
* instance degrades to when there is no `RECURRENCE-ID` to carry it: the user
|
||||||
* children, and this is neither); its EXDATE hole is an absolute instant, so
|
* sees one edited event where the occurrence was, and the rest of the series
|
||||||
* re-timing the whole series brings the occurrence back beside the copy (a
|
* untouched.
|
||||||
* #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
|
* What that costs, plainly, because none of it is recoverable later — the
|
||||||
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
|
* detached row has *no* stored link back to its series, which is the whole
|
||||||
* EXDATE update then fails, since it would be a visible duplicate. The
|
* reason this path exists:
|
||||||
* reverse order risks the worse outcome — an excluded occurrence with no
|
* - It stops travelling with the series. Moving the series to another
|
||||||
* replacement, i.e. an edit that quietly deletes.
|
* calendar leaves it behind ([moveEvent] copies the master and its
|
||||||
|
* `ORIGINAL_ID` children; this is neither), and deleting the whole series
|
||||||
|
* leaves it standing where an exception row would have gone with the parent.
|
||||||
|
* - Its EXDATE hole is an absolute instant. Editing the series' *time* for all
|
||||||
|
* events moves the generated instances but not the hole, so the occurrence
|
||||||
|
* comes back alongside the detached copy. That staleness predates this path
|
||||||
|
* — a #47 delete resurrects the same way — but a duplicate is a louder
|
||||||
|
* symptom than a resurrection, and it wants fixing at the series-update end.
|
||||||
|
* - It is built from the form, not cloned from the parent, so columns the form
|
||||||
|
* doesn't model are dropped rather than inherited: `ORGANIZER`, `STATUS`,
|
||||||
|
* and the organizer/resource attendee rows [reconcileAttendees] otherwise
|
||||||
|
* preserves. Same limitation as [moveEvent]. Rare on the calendars that
|
||||||
|
* reach this path, which are locally authored, but not impossible.
|
||||||
|
*
|
||||||
|
* Order is deliberate. The insert goes first, so a failure there leaves the
|
||||||
|
* series completely untouched (the same discipline as
|
||||||
|
* [updateEventFromOccurrence]). If the EXDATE update then fails, the new row
|
||||||
|
* is a visible duplicate of an occurrence that is still in the series, so it
|
||||||
|
* is rolled back before the failure surfaces — better a save the user can
|
||||||
|
* retry than a silent duplicate. The reverse order would risk the opposite:
|
||||||
|
* an occurrence excluded from the series with no replacement, i.e. an edit
|
||||||
|
* that quietly deletes.
|
||||||
*/
|
*/
|
||||||
private fun detachOccurrence(
|
private fun detachOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
@@ -1256,15 +1281,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
// Already detached (or deleted) from a stale screen still pointing at the
|
// Already detached (or deleted): the series no longer contains this
|
||||||
// parent: the EXDATE merge would fold the repeat away and still report a
|
// occurrence, so there is nothing here to edit. Reached from a stale
|
||||||
// changed row, quietly leaving a *second* standalone copy.
|
// screen still pointing at the parent — without this the EXDATE merge
|
||||||
|
// would fold the repeat away, the update would still report a changed
|
||||||
|
// row, and the save would quietly leave a *second* standalone copy.
|
||||||
if (exdateContains(row.exdate, beginMillis, isAllDay = row.allDay != 0)) {
|
if (exdateContains(row.exdate, beginMillis, isAllDay = row.allDay != 0)) {
|
||||||
throw NoSuchEventException(eventId)
|
throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
// Reminders, guests and colour come along like any new event, and so does
|
// Carries the form's reminders, guests and colour like any new event —
|
||||||
// a fresh UID — the detached row is a separate event now, and sharing the
|
// and a fresh UID, because the detached row really is a separate event
|
||||||
// parent's would collide with it in .ics restore dedup.
|
// now: sharing the parent's would collide with it in .ics restore dedup.
|
||||||
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
||||||
val values = buildOccurrenceExdateValues(
|
val values = buildOccurrenceExdateValues(
|
||||||
existingExdate = row.exdate,
|
existingExdate = row.exdate,
|
||||||
@@ -1275,9 +1302,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
timezone = row.timezone,
|
timezone = row.timezone,
|
||||||
allDay = row.allDay,
|
allDay = row.allDay,
|
||||||
)
|
)
|
||||||
// Rows touched, not occurrences excluded — 1 whenever the series row still
|
// Rows touched, not occurrences excluded: this is 1 whenever the series
|
||||||
// exists. It catches the row disappearing under us, not an EXDATE the
|
// row still exists. It catches the row disappearing under us, not an
|
||||||
// provider's expansion fails to match.
|
// EXDATE the provider's expansion fails to match — that would report
|
||||||
|
// success and leave the duplicate. Same rollback idiom as moveEvent.
|
||||||
val updatedRows = try {
|
val updatedRows = try {
|
||||||
resolver.update(
|
resolver.update(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
@@ -1298,8 +1326,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
|
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
|
||||||
* update failed. Best effort: the caller is already throwing, and the worst
|
* update failed. Best effort: the caller is already throwing, and a rollback
|
||||||
* case is the duplicate we were avoiding — never a lost occurrence.
|
* that itself fails must not replace the real failure with a confusing one.
|
||||||
|
* The worst case is the duplicate we were trying to avoid, which the user can
|
||||||
|
* see and delete — never a lost occurrence.
|
||||||
*/
|
*/
|
||||||
private fun rollBackDetached(detachedId: Long) {
|
private fun rollBackDetached(detachedId: Long) {
|
||||||
runCatching { deleteEvent(detachedId) }.onFailure {
|
runCatching { deleteEvent(detachedId) }.onFailure {
|
||||||
|
|||||||
@@ -248,13 +248,18 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
|
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
|
||||||
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
|
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
|
||||||
*
|
*
|
||||||
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
|
* This is the "edit only this event" shape for a series with **no `_sync_id`**,
|
||||||
* exception row can't attach to its parent at all (Codeberg #234).
|
* where an exception row can't be used at all — see [buildOccurrenceExdateValues]
|
||||||
|
* for why the parent link never forms. The occurrence is dropped from the parent
|
||||||
|
* with EXDATE and re-created as its own event, which is what a detached instance
|
||||||
|
* degrades to without a `RECURRENCE-ID` to carry it.
|
||||||
*
|
*
|
||||||
* The exception path gets the rule dropped for free — the provider clears the
|
* Dropping the rule mirrors what the exception path gets for free: the provider
|
||||||
* RRULE it cloned when an exception carries DTSTART + DURATION
|
* clears the RRULE it cloned from the parent when an exception carries
|
||||||
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
|
* DTSTART + DURATION ([buildOccurrenceExceptionValues]). Here nothing is cloned,
|
||||||
* by hand; leaving it on would insert a second *series* overlapping the first.
|
* so the rule has to be stripped by hand — leaving it on would insert a second
|
||||||
|
* *series* overlapping the first, which is the duplication this path exists to
|
||||||
|
* avoid.
|
||||||
*/
|
*/
|
||||||
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
|
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
|
||||||
|
|
||||||
@@ -452,11 +457,14 @@ internal fun buildOccurrenceExdateValues(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
||||||
* — i.e. it has already been dropped from the series, deleted or detached.
|
* — i.e. this occurrence has already been dropped from the series (deleted, or
|
||||||
|
* detached into its own event).
|
||||||
*
|
*
|
||||||
* Guards the detach path against running twice from a stale screen: the EXDATE
|
* Guards the detach path against running twice for the same occurrence, which a
|
||||||
* merge folds the repeat away silently and the update still reports one row
|
* stale detail screen still pointing at the parent can otherwise reach. The
|
||||||
* changed, so a second save would leave a second standalone copy.
|
* EXDATE merge folds the repeat away silently and the parent update still
|
||||||
|
* reports one row changed, so without this check the second save would leave a
|
||||||
|
* *second* standalone copy and call it a success.
|
||||||
*/
|
*/
|
||||||
internal fun exdateContains(
|
internal fun exdateContains(
|
||||||
existingExdate: String?,
|
existingExdate: String?,
|
||||||
|
|||||||
@@ -265,9 +265,10 @@ fun EventEditScreen(
|
|||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onSaved()
|
onSaved()
|
||||||
}
|
}
|
||||||
// A failed save leaves the form looking unchanged, so the snackbar is
|
// A failed save leaves the user on a form that looks exactly as it
|
||||||
// the only sign anything happened — long rather than the default
|
// did, so the snackbar is the only sign anything happened — it gets
|
||||||
// flash (Codeberg #234: it read as "nothing happens at all").
|
// the long duration rather than the default flash (Codeberg #234:
|
||||||
|
// the failure read as "nothing happens at all").
|
||||||
SaveUiState.Failed -> {
|
SaveUiState.Failed -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
||||||
|
|||||||
@@ -755,13 +755,14 @@ class EventEditViewModel @Inject constructor(
|
|||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
SaveUiState.NeedsPermission
|
SaveUiState.NeedsPermission
|
||||||
} catch (e: NoSuchEventException) {
|
} catch (e: NoSuchEventException) {
|
||||||
// The event or occurrence is already gone: the same answer the
|
// The write found the event (or the occurrence) already gone —
|
||||||
// pre-check gives, and better than a bare "couldn't save".
|
// the same answer the pre-check gives, and a far better one than
|
||||||
|
// a bare "couldn't save" for something that no longer exists.
|
||||||
SaveUiState.Gone
|
SaveUiState.Gone
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// The user only gets a generic snackbar, so without this a failed
|
// The user only gets a generic snackbar, so without this a
|
||||||
// write leaves nothing to report (Codeberg #234). Scope and event
|
// failed write leaves no trace at all to report (Codeberg #234).
|
||||||
// id only — never the form's content.
|
// Scope and event id only — never the form's content.
|
||||||
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
|
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
|
||||||
SaveUiState.Failed
|
SaveUiState.Failed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +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`, 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,40 +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` — 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.
|
|
||||||
*
|
*
|
||||||
* `DATE_CHANGED` is a free extra in the filter that nothing depends on — see
|
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
|
||||||
* [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
|
* XML, and the month widget's refresh button forces an immediate redraw.
|
||||||
* provider XML and the month widget's refresh button.
|
|
||||||
*
|
|
||||||
* Exported for the system broadcasts; an extra redraw from another app is
|
|
||||||
* harmless.
|
|
||||||
*/
|
*/
|
||||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
// 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()
|
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.
|
||||||
@@ -59,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,32 +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] 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,17 +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, so the widget
|
|
||||||
// goes back to *following* the date rather than being pinned to
|
|
||||||
// whichever month was current at the tap. Paging out and back is the
|
|
||||||
// workaround #228's reporter used, and pinning it there would have
|
|
||||||
// stuck them on that month once it stopped being the current one.
|
|
||||||
if (next == currentMonthIndex(systemZone())) {
|
|
||||||
prefs.remove(MONTH_INDEX_KEY)
|
|
||||||
} else {
|
|
||||||
prefs[MONTH_INDEX_KEY] = next
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
MonthWidget().update(context.applicationContext, glanceId)
|
MonthWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,35 +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] 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -571,11 +571,14 @@ class EventWriteMapperTest {
|
|||||||
times = detached.toWriteTimes(berlin),
|
times = detached.toWriteTimes(berlin),
|
||||||
)
|
)
|
||||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||||
// A surviving rule would insert a second *series* overlapping the first
|
// The rule must not survive: without a _sync_id nothing clears an
|
||||||
|
// inherited RRULE the way the exception path's DTSTART + DURATION does,
|
||||||
|
// so keeping it would insert a second *series* overlapping the first
|
||||||
// (Codeberg #234's stray duplicate).
|
// (Codeberg #234's stray duplicate).
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||||
// A one-off row carries DTEND rather than a duration.
|
// A one-off row carries DTEND — the invariant buildEventInsertValues
|
||||||
|
// holds for every non-recurring event.
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||||
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L)
|
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L)
|
||||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||||
@@ -583,8 +586,9 @@ class EventWriteMapperTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a detached occurrence carries every edited field onto the new row`() {
|
fun `a detached occurrence carries every edited field onto the new row`() {
|
||||||
// Built from the form, not cloned from the parent: a field dropped here
|
// The detached row is built from the form rather than cloned from the
|
||||||
// is an edit silently lost.
|
// parent, so anything the user edited has to survive the trip — a field
|
||||||
|
// dropped here is an edit silently lost.
|
||||||
val edited = form(timezone = "America/New_York").copy(
|
val edited = form(timezone = "America/New_York").copy(
|
||||||
title = " Standup ",
|
title = " Standup ",
|
||||||
location = "Room 2",
|
location = "Room 2",
|
||||||
@@ -608,10 +612,12 @@ class EventWriteMapperTest {
|
|||||||
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
|
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
|
||||||
assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
|
assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
|
||||||
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
|
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
|
||||||
// The pinned zone survives — never re-anchored to the device.
|
// The pinned zone survives too — a detached occurrence must not be
|
||||||
|
// silently re-anchored to the device.
|
||||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
|
||||||
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
|
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
|
||||||
// Reminders aren't columns; the insert path seeds them from the form.
|
// Reminders and guests aren't columns — the insert path seeds them from
|
||||||
|
// the form, so they only have to survive on the form itself.
|
||||||
assertThat(detached.reminders).containsExactly(10)
|
assertThat(detached.reminders).containsExactly(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,8 +645,9 @@ class EventWriteMapperTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the detached row lands exactly where the parent's exdate removes it`() {
|
fun `the detached row lands exactly where the parent's exdate removes it`() {
|
||||||
// The two halves must agree on the instant, or the user sees the
|
// The two halves of the no-_sync_id edit have to agree, or the user sees
|
||||||
// occurrence twice or not at all.
|
// the occurrence twice or not at all. With the time left untouched, the
|
||||||
|
// EXDATE stamp and the detached row's DTSTART describe the same instant.
|
||||||
val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY")
|
val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY")
|
||||||
val occurrenceMillis = 1_781_164_800_000L
|
val occurrenceMillis = 1_781_164_800_000L
|
||||||
|
|
||||||
@@ -695,7 +702,8 @@ class EventWriteMapperTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an occurrence already excluded is recognised, timed and all-day`() {
|
fun `an occurrence already excluded is recognised, timed and all-day`() {
|
||||||
// Detaching twice would leave a second standalone copy.
|
// Detaching twice would fold the repeated EXDATE away and still report a
|
||||||
|
// changed row, leaving a second standalone copy of one occurrence.
|
||||||
assertThat(
|
assertThat(
|
||||||
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false),
|
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false),
|
||||||
).isTrue()
|
).isTrue()
|
||||||
@@ -716,7 +724,8 @@ class EventWriteMapperTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an exclusion is found anywhere in a multi-entry exdate list`() {
|
fun `an exclusion is found anywhere in a multi-entry exdate list`() {
|
||||||
// Whitespace after a comma is legal in the stored column.
|
// Whitespace after a comma is legal in the stored column and must not
|
||||||
|
// hide an exclusion — that would let a duplicate through.
|
||||||
assertThat(
|
assertThat(
|
||||||
exdateContains(
|
exdateContains(
|
||||||
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
|
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
|
||||||
|
|||||||
@@ -1,164 +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`() {
|
|
||||||
// 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