Compare commits

..

3 Commits

Author SHA1 Message Date
c0b7ad29ef docs: trim the #234 comments to the load-bearing facts
The detach path's KDoc and inline comments restated the bug narrative that
already lives in the commit message and the issue. Keep what a reader of
the code needs — why the branch exists, what the detached row loses, and
why the insert precedes the EXDATE update — and drop the rest.
2026-08-27 20:21:34 +02:00
bc49730ea5 fix: guard the detach path against a second copy, and say what it costs (#234)
Review pass on the detach path. Three changes, none to the write shape itself.

A second detach of the same occurrence is now refused. Reached from a stale
screen still pointing at the parent, it used to succeed twice over: the EXDATE
merge folds the repeated stamp away and the parent update still reports one row
changed, so the save looked fine and left a *second* standalone copy of one
occurrence, this time with neither copy in the series to make it obvious. The
occurrence is already gone from the series at that point, so the write reports
it as gone — and EventEditViewModel now maps NoSuchEventException from the write
to the same "this event no longer exists" answer its pre-check already gives,
instead of a bare "couldn't save" for something that isn't there.

The rollback comment claimed more than the code delivers. ContentResolver.update
returns rows touched, not occurrences excluded, so the zero check catches the
series row vanishing mid-write and nothing else — an EXDATE the provider's
expansion fails to match reports success and leaves the duplicate standing.
Renamed the variable to match, and the catch now mirrors moveEvent's Throwable
idiom rather than inventing a narrower one for the same insert-then-roll-back
shape.

The rest is honesty about what a detached row can't do, since none of it is
recoverable later and the KDoc previously mentioned only the calendar-move case:
a whole-series delete leaves it standing where an exception row would have gone
with the parent; a series-wide *time* edit moves the generated instances but not
the absolute-instant EXDATE hole, so the occurrence returns alongside its copy;
and building the row from the form rather than cloning the parent drops
ORGANIZER, STATUS and the organizer/resource attendee rows, exactly as moveEvent
does. The EXDATE staleness is not new — a #47 delete resurrects the same way —
but a duplicate is a louder symptom than a resurrection, and it wants fixing at
the series-update end, not here.

Also notes why the branch predicate is stricter than deleteOccurrence's: EXDATE
only means something on a row that recurs, so a row without an RRULE keeps the
old path rather than having a recurrence set written onto a one-off event.

Tests: the tautological "detached == copy(rrule = null)" assertion is replaced by
one that pins every edited field through to the inserted columns, and
exdateContains gets its own cases (timed, all-day, absent, a neighbouring
occurrence, and a multi-entry list with whitespace).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:10:22 +02:00
a1d1894f84 fix: detach the occurrence when editing one instance of an unsynced series (#234)
"Edit only this event" wrote a modified-occurrence exception unconditionally.
That is the right shape for a synced series and the wrong one for everything
else: an exception attaches to its parent through ORIGINAL_SYNC_ID, so on a row
with no _sync_id the link never forms — the insert fails or lands an orphan, the
generic catch in EventEditViewModel turns it into a snackbar, and the scope
dialog just closes again. To the reporter that read as "nothing happens, ever",
with a stray copy of the event left behind the one time the insert did land.

This is the same constraint deleteOccurrence has documented since #47, and the
same calendars: a local calendar, Calendula's own contact special-date
calendars, and — the reporter's case — a Google calendar whose rows the sync
adapter has not stamped yet, which is exactly why it "shows as on-device".

updateOccurrence now branches on _sync_id the way deleteOccurrence does. A
synced series keeps the exception path untouched; its write shape is load-
bearing and verified. A series without one gets the occurrence excluded from the
parent via EXDATE and the edited values inserted as a standalone event on the
same calendar — a detached instance, minus the RECURRENCE-ID the provider has no
way to store here. The cost is honest and worth naming: the edited occurrence
stops travelling with its series. The alternative is an edit that silently does
nothing.

Two things shape the write. The parent update reuses buildOccurrenceExdateValues
unchanged, so it keeps carrying the whole time/recurrence set — an EXDATE-only
update is not a recurrence change to the provider and leaves the expanded
instances standing (#47's first quirk). And the form's RRULE is stripped before
the insert: the exception path gets an inherited rule cleared for free by
DTSTART + DURATION, but nothing clears one here, so leaving it would insert a
second *series* overlapping the first.

Ordering is chosen for the failure cases, not the happy path. The insert runs
first, so a failure there leaves the series completely untouched — the discipline
updateEventFromOccurrence already follows. If the EXDATE update then fails, the
new row is a visible duplicate of an occurrence still in the series, so it is
rolled back (best effort) before the failure surfaces. The reverse order could
strand an occurrence excluded from its series with nothing standing in for it,
turning an edit into a silent delete.

Also makes a failed save legible, since this bug was invisible precisely because
it wasn't: the failure snackbar gets the long duration instead of a flash, and
the catch logs the scope and event id — never the form's content — so a failure
leaves something to report.

Adds JVM tests for the detached shape: the rule is dropped and the row becomes a
one-off with DTEND, all-day stays on UTC midnights, and the detached row's
DTSTART agrees with the EXDATE stamp that removes it from the parent, timed and
all-day. The provider behaviour itself still needs a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:57:35 +02:00
10 changed files with 10 additions and 439 deletions

View File

@@ -8,14 +8,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed ### Fixed
- **Home-screen widgets now turn the page at midnight.** Both the month and the
agenda widget kept highlighting yesterday as "today" — and the agenda kept
greying out the wrong events as already past — until you paged the month back
and forth or removed and re-added the widget. Calendula now wakes itself at the
day boundary and redraws, and re-arms after a reboot, a clock change or a
flight into another timezone. Paging the month widget forward and back also
stops quietly pinning it to that month, so it follows the date again instead of
being stranded on the month you happened to be looking at ([#228]).
- **"Only this event" now actually saves your edit.** On some calendars — - **"Only this event" now actually saves your edit.** On some calendars —
including Google ones that still show as on-device, and any local calendar — including Google ones that still show as on-device, and any local calendar —
editing a single occurrence of a repeating event did nothing at all: the scope editing a single occurrence of a repeating event did nothing at all: the scope
@@ -1491,5 +1483,4 @@ automatically, with zero telemetry and no internet permission.
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192 [#192]: https://codeberg.org/jlmakiola/calendula/issues/192
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196 [#196]: https://codeberg.org/jlmakiola/calendula/issues/196
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214 [#214]: https://codeberg.org/jlmakiola/calendula/issues/214
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234 [#234]: https://codeberg.org/jlmakiola/calendula/issues/234

View File

@@ -50,9 +50,3 @@
# the real names also survives app updates, which would otherwise renumber the # the real names also survives app updates, which would otherwise renumber the
# obfuscated name and orphan the stored mapping. # obfuscated name and orphan the stored mapping.
-keep class * extends androidx.glance.appwidget.GlanceAppWidget -keep class * extends androidx.glance.appwidget.GlanceAppWidget
# Belt and braces one level up: the two receivers are nearly as alike, and the
# provider map is keyed off the receiver component too. Redundant today (AGP's
# manifest-derived rules cover them), but #89 cost a release to diagnose and the
# guarantee should not rest on a component staying in the manifest.
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver

View File

@@ -330,13 +330,9 @@
</receiver> </receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts <!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync). PROVIDER_CHANGED on any data change (our writes and external sync),
The day boundary arrives as the app's own ROLLOVER alarm (#228), by and the system broadcasts the date/time ones at midnight / clock
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is changes so "today" highlighting rolls over. -->
a free extra only, since Android 8+ withholds it from manifest
receivers. The four below re-arm that alarm: TIME_SET /
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
Exported: the system broadcasts arrive from outside the app. -->
<receiver <receiver
android:name=".widget.WidgetUpdateReceiver" android:name=".widget.WidgetUpdateReceiver"
android:exported="true"> android:exported="true">
@@ -350,8 +346,6 @@
<action android:name="android.intent.action.DATE_CHANGED" /> <action android:name="android.intent.action.DATE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" /> <action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" /> <action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter> </intent-filter>
</receiver> </receiver>

View File

@@ -10,7 +10,6 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -45,19 +44,6 @@ class CalendulaApp : Application() {
reconcileSpecialDates() reconcileSpecialDates()
reconcileCalendarVisibility() reconcileCalendarVisibility()
startReminderDelivery() startReminderDelivery()
reconcileWidgetRollover()
}
/**
* Re-arm the widgets' midnight rollover from whatever is actually placed
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
* dropped by a force-stop is armed again the next time the app is opened.
* Off the main thread: a handful of binder calls on every process start.
*/
private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
WidgetRolloverScheduler.sync(this@CalendulaApp)
}
} }
/** /**

View File

@@ -1,115 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.getSystemService
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Holds the app's own wake-up for the next local midnight, so the home-screen
* widgets roll "today" over on the day boundary (#228).
*
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
* implicit broadcast — a manifest-declared receiver has not been given it since
* Android 8, leaving only the throttled `updatePeriodMillis`.
*
* Exactly one alarm exists at a time and every firing re-arms the next, the same
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
* survives doze (plain `set` does not), a rollover a few minutes late is
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
*/
object WidgetRolloverScheduler {
/**
* Fire just *after* midnight: an alarm delivered a few milliseconds early
* would still read the old date and re-arm for an instant later.
*/
internal val ROLLOVER_SLACK = 5.seconds
/**
* Arm the next rollover, or cancel a pending one when no widget is placed.
* Idempotent, so every trigger (boot, app start, widget added/removed, the
* rollover itself, a clock or timezone change) can just call it.
*/
fun sync(context: Context) {
val appContext = context.applicationContext
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
val pendingIntent = rolloverPendingIntent(appContext)
if (!hasPlacedWidgets(appContext)) {
alarmManager.cancel(pendingIntent)
return
}
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
)
}
/**
* The instant just after the next local midnight following [now] in [zone].
*
* The *actual* start of day, not 00:00, so it holds where a DST jump means
* midnight never happens (Havana) and where a date-line move skips a whole
* local date (Apia, December 2011) — the loop walks on to the next real day.
*
* The mirror case — a zone rewinding *across* midnight, so the day starts
* twice — resolves to the earlier start and runs an hour ahead of the clock.
* No live tz entry does that (Brazil dropped DST in 2019) and
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
*/
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date
var days = 1
while (days <= MAX_LOOKAHEAD_DAYS) {
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
if (candidate > now) return candidate
days++
}
// Unreachable for any zone in the tz database. Deliberately an hour and
// not the slack: a 5-second retry would just hit this branch again and
// wake the device in a loop.
return now + 1.hours
}
private fun hasPlacedWidgets(context: Context): Boolean {
val manager = AppWidgetManager.getInstance(context) ?: return false
return PROVIDERS.any {
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
}
}
private fun rolloverPendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java)
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private val PROVIDERS = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
/** A gap of more than a couple of days does not exist in any tz database entry. */
private const val MAX_LOOKAHEAD_DAYS = 3
}

View File

@@ -12,40 +12,19 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Redraws both home-screen widgets when their data goes stale, and keeps the * Redraws both home-screen widgets when their data goes stale. Triggered by:
* midnight rollover alarm armed. Triggered by:
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change, * - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
* so it covers both the app's own writes and external sync. * so it covers both the app's own writes and external sync.
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] — * - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
* the day boundary, so "today" highlighting and the agenda's past-event * and the upcoming window roll over at midnight / on a clock change.
* dimming move on (#228).
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
* re-arm.
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
* latter is also what arms installs upgrading into the fix.
* *
* `DATE_CHANGED` is a free extra in the filter that nothing depends on — see * Both widgets also carry an `updatePeriodMillis` backstop in their provider
* [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the * XML, and the month widget's refresh button forces an immediate redraw.
* provider XML and the month widget's refresh button.
*
* Exported for the system broadcasts; an extra redraw from another app is
* harmless.
*/ */
class WidgetUpdateReceiver : BroadcastReceiver() { class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
// Exported, so anything can reach it with an explicit intent. Nothing
// here crosses a trust boundary, but narrowing to the actions we asked
// for keeps a stray broadcast from costing two wide provider reads.
if (intent.action !in HANDLED_ACTIONS) return
val appContext = context.applicationContext
// Re-arm first, so the next day boundary is covered whatever the redraw
// does. Every handled action either dropped, consumed or invalidated it.
WidgetRolloverScheduler.sync(appContext)
// The host sends APPWIDGET_UPDATE after both of these anyway, so
// redrawing here would only repeat the work in a cold process, at the
// moment the device is most contended.
if (intent.action in REARM_ONLY_ACTIONS) return
val pending = goAsync() val pending = goAsync()
val appContext = context.applicationContext
// Calendar data may have changed (sync / our own write) — drop the cached // Calendar data may have changed (sync / our own write) — drop the cached
// month window so the widgets reload fresh. Month paging does NOT call // month window so the widgets reload fresh. Month paging does NOT call
// this, so arrow taps stay instant. // this, so arrow taps stay instant.
@@ -59,23 +38,4 @@ class WidgetUpdateReceiver : BroadcastReceiver() {
} }
} }
} }
companion object {
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
private val REARM_ONLY_ACTIONS = setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
internal val HANDLED_ACTIONS = REARM_ONLY_ACTIONS + setOf(
ACTION_ROLLOVER,
Intent.ACTION_PROVIDER_CHANGED,
Intent.ACTION_DATE_CHANGED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
)
}
} }

