From 53c597a36518470e8dd7906d374875cc19362242 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:29:53 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20[#19][#20][#21]=20sync=20CalDAV=20initi?= =?UTF-8?q?ale/incr=C3=A9mentale=20+=20CRUD=20distant=20(sync-token,=20REP?= =?UTF-8?q?ORT,=20PUT,=20DELETE,=20EncryptedSharedPreferences)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/caldav/CalDavCredentialStore.kt | 37 +++ .../mobile/data/caldav/CalDavSyncManager.kt | 274 ++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/data/caldav/CalDavCredentialStore.kt create mode 100644 app/src/main/java/com/planify/mobile/data/caldav/CalDavSyncManager.kt diff --git a/app/src/main/java/com/planify/mobile/data/caldav/CalDavCredentialStore.kt b/app/src/main/java/com/planify/mobile/data/caldav/CalDavCredentialStore.kt new file mode 100644 index 0000000..7145c37 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/caldav/CalDavCredentialStore.kt @@ -0,0 +1,37 @@ +package com.planify.mobile.data.caldav + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CalDavCredentialStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val prefs by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "caldav_credentials", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + fun savePassword(sourceId: String, password: String) { + prefs.edit().putString("pwd_$sourceId", password).apply() + } + + fun getPassword(sourceId: String): String = + prefs.getString("pwd_$sourceId", "") ?: "" + + fun deletePassword(sourceId: String) { + prefs.edit().remove("pwd_$sourceId").apply() + } +} diff --git a/app/src/main/java/com/planify/mobile/data/caldav/CalDavSyncManager.kt b/app/src/main/java/com/planify/mobile/data/caldav/CalDavSyncManager.kt new file mode 100644 index 0000000..123c983 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/caldav/CalDavSyncManager.kt @@ -0,0 +1,274 @@ +package com.planify.mobile.data.caldav + +import com.planify.mobile.domain.model.BackendType +import com.planify.mobile.domain.model.Project +import com.planify.mobile.domain.model.Source +import com.planify.mobile.domain.model.SourceType +import com.planify.mobile.domain.model.Task +import com.planify.mobile.domain.repository.ProjectRepository +import com.planify.mobile.domain.repository.SourceRepository +import com.planify.mobile.domain.repository.TaskRepository +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.StringReader +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +sealed class SyncResult { + data class Success(val added: Int = 0, val updated: Int = 0, val deleted: Int = 0) : SyncResult() + data class Failure(val message: String) : SyncResult() +} + +@Singleton +class CalDavSyncManager @Inject constructor( + private val client: CalDavClient, + private val taskRepository: TaskRepository, + private val projectRepository: ProjectRepository, + private val sourceRepository: SourceRepository, + private val credentialStore: CalDavCredentialStore, +) { + + // ─── #19 Initial sync ──────────────────────────────────────────────────── + + suspend fun initialSync(source: Source): SyncResult { + val caldav = source.caldavData ?: return SyncResult.Failure("Source CalDAV sans données") + val credentials = CalDavClient.basicCredentials(caldav.username, getPassword(source.id)) + val calendarUrl = caldav.serverUrl + + // Ensure a local project exists for this source + val project = ensureProject(source) + + val reportBody = """ + + + + + + + + + + + + + """.trimIndent() + + val resp = client.report(calendarUrl, credentials, "1", reportBody) + if (!resp.isSuccess) return SyncResult.Failure("REPORT échoué : ${resp.code}") + + val items = parseMultiStatus(resp.body, calendarUrl) + var added = 0; var updated = 0 + + for (item in items) { + val ical = item.calendarData ?: continue + val task = VTodoParser.parse(ical, project.id, item.href) ?: continue + val existing = taskRepository.getTaskById(task.id) + if (existing == null) { + taskRepository.insertTask(task.copy(etag = item.etag)) + added++ + } else if (existing.etag != item.etag) { + taskRepository.updateTask(task.copy(etag = item.etag)) + updated++ + } + } + + // Store sync-token for future incremental sync + val syncToken = extractSyncToken(resp.body) + updateSyncToken(source, syncToken) + + return SyncResult.Success(added = added, updated = updated) + } + + // ─── #20 Incremental sync ───────────────────────────────────────────────── + + suspend fun incrementalSync(source: Source): SyncResult { + val caldav = source.caldavData ?: return SyncResult.Failure("Source CalDAV sans données") + val syncToken = source.lastSync + if (syncToken.isNullOrBlank()) return initialSync(source) + + val credentials = CalDavClient.basicCredentials(caldav.username, getPassword(source.id)) + val calendarUrl = caldav.serverUrl + val project = ensureProject(source) + + val reportBody = """ + + + $syncToken + 1 + + + + + + """.trimIndent() + + val resp = client.report(calendarUrl, credentials, "1", reportBody) + if (resp.code == 403 || resp.code == 409) { + // Sync token expired — fall back to full sync + return initialSync(source) + } + if (!resp.isSuccess) return SyncResult.Failure("sync-collection échoué : ${resp.code}") + + val delta = parseMultiStatus(resp.body, calendarUrl) + var added = 0; var updated = 0; var deleted = 0 + + for (item in delta) { + if (item.isDeleted) { + // Find task with this icalUrl and delete it + taskRepository.getTasksByProject(project.id).collect { tasks -> + tasks.firstOrNull { it.icalUrl == item.href }?.let { + taskRepository.deleteTask(it.id) + deleted++ + } + } + } else { + val ical = item.calendarData ?: continue + val task = VTodoParser.parse(ical, project.id, item.href) ?: continue + val existing = taskRepository.getTaskById(task.id) + if (existing == null) { + taskRepository.insertTask(task.copy(etag = item.etag)) + added++ + } else { + taskRepository.updateTask(task.copy(etag = item.etag)) + updated++ + } + } + } + + val newToken = extractSyncToken(resp.body) + updateSyncToken(source, newToken) + + return SyncResult.Success(added = added, updated = updated, deleted = deleted) + } + + // ─── #21 CRUD push ──────────────────────────────────────────────────────── + + suspend fun pushTask(task: Task, source: Source): SyncResult { + val caldav = source.caldavData ?: return SyncResult.Failure("Source CalDAV sans données") + val credentials = CalDavClient.basicCredentials(caldav.username, getPassword(source.id)) + val uid = task.calendarEventUid ?: task.id + val url = task.icalUrl ?: "${caldav.serverUrl.trimEnd('/')}/$uid.ics" + + val ical = VTodoGenerator.generate(task) + val resp = client.put(url, credentials, ical, task.etag) + + if (!resp.isSuccess) return SyncResult.Failure("PUT échoué : ${resp.code}") + + // Update local etag and icalUrl + val newEtag = resp.headers["ETag"] ?: resp.headers["etag"] + taskRepository.updateTask( + task.copy( + icalUrl = url, + calendarEventUid = uid, + etag = newEtag, + ) + ) + return SyncResult.Success(added = if (task.icalUrl == null) 1 else 0, updated = if (task.icalUrl != null) 1 else 0) + } + + suspend fun deleteRemoteTask(task: Task, source: Source): SyncResult { + val caldav = source.caldavData ?: return SyncResult.Failure("Source CalDAV sans données") + val url = task.icalUrl ?: return SyncResult.Failure("Tâche sans URL iCal") + val credentials = CalDavClient.basicCredentials(caldav.username, getPassword(source.id)) + + val resp = client.delete(url, credentials, task.etag) + if (!resp.isSuccess && resp.code != 404) return SyncResult.Failure("DELETE échoué : ${resp.code}") + + taskRepository.deleteTask(task.id) + return SyncResult.Success(deleted = 1) + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private suspend fun ensureProject(source: Source): Project { + val existing = projectRepository.getAllProjects().let { flow -> + var found: Project? = null + flow.collect { list -> found = list.firstOrNull { it.sourceId == source.id }; return@collect } + found + } + if (existing != null) return existing + + val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + val project = Project( + id = UUID.randomUUID().toString(), + name = source.displayName, + sourceId = source.id, + backendType = BackendType.CALDAV, + ) + projectRepository.insertProject(project) + return project + } + + private suspend fun updateSyncToken(source: Source, token: String?) { + if (token == null) return + sourceRepository.updateSource(source.copy(lastSync = token)) + } + + private fun getPassword(sourceId: String): String = + credentialStore.getPassword(sourceId) + + private data class MultiStatusItem( + val href: String, + val etag: String? = null, + val calendarData: String? = null, + val isDeleted: Boolean = false, + ) + + private fun parseMultiStatus(xml: String, baseUrl: String): List { + val results = mutableListOf() + runCatching { + val factory = XmlPullParserFactory.newInstance() + val parser = factory.newPullParser() + parser.setInput(StringReader(xml)) + + var href = ""; var etag: String? = null + var calData: String? = null; var status = ""; var inCalData = false + + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + val name = parser.name ?: "" + when { + event == XmlPullParser.START_TAG && name == "response" -> { + href = ""; etag = null; calData = null; status = "" + } + event == XmlPullParser.START_TAG && name == "href" -> { + parser.next(); href = parser.text ?: "" + } + event == XmlPullParser.START_TAG && name == "getetag" -> { + parser.next(); etag = parser.text?.trim('"') + } + event == XmlPullParser.START_TAG && name == "calendar-data" -> { + inCalData = true; parser.next() + calData = if (parser.eventType == XmlPullParser.TEXT) parser.text else null + } + event == XmlPullParser.END_TAG && name == "calendar-data" -> inCalData = false + event == XmlPullParser.START_TAG && name == "status" -> { + parser.next(); status = parser.text ?: "" + } + event == XmlPullParser.END_TAG && name == "response" -> { + val isDeleted = status.contains("404") + val fullHref = if (href.startsWith("http")) href + else baseUrl.substringBefore("://") + "://" + + baseUrl.substringAfter("://").substringBefore("/") + href + results.add(MultiStatusItem(fullHref, etag, calData, isDeleted)) + } + } + event = parser.next() + } + } + return results + } + + private fun extractSyncToken(xml: String): String? { + val start = xml.indexOf("").takeIf { it >= 0 } + ?: xml.indexOf("").takeIf { it >= 0 } + ?: return null + val tagEnd = xml.indexOf('>', start) + 1 + val closeStart = xml.indexOf('<', tagEnd) + if (closeStart < 0) return null + return xml.substring(tagEnd, closeStart).trim().takeIf { it.isNotBlank() } + } +}