diff --git a/app/src/main/java/com/planify/mobile/data/caldav/CalDavClient.kt b/app/src/main/java/com/planify/mobile/data/caldav/CalDavClient.kt new file mode 100644 index 0000000..e7503db --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/caldav/CalDavClient.kt @@ -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 = 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) + } +} 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/CalDavDiscovery.kt b/app/src/main/java/com/planify/mobile/data/caldav/CalDavDiscovery.kt new file mode 100644 index 0000000..a074afb --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/caldav/CalDavDiscovery.kt @@ -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) : 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 = """ + + + + + """.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 = """ + + + + + """.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 { + val body = """ + + + + + + + + + """.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 { + val results = mutableListOf() + 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 +} 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() } + } +} diff --git a/app/src/main/java/com/planify/mobile/data/caldav/VTodoGenerator.kt b/app/src/main/java/com/planify/mobile/data/caldav/VTodoGenerator.kt new file mode 100644 index 0000000..8c9bcc6 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/caldav/VTodoGenerator.kt @@ -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 + } +} diff --git a/app/src/main/java/com/planify/mobile/data/caldav/VTodoParser.kt b/app/src/main/java/com/planify/mobile/data/caldav/VTodoParser.kt new file mode 100644 index 0000000..5d8d6f8 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/caldav/VTodoParser.kt @@ -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 { + val result = mutableListOf() + 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, name: String): List? { + 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): Map { + val result = mutableMapOf() + 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 + } +} diff --git a/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt b/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt index f872021..0190df0 100644 --- a/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt +++ b/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt @@ -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.ReminderDao 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.entity.LabelEntity import com.planify.mobile.data.local.entity.ProjectEntity @@ -32,4 +33,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun sectionDao(): SectionDao abstract fun labelDao(): LabelDao abstract fun reminderDao(): ReminderDao + abstract fun sourceDao(): SourceDao } diff --git a/app/src/main/java/com/planify/mobile/data/local/dao/SourceDao.kt b/app/src/main/java/com/planify/mobile/data/local/dao/SourceDao.kt new file mode 100644 index 0000000..974e6fd --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/local/dao/SourceDao.kt @@ -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> + + @Query("SELECT * FROM sources WHERE id = :id") + suspend fun getSourceById(id: String): SourceEntity? + + @Query("SELECT * FROM sources WHERE type = 'CALDAV'") + suspend fun getCaldavSources(): List + + @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) +} diff --git a/app/src/main/java/com/planify/mobile/data/repository/SourceRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/SourceRepositoryImpl.kt new file mode 100644 index 0000000..a0ed94d --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/repository/SourceRepositoryImpl.kt @@ -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> = + dao.getAllSources().map { list -> list.map { it.toDomain() } } + + override suspend fun getSourceById(id: String): Source? = + dao.getSourceById(id)?.toDomain() + + override suspend fun getCaldavSources(): List = + 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, + ) + } +} diff --git a/app/src/main/java/com/planify/mobile/di/CalDavModule.kt b/app/src/main/java/com/planify/mobile/di/CalDavModule.kt new file mode 100644 index 0000000..f4c7c95 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/di/CalDavModule.kt @@ -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() +} diff --git a/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt b/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt index bc9352e..74ccae4 100644 --- a/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt +++ b/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt @@ -26,4 +26,5 @@ object DatabaseModule { @Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao() @Provides fun provideLabelDao(db: AppDatabase) = db.labelDao() @Provides fun provideReminderDao(db: AppDatabase) = db.reminderDao() + @Provides fun provideSourceDao(db: AppDatabase) = db.sourceDao() } diff --git a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt index 8c0977b..2eb609e 100644 --- a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt +++ b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt @@ -4,11 +4,13 @@ import com.planify.mobile.data.repository.LabelRepositoryImpl import com.planify.mobile.data.repository.ProjectRepositoryImpl import com.planify.mobile.data.repository.ReminderRepositoryImpl 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.domain.repository.LabelRepository import com.planify.mobile.domain.repository.ProjectRepository import com.planify.mobile.domain.repository.ReminderRepository import com.planify.mobile.domain.repository.SectionRepository +import com.planify.mobile.domain.repository.SourceRepository import com.planify.mobile.domain.repository.TaskRepository import dagger.Binds import dagger.Module @@ -34,4 +36,7 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindReminderRepository(impl: ReminderRepositoryImpl): ReminderRepository + + @Binds @Singleton + abstract fun bindSourceRepository(impl: SourceRepositoryImpl): SourceRepository } diff --git a/app/src/main/java/com/planify/mobile/domain/repository/SourceRepository.kt b/app/src/main/java/com/planify/mobile/domain/repository/SourceRepository.kt new file mode 100644 index 0000000..f5871e8 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/domain/repository/SourceRepository.kt @@ -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> + suspend fun getSourceById(id: String): Source? + suspend fun getCaldavSources(): List + suspend fun insertSource(source: Source) + suspend fun updateSource(source: Source) + suspend fun deleteSource(id: String) +}