Merge pull request 'Milestone/lot 4 caldav' (#34) from milestone/lot-4-caldav into develop

Reviewed-on: Gato/Planify-mobile#34
This commit is contained in:
2026-06-06 06:33:42 +02:00
13 changed files with 967 additions and 0 deletions
@@ -0,0 +1,93 @@
package com.planify.mobile.data.caldav
import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject
import javax.inject.Singleton
data class CalDavResponse(
val code: Int,
val body: String,
val headers: Map<String, String> = emptyMap(),
) {
val isSuccess get() = code in 200..299 || code == 207
}
@Singleton
class CalDavClient @Inject constructor(private val okHttpClient: OkHttpClient) {
private val xmlMedia = "application/xml; charset=utf-8".toMediaType()
private val icalMedia = "text/calendar; charset=utf-8".toMediaType()
fun propfind(url: String, credentials: String, depth: String = "1", body: String): CalDavResponse {
val request = Request.Builder()
.url(url)
.method("PROPFIND", body.toRequestBody(xmlMedia))
.header("Authorization", credentials)
.header("Depth", depth)
.header("Content-Type", "application/xml; charset=utf-8")
.build()
return execute(request)
}
fun report(url: String, credentials: String, depth: String = "1", body: String): CalDavResponse {
val request = Request.Builder()
.url(url)
.method("REPORT", body.toRequestBody(xmlMedia))
.header("Authorization", credentials)
.header("Depth", depth)
.header("Content-Type", "application/xml; charset=utf-8")
.build()
return execute(request)
}
fun put(url: String, credentials: String, ical: String, etag: String? = null): CalDavResponse {
val builder = Request.Builder()
.url(url)
.put(ical.toRequestBody(icalMedia))
.header("Authorization", credentials)
.header("Content-Type", "text/calendar; charset=utf-8")
if (etag != null) builder.header("If-Match", etag)
else builder.header("If-None-Match", "*")
return execute(builder.build())
}
fun delete(url: String, credentials: String, etag: String? = null): CalDavResponse {
val builder = Request.Builder()
.url(url)
.delete()
.header("Authorization", credentials)
if (etag != null) builder.header("If-Match", etag)
return execute(builder.build())
}
fun options(url: String, credentials: String): CalDavResponse {
val request = Request.Builder()
.url(url)
.method("OPTIONS", null)
.header("Authorization", credentials)
.build()
return execute(request)
}
private fun execute(request: Request): CalDavResponse = try {
okHttpClient.newCall(request).execute().use { response ->
val headers = response.headers.toMap()
CalDavResponse(
code = response.code,
body = response.body?.string() ?: "",
headers = headers,
)
}
} catch (e: Exception) {
CalDavResponse(code = -1, body = e.message ?: "Network error")
}
companion object {
fun basicCredentials(username: String, password: String): String =
Credentials.basic(username, password)
}
}
@@ -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,190 @@
package com.planify.mobile.data.caldav
import com.planify.mobile.domain.model.CalDavType
import com.planify.mobile.domain.model.Source
import com.planify.mobile.domain.model.SourceCalDavData
import com.planify.mobile.domain.model.SourceType
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 DiscoveryResult {
data class Success(val sources: List<Source>) : DiscoveryResult()
data class Failure(val message: String) : DiscoveryResult()
}
@Singleton
class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
suspend fun discover(baseUrl: String, username: String, password: String): DiscoveryResult {
val credentials = CalDavClient.basicCredentials(username, password)
val normalizedBase = baseUrl.trimEnd('/')
// Step 1: resolve principal URL
val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username)
?: return DiscoveryResult.Failure("Impossible de trouver le principal CalDAV")
// Step 2: find calendar home
val calendarHome = resolveCalendarHome(principalUrl, credentials)
?: return DiscoveryResult.Failure("Impossible de trouver le calendar home")
// Step 3: list VTODO-capable calendars
val calendars = listCalendars(calendarHome, credentials, username, baseUrl)
val caldavType = detectType(normalizedBase)
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val sources = calendars.map { cal ->
Source(
id = UUID.randomUUID().toString(),
type = SourceType.CALDAV,
displayName = cal.displayName,
addedAt = now,
updatedAt = now,
caldavData = SourceCalDavData(
serverUrl = cal.url,
username = username,
calendarHomeUrl = calendarHome,
caldavType = caldavType,
),
)
}
return DiscoveryResult.Success(sources)
}
private fun resolvePrincipalUrl(baseUrl: String, credentials: String, username: String): String? {
val wellKnown = "$baseUrl/.well-known/caldav"
val body = """
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop><current-user-principal/></prop>
</propfind>
""".trimIndent()
for (url in listOf(wellKnown, baseUrl)) {
val resp = client.propfind(url, credentials, "0", body)
if (resp.isSuccess) {
val href = extractHref(resp.body, "current-user-principal")
if (href != null) return resolveUrl(baseUrl, href)
}
}
// Fallback: guess principal path
return "$baseUrl/principals/$username/"
}
private fun resolveCalendarHome(principalUrl: String, credentials: String): String? {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<prop><c:calendar-home-set/></prop>
</propfind>
""".trimIndent()
val resp = client.propfind(principalUrl, credentials, "0", body)
if (!resp.isSuccess) return null
val href = extractHref(resp.body, "calendar-home-set") ?: return null
return resolveUrl(principalUrl, href)
}
private fun listCalendars(homeUrl: String, credentials: String, username: String, baseUrl: String): List<CalendarInfo> {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<prop>
<resourcetype/>
<displayname/>
<c:supported-calendar-component-set/>
</prop>
</propfind>
""".trimIndent()
val resp = client.propfind(homeUrl, credentials, "1", body)
if (!resp.isSuccess) return emptyList()
return parseCalendarList(resp.body, baseUrl)
}
private data class CalendarInfo(val url: String, val displayName: String)
private fun parseCalendarList(xml: String, baseUrl: String): List<CalendarInfo> {
val results = mutableListOf<CalendarInfo>()
runCatching {
val factory = XmlPullParserFactory.newInstance()
val parser = factory.newPullParser()
parser.setInput(StringReader(xml))
var href = ""
var displayName = ""
var isCalendar = false
var supportsTodo = false
var inProp = false
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
val name = parser.name ?: ""
when {
event == XmlPullParser.START_TAG && name == "response" -> {
href = ""; displayName = ""; isCalendar = false; supportsTodo = false
}
event == XmlPullParser.START_TAG && name == "prop" -> inProp = true
event == XmlPullParser.END_TAG && name == "prop" -> inProp = false
event == XmlPullParser.START_TAG && name == "href" && !inProp -> {
parser.next()
href = parser.text ?: ""
}
event == XmlPullParser.START_TAG && name == "displayname" -> {
parser.next()
displayName = parser.text ?: ""
}
event == XmlPullParser.START_TAG && name == "calendar" -> isCalendar = true
event == XmlPullParser.START_TAG && name == "comp" -> {
if (parser.getAttributeValue(null, "name") == "VTODO") supportsTodo = true
}
event == XmlPullParser.END_TAG && name == "response" -> {
if (isCalendar && supportsTodo && href.isNotBlank()) {
val fullUrl = resolveUrl(baseUrl, href)
results.add(CalendarInfo(fullUrl, displayName.ifBlank { href.trimEnd('/').substringAfterLast('/') }))
}
}
}
event = parser.next()
}
}
return results
}
private fun extractHref(xml: String, parentTag: String): String? = runCatching {
val factory = XmlPullParserFactory.newInstance()
val parser = factory.newPullParser()
parser.setInput(StringReader(xml))
var inTarget = false
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
val name = parser.name ?: ""
when {
event == XmlPullParser.START_TAG && name == parentTag -> inTarget = true
event == XmlPullParser.END_TAG && name == parentTag -> inTarget = false
event == XmlPullParser.START_TAG && name == "href" && inTarget -> {
parser.next()
return parser.text
}
}
event = parser.next()
}
null
}.getOrNull()
private fun resolveUrl(base: String, href: String): String {
if (href.startsWith("http")) return href
val origin = base.substringBefore("://") + "://" + base.substringAfter("://").substringBefore("/")
return "$origin$href"
}
private fun detectType(url: String): CalDavType =
if (url.contains("nextcloud") || url.contains("/remote.php/")) CalDavType.NEXTCLOUD
else CalDavType.GENERIC
}
@@ -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() }
}
}
@@ -0,0 +1,82 @@
package com.planify.mobile.data.caldav
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.util.RRuleBuilder
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
object VTodoGenerator {
private val dtFormat = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
private val dateFormat = DateTimeFormatter.ofPattern("yyyyMMdd")
fun generate(task: Task): String {
val now = LocalDateTime.now().format(dtFormat)
val uid = task.calendarEventUid ?: task.id
return buildString {
appendLine("BEGIN:VCALENDAR")
appendLine("VERSION:2.0")
appendLine("PRODID:-//Planify Mobile//Android//EN")
appendLine("BEGIN:VTODO")
appendLine("UID:$uid")
appendLine("DTSTAMP:$now")
appendLine("CREATED:${formatDateTime(task.addedAt, now)}")
appendLine("LAST-MODIFIED:${formatDateTime(task.updatedAt, now)}")
appendLine("SUMMARY:${task.content.encodeIcal()}")
if (task.description.isNotBlank()) {
appendLine("DESCRIPTION:${task.description.encodeIcal()}")
}
appendLine("STATUS:${if (task.checked) "COMPLETED" else "NEEDS-ACTION"}")
if (task.checked && task.completedAt != null) {
appendLine("COMPLETED:${formatDateCompact(task.completedAt)}")
}
val icalPriority = appPriorityToIcal(task.priority)
if (icalPriority > 0) appendLine("PRIORITY:$icalPriority")
task.dueDate?.date?.let { due ->
appendLine("DUE;VALUE=DATE:${due.replace("-", "")}")
}
task.dueDate?.let { dd ->
RRuleBuilder.build(dd)?.let { appendLine(it) }
}
if (task.labels.isNotEmpty()) {
appendLine("CATEGORIES:${task.labels.joinToString(",") { it.encodeIcal() }}")
}
task.parentId?.let { appendLine("RELATED-TO;RELTYPE=PARENT:$it") }
appendLine("END:VTODO")
append("END:VCALENDAR")
}
}
private fun String.encodeIcal(): String =
replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
private fun formatDateTime(iso: String?, fallback: String): String {
if (iso.isNullOrBlank()) return fallback
return runCatching {
val ldt = if (iso.contains("T")) LocalDateTime.parse(iso, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
else LocalDateTime.parse("${iso}T00:00:00", DateTimeFormatter.ISO_LOCAL_DATE_TIME)
ldt.format(dtFormat)
}.getOrDefault(fallback)
}
private fun formatDateCompact(iso: String): String = runCatching {
"${iso.replace("-", "").take(8)}T000000Z"
}.getOrDefault(iso)
private fun appPriorityToIcal(p: Int): Int = when (p) {
1 -> 1
2 -> 5
3 -> 9
else -> 0
}
}
@@ -0,0 +1,128 @@
package com.planify.mobile.data.caldav
import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.util.RRuleBuilder
object VTodoParser {
fun parse(ical: String, projectId: String, icalUrl: String? = null): Task? {
val lines = unfold(ical)
val vtodo = extractComponent(lines, "VTODO") ?: return null
val props = parseProperties(vtodo)
val uid = props["UID"] ?: return null
val summary = props["SUMMARY"]?.decodeIcal() ?: return null
val description = props["DESCRIPTION"]?.decodeIcal() ?: ""
val status = props["STATUS"]
val checked = status == "COMPLETED"
val priority = props["PRIORITY"]?.toIntOrNull()?.let { icalPriorityToApp(it) } ?: 4
val due = props["DUE"]?.let { parseDate(it) }
val dtstart = props["DTSTART"]?.let { parseDate(it) }
val rrule = props["RRULE"]
val dueDate: DueDate? = if (due != null) {
val recDueDate = rrule?.let { RRuleBuilder.parse(it) }
DueDate(
date = due,
isRecurring = recDueDate != null,
recurrencyType = recDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE,
recurrencyInterval = recDueDate?.recurrencyInterval ?: 1,
recurrencyWeekDays = recDueDate?.recurrencyWeekDays ?: emptyList(),
recurrencyCount = recDueDate?.recurrencyCount,
recurrencyEnd = recDueDate?.recurrencyEnd,
)
} else null
val categories = props["CATEGORIES"]?.split(",")?.map { it.trim() } ?: emptyList()
val parentRelated = props.entries
.firstOrNull { it.key.startsWith("RELATED-TO") && it.value.isNotBlank() }
?.value
val completedAt = props["COMPLETED"]?.let { parseDate(it) }
val createdAt = props["CREATED"]?.let { parseDateTime(it) } ?: ""
val lastModified = props["LAST-MODIFIED"]?.let { parseDateTime(it) } ?: ""
return Task(
id = uid,
content = summary,
description = description,
projectId = projectId,
parentId = parentRelated,
priority = priority,
checked = checked,
dueDate = dueDate,
labels = categories,
addedAt = createdAt,
updatedAt = lastModified,
completedAt = completedAt,
calendarEventUid = uid,
icalUrl = icalUrl,
)
}
private fun unfold(ical: String): List<String> {
val result = mutableListOf<String>()
val sb = StringBuilder()
for (line in ical.lines()) {
if (line.startsWith(" ") || line.startsWith("\t")) {
sb.append(line.substring(1))
} else {
if (sb.isNotEmpty()) result.add(sb.toString())
sb.clear()
sb.append(line)
}
}
if (sb.isNotEmpty()) result.add(sb.toString())
return result
}
private fun extractComponent(lines: List<String>, name: String): List<String>? {
val start = lines.indexOfFirst { it == "BEGIN:$name" }
val end = lines.indexOfFirst { it == "END:$name" }
if (start < 0 || end < 0) return null
return lines.subList(start + 1, end)
}
private fun parseProperties(lines: List<String>): Map<String, String> {
val result = mutableMapOf<String, String>()
for (line in lines) {
val colonIdx = line.indexOf(':')
if (colonIdx < 0) continue
val rawKey = line.substring(0, colonIdx).uppercase()
val value = line.substring(colonIdx + 1)
// Key may have parameters like DUE;TZID=UTC — use base key
val key = rawKey.substringBefore(';')
result[key] = value
}
return result
}
private fun String.decodeIcal(): String =
replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
private fun parseDate(value: String): String? {
val v = value.substringAfterLast(':') // strip TZID param if any
return when {
v.length >= 8 -> "${v.take(4)}-${v.substring(4, 6)}-${v.substring(6, 8)}"
else -> null
}
}
private fun parseDateTime(value: String): String? {
val v = value.substringAfterLast(':')
if (v.length < 15) return null
return "${v.take(4)}-${v.substring(4, 6)}-${v.substring(6, 8)}T${v.substring(9, 11)}:${v.substring(11, 13)}:${v.substring(13, 15)}"
}
private fun icalPriorityToApp(p: Int): Int = when {
p == 1 -> 1
p in 2..4 -> 2
p in 5..6 -> 3
else -> 4
}
}
@@ -6,6 +6,7 @@ import com.planify.mobile.data.local.dao.LabelDao
import com.planify.mobile.data.local.dao.ProjectDao import com.planify.mobile.data.local.dao.ProjectDao
import com.planify.mobile.data.local.dao.ReminderDao import com.planify.mobile.data.local.dao.ReminderDao
import com.planify.mobile.data.local.dao.SectionDao import com.planify.mobile.data.local.dao.SectionDao
import com.planify.mobile.data.local.dao.SourceDao
import com.planify.mobile.data.local.dao.TaskDao import com.planify.mobile.data.local.dao.TaskDao
import com.planify.mobile.data.local.entity.LabelEntity import com.planify.mobile.data.local.entity.LabelEntity
import com.planify.mobile.data.local.entity.ProjectEntity import com.planify.mobile.data.local.entity.ProjectEntity
@@ -32,4 +33,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun sectionDao(): SectionDao abstract fun sectionDao(): SectionDao
abstract fun labelDao(): LabelDao abstract fun labelDao(): LabelDao
abstract fun reminderDao(): ReminderDao abstract fun reminderDao(): ReminderDao
abstract fun sourceDao(): SourceDao
} }
@@ -0,0 +1,30 @@
package com.planify.mobile.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.planify.mobile.data.local.entity.SourceEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SourceDao {
@Query("SELECT * FROM sources ORDER BY added_at")
fun getAllSources(): Flow<List<SourceEntity>>
@Query("SELECT * FROM sources WHERE id = :id")
suspend fun getSourceById(id: String): SourceEntity?
@Query("SELECT * FROM sources WHERE type = 'CALDAV'")
suspend fun getCaldavSources(): List<SourceEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(source: SourceEntity)
@Update
suspend fun update(source: SourceEntity)
@Query("DELETE FROM sources WHERE id = :id")
suspend fun delete(id: String)
}
@@ -0,0 +1,88 @@
package com.planify.mobile.data.repository
import com.planify.mobile.data.local.dao.SourceDao
import com.planify.mobile.data.local.entity.SourceEntity
import com.planify.mobile.domain.model.CalDavType
import com.planify.mobile.domain.model.Source
import com.planify.mobile.domain.model.SourceCalDavData
import com.planify.mobile.domain.model.SourceType
import com.planify.mobile.domain.repository.SourceRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject
class SourceRepositoryImpl @Inject constructor(
private val dao: SourceDao,
) : SourceRepository {
override fun getAllSources(): Flow<List<Source>> =
dao.getAllSources().map { list -> list.map { it.toDomain() } }
override suspend fun getSourceById(id: String): Source? =
dao.getSourceById(id)?.toDomain()
override suspend fun getCaldavSources(): List<Source> =
dao.getCaldavSources().map { it.toDomain() }
override suspend fun insertSource(source: Source) = dao.insert(source.toEntity())
override suspend fun updateSource(source: Source) = dao.update(source.toEntity())
override suspend fun deleteSource(id: String) = dao.delete(id)
private fun SourceEntity.toDomain(): Source {
val caldav = runCatching {
val obj = Json.parseToJsonElement(data).jsonObject
SourceCalDavData(
serverUrl = obj["serverUrl"]?.jsonPrimitive?.content ?: "",
username = obj["username"]?.jsonPrimitive?.content ?: "",
calendarHomeUrl = obj["calendarHomeUrl"]?.jsonPrimitive?.content,
userDisplayName = obj["userDisplayName"]?.jsonPrimitive?.content,
userEmail = obj["userEmail"]?.jsonPrimitive?.content,
caldavType = CalDavType.valueOf(obj["caldavType"]?.jsonPrimitive?.content ?: "GENERIC"),
ignoreSsl = obj["ignoreSsl"]?.jsonPrimitive?.content == "true",
)
}.getOrNull()
return Source(
id = id,
type = SourceType.valueOf(type),
displayName = displayName,
addedAt = addedAt,
updatedAt = updatedAt,
isVisible = isVisible,
lastSync = lastSync,
caldavData = caldav,
)
}
private fun Source.toEntity(): SourceEntity {
val dataJson = caldavData?.let { cd ->
buildString {
append("{")
append("\"serverUrl\":\"${cd.serverUrl}\",")
append("\"username\":\"${cd.username}\",")
cd.calendarHomeUrl?.let { append("\"calendarHomeUrl\":\"$it\",") }
cd.userDisplayName?.let { append("\"userDisplayName\":\"$it\",") }
cd.userEmail?.let { append("\"userEmail\":\"$it\",") }
append("\"caldavType\":\"${cd.caldavType.name}\",")
append("\"ignoreSsl\":\"${cd.ignoreSsl}\"")
append("}")
}
} ?: "{}"
return SourceEntity(
id = id,
type = type.name,
displayName = displayName,
addedAt = addedAt,
updatedAt = updatedAt,
isVisible = isVisible,
lastSync = lastSync,
data = dataJson,
)
}
}
@@ -0,0 +1,24 @@
package com.planify.mobile.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object CalDavModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.followRedirects(true)
.build()
}
@@ -26,4 +26,5 @@ object DatabaseModule {
@Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao() @Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao()
@Provides fun provideLabelDao(db: AppDatabase) = db.labelDao() @Provides fun provideLabelDao(db: AppDatabase) = db.labelDao()
@Provides fun provideReminderDao(db: AppDatabase) = db.reminderDao() @Provides fun provideReminderDao(db: AppDatabase) = db.reminderDao()
@Provides fun provideSourceDao(db: AppDatabase) = db.sourceDao()
} }
@@ -4,11 +4,13 @@ import com.planify.mobile.data.repository.LabelRepositoryImpl
import com.planify.mobile.data.repository.ProjectRepositoryImpl import com.planify.mobile.data.repository.ProjectRepositoryImpl
import com.planify.mobile.data.repository.ReminderRepositoryImpl import com.planify.mobile.data.repository.ReminderRepositoryImpl
import com.planify.mobile.data.repository.SectionRepositoryImpl import com.planify.mobile.data.repository.SectionRepositoryImpl
import com.planify.mobile.data.repository.SourceRepositoryImpl
import com.planify.mobile.data.repository.TaskRepositoryImpl import com.planify.mobile.data.repository.TaskRepositoryImpl
import com.planify.mobile.domain.repository.LabelRepository import com.planify.mobile.domain.repository.LabelRepository
import com.planify.mobile.domain.repository.ProjectRepository import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.ReminderRepository import com.planify.mobile.domain.repository.ReminderRepository
import com.planify.mobile.domain.repository.SectionRepository import com.planify.mobile.domain.repository.SectionRepository
import com.planify.mobile.domain.repository.SourceRepository
import com.planify.mobile.domain.repository.TaskRepository import com.planify.mobile.domain.repository.TaskRepository
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
@@ -34,4 +36,7 @@ abstract class RepositoryModule {
@Binds @Singleton @Binds @Singleton
abstract fun bindReminderRepository(impl: ReminderRepositoryImpl): ReminderRepository abstract fun bindReminderRepository(impl: ReminderRepositoryImpl): ReminderRepository
@Binds @Singleton
abstract fun bindSourceRepository(impl: SourceRepositoryImpl): SourceRepository
} }
@@ -0,0 +1,13 @@
package com.planify.mobile.domain.repository
import com.planify.mobile.domain.model.Source
import kotlinx.coroutines.flow.Flow
interface SourceRepository {
fun getAllSources(): Flow<List<Source>>
suspend fun getSourceById(id: String): Source?
suspend fun getCaldavSources(): List<Source>
suspend fun insertSource(source: Source)
suspend fun updateSource(source: Source)
suspend fun deleteSource(id: String)
}