From 837d6f088a1befa35efb81fb223c4f12f45c196f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 17 Sep 2026 16:52:58 +0200 Subject: [PATCH 1/5] fix(calendar): ignore external subscription hrefs --- .../428-calendar-subscription-hrefs.md | 7 ++++ .../nextcloudnative/app/GroupwareDav.kt | 42 ++++++++++++++++++- .../nextcloudnative/app/GroupwareDavTest.kt | 28 +++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 changes/unreleased/428-calendar-subscription-hrefs.md diff --git a/changes/unreleased/428-calendar-subscription-hrefs.md b/changes/unreleased/428-calendar-subscription-hrefs.md new file mode 100644 index 000000000..7158b5d21 --- /dev/null +++ b/changes/unreleased/428-calendar-subscription-hrefs.md @@ -0,0 +1,7 @@ +category: fix +issue: 472 +pull: none +platforms: android, desktop +user-facing: yes + +Calendar discovery now ignores external source hrefs on subscribed calendars, so webcal subscriptions no longer block the remaining calendar list. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt index c3fa97b6c..85c89a9bc 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt @@ -285,7 +285,7 @@ private fun parseGroupwareCalendarsForComponent( require(response.status in 200..299) { "Calendar discovery failed (HTTP ${response.status})." } val xml = response.body.decodeToString() return xml.xmlElements("response").mapNotNull { block -> - val href = block.xmlText("href")?.decodeXmlEntities()?.trim()?.takeIf { it.endsWith('/') } + val href = block.xmlDirectChildText("href")?.decodeXmlEntities()?.trim()?.takeIf { it.endsWith('/') } ?: return@mapNotNull null if (!block.containsXmlElement("calendar")) return@mapNotNull null val supportsComponent = block.xmlOpeningTags("comp").any { component -> @@ -307,7 +307,7 @@ private fun parseGroupwareCalendarsForComponent( fun parseGroupwareAddressBooks(response: NextcloudApiResponse): List { require(response.status in 200..299) { "Address-book discovery failed (HTTP ${response.status})." } return response.body.decodeToString().xmlElements("response").mapNotNull { block -> - val href = block.xmlText("href")?.decodeXmlEntities()?.trim()?.takeIf { it.endsWith('/') } + val href = block.xmlDirectChildText("href")?.decodeXmlEntities()?.trim()?.takeIf { it.endsWith('/') } ?: return@mapNotNull null if (!block.containsXmlElement("addressbook")) return@mapNotNull null val privileges = block.xmlElements("privilege").flatMap { it.xmlElementNames() } @@ -1105,6 +1105,44 @@ internal fun String.xmlElements(localName: String): List { return results } +internal fun String.xmlDirectChildText(localName: String): String? { + var cursor = 0 + var depth = 0 + while (cursor < length) { + val opening = indexOf('<', cursor) + if (opening < 0) break + val nameStart = opening + 1 + val marker = getOrNull(nameStart) + if (marker == '!') { + cursor = indexOf('>', nameStart).takeIf { it >= 0 }?.plus(1) ?: break + continue + } + if (marker == '?') { + cursor = indexOf("?>", nameStart).takeIf { it >= 0 }?.plus(2) ?: break + continue + } + if (marker == '/') { + depth = (depth - 1).coerceAtLeast(0) + cursor = indexOf('>', nameStart).takeIf { it >= 0 }?.plus(1) ?: break + continue + } + val nameEnd = indexOfAny(charArrayOf(' ', '\t', '\r', '\n', '>', '/'), nameStart) + if (nameEnd < 0) break + val qualifiedName = substring(nameStart, nameEnd) + val openingEnd = indexOf('>', nameEnd) + if (openingEnd < 0) break + val selfClosing = getOrNull(openingEnd - 1) == '/' + if (depth == 1 && qualifiedName.substringAfter(':').equals(localName, ignoreCase = true)) { + if (selfClosing) return null + val closingStart = indexOf("= 0 }?.let { substring(openingEnd + 1, it) } + } + if (!selfClosing) depth++ + cursor = openingEnd + 1 + } + return null +} + internal fun String.xmlText(localName: String): String? = xmlElements(localName).firstOrNull()?.let { element -> val openingEnd = element.indexOf('>') val closingStart = element.lastIndexOf(" assertTrue(calendars.single().writable) } + @Test + fun `calendar collection href ignores subscribed calendar source href`() { + val response = NextcloudApiResponse( + status = 207, + contentType = "application/xml", + etag = null, + body = """ + + + + https://external.example.test/calendar.ics + + /remote.php/dav/calendars/opaque-user/subscribed/ + + + + + + + """.trimIndent().encodeToByteArray(), + ) + + val calendars = parseGroupwareCalendars(response) + + assertEquals("/remote.php/dav/calendars/opaque-user/subscribed/", calendars.single().href) + } + @Test fun `calendar report parses folded escaped and all-day event data`() { val response = NextcloudApiResponse( From 1f39b686a7944704b884fb5fe8660806aa04cb9f Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:00:49 +0000 Subject: [PATCH 2/5] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 1eaf440f6..18c0184aa 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -546,7 +546,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "096a154e9d6169da4cc82c0f87d2c572a1a5e97e396cae9eb739d05ce05b0d82", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "1d206c76800e92662b8980a41cd7792684e4c8b8ef94d0119109d5420da343c8", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt": "88f84a03a3c2d95b130601d5aac62fad4b4e1ed559c7851d7d47a3b44d1d2bc9", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt": "591cc77bd31cf17e7a1a6c3edc3c7f1c5b1ba87eda78c2415e961b15e472b931", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavMutation.kt": "f32d31bb3564ef2e4a565f840c0db227e2632f6e15251a29151f233c0b25f718", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavResourceStatus.kt": "816453d49fb983cf4eca6c93335d102570f037a6e064188f89f9b734ec1ebea0", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTaskDetails.kt": "5d4a6bc89bce14857ac7aaeb487c9effd4471d243698d350ef06586a0f10ce31", From 81c03a5cfec37aa7d2ee60e6428f9c191eff4bae Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 17 Sep 2026 17:47:20 +0200 Subject: [PATCH 3/5] fix(calendar): address DAV review feedback --- .../nextcloudnative/app/GroupwareDav.kt | 183 ----------------- .../nextcloudnative/app/GroupwareDavXml.kt | 193 ++++++++++++++++++ .../app/GroupwareDavCalendarTest.kt | 53 +++++ .../nextcloudnative/app/GroupwareDavTest.kt | 28 --- 4 files changed, 246 insertions(+), 211 deletions(-) create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavXml.kt create mode 100644 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavCalendarTest.kt diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt index 85c89a9bc..02e8e9eba 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt @@ -1067,189 +1067,6 @@ private fun String.decodePercentEncoding(): String { return bytes.toByteArray().decodeToString() } -internal fun String.xmlElements(localName: String): List { - val results = mutableListOf() - var cursor = 0 - while (cursor < length) { - val opening = indexOf('<', cursor) - if (opening < 0) break - val nameStart = opening + 1 - if (getOrNull(nameStart) in listOf('/', '!', '?')) { - cursor = nameStart + 1 - continue - } - val nameEnd = indexOfAny(charArrayOf(' ', '\t', '\r', '\n', '>', '/'), nameStart) - if (nameEnd < 0) break - val qualifiedName = substring(nameStart, nameEnd) - if (!qualifiedName.substringAfter(':').equals(localName, ignoreCase = true)) { - cursor = nameEnd - continue - } - val openingEnd = indexOf('>', nameEnd) - if (openingEnd < 0) break - if (getOrNull(openingEnd - 1) == '/') { - results += substring(opening, openingEnd + 1) - cursor = openingEnd + 1 - continue - } - val closingStart = indexOf("', closingStart + qualifiedName.length + 2) - if (closingEnd < 0) break - results += substring(opening, closingEnd + 1) - cursor = closingEnd + 1 - } - return results -} - -internal fun String.xmlDirectChildText(localName: String): String? { - var cursor = 0 - var depth = 0 - while (cursor < length) { - val opening = indexOf('<', cursor) - if (opening < 0) break - val nameStart = opening + 1 - val marker = getOrNull(nameStart) - if (marker == '!') { - cursor = indexOf('>', nameStart).takeIf { it >= 0 }?.plus(1) ?: break - continue - } - if (marker == '?') { - cursor = indexOf("?>", nameStart).takeIf { it >= 0 }?.plus(2) ?: break - continue - } - if (marker == '/') { - depth = (depth - 1).coerceAtLeast(0) - cursor = indexOf('>', nameStart).takeIf { it >= 0 }?.plus(1) ?: break - continue - } - val nameEnd = indexOfAny(charArrayOf(' ', '\t', '\r', '\n', '>', '/'), nameStart) - if (nameEnd < 0) break - val qualifiedName = substring(nameStart, nameEnd) - val openingEnd = indexOf('>', nameEnd) - if (openingEnd < 0) break - val selfClosing = getOrNull(openingEnd - 1) == '/' - if (depth == 1 && qualifiedName.substringAfter(':').equals(localName, ignoreCase = true)) { - if (selfClosing) return null - val closingStart = indexOf("= 0 }?.let { substring(openingEnd + 1, it) } - } - if (!selfClosing) depth++ - cursor = openingEnd + 1 - } - return null -} - -internal fun String.xmlText(localName: String): String? = xmlElements(localName).firstOrNull()?.let { element -> - val openingEnd = element.indexOf('>') - val closingStart = element.lastIndexOf("= 0 && closingStart > openingEnd) element.substring(openingEnd + 1, closingStart) else null -} - -private fun String.containsXmlElement(localName: String): Boolean = xmlElements(localName).isNotEmpty() - -private fun String.xmlAttribute(name: String): String? { - val openingEnd = indexOf('>').takeIf { it >= 0 } ?: return null - val opening = substring(0, openingEnd) - val marker = "$name=" - val markerIndex = opening.indexOf(marker, ignoreCase = true) - if (markerIndex < 0) return null - val quote = opening.getOrNull(markerIndex + marker.length)?.takeIf { it == '"' || it == '\'' } ?: return null - val valueStart = markerIndex + marker.length + 1 - val valueEnd = opening.indexOf(quote, valueStart) - return valueEnd.takeIf { it >= 0 }?.let { opening.substring(valueStart, it) } -} - -private fun String.xmlElementNames(): List { - val names = mutableListOf() - var cursor = 0 - while (cursor < length) { - val opening = indexOf('<', cursor) - if (opening < 0) break - val start = opening + 1 - if (getOrNull(start) in listOf('/', '!', '?')) { - cursor = start + 1 - continue - } - val end = indexOfAny(charArrayOf(' ', '\t', '\r', '\n', '>', '/'), start) - if (end < 0) break - names += substring(start, end).substringAfter(':').lowercase() - cursor = end - } - return names -} - -private fun String.xmlOpeningTags(localName: String): List { - val tags = mutableListOf() - var cursor = 0 - while (cursor < length) { - val opening = indexOf('<', cursor) - if (opening < 0) break - val start = opening + 1 - if (getOrNull(start) in listOf('/', '!', '?')) { - cursor = start + 1 - continue - } - val end = indexOfAny(charArrayOf(' ', '\t', '\r', '\n', '>', '/'), start) - if (end < 0) break - val qualifiedName = substring(start, end) - val openingEnd = indexOf('>', end) - if (openingEnd < 0) break - if (qualifiedName.substringAfter(':').equals(localName, ignoreCase = true)) { - tags += substring(opening, openingEnd + 1) - } - cursor = openingEnd + 1 - } - return tags -} - -internal fun String.decodeXmlEntities(): String { - val numeric = buildString(length) { - var cursor = 0 - while (cursor < this@decodeXmlEntities.length) { - if (this@decodeXmlEntities[cursor] == '&' && - this@decodeXmlEntities.getOrNull(cursor + 1) == '#' - ) { - val end = this@decodeXmlEntities.indexOf(';', cursor + 2) - .takeIf { it in (cursor + 3)..(cursor + 10) } - if (end != null) { - val encoded = this@decodeXmlEntities.substring(cursor + 2, end) - val codePoint = if (encoded.startsWith('x', ignoreCase = true)) { - encoded.drop(1).toIntOrNull(16) - } else { - encoded.toIntOrNull() - } - if (codePoint != null && codePoint in 0..0x10ffff && codePoint !in 0xd800..0xdfff) { - appendCodePoint(codePoint) - cursor = end + 1 - continue - } - } - } - append(this@decodeXmlEntities[cursor]) - cursor += 1 - } - } - return numeric.replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'") - .replace("&", "&") -} - -private fun StringBuilder.appendCodePoint(codePoint: Int) { - if (codePoint <= 0xffff) { - append(codePoint.toChar()) - } else { - val adjusted = codePoint - 0x10000 - append(((adjusted shr 10) + 0xd800).toChar()) - append(((adjusted and 0x3ff) + 0xdc00).toChar()) - } -} - internal fun String.requireSafeDavHref(): String { val normalized = lowercase() require( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavXml.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavXml.kt new file mode 100644 index 000000000..88dbdc771 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavXml.kt @@ -0,0 +1,193 @@ +package dev.obiente.nextcloudnative.app + +internal fun String.xmlElements(localName: String): List { + val results = mutableListOf() + var cursor = 0 + while (cursor < length) { + val opening = indexOf('<', cursor) + if (opening < 0) break + val nameStart = opening + 1 + if (getOrNull(nameStart) in listOf('/', '!', '?')) { + cursor = nameStart + 1 + continue + } + val nameEnd = indexOfAny(charArrayOf(' ', '\t', '\r', '\n', '>', '/'), nameStart) + if (nameEnd < 0) break + val qualifiedName = substring(nameStart, nameEnd) + if (!qualifiedName.substringAfter(':').equals(localName, ignoreCase = true)) { + cursor = nameEnd + continue + } + val openingEnd = indexOf('>', nameEnd) + if (openingEnd < 0) break + if (getOrNull(openingEnd - 1) == '/') { + results += substring(opening, openingEnd + 1) + cursor = openingEnd + 1 + continue + } + val closingStart = indexOf("', closingStart + qualifiedName.length + 2) + if (closingEnd < 0) break + results += substring(opening, closingEnd + 1) + cursor = closingEnd + 1 + } + return results +} + +internal fun String.xmlDirectChildText(localName: String): String? { + var cursor = 0 + var depth = 0 + while (cursor < length) { + val opening = indexOf('<', cursor) + if (opening < 0) break + val nameStart = opening + 1 + when { + startsWith("", nameStart + 3).takeIf { it >= 0 }?.plus(3) ?: break + continue + } + startsWith(" { + cursor = indexOf("]]>", nameStart + 8).takeIf { it >= 0 }?.plus(3) ?: break + continue + } + getOrNull(nameStart) == '!' -> { + cursor = indexOf('>', nameStart).takeIf { it >= 0 }?.plus(1) ?: break + continue + } + getOrNull(nameStart) == '?' -> { + cursor = indexOf("?>", nameStart).takeIf { it >= 0 }?.plus(2) ?: break + continue + } + getOrNull(nameStart) == '/' -> { + depth = (depth - 1).coerceAtLeast(0) + cursor = indexOf('>', nameStart).takeIf { it >= 0 }?.plus(1) ?: break + continue + } + } + val nameEnd = indexOfAny(charArrayOf(' ', '\t', '\r', '\n', '>', '/'), nameStart) + if (nameEnd < 0) break + val qualifiedName = substring(nameStart, nameEnd) + val openingEnd = indexOf('>', nameEnd) + if (openingEnd < 0) break + val selfClosing = getOrNull(openingEnd - 1) == '/' + if (depth == 1 && qualifiedName.substringAfter(':').equals(localName, ignoreCase = true)) { + if (selfClosing) return null + val closingStart = indexOf("= 0 }?.let { substring(openingEnd + 1, it) } + } + if (!selfClosing) depth++ + cursor = openingEnd + 1 + } + return null +} + +internal fun String.xmlText(localName: String): String? = xmlElements(localName).firstOrNull()?.let { element -> + val openingEnd = element.indexOf('>') + val closingStart = element.lastIndexOf("= 0 && closingStart > openingEnd) element.substring(openingEnd + 1, closingStart) else null +} + +internal fun String.containsXmlElement(localName: String): Boolean = xmlElements(localName).isNotEmpty() + +internal fun String.xmlAttribute(name: String): String? { + val openingEnd = indexOf('>').takeIf { it >= 0 } ?: return null + val opening = substring(0, openingEnd) + val marker = "$name=" + val markerIndex = opening.indexOf(marker, ignoreCase = true) + if (markerIndex < 0) return null + val quote = opening.getOrNull(markerIndex + marker.length)?.takeIf { it == '"' || it == '\'' } ?: return null + val valueStart = markerIndex + marker.length + 1 + val valueEnd = opening.indexOf(quote, valueStart) + return valueEnd.takeIf { it >= 0 }?.let { opening.substring(valueStart, it) } +} + +internal fun String.xmlElementNames(): List { + val names = mutableListOf() + var cursor = 0 + while (cursor < length) { + val opening = indexOf('<', cursor) + if (opening < 0) break + val start = opening + 1 + if (getOrNull(start) in listOf('/', '!', '?')) { + cursor = start + 1 + continue + } + val end = indexOfAny(charArrayOf(' ', '\t', '\r', '\n', '>', '/'), start) + if (end < 0) break + names += substring(start, end).substringAfter(':').lowercase() + cursor = end + } + return names +} + +internal fun String.xmlOpeningTags(localName: String): List { + val tags = mutableListOf() + var cursor = 0 + while (cursor < length) { + val opening = indexOf('<', cursor) + if (opening < 0) break + val start = opening + 1 + if (getOrNull(start) in listOf('/', '!', '?')) { + cursor = start + 1 + continue + } + val end = indexOfAny(charArrayOf(' ', '\t', '\r', '\n', '>', '/'), start) + if (end < 0) break + val qualifiedName = substring(start, end) + val openingEnd = indexOf('>', end) + if (openingEnd < 0) break + if (qualifiedName.substringAfter(':').equals(localName, ignoreCase = true)) { + tags += substring(opening, openingEnd + 1) + } + cursor = openingEnd + 1 + } + return tags +} + +internal fun String.decodeXmlEntities(): String { + val numeric = buildString(length) { + var cursor = 0 + while (cursor < this@decodeXmlEntities.length) { + if (this@decodeXmlEntities[cursor] == '&' && + this@decodeXmlEntities.getOrNull(cursor + 1) == '#' + ) { + val end = this@decodeXmlEntities.indexOf(';', cursor + 2) + .takeIf { it in (cursor + 3)..(cursor + 10) } + if (end != null) { + val encoded = this@decodeXmlEntities.substring(cursor + 2, end) + val codePoint = if (encoded.startsWith('x', ignoreCase = true)) { + encoded.drop(1).toIntOrNull(16) + } else { + encoded.toIntOrNull() + } + if (codePoint != null && codePoint in 0..0x10ffff && codePoint !in 0xd800..0xdfff) { + appendCodePoint(codePoint) + cursor = end + 1 + continue + } + } + } + append(this@decodeXmlEntities[cursor]) + cursor += 1 + } + } + return numeric.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +private fun StringBuilder.appendCodePoint(codePoint: Int) { + if (codePoint <= 0xffff) { + append(codePoint.toChar()) + } else { + val adjusted = codePoint - 0x10000 + append(((adjusted shr 10) + 0xd800).toChar()) + append(((adjusted and 0x3ff) + 0xdc00).toChar()) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavCalendarTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavCalendarTest.kt new file mode 100644 index 000000000..b84856603 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavCalendarTest.kt @@ -0,0 +1,53 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals + +class GroupwareDavCalendarTest { + @Test + fun `calendar collection href ignores subscribed calendar source href`() { + val response = calendarDiscoveryResponse( + """ + + https://external.example.test/calendar.ics + + """.trimIndent(), + ) + + val calendars = parseGroupwareCalendars(response) + + assertEquals("/remote.php/dav/calendars/opaque-user/subscribed/", calendars.single().href) + } + + @Test + fun `calendar collection href ignores markup in comments before response href`() { + val response = calendarDiscoveryResponse( + """ + + """.trimIndent(), + ) + + val calendars = parseGroupwareCalendars(response) + + assertEquals("/remote.php/dav/calendars/opaque-user/subscribed/", calendars.single().href) + } + + private fun calendarDiscoveryResponse(prefix: String): NextcloudApiResponse = NextcloudApiResponse( + status = 207, + contentType = "application/xml", + etag = null, + body = """ + + + $prefix + /remote.php/dav/calendars/opaque-user/subscribed/ + + + + + + + """.trimIndent().encodeToByteArray(), + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavTest.kt index fa8b4c055..076b6b1b0 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavTest.kt @@ -509,34 +509,6 @@ END:VCARD assertTrue(calendars.single().writable) } - @Test - fun `calendar collection href ignores subscribed calendar source href`() { - val response = NextcloudApiResponse( - status = 207, - contentType = "application/xml", - etag = null, - body = """ - - - - https://external.example.test/calendar.ics - - /remote.php/dav/calendars/opaque-user/subscribed/ - - - - - - - """.trimIndent().encodeToByteArray(), - ) - - val calendars = parseGroupwareCalendars(response) - - assertEquals("/remote.php/dav/calendars/opaque-user/subscribed/", calendars.single().href) - } - @Test fun `calendar report parses folded escaped and all-day event data`() { val response = NextcloudApiResponse( From c13c084aebbe9fb87a037e2b9cf3c81d4cdf817f Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:55:24 +0000 Subject: [PATCH 4/5] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 18c0184aa..de4fff7b6 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -133,6 +133,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavMutation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavResourceStatus.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavXml.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTaskDetails.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTaskEditor.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTaskOperations.kt", @@ -546,9 +547,10 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "096a154e9d6169da4cc82c0f87d2c572a1a5e97e396cae9eb739d05ce05b0d82", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "1d206c76800e92662b8980a41cd7792684e4c8b8ef94d0119109d5420da343c8", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt": "591cc77bd31cf17e7a1a6c3edc3c7f1c5b1ba87eda78c2415e961b15e472b931", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt": "03e0c01117c5b9a122da02a1b795d5b4a6a077cb1169e96bda35a2316b4159de", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavMutation.kt": "f32d31bb3564ef2e4a565f840c0db227e2632f6e15251a29151f233c0b25f718", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavResourceStatus.kt": "816453d49fb983cf4eca6c93335d102570f037a6e064188f89f9b734ec1ebea0", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavXml.kt": "af978b0ce8e2e8b16a07653bb6ba6779ea04d79316b32e15b214dcd26a397f02", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTaskDetails.kt": "5d4a6bc89bce14857ac7aaeb487c9effd4471d243698d350ef06586a0f10ce31", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTaskEditor.kt": "8b8aef307906f94a421bc1b8735cf1e6fb29c5a3c8fed3d7fb14dcaea2aba51f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTaskOperations.kt": "b9ce1216ed1bb27e6296615a719614eb058f9204ad85e1275db5f53935b1e9db", From 2aa6fee8551ae724f8d02bfc117a8ffc059a96ab Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 17 Sep 2026 18:05:16 +0200 Subject: [PATCH 5/5] fix(ci): avoid obsolete Android SDK tools package --- .github/workflows/ci.yml | 2 ++ .github/workflows/nightly.yml | 2 ++ .github/workflows/prerelease.yml | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71687a106..05e89c5d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,8 @@ jobs: steps.changes.outcome != 'success' || steps.changes.outputs.android == 'true' uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 + with: + packages: "platform-tools" - name: Install Android platform if: >- diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 36c4605ac..7afa58695 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -104,6 +104,8 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 + with: + packages: "platform-tools" - name: Install Android platform run: sdkmanager "platforms;android-36" "build-tools;35.0.0" diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index ee2aacd32..9315fa102 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -112,6 +112,8 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 + with: + packages: "platform-tools" - name: Install Android platform run: sdkmanager "platforms;android-36" "build-tools;35.0.0" @@ -264,6 +266,8 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 + with: + packages: "platform-tools" - name: Install Android platform run: sdkmanager "platforms;android-36" "build-tools;35.0.0"