Compare commits

...

4 Commits

Author SHA1 Message Date
84e6402031 fix(contacts): serialize sync/teardown and stop retrying on lost permission
- The periodic job and the immediate 'Sync now'/foreground runs had no mutual
  exclusion, so two overlapping reconciles could each see no managed calendar
  and both create one (duplicate 'Birthdays', doubled events); likewise a
  disable-teardown racing an in-flight sync got its calendars recreated right
  after deletion. A lifecycle Mutex now makes sync() and teardown() atomic, and
  sync() re-reads the enabled flag inside the lock so a teardown always wins.

- The foreground resume trigger shared a unique work name with enable/'Sync
  now' under ExistingWorkPolicy.REPLACE, so a debounced foreground enqueue could
  cancel-and-swallow a pending enable sync (feature on, no calendars for up to a
  day). It now uses its own work name.

- doWork() mapped every exception to retry(), so a revoked calendar permission
  retried with backoff forever and never surfaced. A SecurityException now parks
  the feature in the stalled state instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:04:26 +02:00
7fe59b36c3 fix(contacts): keep distinct custom dates instead of collapsing them
The mirror keyed each event on (contact, type) only, and the read deduped on
that same pair from an unordered query. So a contact with two custom dates
('Wedding', 'Graduation') mirrored just one — and, the winner being
order-dependent, the single event could ping-pong between the two dates across
syncs. A Custom date's UID now carries a discriminator (its label, else its
month-day), the read dedups on the full UID, and the query is ordered by
Data._ID so a genuine duplicate resolves the same way every time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:04:06 +02:00
8881559e67 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>
2026-07-02 23:03:51 +02:00
9f7e93d5f8 fix(contacts): seal managed calendars off from user edits
Managed special-dates calendars were only recognised by an id cached in
preferences, and were offered as ordinary write targets — so a user could
create an event in 'Birthdays' (which the next sync then deleted), and after
a backup restore wiped the prefs the editor lock silently disappeared.

CalendarSource now carries isManaged, read from the durable CAL_SYNC2 marker
the data layer already stamps, so identity survives a restore. With it:
- the new-event calendar picker excludes managed calendars (no user events
  land in a calendar the sync owns);
- the editor lock keys off the marker, not a stored id;
- a managed event's save is forced to the whole series instead of parking in
  the scope dialog (a 'this/following' split creates rows the sync reverts);
- the detail sheet hides Delete for managed events (the sync would just
  resurrect them — a contact date is removed at its source).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:03:33 +02:00
15 changed files with 297 additions and 61 deletions

View File

