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 {
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

@@ -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

@@ -250,6 +250,9 @@ fun EventDetailScreen(
contentDescription = stringResource(R.string.event_detail_edit),
)
}
// 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,
@@ -260,6 +263,7 @@ fun EventDetailScreen(
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,

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
}