feat: [#19][#20][#21] sync CalDAV initiale/incrémentale + CRUD distant (sync-token, REPORT, PUT, DELETE, EncryptedSharedPreferences)

This commit is contained in:
2026-06-06 06:29:53 +02:00
parent ab1e59b237
commit 53c597a365
2 changed files with 311 additions and 0 deletions
@@ -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()
}
}
@@ -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 = """
<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VTODO"/>
</c:comp-filter>
</c:filter>
</c:calendar-query>
""".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 = """
<?xml version="1.0" encoding="utf-8"?>
<d:sync-collection xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:sync-token>$syncToken</d:sync-token>
<d:sync-level>1</d:sync-level>
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
</d:sync-collection>
""".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<MultiStatusItem> {
val results = mutableListOf<MultiStatusItem>()
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("<d:sync-token>").takeIf { it >= 0 }
?: xml.indexOf("<sync-token>").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() }
}
}