@@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes(
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
* [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.EventStatus
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.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
@@ -135,11 +137,12 @@ interface CalendarDataSource {
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
/**
* Replace the reminders on **every** event in [calendarId] with [minutes]
* (all-day lead times), each encoded from its own date so it fires at
* [allDayReminderTimeMinutes]. Used by the special-dates section so a reminder
* change applies to existing events too, not just future ones — the managed
* calendars treat reminders as a calendar-level setting.
* Replace the reminders on **every managed** event in [calendarId] with
* [minutes] (all-day lead times), each encoded so it fires at
* [allDayReminderTimeMinutes] on its upcoming occurrence. Used by the
* special-dates section so a reminder change applies to existing events too,
* 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(
calendarId: Long,
@@ -393,10 +396,13 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.DTSTART,
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.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} IS NOT NULL",
arrayOf(calendarId.toString()),
"${CalendarContract.Events.UID_2445} LIKE ?",
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null,
)?.use { c ->
buildList {
@@ -418,13 +424,19 @@ class AndroidCalendarDataSource @Inject constructor(
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
// Deterministic UID = the reconciliation key. A re-sync reads it back
// (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())
?.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.
seedReminders(eventId, form, allDayReminderTimeMinutes)
// birthday reminder keeps their change. Encoded against the upcoming
// 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
}
@@ -443,23 +455,26 @@ class AndroidCalendarDataSource @Inject constructor(
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
val zone = ZoneId.systemDefault()
resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
"${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
arrayOf(calendarId.toString()),
// Only our own mirror events, so a stray user event in this calendar
// 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,
)?.use { c ->
while (c.moveToNext()) {
val eventId = c.getLong(0)
// Managed events are all-day: DTSTART is a UTC midnight.
val startDate = Instant.ofEpochMilli(c.getLong(1))
// Managed events are all-day: DTSTART is a UTC midnight anchor.
val anchor = Instant.ofEpochMilli(c.getLong(1))
.atZone(ZoneOffset.UTC).toLocalDate()
val encoded = minutes
.map { toProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) }
.distinct()
reconcileReminders(eventId, encoded)
reconcileReminders(
eventId,
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
)
}
}
}
@@ -780,8 +795,12 @@ class AndroidCalendarDataSource @Inject constructor(
* (spec §8): the event exists at this point — a reminder that fails to
* attach is logged, not surfaced as a failed create.
*/
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) {
encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes ->
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) =
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 {
put(CalendarContract.Reminders.EVENT_ID, eventId)
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(
eventId: Long,
original: EventForm,
@@ -1217,11 +1255,12 @@ class AndroidCalendarDataSource @Inject constructor(
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.
* Column and namespace for the special-dates marker (shared with the
* 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
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
val MANAGED_MARKER_COLUMN: String = CalendarProjection.MANAGED_MARKER_COLUMN
const val MANAGED_MARKER_PREFIX = CalendarProjection.MANAGED_MARKER_PREFIX
/**
* How far ahead/behind a search looks for a recurring event's nearest

View File

@@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
} else {
null
},
// A special-dates mirror calendar, recognised by its durable CAL_SYNC2
// marker (only meaningful on the local calendars the app owns). This is
// the source of truth for the editor lock — independent of any stored
// preference that a backup restore could have wiped.
isManaged = isLocal &&
getString(CalendarProjection.IDX_MANAGED_MARKER)
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
)
}

View File

@@ -15,9 +15,17 @@ internal object CalendarProjection {
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
// so the mapper only reads it for local calendars).
DESCRIPTION_COLUMN,
// The special-dates marker (CAL_SYNC2) — the durable identity the app
// uses to recognise its own managed calendars, independent of any
// stored preference id (which a backup restore / data wipe can lose).
MANAGED_MARKER_COLUMN,
)
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
const val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
/** Namespace prefix of every managed-calendar [MANAGED_MARKER_COLUMN] value. */
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
const val IDX_ID = 0
const val IDX_DISPLAY_NAME = 1
@@ -27,6 +35,7 @@ internal object CalendarProjection {
const val IDX_VISIBLE = 5
const val IDX_ACCESS_LEVEL = 6
const val IDX_DESCRIPTION = 7
const val IDX_MANAGED_MARKER = 8
}
internal object InstanceProjection {

View File

@@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
import javax.inject.Inject
import javax.inject.Singleton
@@ -50,9 +51,12 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
override fun readSpecialDates(): List<ContactSpecialDate> {
if (!hasPermission()) return emptyList()
val resolver = context.contentResolver
// A contact can carry the same date more than once (multiple raw
// contacts under one aggregate); keep the first per (contact, type).
val seen = HashSet<Pair<String, SpecialDateType>>()
// A contact can carry the same date more than once (multiple raw contacts
// under one aggregate); dedup on the mirror's reconciliation key so exact
// duplicates collapse while genuinely distinct dates (two custom events on
// one contact) are all kept. Ordered by Data._ID so which of two truly
// conflicting rows wins is stable across syncs (no event ping-pong).
val seen = HashSet<String>()
val result = ArrayList<ContactSpecialDate>()
runCatching {
resolver.query(
@@ -60,7 +64,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
PROJECTION,
"${ContactsContract.Data.MIMETYPE} = ?",
arrayOf(Event.CONTENT_ITEM_TYPE),
null,
"${ContactsContract.Data._ID} ASC",
)?.use { c ->
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
@@ -71,8 +75,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
val type = specialDateTypeForRawEventType(c.getInt(idxType))
if (!seen.add(lookup to type)) continue
result += ContactSpecialDate(
val date = ContactSpecialDate(
lookupKey = lookup,
displayName = c.getString(idxName)?.trim().orEmpty(),
type = type,
@@ -81,6 +84,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
year = parts.year,
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
)
if (seen.add(date.managedUid())) result += date
}
}
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }

View File

@@ -30,6 +30,7 @@ object SpecialDatesScheduler {
private const val WORK_NAME = "special-dates-sync"
private const val WORK_NAME_NOW = "special-dates-sync-now"
private const val WORK_NAME_FOREGROUND = "special-dates-sync-foreground"
private const val KEY_FOREGROUND = "foreground"
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
@@ -38,6 +39,7 @@ object SpecialDatesScheduler {
if (!enabled) {
workManager.cancelUniqueWork(WORK_NAME)
workManager.cancelUniqueWork(WORK_NAME_NOW)
workManager.cancelUniqueWork(WORK_NAME_FOREGROUND)
return
}
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
@@ -48,15 +50,19 @@ object SpecialDatesScheduler {
}
/**
* Run one reconcile immediately. [foreground] runs are debounced (the app can
* resume often); enable/"Sync now" runs ([foreground] false) always sync.
* Run one reconcile immediately. [foreground] runs (the app resuming) are
* debounced inside the worker and use their own work name, so a frequent
* foreground resync can never REPLACE — and swallow — a pending enable /
* "Sync now" run, which always syncs. The engine serializes the two if they
* overlap.
*/
fun runNow(context: Context, foreground: Boolean = false) {
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
.build()
val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW
WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME_NOW, ExistingWorkPolicy.REPLACE, request)
.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request)
}
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
@@ -107,6 +113,13 @@ class SpecialDatesSyncWorker(
}
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
Result.success()
} catch (e: SecurityException) {
// A revoked calendar/contacts permission won't fix itself on retry —
// park the feature in a stalled state (surfaced in settings) instead
// of retrying with backoff forever.
Log.w(TAG, "Special-dates sync lacks a required permission", e)
prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked)
Result.success()
} catch (e: Exception) {
Log.w(TAG, "Special-dates sync failed", e)
Result.retry()

View File

@@ -13,10 +13,12 @@ import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
import de.jeanlucmakiola.calendula.domain.contacts.managedEventUid
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
import de.jeanlucmakiola.calendula.domain.toRRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import java.time.ZoneId
@@ -64,11 +66,19 @@ class SpecialDatesSyncEngine @Inject constructor(
private val spec: SpecialDatesCalendarSpec,
) {
// Serializes calendar-lifecycle work: two overlapping syncs (the daily job
// racing a "Sync now"/foreground run) would each see no managed calendar and
// both create one; a teardown racing an in-flight sync would delete calendars
// the sync then recreates. Holding this for the whole of sync()/teardown()
// makes those check-then-act sequences atomic, and — because sync() re-reads
// the enabled flag inside the lock — a teardown always wins the race.
private val lifecycleMutex = Mutex()
/**
* Reconcile every enabled type against the device's contacts. Returns why it
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
*/
suspend fun sync(): SpecialDatesSyncResult {
suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock {
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
@@ -91,7 +101,7 @@ class SpecialDatesSyncEngine @Inject constructor(
reminderCtx = reminderCtx,
)
}
return SpecialDatesSyncResult.Success
SpecialDatesSyncResult.Success
}
/**
@@ -117,7 +127,7 @@ class SpecialDatesSyncEngine @Inject constructor(
}
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
suspend fun teardown() {
suspend fun teardown() = lifecycleMutex.withLock {
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
}
@@ -211,7 +221,7 @@ class SpecialDatesSyncEngine @Inject constructor(
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
return BuiltManagedEvent(
desired = ManagedEventDesired(
uid = managedEventUid(type, sd.lookupKey),
uid = sd.managedUid(),
title = title,
dtStartMillis = dtStartMillis,
rrule = YEARLY_RRULE,

View File

@@ -26,6 +26,14 @@ data class CalendarSource(
* owns for its own calendars). Always null for synced calendars.
*/
val description: String? = null,
/**
* A special-dates mirror calendar the app manages (birthdays/anniversaries
* from contacts). Its events' title/date/recurrence are owned by the sync,
* so it's hidden from the new-event calendar picker and its events lock those
* fields in the editor. Recognised by a durable provider marker, so it holds
* even after a backup restore clears the app's stored ids.
*/
val isManaged: Boolean = false,
)
data class EventInstance(

View File

@@ -3,12 +3,40 @@ package de.jeanlucmakiola.calendula.domain.contacts
import kotlinx.datetime.LocalDate
/**
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
* contact date. Stable across syncs (the reconciliation key), and namespaced by
* type so the same contact's birthday and anniversary never collide.
* Prefix of every managed-event `UID_2445`. Distinguishes mirror events from
* user-created ones (which carry a random `<uuid>@calendula` UID), so the sync
* only ever reconciles — and never deletes — events it actually owns.
*/
fun managedEventUid(type: SpecialDateType, lookupKey: String): String =
"contact-${type.name.lowercase()}:$lookupKey@calendula"
const val MANAGED_UID_PREFIX = "contact-"
/**
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
* contact date. Stable across syncs (the reconciliation key), namespaced by
* type so the same contact's birthday and anniversary never collide, and — when
* a [discriminator] is given — by it too, so two Custom dates on one contact
* (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering
* each other.
*/
fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String {
val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty()
return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula"
}
/**
* The reconciliation key for this date's mirrored event. Birthdays/anniversaries
* are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom]
* date adds a discriminator (its label, else its month-day) so distinct custom
* dates on one contact don't collapse into a single event.
*/
fun ContactSpecialDate.managedUid(): String =
managedEventUid(type, lookupKey, customDiscriminator())
private fun ContactSpecialDate.customDiscriminator(): String? =
if (type == SpecialDateType.Custom) {
label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day)
} else {
null
}
/**
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real

View File

@@ -250,14 +250,18 @@ fun EventDetailScreen(
contentDescription = stringResource(R.string.event_detail_edit),
)
}
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
// Managed special-dates events have no delete — the sync
// would resurrect them; the date is removed at the contact.
if (!s.isManaged) {
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
}
}
}
},

View File

@@ -15,6 +15,12 @@ sealed interface EventDetailUiState {
val calendarName: String?,
/** Whether the owning calendar allows modifying events (shows edit/delete). */
val canModify: Boolean = false,
/**
* The event belongs to a managed special-dates calendar: its editable
* fields (reminders, notes) can still be edited, but delete is hidden —
* the sync would just resurrect it. Contact dates are removed at the source.
*/
val isManaged: Boolean = false,
) : EventDetailUiState
}

View File

@@ -154,6 +154,7 @@ class EventDetailViewModel @Inject constructor(
detail = corrected,
calendarName = calendar?.displayName,
canModify = calendar?.canModifyContents == true,
isManaged = calendar?.isManaged == true,
)
} catch (e: CancellationException) {
throw e

View File

@@ -122,18 +122,24 @@ class EventEditViewModel @Inject constructor(
val autofocusTitle: Boolean,
)
/** Every calendar the provider exposes; the source for both lists below. */
private val allCalendars: Flow<List<CalendarSource>> =
repository.calendars().catch { emit(emptyList()) }
/**
* Writable calendars — the only valid event targets. Disabled calendars are
* excluded, so you can't create into a calendar you've removed from the app;
* a last-used preselect landing on a now-disabled calendar falls back to the
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
* Managed special-dates calendars are excluded too: their events are owned by
* the contact sync, which would delete any user event created there.
*/
private val writableCalendars: Flow<List<CalendarSource>> = combine(
repository.calendars(),
allCalendars,
prefs.disabledCalendarIds,
) { calendars, disabled ->
calendars.filter { it.canModifyContents && it.id !in disabled }
}.catch { emit(emptyList()) }
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
}
/** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine(
@@ -165,24 +171,33 @@ class EventEditViewModel @Inject constructor(
::ExternalInputs,
).flowOn(io),
colorPalette,
settingsPrefs.managedCalendarIds,
) { local, external, palette, managedIds ->
allCalendars,
) { local, external, palette, allCalendars ->
val form = local.form ?: return@combine null
val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
?: external.writable.firstOrNull()?.id
val resolved = form.copy(calendarId = resolvedId)
val resolvedCalendar = allCalendars.firstOrNull { it.id == resolvedId }
// Editing an event in a managed calendar locks its synced fields. Keyed
// off the calendar's durable marker, not a stored id, so it holds after a
// backup restore too.
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
// The picker offers writable calendars only; when editing a managed event
// its own (excluded) calendar is added back so the row still names it.
val pickerCalendars =
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
else external.writable
val visibleFields = external.defaultFields + local.revealed
EventEditUiState(
form = resolved,
calendars = external.writable,
calendars = pickerCalendars,
problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState,
visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null,
// Editing an event in a managed calendar locks its synced fields.
isManaged = local.editTarget != null && resolvedId != null && resolvedId in managedIds,
isManaged = isManaged,
autofocusTitle = external.autofocusTitle,
// A modified-occurrence exception can't carry its own rule, so
// the scope dialog drops "only this event" after a rule change.
@@ -420,7 +435,10 @@ class EventEditViewModel @Inject constructor(
_saveState.value = SaveUiState.Saved
return
}
if (target != null && target.original.rrule != null) {
// Managed events are a yearly series whose editable fields (reminders,
// notes, location) live on the series row — never offer the scope dialog,
// which would split the series into an exception the sync then reverts.
if (target != null && target.original.rrule != null && !current.isManaged) {
_saveState.value = SaveUiState.AwaitingScope
return
}

View File

@@ -83,6 +83,43 @@ class AllDayReminderEncodingTest {
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
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An

View File

@@ -23,6 +23,37 @@ class SpecialDateFormattingTest {
.isEqualTo("contact-anniversary:abc@calendula")
}
private fun custom(label: String?, month: Int, day: Int) = ContactSpecialDate(
lookupKey = "k",
displayName = "Jane",
type = SpecialDateType.Custom,
month = month,
day = day,
year = null,
label = label,
)
@Test
fun `birthday and anniversary uids ignore the discriminator`() {
assertThat(birthday(5, 14, 1990).managedUid()).isEqualTo("contact-birthday:k@calendula")
}
@Test
fun `two custom dates on one contact get distinct uids by label`() {
assertThat(custom("Wedding", 6, 10).managedUid())
.isEqualTo("contact-custom:k:wedding@calendula")
assertThat(custom("Graduation", 9, 1).managedUid())
.isNotEqualTo(custom("Wedding", 6, 10).managedUid())
}
@Test
fun `label-less custom dates fall back to month-day so distinct dates survive`() {
assertThat(custom(null, 6, 10).managedUid())
.isEqualTo("contact-custom:k:06-10@calendula")
assertThat(custom(null, 9, 1).managedUid())
.isNotEqualTo(custom(null, 6, 10).managedUid())
}
@Test
fun `anchor date uses the known year`() {
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))