View File

@@ -1,10 +1,7 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
/** /**
* Host-facing receiver for the agenda widget. Declared in the manifest with the * Host-facing receiver for the agenda widget. Declared in the manifest with the
@@ -13,32 +10,4 @@ import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
*/ */
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AgendaWidget() override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
* no month widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
} }

View File

@@ -152,17 +152,7 @@ class ShiftMonthAction : ActionCallback {
val delta = parameters[deltaKey] ?: 0 val delta = parameters[deltaKey] ?: 0
updateAppWidgetState(context, glanceId) { prefs -> updateAppWidgetState(context, glanceId) { prefs ->
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone()) val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
val next = cur + delta prefs[MONTH_INDEX_KEY] = cur + delta
// Landing back on the current month clears the key, so the widget
// goes back to *following* the date rather than being pinned to
// whichever month was current at the tap. Paging out and back is the
// workaround #228's reporter used, and pinning it there would have
// stuck them on that month once it stopped being the current one.
if (next == currentMonthIndex(systemZone())) {
prefs.remove(MONTH_INDEX_KEY)
} else {
prefs[MONTH_INDEX_KEY] = next
}
} }
MonthWidget().update(context.applicationContext, glanceId) MonthWidget().update(context.applicationContext, glanceId)
} }

View File

