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>
This commit is contained in:
2026-07-02 23:03:33 +02:00
parent 86d4e11584
commit 9f7e93d5f8
7 changed files with 70 additions and 17 deletions

View File

@@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
} else { } else {
null 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, // own we stash one in CAL_SYNC1 (synced rows put their sync token here,
// so the mapper only reads it for local calendars). // so the mapper only reads it for local calendars).
DESCRIPTION_COLUMN, 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 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_ID = 0
const val IDX_DISPLAY_NAME = 1 const val IDX_DISPLAY_NAME = 1
@@ -27,6 +35,7 @@ internal object CalendarProjection {
const val IDX_VISIBLE = 5 const val IDX_VISIBLE = 5
const val IDX_ACCESS_LEVEL = 6 const val IDX_ACCESS_LEVEL = 6
const val IDX_DESCRIPTION = 7 const val IDX_DESCRIPTION = 7
const val IDX_MANAGED_MARKER = 8
} }
internal object InstanceProjection { internal object InstanceProjection {

View File

@@ -26,6 +26,14 @@ data class CalendarSource(
* owns for its own calendars). Always null for synced calendars. * owns for its own calendars). Always null for synced calendars.
*/ */
val description: String? = null, 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( data class EventInstance(

View File

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

View File

@@ -15,6 +15,12 @@ sealed interface EventDetailUiState {
val calendarName: String?, val calendarName: String?,
/** Whether the owning calendar allows modifying events (shows edit/delete). */ /** Whether the owning calendar allows modifying events (shows edit/delete). */
val canModify: Boolean = false, 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 ) : EventDetailUiState
} }

View File

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

View File

@@ -122,18 +122,24 @@ class EventEditViewModel @Inject constructor(
val autofocusTitle: Boolean, 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 * 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; * 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 * a last-used preselect landing on a now-disabled calendar falls back to the
* first remaining writable one (handled by [resolvedCalendarId] and [state]). * 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( private val writableCalendars: Flow<List<CalendarSource>> = combine(
repository.calendars(), allCalendars,
prefs.disabledCalendarIds, prefs.disabledCalendarIds,
) { calendars, disabled -> ) { calendars, disabled ->
calendars.filter { it.canModifyContents && it.id !in disabled } calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
}.catch { emit(emptyList()) } }
/** The target calendar id, resolved exactly as the form shows it. */ /** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine( private val resolvedCalendarId: Flow<Long?> = combine(
@@ -165,24 +171,33 @@ class EventEditViewModel @Inject constructor(
::ExternalInputs, ::ExternalInputs,
).flowOn(io), ).flowOn(io),
colorPalette, colorPalette,
settingsPrefs.managedCalendarIds, allCalendars,
) { local, external, palette, managedIds -> ) { local, external, palette, allCalendars ->
val form = local.form ?: return@combine null val form = local.form ?: return@combine null
val resolvedId = form.calendarId val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
?: external.writable.firstOrNull()?.id ?: external.writable.firstOrNull()?.id
val resolved = form.copy(calendarId = resolvedId) 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 val visibleFields = external.defaultFields + local.revealed
EventEditUiState( EventEditUiState(
form = resolved, form = resolved,
calendars = external.writable, calendars = pickerCalendars,
problems = if (local.showProblems) resolved.problems() else emptySet(), problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState, saveState = local.saveState,
visibleFields = visibleFields, visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null, isEditing = local.editTarget != null,
// Editing an event in a managed calendar locks its synced fields. isManaged = isManaged,
isManaged = local.editTarget != null && resolvedId != null && resolvedId in managedIds,
autofocusTitle = external.autofocusTitle, autofocusTitle = external.autofocusTitle,
// A modified-occurrence exception can't carry its own rule, so // A modified-occurrence exception can't carry its own rule, so
// the scope dialog drops "only this event" after a rule change. // the scope dialog drops "only this event" after a rule change.
@@ -420,7 +435,10 @@ class EventEditViewModel @Inject constructor(
_saveState.value = SaveUiState.Saved _saveState.value = SaveUiState.Saved
return 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 _saveState.value = SaveUiState.AwaitingScope
return return
} }