feat(contacts): add managed-event write surface to CalendarDataSource

The special-dates mirror needs a durable, user-invisible per-event key so a
re-sync diffs instead of duplicating. Rather than Events.SYNC_DATA1 (which the
provider drops on a LOCAL calendar unless written via a sync-adapter URI), the
key is a deterministic UID_2445 (contact-<type>:<lookupKey>@calendula), written
and read through the normal event URIs the app already uses.

- createManagedCalendar: a normal local calendar plus a CAL_SYNC2 marker, so the
  mirror can re-adopt its calendars after a prefs wipe (findManagedCalendars).
- queryManagedEvents: reads back _ID/UID_2445/TITLE/DTSTART/RRULE (DELETED=0) as
  the existing side of the diff.
- insertManagedEvent: writes the deterministic UID and seeds reminder rows once.
- updateManagedFields: a bare, targeted column update — never reconciles
  reminders/attendees, so user-owned event data survives every sync.

Reuses toWriteTimes/toRfc2445Duration (all-day UTC-midnight DTSTART + P1D
DURATION for the FREQ=YEARLY row) and the existing deleteEvent for removals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 10:22:28 +02:00
parent 93ca38d974
commit 9a8d9b2177
2 changed files with 206 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ 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.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
@@ -97,6 +98,41 @@ interface CalendarDataSource {
/** Permanently delete a local calendar the app owns, with all its events. */ /** Permanently delete a local calendar the app owns, with all its events. */
fun deleteCalendar(id: Long) fun deleteCalendar(id: Long)
/**
* Create a local calendar tagged as the special-dates mirror for [type]
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
* local calendar, so it inherits per-calendar colour/visibility/reminders.
*/
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
/**
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
* detect when the user deleted one in system settings.
*/
fun findManagedCalendars(): List<ManagedCalendarRow>
/**
* The managed events in [calendarId] (those carrying a `UID_2445`, not
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
*/
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
/**
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
* seeds its reminder rows from [form] (never touched again by sync). Returns
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
/**
* Overwrite exactly the given managed columns on [eventId] (keys are
* `CalendarContract.Events` columns; a null value clears the column). A
* bare, targeted update — it never reconciles reminders or attendees, so
* user-owned data on the event survives every sync. No-ops on an empty map.
*/
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
/** /**
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes] * Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
* (minutes from local midnight) is the wall-clock time all-day reminders * (minutes from local midnight) is the wall-clock time all-day reminders
@@ -292,6 +328,134 @@ class AndroidCalendarDataSource @Inject constructor(
if (deleted == 0) throw WriteFailedException("delete calendar id=$id") if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
} }
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
put(
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
CalendarContract.Calendars.CAL_ACCESS_OWNER,
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
// The marker that identifies this as our special-dates calendar,
// independent of the (user-editable) display name. CAL_SYNC1 already
// holds the description, so the marker lives in CAL_SYNC2.
put(MANAGED_MARKER_COLUMN, markerFor(type))
}
val uri = resolver.insert(localCalendarsUri(), values)
?: throw WriteFailedException("create managed calendar")
return ContentUris.parseId(uri)
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
CalendarContract.Calendars.CONTENT_URI,
arrayOf(CalendarContract.Calendars._ID, MANAGED_MARKER_COLUMN),
"${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
"$MANAGED_MARKER_COLUMN IS NOT NULL",
arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL, LOCAL_ACCOUNT_NAME),
null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
val type = typeForMarker(c.getString(1)) ?: continue
add(ManagedCalendarRow(id = c.getLong(0), type = type))
}
}
} ?: emptyList()
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> = resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.UID_2445,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE,
),
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} IS NOT NULL",
arrayOf(calendarId.toString()),
null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
val uid = c.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
add(
ManagedEventRow(
eventId = c.getLong(0),
uid = uid,
title = c.getString(2).orEmpty(),
dtStartMillis = c.getLong(3),
rrule = c.getString(4)?.takeIf { it.isNotEmpty() },
),
)
}
}
} ?: emptyList()
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
val times = form.toWriteTimes(ZoneId.systemDefault())
val values = ContentValues().apply {
put(
CalendarContract.Events.CALENDAR_ID,
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
)
// Deterministic UID = the reconciliation key. A re-sync reads it back
// (queryManagedEvents) and diffs on it, so a contact never doubles.
put(CalendarContract.Events.UID_2445, uid)
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
if (form.rrule == null) {
put(CalendarContract.Events.DTEND, times.dtEndMillis)
} else {
put(CalendarContract.Events.RRULE, form.rrule)
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
}
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
form.location.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?.let(ContentUris::parseId)
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
// Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change.
encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to managed event $eventId")
}
}
return eventId
}
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
if (values.isEmpty()) return
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> { override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered() ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
@@ -1038,6 +1202,17 @@ class AndroidCalendarDataSource @Inject constructor(
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add) while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
} }
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
private fun markerFor(type: SpecialDateType): String = when (type) {
SpecialDateType.Birthday -> "$MANAGED_MARKER_PREFIX:birthday"
SpecialDateType.Anniversary -> "$MANAGED_MARKER_PREFIX:anniversary"
SpecialDateType.Custom -> "$MANAGED_MARKER_PREFIX:custom"
}
/** Inverse of [markerFor]; null for a value that isn't one of ours. */
private fun typeForMarker(marker: String?): SpecialDateType? =
SpecialDateType.entries.firstOrNull { markerFor(it) == marker }
private companion object { private companion object {
const val TAG = "CalendarDataSource" const val TAG = "CalendarDataSource"
@@ -1047,6 +1222,13 @@ 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
* holds the local-calendar description, so the marker uses CAL_SYNC2.
*/
val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
/** /**
* 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
* occurrence (~2 years). Wide enough for everyday series; a series that * occurrence (~2 years). Wide enough for everyday series; a series that

View File

@@ -0,0 +1,24 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
/**
* One of the app's special-dates calendars, discovered by the marker stamped in
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
* stored ids in preferences were lost (e.g. an app-data wipe).
*/
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
/**
* A managed event as read back for the sync diff. [uid] is the deterministic
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
* the desired state to decide whether a targeted update is needed.
*/
data class ManagedEventRow(
val eventId: Long,
val uid: String,
val title: String,
val dtStartMillis: Long,
val rrule: String?,
)