From 93a26722d84c1725bfc7499751478374e028f2a5 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 08:26:34 +0200 Subject: [PATCH] fix: connexion Nextcloud en utilisant le chemin CalDAV direct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pour Nextcloud, bypass la chaîne PROPFIND (principal → calendar-home) et accède directement à $baseUrl/calendars/$username/ conformément à la doc officielle Nextcloud. Ajoute les codes HTTP dans les messages d'erreur de la discovery générique pour faciliter le debug. v0.0.4 Co-Authored-By: Claude Sonnet 4.6 --- app/build.gradle.kts | 2 +- .../mobile/data/caldav/CalDavDiscovery.kt | 228 +++++++++++------- 2 files changed, 138 insertions(+), 92 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 78789db..c3af3fa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 1 - versionName = "0.0.3" + versionName = "0.0.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } 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 index dc7cc7b..ae2c192 100644 --- a/app/src/main/java/com/planify/mobile/data/caldav/CalDavDiscovery.kt +++ b/app/src/main/java/com/planify/mobile/data/caldav/CalDavDiscovery.kt @@ -26,25 +26,143 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) { suspend fun discover(baseUrl: String, username: String, password: String): DiscoveryResult = withContext(Dispatchers.IO) { val credentials = CalDavClient.basicCredentials(username, password) val normalizedBase = baseUrl.trimEnd('/') + val caldavType = detectType(normalizedBase) - // Step 1: resolve principal URL - val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username) - ?: return@withContext DiscoveryResult.Failure("Étape 1 échouée : principal CalDAV introuvable pour $username sur $normalizedBase") - - // Step 2: find calendar home - val calendarHome = resolveCalendarHome(principalUrl, credentials) - ?: return@withContext DiscoveryResult.Failure("Étape 2 échouée : calendar-home-set introuvable sur $principalUrl") - - // Step 3: list VTODO-capable calendars - val calendars = listCalendars(calendarHome, credentials, username, baseUrl) - if (calendars.isEmpty()) { - return@withContext DiscoveryResult.Failure("Étape 3 : aucun calendrier VTODO trouvé sur $calendarHome") + // For Nextcloud, use the known URL structure directly (avoids fragile PROPFIND chain) + if (caldavType == CalDavType.NEXTCLOUD) { + val result = discoverNextcloud(normalizedBase, username, credentials) + if (result != null) return@withContext result } - val caldavType = detectType(normalizedBase) - val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + // Generic CalDAV discovery via PROPFIND chain + val diag = StringBuilder() - val sources = calendars.map { cal -> + val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username, diag) + ?: return@withContext DiscoveryResult.Failure("Étape 1 échouée : principal introuvable\n$diag") + + val calendarHome = resolveCalendarHome(principalUrl, credentials, diag) + ?: return@withContext DiscoveryResult.Failure("Étape 2 échouée sur $principalUrl\n$diag") + + val calendars = listCalendars(calendarHome, credentials, normalizedBase) + if (calendars.isEmpty()) { + return@withContext DiscoveryResult.Failure("Étape 3 : aucun calendrier VTODO sur $calendarHome\n$diag") + } + + DiscoveryResult.Success(buildSources(calendars, calendarHome, username, caldavType, normalizedBase)) + } + + // ── Nextcloud direct path ───────────────────────────────────────────────── + + private fun discoverNextcloud(baseUrl: String, username: String, credentials: String): DiscoveryResult? { + val calendarHome = "$baseUrl/calendars/$username/" + val resp = client.propfind(calendarHome, credentials, "1", calendarListBody()) + if (!resp.isSuccess) return null + + val calendars = parseCalendarList(resp.body, baseUrl) + if (calendars.isEmpty()) { + // Home exists but no VTODO calendars — still a successful connection + return DiscoveryResult.Success( + listOf( + Source( + id = UUID.randomUUID().toString(), + type = SourceType.CALDAV, + displayName = username, + addedAt = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + updatedAt = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + caldavData = SourceCalDavData( + serverUrl = calendarHome, + username = username, + calendarHomeUrl = calendarHome, + caldavType = CalDavType.NEXTCLOUD, + ), + ) + ) + ) + } + return DiscoveryResult.Success(buildSources(calendars, calendarHome, username, CalDavType.NEXTCLOUD, baseUrl)) + } + + // ── Generic PROPFIND discovery ──────────────────────────────────────────── + + private fun resolvePrincipalUrl(baseUrl: String, credentials: String, username: String, diag: StringBuilder): String? { + val origin = baseUrl.substringBefore("://") + "://" + baseUrl.substringAfter("://").substringBefore("/") + val body = """ + + + + + """.trimIndent() + + for (url in listOf("$origin/.well-known/caldav", baseUrl)) { + val resp = client.propfind(url, credentials, "0", body) + diag.append("PROPFIND $url → ${resp.code}\n") + if (resp.isSuccess) { + val href = extractHref(resp.body, "current-user-principal") + if (href != null) return resolveUrl(baseUrl, href) + } + } + // Fallbacks + for (candidate in listOf("$baseUrl/principals/users/$username/", "$baseUrl/principals/$username/")) { + val resp = client.propfind(candidate, credentials, "0", body) + diag.append("PROPFIND $candidate → ${resp.code}\n") + if (resp.isSuccess) return candidate + } + return null + } + + private fun resolveCalendarHome(principalUrl: String, credentials: String, diag: StringBuilder): String? { + val body = """ + + + + + """.trimIndent() + + val urlsToTry = mutableListOf(principalUrl) + if (principalUrl.contains("/principals/") && !principalUrl.contains("/principals/users/")) { + urlsToTry.add(principalUrl.replaceFirst("/principals/", "/principals/users/")) + } + + for (url in urlsToTry) { + val resp = client.propfind(url, credentials, "0", body) + diag.append("PROPFIND calendar-home $url → ${resp.code}\n") + if (!resp.isSuccess) continue + val href = extractHref(resp.body, "calendar-home-set") ?: continue + return resolveUrl(url, href) + } + return null + } + + private fun listCalendars(homeUrl: String, credentials: String, baseUrl: String): List { + val resp = client.propfind(homeUrl, credentials, "1", calendarListBody()) + if (!resp.isSuccess) return emptyList() + return parseCalendarList(resp.body, baseUrl) + } + + private fun calendarListBody() = """ + + + + + + + + + """.trimIndent() + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private data class CalendarInfo(val url: String, val displayName: String) + + private fun buildSources( + calendars: List, + calendarHome: String, + username: String, + caldavType: CalDavType, + baseUrl: String, + ): List { + val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + return calendars.map { cal -> Source( id = UUID.randomUUID().toString(), type = SourceType.CALDAV, @@ -59,76 +177,8 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) { ), ) } - DiscoveryResult.Success(sources) } - private fun resolvePrincipalUrl(baseUrl: String, credentials: String, username: String): String? { - val origin = baseUrl.substringBefore("://") + "://" + baseUrl.substringAfter("://").substringBefore("/") - val wellKnown = "$origin/.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) - } - } - // Nextcloud uses principals/users/, fallback to that first - val nextcloudFallback = "$baseUrl/principals/users/$username/" - val resp = client.propfind(nextcloudFallback, credentials, "0", body) - if (resp.isSuccess) return nextcloudFallback - return "$baseUrl/principals/$username/" - } - - private fun resolveCalendarHome(principalUrl: String, credentials: String): String? { - val body = """ - - - - - """.trimIndent() - - val urlsToTry = mutableListOf(principalUrl) - // Nextcloud alias /principals/username/ doesn't expose calendar-home-set, - // but /principals/users/username/ does — add it as fallback - if (principalUrl.contains("/principals/") && !principalUrl.contains("/principals/users/")) { - urlsToTry.add(principalUrl.replaceFirst("/principals/", "/principals/users/")) - } - - for (url in urlsToTry) { - val resp = client.propfind(url, credentials, "0", body) - if (!resp.isSuccess) continue - val href = extractHref(resp.body, "calendar-home-set") ?: continue - return resolveUrl(url, href) - } - return null - } - - 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 { @@ -152,12 +202,10 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) { 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 ?: "" + parser.next(); href = parser.text ?: "" } event == XmlPullParser.START_TAG && name == "displayname" -> { - parser.next() - displayName = parser.text ?: "" + parser.next(); displayName = parser.text ?: "" } event == XmlPullParser.START_TAG && name == "calendar" -> isCalendar = true event == XmlPullParser.START_TAG && name == "comp" -> { @@ -165,8 +213,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) { } 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('/') })) + results.add(CalendarInfo(resolveUrl(baseUrl, href), displayName.ifBlank { href.trimEnd('/').substringAfterLast('/') })) } } } @@ -188,8 +235,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) { 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 + parser.next(); return parser.text } } event = parser.next()