fix(contacts): scope managed-event queries + fix reminder fire hour

Two provider-level bugs in the managed-event data path:

- queryManagedEvents and applyManagedCalendarReminders matched every event in
  the calendar (UID_2445 IS NOT NULL / no filter). A stray user event there
  (e.g. an .ics import) was treated as 'existing but not desired' and deleted,
  or had its own reminders wiped and all-day-re-encoded. Both now match only
  our own mirror events (the 'contact-' UID prefix).

- All-day reminder offsets were sampled at the event's DTSTART, which for a
  year-less birthday is the 1972 leap anchor — a year whose timezone offset
  (pre-DST) differs from today's, skewing every modern occurrence by up to an
  hour. The offset is now sampled at the upcoming occurrence (nextYearlyOccurrence),
  leaving only the inherent ±1h DST drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 23:03:51 +02:00
parent 9f7e93d5f8
commit 8881559e67
3 changed files with 122 additions and 25 deletions

View File

@@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes(
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt() return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
} }
/**
* The next date on or after [today] carrying [month]/[day] — the date to sample
* a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an
* ancient anchor (1972 for year-less dates), a year whose timezone rules
* (pre-DST offsets) differ from today's; sampling the offset there skews every
* modern occurrence by hours. Sampling at the upcoming occurrence makes the
* stored offset correct for it and its neighbours (only ±1h DST drift remains,
* the inherent limit). Feb-29 skips forward to the next leap year.
*/
internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): LocalDate {
var year = today.year
// A Feb-29 date is valid only every ~4 years; 8 tries always reaches one.
repeat(8) {
val candidate = runCatching { LocalDate.of(year, month, day) }.getOrNull()
if (candidate != null && !candidate.isBefore(today)) return candidate
year++
}
// Unreachable for real month/day inputs; fall back to the raw anchor.
return LocalDate.of(today.year, month, day)
}
/** /**
* Recover the semantic whole-day lead time from a raw all-day reminder * Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it * [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it

View File

@@ -24,12 +24,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
@@ -135,11 +137,12 @@ interface CalendarDataSource {
fun updateManagedFields(eventId: Long, values: Map<String, Any?>) fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
/** /**
* Replace the reminders on **every** event in [calendarId] with [minutes] * Replace the reminders on **every managed** event in [calendarId] with
* (all-day lead times), each encoded from its own date so it fires at * [minutes] (all-day lead times), each encoded so it fires at
* [allDayReminderTimeMinutes]. Used by the special-dates section so a reminder * [allDayReminderTimeMinutes] on its upcoming occurrence. Used by the
* change applies to existing events too, not just future ones — the managed * special-dates section so a reminder change applies to existing events too,
* calendars treat reminders as a calendar-level setting. * not just future ones — the managed calendars treat reminders as a
* calendar-level setting. Skips any non-managed (user) event in the calendar.
*/ */
fun applyManagedCalendarReminders( fun applyManagedCalendarReminders(
calendarId: Long, calendarId: Long,
@@ -393,10 +396,13 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.DTSTART, CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE, CalendarContract.Events.RRULE,
), ),
// Only our own mirror events (UID prefix), so a user event that somehow
// landed in this calendar (e.g. an .ics import) is never seen as
// "existing but not desired" and deleted by the diff.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " + "${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.DELETED} = 0 AND " + "${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} IS NOT NULL", "${CalendarContract.Events.UID_2445} LIKE ?",
arrayOf(calendarId.toString()), arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null, null,
)?.use { c -> )?.use { c ->
buildList { buildList {
@@ -418,13 +424,19 @@ class AndroidCalendarDataSource @Inject constructor(
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long { override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
// Deterministic UID = the reconciliation key. A re-sync reads it back // Deterministic UID = the reconciliation key. A re-sync reads it back
// (queryManagedEvents) and diffs on it, so a contact never doubles. // (queryManagedEvents) and diffs on it, so a contact never doubles.
val values = buildEventInsertValues(form, uid, form.toWriteTimes(ZoneId.systemDefault())) val times = form.toWriteTimes(ZoneId.systemDefault())
val values = buildEventInsertValues(form, uid, times)
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
?.let(ContentUris::parseId) ?.let(ContentUris::parseId)
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}") ?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
// Seeded once, then never re-touched by sync — so a user who moves a // Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change. // birthday reminder keeps their change. Encoded against the upcoming
seedReminders(eventId, form, allDayReminderTimeMinutes) // occurrence, not the ancient DTSTART anchor (wrong timezone offset).
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
insertReminderRows(
eventId,
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
)
return eventId return eventId
} }
@@ -443,23 +455,26 @@ class AndroidCalendarDataSource @Inject constructor(
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
minutes: List<Int>, minutes: List<Int>,
) { ) {
val zone = ZoneId.systemDefault()
resolver.query( resolver.query(
CalendarContract.Events.CONTENT_URI, CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART), arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
"${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.DELETED} = 0", // Only our own mirror events, so a stray user event in this calendar
arrayOf(calendarId.toString()), // isn't all-day-re-encoded or stripped of its own reminders.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} LIKE ?",
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null, null,
)?.use { c -> )?.use { c ->
while (c.moveToNext()) { while (c.moveToNext()) {
val eventId = c.getLong(0) val eventId = c.getLong(0)
// Managed events are all-day: DTSTART is a UTC midnight. // Managed events are all-day: DTSTART is a UTC midnight anchor.
val startDate = Instant.ofEpochMilli(c.getLong(1)) val anchor = Instant.ofEpochMilli(c.getLong(1))
.atZone(ZoneOffset.UTC).toLocalDate() .atZone(ZoneOffset.UTC).toLocalDate()
val encoded = minutes reconcileReminders(
.map { toProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) } eventId,
.distinct() managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
reconcileReminders(eventId, encoded) )
} }
} }
} }
@@ -780,8 +795,12 @@ class AndroidCalendarDataSource @Inject constructor(
* (spec §8): the event exists at this point — a reminder that fails to * (spec §8): the event exists at this point — a reminder that fails to
* attach is logged, not surfaced as a failed create. * attach is logged, not surfaced as a failed create.
*/ */
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) { private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) =
encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes -> insertReminderRows(eventId, encodedReminders(form, allDayReminderTimeMinutes))
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
encodedMinutes.forEach { minutes ->
val reminder = ContentValues().apply { val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId) put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes) put(CalendarContract.Reminders.MINUTES, minutes)
@@ -793,6 +812,25 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
/**
* A managed all-day event's reminders, encoded so they fire at
* [allDayReminderTimeMinutes] on its **upcoming** occurrence. The event's
* DTSTART is an ancient recurrence anchor (see [nextYearlyOccurrence]); using
* it directly would sample a stale timezone offset, so the offset is sampled
* at the next occurrence of the anchor's month/day instead.
*/
private fun managedAllDayReminderMinutes(
anchor: LocalDate,
reminders: List<Int>,
allDayReminderTimeMinutes: Int,
): List<Int> {
val zone = ZoneId.systemDefault()
val onDate = nextYearlyOccurrence(anchor.monthValue, anchor.dayOfMonth, LocalDate.now(zone))
return reminders
.map { toProviderAllDayMinutes(it, onDate, zone, allDayReminderTimeMinutes) }
.distinct()
}
override fun updateEvent( override fun updateEvent(
eventId: Long, eventId: Long,
original: EventForm, original: EventForm,
@@ -1217,11 +1255,12 @@ class AndroidCalendarDataSource @Inject constructor(
const val LOCAL_ACCOUNT_NAME = "Calendula" const val LOCAL_ACCOUNT_NAME = "Calendula"
/** /**
* Column and namespace for the special-dates marker. CAL_SYNC1 already * Column and namespace for the special-dates marker (shared with the
* holds the local-calendar description, so the marker uses CAL_SYNC2. * calendar projection, which reads it into [CalendarSource.isManaged]).
* CAL_SYNC1 already holds the local-calendar description, so it uses CAL_SYNC2.
*/ */
val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2 val MANAGED_MARKER_COLUMN: String = CalendarProjection.MANAGED_MARKER_COLUMN
const val MANAGED_MARKER_PREFIX = "calendula.specialdates" const val MANAGED_MARKER_PREFIX = CalendarProjection.MANAGED_MARKER_PREFIX
/** /**
* How far ahead/behind a search looks for a recurring event's nearest * How far ahead/behind a search looks for a recurring event's nearest

View File

@@ -83,6 +83,43 @@ class AllDayReminderEncodingTest {
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1) assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
} }
@Test
fun `next yearly occurrence wraps to next year once this year's date has passed`() {
val today = LocalDate.of(2026, 8, 1)
assertThat(nextYearlyOccurrence(5, 14, today)).isEqualTo(LocalDate.of(2027, 5, 14))
assertThat(nextYearlyOccurrence(12, 25, today)).isEqualTo(LocalDate.of(2026, 12, 25))
}
@Test
fun `next yearly occurrence returns today when the date is today`() {
val today = LocalDate.of(2026, 3, 10)
assertThat(nextYearlyOccurrence(3, 10, today)).isEqualTo(today)
}
@Test
fun `next yearly occurrence skips non-leap years for a Feb-29 date`() {
assertThat(nextYearlyOccurrence(2, 29, LocalDate.of(2026, 6, 1)))
.isEqualTo(LocalDate.of(2028, 2, 29))
}
@Test
fun `sampling the offset at the next occurrence, not a 1972 anchor, fixes the fire hour`() {
// A year-less birthday's DTSTART anchor is 1972 (Berlin had no DST then,
// UTC+1). Sampling the offset there skews a modern CEST occurrence by an
// hour; sampling at the upcoming occurrence fires at the intended time.
val anchor1972 = LocalDate.of(1972, 7, 15)
val nextOccurrence = nextYearlyOccurrence(7, 15, LocalDate.of(2026, 6, 1)) // 2026-07-15, CEST
val rawFromAnchor = toProviderAllDayMinutes(0, anchor1972, berlin, nineAm)
val rawFromNext = toProviderAllDayMinutes(0, nextOccurrence, berlin, nineAm)
val fireFromAnchor = actualFire(rawFromAnchor, nextOccurrence)
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
val fireFromNext = actualFire(rawFromNext, nextOccurrence)
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
assertThat(fireFromAnchor).isEqualTo(LocalTime.of(10, 0)) // skewed +1h
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
}
@Test @Test
fun `a winter-anchored offset drifts one hour on a summer occurrence`() { fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An // Known limitation: one fixed MINUTES per series can't track DST. An