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