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()