feat: [#19][#20][#21] sync CalDAV initiale/incrémentale + CRUD distant (sync-token, REPORT, PUT, DELETE, EncryptedSharedPreferences)
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user