Compare commits
2 Commits
bc49730ea5
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf72dce0e7 | ||
|
|
398cfc8906 |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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 —
|
||||||
|
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
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1471,3 +1491,5 @@ 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
|
||||||
|
|||||||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@@ -50,3 +50,9 @@
|
|||||||
# the real names also survives app updates, which would otherwise renumber the
|
# 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,9 +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).
|
||||||
and the system broadcasts the date/time ones at midnight / clock
|
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
||||||
changes so "today" highlighting rolls over. -->
|
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
||||||
|
a free extra only, since Android 8+ withholds it from manifest
|
||||||
|
receivers. The four below re-arm that alarm: TIME_SET /
|
||||||
|
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
||||||
|
Exported: the system broadcasts arrive from outside the app. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.WidgetUpdateReceiver"
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -346,6 +350,8 @@
|
|||||||
<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,6 +10,7 @@ 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
|
||||||
@@ -44,6 +45,19 @@ 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -232,10 +232,15 @@ interface CalendarDataSource {
|
|||||||
): Long
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event by inserting a
|
* Change a single occurrence of a recurring event at [beginMillis] (the
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
|
||||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
* `Events._ID` of the row now holding them.
|
||||||
* 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,
|
||||||
@@ -1194,6 +1199,13 @@ 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,
|
||||||
@@ -1212,6 +1224,89 @@ 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,6 +243,21 @@ 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`
|
||||||
@@ -435,6 +450,23 @@ 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,6 +64,7 @@ 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
|
||||||
@@ -264,13 +265,16 @@ 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)
|
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
||||||
}
|
}
|
||||||
SaveUiState.NeedsPermission -> {
|
SaveUiState.NeedsPermission -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(writeDeniedMessage)
|
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long)
|
||||||
}
|
}
|
||||||
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
||||||
else -> Unit
|
else -> Unit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
@@ -56,6 +57,8 @@ 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
|
||||||
@@ -751,7 +754,15 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the app's own wake-up for the next local midnight, so the home-screen
|
||||||
|
* widgets roll "today" over on the day boundary (#228).
|
||||||
|
*
|
||||||
|
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
|
||||||
|
* implicit broadcast — a manifest-declared receiver has not been given it since
|
||||||
|
* Android 8, leaving only the throttled `updatePeriodMillis`.
|
||||||
|
*
|
||||||
|
* Exactly one alarm exists at a time and every firing re-arms the next, the same
|
||||||
|
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
|
||||||
|
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
|
||||||
|
* survives doze (plain `set` does not), a rollover a few minutes late is
|
||||||
|
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
|
||||||
|
*/
|
||||||
|
object WidgetRolloverScheduler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire just *after* midnight: an alarm delivered a few milliseconds early
|
||||||
|
* would still read the old date and re-arm for an instant later.
|
||||||
|
*/
|
||||||
|
internal val ROLLOVER_SLACK = 5.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the next rollover, or cancel a pending one when no widget is placed.
|
||||||
|
* Idempotent, so every trigger (boot, app start, widget added/removed, the
|
||||||
|
* rollover itself, a clock or timezone change) can just call it.
|
||||||
|
*/
|
||||||
|
fun sync(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
|
||||||
|
val pendingIntent = rolloverPendingIntent(appContext)
|
||||||
|
if (!hasPlacedWidgets(appContext)) {
|
||||||
|
alarmManager.cancel(pendingIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant just after the next local midnight following [now] in [zone].
|
||||||
|
*
|
||||||
|
* The *actual* start of day, not 00:00, so it holds where a DST jump means
|
||||||
|
* midnight never happens (Havana) and where a date-line move skips a whole
|
||||||
|
* local date (Apia, December 2011) — the loop walks on to the next real day.
|
||||||
|
*
|
||||||
|
* The mirror case — a zone rewinding *across* midnight, so the day starts
|
||||||
|
* twice — resolves to the earlier start and runs an hour ahead of the clock.
|
||||||
|
* No live tz entry does that (Brazil dropped DST in 2019) and
|
||||||
|
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
|
||||||
|
*/
|
||||||
|
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
|
||||||
|
val date = now.toLocalDateTime(zone).date
|
||||||
|
var days = 1
|
||||||
|
while (days <= MAX_LOOKAHEAD_DAYS) {
|
||||||
|
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
|
||||||
|
if (candidate > now) return candidate
|
||||||
|
days++
|
||||||
|
}
|
||||||
|
// Unreachable for any zone in the tz database. Deliberately an hour and
|
||||||
|
// not the slack: a 5-second retry would just hit this branch again and
|
||||||
|
// wake the device in a loop.
|
||||||
|
return now + 1.hours
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPlacedWidgets(context: Context): Boolean {
|
||||||
|
val manager = AppWidgetManager.getInstance(context) ?: return false
|
||||||
|
return PROVIDERS.any {
|
||||||
|
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rolloverPendingIntent(context: Context): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ROLLOVER_REQUEST_CODE,
|
||||||
|
Intent(context, WidgetUpdateReceiver::class.java)
|
||||||
|
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val PROVIDERS = listOf(
|
||||||
|
MonthWidgetReceiver::class.java,
|
||||||
|
AgendaWidgetReceiver::class.java,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
||||||
|
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
||||||
|
|
||||||
|
/** A gap of more than a couple of days does not exist in any tz database entry. */
|
||||||
|
private const val MAX_LOOKAHEAD_DAYS = 3
|
||||||
|
}
|
||||||
@@ -12,19 +12,40 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redraws both home-screen widgets when their data goes stale. Triggered by:
|
* Redraws both home-screen widgets when their data goes stale, and keeps the
|
||||||
|
* midnight rollover alarm armed. Triggered by:
|
||||||
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
* - `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.
|
||||||
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
|
||||||
* and the upcoming window roll over at midnight / on a clock change.
|
* the day boundary, so "today" highlighting and the agenda's past-event
|
||||||
|
* dimming move on (#228).
|
||||||
|
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
|
||||||
|
* re-arm.
|
||||||
|
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
|
||||||
|
* latter is also what arms installs upgrading into the fix.
|
||||||
*
|
*
|
||||||
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
|
* `DATE_CHANGED` is a free extra in the filter that nothing depends on — see
|
||||||
* XML, and the month widget's refresh button forces an immediate redraw.
|
* [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
|
||||||
|
* provider XML and the month widget's refresh button.
|
||||||
|
*
|
||||||
|
* Exported for the system broadcasts; an extra redraw from another app is
|
||||||
|
* harmless.
|
||||||
*/
|
*/
|
||||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val pending = goAsync()
|
// Exported, so anything can reach it with an explicit intent. Nothing
|
||||||
|
// here crosses a trust boundary, but narrowing to the actions we asked
|
||||||
|
// for keeps a stray broadcast from costing two wide provider reads.
|
||||||
|
if (intent.action !in HANDLED_ACTIONS) return
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
|
// Re-arm first, so the next day boundary is covered whatever the redraw
|
||||||
|
// does. Every handled action either dropped, consumed or invalidated it.
|
||||||
|
WidgetRolloverScheduler.sync(appContext)
|
||||||
|
// The host sends APPWIDGET_UPDATE after both of these anyway, so
|
||||||
|
// redrawing here would only repeat the work in a cold process, at the
|
||||||
|
// moment the device is most contended.
|
||||||
|
if (intent.action in REARM_ONLY_ACTIONS) return
|
||||||
|
val pending = goAsync()
|
||||||
// Calendar data may have changed (sync / our own write) — drop the cached
|
// 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.
|
||||||
@@ -38,4 +59,23 @@ class WidgetUpdateReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
|
||||||
|
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
||||||
|
|
||||||
|
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
|
||||||
|
private val REARM_ONLY_ACTIONS = setOf(
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val HANDLED_ACTIONS = REARM_ONLY_ACTIONS + setOf(
|
||||||
|
ACTION_ROLLOVER,
|
||||||
|
Intent.ACTION_PROVIDER_CHANGED,
|
||||||
|
Intent.ACTION_DATE_CHANGED,
|
||||||
|
Intent.ACTION_TIME_CHANGED,
|
||||||
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
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
|
||||||
@@ -10,4 +13,32 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
|||||||
*/
|
*/
|
||||||
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,7 +152,17 @@ 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())
|
||||||
prefs[MONTH_INDEX_KEY] = cur + delta
|
val next = cur + delta
|
||||||
|
// Landing back on the current month clears the key, so the widget
|
||||||
|
// goes back to *following* the date rather than being pinned to
|
||||||
|
// whichever month was current at the tap. Paging out and back is the
|
||||||
|
// workaround #228's reporter used, and pinning it there would have
|
||||||
|
// stuck them on that month once it stopped being the current one.
|
||||||
|
if (next == currentMonthIndex(systemZone())) {
|
||||||
|
prefs.remove(MONTH_INDEX_KEY)
|
||||||
|
} else {
|
||||||
|
prefs[MONTH_INDEX_KEY] = next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MonthWidget().update(context.applicationContext, glanceId)
|
MonthWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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
|
||||||
@@ -9,4 +12,35 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
|||||||
*/
|
*/
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -558,6 +558,174 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rollover alarm is what makes the widgets stop highlighting yesterday
|
||||||
|
* (#228), so the "when is the next local midnight" arithmetic is the one piece
|
||||||
|
* worth pinning down — especially where midnight is not 00:00.
|
||||||
|
*/
|
||||||
|
class WidgetRolloverSchedulerTest {
|
||||||
|
|
||||||
|
private val berlin = TimeZone.of("Europe/Berlin")
|
||||||
|
|
||||||
|
private fun at(local: String, zone: TimeZone): Instant =
|
||||||
|
LocalDateTime.parse(local).toInstant(zone)
|
||||||
|
|
||||||
|
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
|
||||||
|
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
|
||||||
|
|
||||||
|
// --- the ordinary day ----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `midday rolls over at the coming midnight`() {
|
||||||
|
val next = nextRollover("2026-08-27T12:00:00")
|
||||||
|
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a second before midnight still targets tonight, not tomorrow night`() {
|
||||||
|
val next = nextRollover("2026-08-27T23:59:59")
|
||||||
|
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `at midnight exactly the target is the next day, never the current instant`() {
|
||||||
|
// Re-arming after a firing must move a whole day on, or the widget wakes
|
||||||
|
// itself in a tight loop.
|
||||||
|
val now = at("2026-08-28T00:00:00", berlin)
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
|
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
|
||||||
|
assertThat(next - now).isGreaterThan(Duration.ZERO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `re-arming from the slack instant itself moves a full day on`() {
|
||||||
|
// What actually happens in practice: the receiver runs at midnight + slack.
|
||||||
|
val now = at("2026-08-28T00:00:05", berlin)
|
||||||
|
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
|
||||||
|
.isEqualTo(at("2026-08-29T00:00:05", berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the result is always in the future for every minute of a day`() {
|
||||||
|
val zone = berlin
|
||||||
|
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
|
||||||
|
// in the past, i.e. an alarm that fires immediately, forever.
|
||||||
|
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
|
||||||
|
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
|
||||||
|
while (probe < end) {
|
||||||
|
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
|
||||||
|
probe += 1.minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- daylight saving -----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `spring forward keeps the rollover one day away, not one hour short`() {
|
||||||
|
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
|
||||||
|
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
|
||||||
|
val now = at("2024-03-30T12:00:00", berlin)
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
|
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
|
||||||
|
assertThat(next - now).isLessThan(24.hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fall back does not overshoot into the repeated hour`() {
|
||||||
|
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
|
||||||
|
// "now + 24h" would land at 23:00 on the 26th and never roll over.
|
||||||
|
val now = at("2024-10-26T12:00:00", berlin)
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
|
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
|
||||||
|
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zone that repeats midnight takes the first start of day`() {
|
||||||
|
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
|
||||||
|
// began twice. Pinned as the accepted trade: the widget runs an hour
|
||||||
|
// ahead until the next redraw. No live zone does this since 2019.
|
||||||
|
val saoPaulo = TimeZone.of("America/Sao_Paulo")
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(
|
||||||
|
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
|
||||||
|
)
|
||||||
|
val local = next.toLocalDateTime(saoPaulo)
|
||||||
|
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
|
||||||
|
assertThat(local.hour).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zone where midnight does not exist rolls over at the real start of day`() {
|
||||||
|
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
|
||||||
|
// Targeting a literal 00:00 there would arm an instant on the wrong day.
|
||||||
|
val havana = TimeZone.of("America/Havana")
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
|
||||||
|
val local = next.toLocalDateTime(havana)
|
||||||
|
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
|
||||||
|
assertThat(local.hour).isEqualTo(1)
|
||||||
|
assertThat(local.minute).isEqualTo(0)
|
||||||
|
// And it is genuinely the first instant of that date, not a guess.
|
||||||
|
assertThat(next).isEqualTo(
|
||||||
|
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
|
||||||
|
WidgetRolloverScheduler.ROLLOVER_SLACK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- timezone changes ----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same instant rolls over at different times in different zones`() {
|
||||||
|
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
|
||||||
|
// follows the zone, not a cached offset.
|
||||||
|
val instant = at("2026-08-27T12:00:00", berlin)
|
||||||
|
val tokyo = TimeZone.of("Asia/Tokyo")
|
||||||
|
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
|
||||||
|
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
|
||||||
|
assertThat(tokyoNext).isNotEqualTo(berlinNext)
|
||||||
|
assertThat(tokyoNext).isLessThan(berlinNext)
|
||||||
|
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a half-hour offset zone still lands on its own midnight`() {
|
||||||
|
val kathmandu = TimeZone.of("Asia/Kathmandu")
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(
|
||||||
|
at("2026-08-27T12:00:00", kathmandu), kathmandu,
|
||||||
|
)
|
||||||
|
val local = next.toLocalDateTime(kathmandu)
|
||||||
|
assertThat(local.date.toString()).isEqualTo("2026-08-28")
|
||||||
|
assertThat(local.hour).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the wiring ----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the receiver actually handles the action the alarm is sent with`() {
|
||||||
|
// The single point where the whole fix would die silently: the alarm
|
||||||
|
// fires, the receiver drops it on the action guard, nothing redraws.
|
||||||
|
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
||||||
|
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user