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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user