Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/428-calendar-subscription-hrefs.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -307,7 +307,7 @@ private fun parseGroupwareCalendarsForComponent(
fun parseGroupwareAddressBooks(response: NextcloudApiResponse): List<GroupwareAddressBook> {
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() }
Expand Down Expand Up @@ -1067,151 +1067,6 @@ private fun String.decodePercentEncoding(): String {
return bytes.toByteArray().decodeToString()
}

internal fun String.xmlElements(localName: String): List<String> {
val results = mutableListOf<String>()
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("</$qualifiedName", openingEnd + 1, ignoreCase = true)
if (closingStart < 0) {
cursor = openingEnd + 1
continue
}
val closingEnd = indexOf('>', closingStart + qualifiedName.length + 2)
if (closingEnd < 0) break
results += substring(opening, closingEnd + 1)
cursor = closingEnd + 1
}
return results
}

internal fun String.xmlText(localName: String): String? = xmlElements(localName).firstOrNull()?.let { element ->
val openingEnd = element.indexOf('>')
val closingStart = element.lastIndexOf("</")
if (openingEnd >= 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<String> {
val names = mutableListOf<String>()
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<String> {
val tags = mutableListOf<String>()
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("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&")
}

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(
Expand Down
Loading