From 254efff4b3169307c638f7a326f3654385c864cc Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:27:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20[#16][#17]=20client=20CalDAV=20OkHttp?= =?UTF-8?q?=20(PROPFIND/REPORT/PUT/DELETE)=20+=20d=C3=A9couverte=20serveur?= =?UTF-8?q?=20CalDAV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/data/caldav/CalDavClient.kt | 93 +++++++++ .../mobile/data/caldav/CalDavDiscovery.kt | 190 ++++++++++++++++++ .../planify/mobile/data/local/AppDatabase.kt | 2 + .../mobile/data/local/dao/SourceDao.kt | 30 +++ .../data/repository/SourceRepositoryImpl.kt | 88 ++++++++ .../com/planify/mobile/di/CalDavModule.kt | 24 +++ .../com/planify/mobile/di/DatabaseModule.kt | 1 + .../com/planify/mobile/di/RepositoryModule.kt | 5 + .../domain/repository/SourceRepository.kt | 13 ++ 9 files changed, 446 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/data/caldav/CalDavClient.kt create mode 100644 app/src/main/java/com/planify/mobile/data/caldav/CalDavDiscovery.kt create mode 100644 app/src/main/java/com/planify/mobile/data/local/dao/SourceDao.kt create mode 100644 app/src/main/java/com/planify/mobile/data/repository/SourceRepositoryImpl.kt create mode 100644 app/src/main/java/com/planify/mobile/di/CalDavModule.kt create mode 100644 app/src/main/java/com/planify/mobile/domain/repository/SourceRepository.kt 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/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/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) +}