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:
@@ -16,7 +16,7 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.0.3"
|
versionName = "0.0.4"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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) {
|
suspend fun discover(baseUrl: String, username: String, password: String): DiscoveryResult = withContext(Dispatchers.IO) {
|
||||||
val credentials = CalDavClient.basicCredentials(username, password)
|
val credentials = CalDavClient.basicCredentials(username, password)
|
||||||
val normalizedBase = baseUrl.trimEnd('/')
|
val normalizedBase = baseUrl.trimEnd('/')
|
||||||
|
val caldavType = detectType(normalizedBase)
|
||||||
|
|
||||||
// Step 1: resolve principal URL
|
// For Nextcloud, use the known URL structure directly (avoids fragile PROPFIND chain)
|
||||||
val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username)
|
if (caldavType == CalDavType.NEXTCLOUD) {
|
||||||
?: return@withContext DiscoveryResult.Failure("Étape 1 échouée : principal CalDAV introuvable pour $username sur $normalizedBase")
|
val result = discoverNextcloud(normalizedBase, username, credentials)
|
||||||
|
if (result != null) return@withContext result
|
||||||
// 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val caldavType = detectType(normalizedBase)
|
// Generic CalDAV discovery via PROPFIND chain
|
||||||
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
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(
|
Source(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
type = SourceType.CALDAV,
|
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> {
|
private fun parseCalendarList(xml: String, baseUrl: String): List<CalendarInfo> {
|
||||||
val results = mutableListOf<CalendarInfo>()
|
val results = mutableListOf<CalendarInfo>()
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -152,12 +202,10 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
|||||||
event == XmlPullParser.START_TAG && name == "prop" -> inProp = true
|
event == XmlPullParser.START_TAG && name == "prop" -> inProp = true
|
||||||
event == XmlPullParser.END_TAG && name == "prop" -> inProp = false
|
event == XmlPullParser.END_TAG && name == "prop" -> inProp = false
|
||||||
event == XmlPullParser.START_TAG && name == "href" && !inProp -> {
|
event == XmlPullParser.START_TAG && name == "href" && !inProp -> {
|
||||||
parser.next()
|
parser.next(); href = parser.text ?: ""
|
||||||
href = parser.text ?: ""
|
|
||||||
}
|
}
|
||||||
event == XmlPullParser.START_TAG && name == "displayname" -> {
|
event == XmlPullParser.START_TAG && name == "displayname" -> {
|
||||||
parser.next()
|
parser.next(); displayName = parser.text ?: ""
|
||||||
displayName = parser.text ?: ""
|
|
||||||
}
|
}
|
||||||
event == XmlPullParser.START_TAG && name == "calendar" -> isCalendar = true
|
event == XmlPullParser.START_TAG && name == "calendar" -> isCalendar = true
|
||||||
event == XmlPullParser.START_TAG && name == "comp" -> {
|
event == XmlPullParser.START_TAG && name == "comp" -> {
|
||||||
@@ -165,8 +213,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
|||||||
}
|
}
|
||||||
event == XmlPullParser.END_TAG && name == "response" -> {
|
event == XmlPullParser.END_TAG && name == "response" -> {
|
||||||
if (isCalendar && supportsTodo && href.isNotBlank()) {
|
if (isCalendar && supportsTodo && href.isNotBlank()) {
|
||||||
val fullUrl = resolveUrl(baseUrl, href)
|
results.add(CalendarInfo(resolveUrl(baseUrl, href), displayName.ifBlank { href.trimEnd('/').substringAfterLast('/') }))
|
||||||
results.add(CalendarInfo(fullUrl, 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.START_TAG && name == parentTag -> inTarget = true
|
||||||
event == XmlPullParser.END_TAG && name == parentTag -> inTarget = false
|
event == XmlPullParser.END_TAG && name == parentTag -> inTarget = false
|
||||||
event == XmlPullParser.START_TAG && name == "href" && inTarget -> {
|
event == XmlPullParser.START_TAG && name == "href" && inTarget -> {
|
||||||
parser.next()
|
parser.next(); return parser.text
|
||||||
return parser.text
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
event = parser.next()
|
event = parser.next()
|
||||||
|
|||||||
Reference in New Issue
Block a user