@@ -1,10 +1,7 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
/** /**
* Host-facing receiver for the month widget. Declared in the manifest with the * Host-facing receiver for the month widget. Declared in the manifest with the
@@ -12,35 +9,4 @@ import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
*/ */
class MonthWidgetReceiver : GlanceAppWidgetReceiver() { class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthWidget() override val glanceAppWidget: GlanceAppWidget = MonthWidget()
/** First month widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
* no agenda widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
* owns, so it doubles as the alarm's self-heal: anything that drops a
* pending alarm without a broadcast (force-stop, battery restriction, an OEM
* freeze) is repaired here rather than on the next app open. Re-arming
* closer to midnight also narrows the inexact delivery window.
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
} }

View File

@@ -1,164 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* The rollover alarm is what makes the widgets stop highlighting yesterday
* (#228), so the "when is the next local midnight" arithmetic is the one piece
* worth pinning down — especially where midnight is not 00:00.
*/
class WidgetRolloverSchedulerTest {
private val berlin = TimeZone.of("Europe/Berlin")
private fun at(local: String, zone: TimeZone): Instant =
LocalDateTime.parse(local).toInstant(zone)
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
// --- the ordinary day ----------------------------------------------------
@Test
fun `midday rolls over at the coming midnight`() {
val next = nextRollover("2026-08-27T12:00:00")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `a second before midnight still targets tonight, not tomorrow night`() {
val next = nextRollover("2026-08-27T23:59:59")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `at midnight exactly the target is the next day, never the current instant`() {
// Re-arming after a firing must move a whole day on, or the widget wakes
// itself in a tight loop.
val now = at("2026-08-28T00:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
assertThat(next - now).isGreaterThan(Duration.ZERO)
}
@Test
fun `re-arming from the slack instant itself moves a full day on`() {
// What actually happens in practice: the receiver runs at midnight + slack.
val now = at("2026-08-28T00:00:05", berlin)
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
.isEqualTo(at("2026-08-29T00:00:05", berlin))
}
@Test
fun `the result is always in the future for every minute of a day`() {
val zone = berlin
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
// in the past, i.e. an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) {
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
probe += 1.minutes
}
}
// --- daylight saving -----------------------------------------------------
@Test
fun `spring forward keeps the rollover one day away, not one hour short`() {
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
val now = at("2024-03-30T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
assertThat(next - now).isLessThan(24.hours)
}
@Test
fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
// "now + 24h" would land at 23:00 on the 26th and never roll over.
val now = at("2024-10-26T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
}
@Test
fun `a zone that repeats midnight takes the first start of day`() {
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
// began twice. Pinned as the accepted trade: the widget runs an hour
// ahead until the next redraw. No live zone does this since 2019.
val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
)
val local = next.toLocalDateTime(saoPaulo)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
assertThat(local.hour).isEqualTo(0)
}
@Test
fun `a zone where midnight does not exist rolls over at the real start of day`() {
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
// Targeting a literal 00:00 there would arm an instant on the wrong day.
val havana = TimeZone.of("America/Havana")
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
val local = next.toLocalDateTime(havana)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
assertThat(local.hour).isEqualTo(1)
assertThat(local.minute).isEqualTo(0)
// And it is genuinely the first instant of that date, not a guess.
assertThat(next).isEqualTo(
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
WidgetRolloverScheduler.ROLLOVER_SLACK,
)
}
// --- timezone changes ----------------------------------------------------
@Test
fun `the same instant rolls over at different times in different zones`() {
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
// follows the zone, not a cached offset.
val instant = at("2026-08-27T12:00:00", berlin)
val tokyo = TimeZone.of("Asia/Tokyo")
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
assertThat(tokyoNext).isNotEqualTo(berlinNext)
assertThat(tokyoNext).isLessThan(berlinNext)
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
}
@Test
fun `a half-hour offset zone still lands on its own midnight`() {
val kathmandu = TimeZone.of("Asia/Kathmandu")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2026-08-27T12:00:00", kathmandu), kathmandu,
)
val local = next.toLocalDateTime(kathmandu)
assertThat(local.date.toString()).isEqualTo("2026-08-28")
assertThat(local.hour).isEqualTo(0)
}
// --- the wiring ----------------------------------------------------------
@Test
fun `the receiver actually handles the action the alarm is sent with`() {
// The single point where the whole fix would die silently: the alarm
// fires, the receiver drops it on the action guard, nothing redraws.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
}
}