CalendarBridge.kt
197 lignes · 8887 octets
package fr.ebii.card2vcf.sync import android.content.ContentResolver import android.content.ContentUris import android.content.ContentValues import android.net.Uri import android.provider.CalendarContract import java.util.TimeZone /** * Encode/décode le `serverId` distant d'un événement dans `Events.DESCRIPTION`. * * Choix : préfixe de ligne stable plutôt qu'extended properties Calendar, dont la fiabilité varie * selon les fournisseurs de calendrier hors-GMS (certains ne les exposent pas via `ContentResolver` * sans compte de synchronisation). La description visible par l'utilisateur ([CalendarEventSnapshot.descriptionBody]) * suit immédiatement la ligne de préfixe. */ object CalendarServerIdCodec { private const val PREFIX = "card2vcf:serverId=" fun encode(serverId: String?, descriptionBody: String): String { if (serverId == null) return descriptionBody return "$PREFIX$serverId\n$descriptionBody" } /** Retourne `(serverId, descriptionBody)` ; `serverId` est `null` si le préfixe est absent. */ fun decode(description: String?): Pair<String?, String> { if (description == null) return null to "" if (!description.startsWith(PREFIX)) return null to description val rest = description.removePrefix(PREFIX) val newlineIndex = rest.indexOf('\n') return if (newlineIndex == -1) { rest to "" } else { rest.substring(0, newlineIndex) to rest.substring(newlineIndex + 1) } } } /** Préfixe de titre distinguant les indisponibilités (miroir lecture) des réservations/RDV. */ object CalendarEventTitles { private const val INDISPO_PREFIX = "[Indispo] " fun indispoTitle(title: String): String = "$INDISPO_PREFIX$title" fun isIndispoTitle(title: String): Boolean = title.startsWith(INDISPO_PREFIX) fun stripIndispoPrefix(title: String): String = title.removePrefix(INDISPO_PREFIX) } /** Contrat pont Agenda utilisé par [SyncEngine]/[AgendaSyncCoordinator] ; [CalendarBridge] l'implémente, fake en test. */ interface CalendarBridgeApi { fun ensureLocalCalendar(displayName: String): Long /** Supprime le calendrier local et, en cascade côté Android, tous ses événements. */ fun deleteCalendar(calendarId: Long) fun listEvents(calendarId: Long): List<CalendarEventSnapshot> fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long fun deleteEvent(eventId: Long) } /** * Pont Room ↔ Agenda Android via des calendriers **locaux** (`ACCOUNT_TYPE_LOCAL`), pour rester * fonctionnel sans compte Google / services Play. Le `serverId` distant est encodé dans * `Events.DESCRIPTION` (voir [CalendarServerIdCodec]). */ class CalendarBridge(private val resolver: ContentResolver) : CalendarBridgeApi { /** Crée le calendrier local `displayName` s'il n'existe pas encore, et retourne son id. */ override fun ensureLocalCalendar(displayName: String): Long { findCalendarId(displayName)?.let { return it } val uri = asSyncAdapter(CalendarContract.Calendars.CONTENT_URI) val values = ContentValues().apply { put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME) put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) put(CalendarContract.Calendars.NAME, displayName) put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, displayName) put(CalendarContract.Calendars.CALENDAR_COLOR, DEFAULT_COLOR) put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER) put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT_NAME) put(CalendarContract.Calendars.VISIBLE, 1) put(CalendarContract.Calendars.SYNC_EVENTS, 1) } val created = resolver.insert(uri, values) ?: error("Échec de création du calendrier local « $displayName »") return ContentUris.parseId(created) } /** * Supprime le calendrier local [calendarId] de l'appareil. Le fournisseur Agenda supprime ses * événements en cascade. Un id inconnu est sans effet (`delete` retourne 0). */ override fun deleteCalendar(calendarId: Long) { val uri = asSyncAdapter(ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId)) resolver.delete(uri, null, null) } override fun listEvents(calendarId: Long): List<CalendarEventSnapshot> { val projection = arrayOf( CalendarContract.Events._ID, CalendarContract.Events.TITLE, CalendarContract.Events.DESCRIPTION, CalendarContract.Events.DTSTART, CalendarContract.Events.DTEND, ) val events = mutableListOf<CalendarEventSnapshot>() resolver.query( CalendarContract.Events.CONTENT_URI, projection, "${CalendarContract.Events.CALENDAR_ID} = ?", arrayOf(calendarId.toString()), null, )?.use { cursor -> val idIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events._ID) val titleIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.TITLE) val descIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DESCRIPTION) val startIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DTSTART) val endIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DTEND) while (cursor.moveToNext()) { val (serverId, body) = CalendarServerIdCodec.decode(cursor.getString(descIdx)) events += CalendarEventSnapshot( title = cursor.getString(titleIdx) ?: "", debutMs = cursor.getLong(startIdx), finMs = cursor.getLong(endIdx), eventId = cursor.getLong(idIdx), serverId = serverId, descriptionBody = body, ) } } return events } /** Crée l'événement (si `snap.eventId == null`) ou le met à jour, et retourne son id. */ override fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long { val values = ContentValues().apply { put(CalendarContract.Events.CALENDAR_ID, calendarId) put(CalendarContract.Events.TITLE, snap.title) put(CalendarContract.Events.DESCRIPTION, CalendarServerIdCodec.encode(snap.serverId, snap.descriptionBody)) put(CalendarContract.Events.DTSTART, snap.debutMs) put(CalendarContract.Events.DTEND, snap.finMs) put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) } val existingEventId = snap.eventId if (existingEventId != null) { val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, existingEventId) resolver.update(uri, values, null, null) return existingEventId } val created = resolver.insert(CalendarContract.Events.CONTENT_URI, values) ?: error("Échec de création de l'événement « ${snap.title} »") return ContentUris.parseId(created) } override fun deleteEvent(eventId: Long) { val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) resolver.delete(uri, null, null) } /** Écriture sur `Calendars` en mode sync-adapter : mêmes compte/type qu'à la création. */ private fun asSyncAdapter(uri: Uri): Uri = uri.buildUpon() .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME) .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) .build() private fun findCalendarId(displayName: String): Long? { val projection = arrayOf(CalendarContract.Calendars._ID) val selection = "${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " + "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + "${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME} = ?" val args = arrayOf(ACCOUNT_NAME, CalendarContract.ACCOUNT_TYPE_LOCAL, displayName) return resolver.query(CalendarContract.Calendars.CONTENT_URI, projection, selection, args, null)?.use { cursor -> if (cursor.moveToFirst()) { cursor.getLong(cursor.getColumnIndexOrThrow(CalendarContract.Calendars._ID)) } else { null } } } companion object { /** Compte local dédié Card2vcf, hors-GMS : pas de synchronisation cloud. */ const val ACCOUNT_NAME = "card2vcf" private const val DEFAULT_COLOR = 0xFF000000.toInt() } }
GitRust