Compare commits
2 Commits
release/v2
...
7e7079df00
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e7079df00 | |||
| 809013997d |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -16,16 +16,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
flight into another timezone. Paging the month widget forward and back also
|
flight into another timezone. Paging the month widget forward and back also
|
||||||
stops quietly pinning it to that month, so it follows the date again instead of
|
stops quietly pinning it to that month, so it follows the date again instead of
|
||||||
being stranded on the month you happened to be looking at ([#228]).
|
being stranded on the month you happened to be looking at ([#228]).
|
||||||
- **"Only this event" now actually saves your edit.** On some calendars —
|
|
||||||
including Google ones that still show as on-device, and any local calendar —
|
|
||||||
editing a single occurrence of a repeating event did nothing at all: the scope
|
|
||||||
dialog closed, the edit screen stayed put, and saving again just repeated it.
|
|
||||||
Android can only attach a single-occurrence change to its series once the
|
|
||||||
calendar has been synced at least once, so on those calendars the change had
|
|
||||||
nowhere to go. Calendula now removes that one occurrence from the series and
|
|
||||||
saves the edit as its own event instead, which is what you see either way. A
|
|
||||||
save that does fail also says so for longer, rather than flashing past
|
|
||||||
([#234]).
|
|
||||||
|
|
||||||
## [2.19.3] — 2026-08-22
|
## [2.19.3] — 2026-08-22
|
||||||
|
|
||||||
@@ -1492,4 +1482,3 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||||
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
|
||||||
|
|||||||
11
app/proguard-rules.pro
vendored
11
app/proguard-rules.pro
vendored
@@ -51,8 +51,11 @@
|
|||||||
# obfuscated name and orphan the stored mapping.
|
# obfuscated name and orphan the stored mapping.
|
||||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
|
||||||
# Belt and braces one level up: the two receivers are nearly as alike, and the
|
# Belt and braces one level up: MonthWidgetReceiver and AgendaWidgetReceiver are
|
||||||
# provider map is keyed off the receiver component too. Redundant today (AGP's
|
# nearly as alike (same supertype, same overrides, only a differing property
|
||||||
# manifest-derived rules cover them), but #89 cost a release to diagnose and the
|
# initializer), and Glance's provider map is keyed off the receiver component
|
||||||
# guarantee should not rest on a component staying in the manifest.
|
# too. AGP's manifest-derived keep rules already cover them, and the rule above
|
||||||
|
# keeps the two widgets distinct enough that the receivers' constructors differ —
|
||||||
|
# so this is redundant today. It is here because #89 cost a release to diagnose
|
||||||
|
# and the guarantee should not rest on a component staying in the manifest.
|
||||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver
|
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|||||||
@@ -330,12 +330,13 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||||
PROVIDER_CHANGED on any data change (our writes and external sync).
|
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||||
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
and the day boundary arrives as the app's own ROLLOVER alarm (#228),
|
||||||
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
delivered by an explicit PendingIntent so it needs no filter here.
|
||||||
a free extra only, since Android 8+ withholds it from manifest
|
DATE_CHANGED is kept as a free extra only — it is not an exempted
|
||||||
receivers. The four below re-arm that alarm: TIME_SET /
|
implicit broadcast, so a manifest-declared receiver is not given it
|
||||||
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
on Android 8+. TIME_SET / TIMEZONE_CHANGED move the day boundary,
|
||||||
|
and boot / package-replace wipe the alarm, so all four re-arm it.
|
||||||
Exported: the system broadcasts arrive from outside the app. -->
|
Exported: the system broadcasts arrive from outside the app. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.WidgetUpdateReceiver"
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
|
|||||||
@@ -50,9 +50,11 @@ class CalendulaApp : Application() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-arm the widgets' midnight rollover from whatever is actually placed
|
* Re-arm the widgets' midnight rollover from whatever is actually placed
|
||||||
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
|
* (#228). Idempotent, and it covers the cases no broadcast reaches — an
|
||||||
* dropped by a force-stop is armed again the next time the app is opened.
|
* install upgrading into the fix, or an alarm dropped by a force-stop, is
|
||||||
* Off the main thread: a handful of binder calls on every process start.
|
* armed again the next time the app is opened. Off the main thread because
|
||||||
|
* it makes a handful of binder calls and every process start runs it,
|
||||||
|
* including ones a worker or a receiver triggered.
|
||||||
*/
|
*/
|
||||||
private fun reconcileWidgetRollover() {
|
private fun reconcileWidgetRollover() {
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
|||||||
@@ -232,15 +232,10 @@ interface CalendarDataSource {
|
|||||||
): Long
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event at [beginMillis] (the
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
* `Events._ID` of the row now holding them.
|
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||||
*
|
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
* A series with a `_sync_id` gets a modified-occurrence exception. One
|
|
||||||
* without gets the occurrence excluded from the parent via EXDATE plus a
|
|
||||||
* standalone event carrying the edits — an exception cannot link to its
|
|
||||||
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
|
|
||||||
* [allDayReminderTimeMinutes]: see [insertEvent].
|
|
||||||
*/
|
*/
|
||||||
fun updateOccurrence(
|
fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
@@ -1199,13 +1194,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
val row = querySeriesRow(eventId)
|
|
||||||
// Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means
|
|
||||||
// something on a row that recurs, so a non-recurring one keeps the
|
|
||||||
// exception path rather than getting a recurrence set written onto it.
|
|
||||||
if (row.syncId == null && !row.rrule.isNullOrBlank()) {
|
|
||||||
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
|
|
||||||
}
|
|
||||||
// The provider clones the series row and applies these values on top.
|
// The provider clones the series row and applies these values on top.
|
||||||
val values = buildOccurrenceExceptionValues(
|
val values = buildOccurrenceExceptionValues(
|
||||||
form = form,
|
form = form,
|
||||||
@@ -1224,89 +1212,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return exceptionId
|
return exceptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "Edit only this event" on a series with **no `_sync_id`**: drop the
|
|
||||||
* occurrence from the parent with EXDATE and insert the edited values as a
|
|
||||||
* standalone event on the same calendar.
|
|
||||||
*
|
|
||||||
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
|
|
||||||
* exactly like the cancelled one [deleteOccurrence] documents; with no
|
|
||||||
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
|
|
||||||
* EXDATE plus a standalone row needs no link — what a detached instance
|
|
||||||
* degrades to without a `RECURRENCE-ID` to carry it.
|
|
||||||
*
|
|
||||||
* The detached row keeps no stored link back to its series, so: it no longer
|
|
||||||
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
|
|
||||||
* children, and this is neither); its EXDATE hole is an absolute instant, so
|
|
||||||
* re-timing the whole series brings the occurrence back beside the copy (a
|
|
||||||
* #47 delete resurrects the same way); and it is built from the form, not
|
|
||||||
* cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
|
|
||||||
* preserves are dropped — the same limitation as [moveEvent].
|
|
||||||
*
|
|
||||||
* Insert first, so a failure leaves the series untouched
|
|
||||||
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
|
|
||||||
* EXDATE update then fails, since it would be a visible duplicate. The
|
|
||||||
* reverse order risks the worse outcome — an excluded occurrence with no
|
|
||||||
* replacement, i.e. an edit that quietly deletes.
|
|
||||||
*/
|
|
||||||
private fun detachOccurrence(
|
|
||||||
eventId: Long,
|
|
||||||
beginMillis: Long,
|
|
||||||
row: SeriesRow,
|
|
||||||
form: EventForm,
|
|
||||||
allDayReminderTimeMinutes: Int,
|
|
||||||
): Long {
|
|
||||||
// Already detached (or deleted) from a stale screen still pointing at the
|
|
||||||
// parent: the EXDATE merge would fold the repeat away and still report a
|
|
||||||
// changed row, quietly leaving a *second* standalone copy.
|
|
||||||
if (exdateContains(row.exdate, beginMillis, isAllDay = row.allDay != 0)) {
|
|
||||||
throw NoSuchEventException(eventId)
|
|
||||||
}
|
|
||||||
// Reminders, guests and colour come along like any new event, and so does
|
|
||||||
// a fresh UID — the detached row is a separate event now, and sharing the
|
|
||||||
// parent's would collide with it in .ics restore dedup.
|
|
||||||
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
|
||||||
val values = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = row.exdate,
|
|
||||||
occurrenceMillis = beginMillis,
|
|
||||||
dtStartMillis = row.dtStartMillis,
|
|
||||||
rrule = row.rrule,
|
|
||||||
duration = row.duration,
|
|
||||||
timezone = row.timezone,
|
|
||||||
allDay = row.allDay,
|
|
||||||
)
|
|
||||||
// Rows touched, not occurrences excluded — 1 whenever the series row still
|
|
||||||
// exists. It catches the row disappearing under us, not an EXDATE the
|
|
||||||
// provider's expansion fails to match.
|
|
||||||
val updatedRows = try {
|
|
||||||
resolver.update(
|
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
|
||||||
values.toContentValues(), null, null,
|
|
||||||
)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
rollBackDetached(detachedId)
|
|
||||||
throw t
|
|
||||||
}
|
|
||||||
if (updatedRows == 0) {
|
|
||||||
rollBackDetached(detachedId)
|
|
||||||
throw WriteFailedException(
|
|
||||||
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return detachedId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
|
|
||||||
* update failed. Best effort: the caller is already throwing, and the worst
|
|
||||||
* case is the duplicate we were avoiding — never a lost occurrence.
|
|
||||||
*/
|
|
||||||
private fun rollBackDetached(detachedId: Long) {
|
|
||||||
runCatching { deleteEvent(detachedId) }.onFailure {
|
|
||||||
Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateEventFromOccurrence(
|
override fun updateEventFromOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -243,21 +243,6 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The form as a **detached occurrence**: the same edited values, with the
|
|
||||||
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
|
|
||||||
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
|
|
||||||
*
|
|
||||||
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
|
|
||||||
* exception row can't attach to its parent at all (Codeberg #234).
|
|
||||||
*
|
|
||||||
* The exception path gets the rule dropped for free — the provider clears the
|
|
||||||
* RRULE it cloned when an exception carries DTSTART + DURATION
|
|
||||||
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
|
|
||||||
* by hand; leaving it on would insert a second *series* overlapping the first.
|
|
||||||
*/
|
|
||||||
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||||
@@ -450,23 +435,6 @@ internal fun buildOccurrenceExdateValues(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
|
||||||
* — i.e. it has already been dropped from the series, deleted or detached.
|
|
||||||
*
|
|
||||||
* Guards the detach path against running twice from a stale screen: the EXDATE
|
|
||||||
* merge folds the repeat away silently and the update still reports one row
|
|
||||||
* changed, so a second save would leave a second standalone copy.
|
|
||||||
*/
|
|
||||||
internal fun exdateContains(
|
|
||||||
existingExdate: String?,
|
|
||||||
occurrenceMillis: Long,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
): Boolean {
|
|
||||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
|
||||||
return existingExdate?.split(',')?.any { it.trim() == stamp } == true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -265,16 +264,13 @@ fun EventEditScreen(
|
|||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onSaved()
|
onSaved()
|
||||||
}
|
}
|
||||||
// A failed save leaves the form looking unchanged, so the snackbar is
|
|
||||||
// the only sign anything happened — long rather than the default
|
|
||||||
// flash (Codeberg #234: it read as "nothing happens at all").
|
|
||||||
SaveUiState.Failed -> {
|
SaveUiState.Failed -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
snackbarHostState.showSnackbar(saveFailedMessage)
|
||||||
}
|
}
|
||||||
SaveUiState.NeedsPermission -> {
|
SaveUiState.NeedsPermission -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long)
|
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||||
}
|
}
|
||||||
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
||||||
else -> Unit
|
else -> Unit
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -57,8 +56,6 @@ import kotlin.time.Duration.Companion.minutes
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val TAG = "EventEdit"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||||
* want different reminder handling (#49), and differ in whether they own the
|
* want different reminder handling (#49), and differ in whether they own the
|
||||||
@@ -754,15 +751,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
throw e
|
throw e
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
SaveUiState.NeedsPermission
|
SaveUiState.NeedsPermission
|
||||||
} catch (e: NoSuchEventException) {
|
|
||||||
// The event or occurrence is already gone: the same answer the
|
|
||||||
// pre-check gives, and better than a bare "couldn't save".
|
|
||||||
SaveUiState.Gone
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// The user only gets a generic snackbar, so without this a failed
|
|
||||||
// write leaves nothing to report (Codeberg #234). Scope and event
|
|
||||||
// id only — never the form's content.
|
|
||||||
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
|
|
||||||
SaveUiState.Failed
|
SaveUiState.Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,21 +23,26 @@ import kotlin.time.Instant
|
|||||||
* Holds the app's own wake-up for the next local midnight, so the home-screen
|
* Holds the app's own wake-up for the next local midnight, so the home-screen
|
||||||
* widgets roll "today" over on the day boundary (#228).
|
* widgets roll "today" over on the day boundary (#228).
|
||||||
*
|
*
|
||||||
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
|
* The widgets used to lean on `ACTION_DATE_CHANGED`, but that broadcast is not
|
||||||
* implicit broadcast — a manifest-declared receiver has not been given it since
|
* on the implicit-broadcast exemption list, so a manifest-declared receiver has
|
||||||
* Android 8, leaving only the throttled `updatePeriodMillis`.
|
* never been given it since Android 8 — leaving only `updatePeriodMillis`, which
|
||||||
|
* the system defers in doze and OEM skins throttle harder still. The result was
|
||||||
|
* yesterday staying highlighted (and the agenda's past-event dimming staying
|
||||||
|
* anchored to yesterday) until something else forced a redraw.
|
||||||
*
|
*
|
||||||
* Exactly one alarm exists at a time and every firing re-arms the next, the same
|
* Exactly one alarm exists at a time and every firing re-arms the next one, the
|
||||||
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
|
* same shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
|
||||||
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
|
* It is deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
|
||||||
* survives doze (plain `set` does not), a rollover a few minutes late is
|
* survives doze (which plain `set` does not), and a rollover that lands a few
|
||||||
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
|
* minutes late is invisible on a sleeping screen. Exact alarms stay reserved for
|
||||||
|
* reminder snooze.
|
||||||
*/
|
*/
|
||||||
object WidgetRolloverScheduler {
|
object WidgetRolloverScheduler {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fire just *after* midnight: an alarm delivered a few milliseconds early
|
* Fire just *after* midnight, never exactly on it. An alarm delivered a few
|
||||||
* would still read the old date and re-arm for an instant later.
|
* milliseconds early would still read the old date and re-arm for an instant
|
||||||
|
* later; the offset makes "the day has changed" unambiguous.
|
||||||
*/
|
*/
|
||||||
internal val ROLLOVER_SLACK = 5.seconds
|
internal val ROLLOVER_SLACK = 5.seconds
|
||||||
|
|
||||||
@@ -63,14 +68,16 @@ object WidgetRolloverScheduler {
|
|||||||
/**
|
/**
|
||||||
* The instant just after the next local midnight following [now] in [zone].
|
* The instant just after the next local midnight following [now] in [zone].
|
||||||
*
|
*
|
||||||
* The *actual* start of day, not 00:00, so it holds where a DST jump means
|
* Uses the *actual* start of the day rather than 00:00, so it stays correct
|
||||||
* midnight never happens (Havana) and where a date-line move skips a whole
|
* where a DST jump means midnight never happens (Havana springs from 00:00 to
|
||||||
* local date (Apia, December 2011) — the loop walks on to the next real day.
|
* 01:00) and where a whole local date is skipped by a date-line move (Apia
|
||||||
|
* had no 30 December 2011) — the loop then walks on to the next real day.
|
||||||
*
|
*
|
||||||
* The mirror case — a zone rewinding *across* midnight, so the day starts
|
* The mirror case, a zone that rewinds *across* midnight so the day starts
|
||||||
* twice — resolves to the earlier start and runs an hour ahead of the clock.
|
* twice, resolves to the earlier start; the widget would then run an hour
|
||||||
* No live tz entry does that (Brazil dropped DST in 2019) and
|
* ahead of the clock. No entry in the current tz database does that (Brazil,
|
||||||
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
|
* which used to, dropped DST in 2019), and `updatePeriodMillis` covers it,
|
||||||
|
* so it is not worth carrying state to detect.
|
||||||
*/
|
*/
|
||||||
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
|
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
|
||||||
val date = now.toLocalDateTime(zone).date
|
val date = now.toLocalDateTime(zone).date
|
||||||
|
|||||||
@@ -19,31 +19,38 @@ import kotlinx.coroutines.launch
|
|||||||
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
|
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
|
||||||
* the day boundary, so "today" highlighting and the agenda's past-event
|
* the day boundary, so "today" highlighting and the agenda's past-event
|
||||||
* dimming move on (#228).
|
* dimming move on (#228).
|
||||||
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
|
* - `TIME_SET` / `TIMEZONE_CHANGED` — a clock or zone change moves the day
|
||||||
* re-arm.
|
* boundary relative to the armed alarm, so both redraw *and* re-arm.
|
||||||
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
|
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms. The
|
||||||
* latter is also what arms installs upgrading into the fix.
|
* package-replaced one is also what arms existing installs that upgrade into
|
||||||
|
* the fix without re-adding their widget.
|
||||||
*
|
*
|
||||||
* `DATE_CHANGED` is a free extra in the filter that nothing depends on — see
|
* `DATE_CHANGED` is still in the manifest filter as a free extra, but nothing
|
||||||
* [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
|
* depends on it: it is not an exempted implicit broadcast, so a manifest-declared
|
||||||
* provider XML and the month widget's refresh button.
|
* receiver has not actually been given it since Android 8. The widgets also carry
|
||||||
|
* an `updatePeriodMillis` backstop in their provider XML, and the month widget's
|
||||||
|
* refresh button forces an immediate redraw.
|
||||||
*
|
*
|
||||||
* Exported for the system broadcasts; an extra redraw from another app is
|
* Exported for the system broadcasts; an extra redraw triggered by another app
|
||||||
* harmless.
|
* is harmless.
|
||||||
*/
|
*/
|
||||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
// Exported, so anything can reach it with an explicit intent. Nothing
|
// The receiver has to stay exported for the system broadcasts, so an
|
||||||
// here crosses a trust boundary, but narrowing to the actions we asked
|
// explicit intent can reach it with anything in it. Nothing here reads
|
||||||
// for keeps a stray broadcast from costing two wide provider reads.
|
// the intent's data and nothing crosses a trust boundary, but narrowing
|
||||||
|
// to the actions we actually asked for keeps a stray broadcast from
|
||||||
|
// costing two wide provider reads.
|
||||||
if (intent.action !in HANDLED_ACTIONS) return
|
if (intent.action !in HANDLED_ACTIONS) return
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
// Re-arm first, so the next day boundary is covered whatever the redraw
|
// Re-arm first: whatever happens to the redraw, the next day boundary is
|
||||||
// does. Every handled action either dropped, consumed or invalidated it.
|
// covered. Boot and package-replace dropped the alarm outright; a
|
||||||
|
// rollover just consumed it; a clock change invalidated it.
|
||||||
WidgetRolloverScheduler.sync(appContext)
|
WidgetRolloverScheduler.sync(appContext)
|
||||||
// The host sends APPWIDGET_UPDATE after both of these anyway, so
|
// Boot and package-replace only cost us the alarm. The host sends
|
||||||
// redrawing here would only repeat the work in a cold process, at the
|
// APPWIDGET_UPDATE after both anyway, so redrawing here would just repeat
|
||||||
// moment the device is most contended.
|
// two wide provider reads and two RemoteViews serialisations in a cold
|
||||||
|
// process, at the moment the device is most contended.
|
||||||
if (intent.action in REARM_ONLY_ACTIONS) return
|
if (intent.action in REARM_ONLY_ACTIONS) return
|
||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
// Calendar data may have changed (sync / our own write) — drop the cached
|
// Calendar data may have changed (sync / our own write) — drop the cached
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
* Last agenda widget removed. [WidgetRolloverScheduler.sync] only cancels the
|
||||||
* no month widget is left either.
|
* alarm if no month widget is left either, so removing one kind never stops
|
||||||
|
* the other from rolling over.
|
||||||
*/
|
*/
|
||||||
override fun onDisabled(context: Context) {
|
override fun onDisabled(context: Context) {
|
||||||
super.onDisabled(context)
|
super.onDisabled(context)
|
||||||
|
|||||||
@@ -153,11 +153,13 @@ class ShiftMonthAction : ActionCallback {
|
|||||||
updateAppWidgetState(context, glanceId) { prefs ->
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
||||||
val next = cur + delta
|
val next = cur + delta
|
||||||
// Landing back on the current month clears the key, so the widget
|
// Landing back on the current month clears the key rather than
|
||||||
// goes back to *following* the date rather than being pinned to
|
// storing today's index, so the widget goes back to *following* the
|
||||||
// whichever month was current at the tap. Paging out and back is the
|
// date instead of being pinned to the month that happened to be
|
||||||
// workaround #228's reporter used, and pinning it there would have
|
// current when it was tapped. Paging forward and back is the very
|
||||||
// stuck them on that month once it stopped being the current one.
|
// workaround #228's reporter used to force a redraw; storing the
|
||||||
|
// index there would have left them stuck on that month for good once
|
||||||
|
// it stopped being the current one.
|
||||||
if (next == currentMonthIndex(systemZone())) {
|
if (next == currentMonthIndex(systemZone())) {
|
||||||
prefs.remove(MONTH_INDEX_KEY)
|
prefs.remove(MONTH_INDEX_KEY)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
* Last month widget removed. [WidgetRolloverScheduler.sync] only cancels the
|
||||||
* no agenda widget is left either.
|
* alarm if no agenda widget is left either, so removing one kind never stops
|
||||||
|
* the other from rolling over.
|
||||||
*/
|
*/
|
||||||
override fun onDisabled(context: Context) {
|
override fun onDisabled(context: Context) {
|
||||||
super.onDisabled(context)
|
super.onDisabled(context)
|
||||||
@@ -30,10 +31,11 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
|
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
|
||||||
* owns, so it doubles as the alarm's self-heal: anything that drops a
|
* owns, so it doubles as the rollover alarm's self-heal: anything that drops
|
||||||
* pending alarm without a broadcast (force-stop, battery restriction, an OEM
|
* a pending alarm without a broadcast — a force-stop, a battery-restricted
|
||||||
* freeze) is repaired here rather than on the next app open. Re-arming
|
* transition, an OEM freeze — is repaired here rather than waiting for the
|
||||||
* closer to midnight also narrows the inexact delivery window.
|
* app to be opened. Re-arming closer to midnight also narrows the inexact
|
||||||
|
* alarm's delivery window, which scales with how far out it was set.
|
||||||
*/
|
*/
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|||||||
@@ -558,174 +558,6 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- toDetachedOccurrence ("edit only this event", no _sync_id) ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a detached occurrence drops the series rule and becomes a one-off row`() {
|
|
||||||
val edited = form().copy(title = "Moved", rrule = "FREQ=WEEKLY;BYDAY=TH")
|
|
||||||
val detached = edited.toDetachedOccurrence()
|
|
||||||
|
|
||||||
val values = buildEventInsertValues(
|
|
||||||
form = detached,
|
|
||||||
uid = "uid@calendula",
|
|
||||||
times = detached.toWriteTimes(berlin),
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
|
||||||
// A surviving rule would insert a second *series* overlapping the first
|
|
||||||
// (Codeberg #234's stray duplicate).
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
|
||||||
// A one-off row carries DTEND rather than a duration.
|
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
|
||||||
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L)
|
|
||||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a detached occurrence carries every edited field onto the new row`() {
|
|
||||||
// Built from the form, not cloned from the parent: a field dropped here
|
|
||||||
// is an edit silently lost.
|
|
||||||
val edited = form(timezone = "America/New_York").copy(
|
|
||||||
title = " Standup ",
|
|
||||||
location = "Room 2",
|
|
||||||
description = "notes",
|
|
||||||
reminders = listOf(10),
|
|
||||||
availability = Availability.Free,
|
|
||||||
accessLevel = AccessLevel.Private,
|
|
||||||
rrule = "FREQ=DAILY",
|
|
||||||
)
|
|
||||||
val detached = edited.toDetachedOccurrence()
|
|
||||||
|
|
||||||
val values = buildEventInsertValues(
|
|
||||||
form = detached,
|
|
||||||
uid = "uid@calendula",
|
|
||||||
times = detached.toWriteTimes(berlin),
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Standup")
|
|
||||||
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Room 2")
|
|
||||||
assertThat(values[CalendarContract.Events.DESCRIPTION]).isEqualTo("notes")
|
|
||||||
assertThat(values[CalendarContract.Events.AVAILABILITY])
|
|
||||||
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
|
|
||||||
assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
|
|
||||||
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
|
|
||||||
// The pinned zone survives — never re-anchored to the device.
|
|
||||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
|
|
||||||
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
|
|
||||||
// Reminders aren't columns; the insert path seeds them from the form.
|
|
||||||
assertThat(detached.reminders).containsExactly(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a detached all-day occurrence stays on UTC midnights`() {
|
|
||||||
val edited = form(
|
|
||||||
isAllDay = true,
|
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
|
||||||
).copy(title = "Birthday", rrule = "FREQ=YEARLY")
|
|
||||||
val detached = edited.toDetachedOccurrence()
|
|
||||||
|
|
||||||
val values = buildEventInsertValues(
|
|
||||||
form = detached,
|
|
||||||
uid = "uid@calendula",
|
|
||||||
times = detached.toWriteTimes(berlin),
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
|
||||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
|
|
||||||
// Exclusive DTEND — the next UTC midnight.
|
|
||||||
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_222_400_000L)
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the detached row lands exactly where the parent's exdate removes it`() {
|
|
||||||
// The two halves must agree on the instant, or the user sees the
|
|
||||||
// occurrence twice or not at all.
|
|
||||||
val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY")
|
|
||||||
val occurrenceMillis = 1_781_164_800_000L
|
|
||||||
|
|
||||||
val parent = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = null,
|
|
||||||
occurrenceMillis = occurrenceMillis,
|
|
||||||
dtStartMillis = 1_780_560_000_000L,
|
|
||||||
rrule = "FREQ=WEEKLY",
|
|
||||||
duration = "P5400S",
|
|
||||||
timezone = "Europe/Berlin",
|
|
||||||
allDay = 0,
|
|
||||||
)
|
|
||||||
val detached = edited.toDetachedOccurrence()
|
|
||||||
val inserted = buildEventInsertValues(
|
|
||||||
form = detached,
|
|
||||||
uid = "uid@calendula",
|
|
||||||
times = detached.toWriteTimes(berlin),
|
|
||||||
)
|
|
||||||
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611T080000Z")
|
|
||||||
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(occurrenceMillis)
|
|
||||||
// The parent keeps its own anchor and rule — only this occurrence leaves.
|
|
||||||
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
|
|
||||||
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a detached all-day occurrence matches its date-only exdate stamp`() {
|
|
||||||
val parent = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = null,
|
|
||||||
occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z
|
|
||||||
dtStartMillis = 1_749_600_000_000L,
|
|
||||||
rrule = "FREQ=YEARLY",
|
|
||||||
duration = "P1D",
|
|
||||||
timezone = "UTC",
|
|
||||||
allDay = 1,
|
|
||||||
)
|
|
||||||
val detached = form(
|
|
||||||
isAllDay = true,
|
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
|
||||||
).copy(rrule = "FREQ=YEARLY").toDetachedOccurrence()
|
|
||||||
val inserted = buildEventInsertValues(
|
|
||||||
form = detached,
|
|
||||||
uid = "uid@calendula",
|
|
||||||
times = detached.toWriteTimes(berlin),
|
|
||||||
)
|
|
||||||
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611")
|
|
||||||
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- exdateContains (guards a second detach of the same occurrence) ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an occurrence already excluded is recognised, timed and all-day`() {
|
|
||||||
// Detaching twice would leave a second standalone copy.
|
|
||||||
assertThat(
|
|
||||||
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false),
|
|
||||||
).isTrue()
|
|
||||||
assertThat(
|
|
||||||
exdateContains("20260611", 1_781_136_000_000L, isAllDay = true),
|
|
||||||
).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an occurrence not in the exdate list is not mistaken for an excluded one`() {
|
|
||||||
assertThat(exdateContains(null, 1_781_164_800_000L, isAllDay = false)).isFalse()
|
|
||||||
assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false)).isFalse()
|
|
||||||
// A neighbouring occurrence must not match — the guard is per-instant.
|
|
||||||
assertThat(
|
|
||||||
exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false),
|
|
||||||
).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an exclusion is found anywhere in a multi-entry exdate list`() {
|
|
||||||
// Whitespace after a comma is legal in the stored column.
|
|
||||||
assertThat(
|
|
||||||
exdateContains(
|
|
||||||
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
|
|
||||||
1_781_164_800_000L,
|
|
||||||
isAllDay = false,
|
|
||||||
),
|
|
||||||
).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- per-event colour ---
|
// --- per-event colour ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ class WidgetRolloverSchedulerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `at midnight exactly the target is the next day, never the current instant`() {
|
fun `at midnight exactly the target is the next day, never the current instant`() {
|
||||||
// Re-arming after a firing must move a whole day on, or the widget wakes
|
// The alarm has just fired and is re-arming: it must move a whole day on,
|
||||||
// itself in a tight loop.
|
// otherwise the widget would wake itself in a tight loop.
|
||||||
val now = at("2026-08-28T00:00:00", berlin)
|
val now = at("2026-08-28T00:00:00", berlin)
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
|
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
|
||||||
@@ -63,8 +63,8 @@ class WidgetRolloverSchedulerTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `the result is always in the future for every minute of a day`() {
|
fun `the result is always in the future for every minute of a day`() {
|
||||||
val zone = berlin
|
val zone = berlin
|
||||||
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
|
// Spans Berlin's 2024 spring-forward, the case most likely to produce a
|
||||||
// in the past, i.e. an alarm that fires immediately, forever.
|
// target in the past and so an alarm that fires immediately, forever.
|
||||||
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
|
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
|
||||||
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
|
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
|
||||||
while (probe < end) {
|
while (probe < end) {
|
||||||
@@ -87,8 +87,9 @@ class WidgetRolloverSchedulerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `fall back does not overshoot into the repeated hour`() {
|
fun `fall back does not overshoot into the repeated hour`() {
|
||||||
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
|
// Berlin repeated 02:00-03:00 on 27 October 2024: midnight itself is
|
||||||
// "now + 24h" would land at 23:00 on the 26th and never roll over.
|
// unambiguous, but the day is 25h long, so "now + 24h" would land at
|
||||||
|
// 23:00 on the 26th and never roll the date over at all.
|
||||||
val now = at("2024-10-26T12:00:00", berlin)
|
val now = at("2024-10-26T12:00:00", berlin)
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
|
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
|
||||||
@@ -98,8 +99,9 @@ class WidgetRolloverSchedulerTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `a zone that repeats midnight takes the first start of day`() {
|
fun `a zone that repeats midnight takes the first start of day`() {
|
||||||
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
|
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
|
||||||
// began twice. Pinned as the accepted trade: the widget runs an hour
|
// began twice. Pinned deliberately: the widget then runs an hour ahead
|
||||||
// ahead until the next redraw. No live zone does this since 2019.
|
// of the clock until the next redraw, which is the accepted trade
|
||||||
|
// (Brazil dropped DST in 2019, so no live zone does this).
|
||||||
val saoPaulo = TimeZone.of("America/Sao_Paulo")
|
val saoPaulo = TimeZone.of("America/Sao_Paulo")
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(
|
val next = WidgetRolloverScheduler.nextRolloverAt(
|
||||||
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
|
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
|
||||||
@@ -130,8 +132,8 @@ class WidgetRolloverSchedulerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the same instant rolls over at different times in different zones`() {
|
fun `the same instant rolls over at different times in different zones`() {
|
||||||
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
|
// Flying east and getting TIMEZONE_CHANGED must re-arm to the new local
|
||||||
// follows the zone, not a cached offset.
|
// midnight — the arithmetic follows the zone, not a cached offset.
|
||||||
val instant = at("2026-08-27T12:00:00", berlin)
|
val instant = at("2026-08-27T12:00:00", berlin)
|
||||||
val tokyo = TimeZone.of("Asia/Tokyo")
|
val tokyo = TimeZone.of("Asia/Tokyo")
|
||||||
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
|
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
|
||||||
|
|||||||
Reference in New Issue
Block a user