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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user