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