fix: connexion Nextcloud en utilisant le chemin CalDAV direct

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 08:26:34 +02:00
parent 0c00d7d5b0
commit 93a26722d8
2 changed files with 138 additions and 92 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ android {
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.0.3"
versionName = "0.0.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -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 = """
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop><current-user-principal/></prop>
</propfind>
""".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 = """
<?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 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<CalendarInfo> {
val resp = client.propfind(homeUrl, credentials, "1", calendarListBody())
if (!resp.isSuccess) return emptyList()
return parseCalendarList(resp.body, baseUrl)
}
private fun calendarListBody() = """
<?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()
// ── Helpers ───────────────────────────────────────────────────────────────
private data class CalendarInfo(val url: String, val displayName: String)
private fun buildSources(
calendars: List<CalendarInfo>,
calendarHome: String,
username: String,
caldavType: CalDavType,
baseUrl: String,
): List<Source> {
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 = """
<?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)
}
}
// Nextcloud uses principals/users/<username>, 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 = """
<?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 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<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 {
@@ -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()