From 9f7e93d5f83cc0910b024cbf02c0f8193d781d1b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 23:03:33 +0200 Subject: [PATCH] fix(contacts): seal managed calendars off from user edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../calendula/data/calendar/CalendarMapper.kt | 7 ++++ .../calendula/data/calendar/Projections.kt | 9 +++++ .../jeanlucmakiola/calendula/domain/Models.kt | 8 +++++ .../calendula/ui/detail/EventDetailScreen.kt | 20 ++++++----- .../calendula/ui/detail/EventDetailUiState.kt | 6 ++++ .../ui/detail/EventDetailViewModel.kt | 1 + .../calendula/ui/edit/EventEditViewModel.kt | 36 ++++++++++++++----- 7 files changed, 70 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt index dda3126..c4d214a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt @@ -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, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 5a362bf..2066ac7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 5d5b89f..aaf4fb5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index c187ac3..eefce33 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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), + ) + } } } }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt index 60d405b..6f6dd97 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 40f7ce8..8374c21 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 673501a..fe1ec73 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -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> = + 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> = 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 = 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 }