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()
    }
}