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