feat: [#16][#17] client CalDAV OkHttp (PROPFIND/REPORT/PUT/DELETE) + découverte serveur CalDAV

This commit is contained in:
2026-06-06 06:27:58 +02:00
parent 40fff7c9a8
commit 254efff4b3
9 changed files with 446 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,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
}
@@ -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
}
@@ -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 provideLabelDao(db: AppDatabase) = db.labelDao()
@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.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
}
@@ -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)
}