Release 1.2.0 (#103)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 1m11s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m4s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m33s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped

Stable 1.2.0, after 1.2.0-beta.1 and beta.2.

- Version bumped to 1.2.0 (1020099); the CHANGELOG entries moved from Unreleased to 1.2.0.
- What's New for every shipped locale, now including cs-CZ.
- Full store listing (title, short and full description) for every locale; new for cs-CZ, ar and fr-FR. Play only gets these through the `listing` lane.
- `main` is merged in with the branch side kept. #81 and the Weblate updates already came in through the #88 squash, and the only lines unique to `main` were string keys that #88 removed.

The #75 fix landed after beta.2, so no reporter has tested it yet.

Closes #72, closes #78, closes #75, closes #92

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/103
This commit is contained in:
Jean-Luc Makiola
2026-10-26 20:41:58 +01:00
co-authored by makiolaj
parent 1d6c2a0d59
commit 08c297f30a
110 changed files with 6491 additions and 784 deletions
@@ -0,0 +1,29 @@
package de.jeanlucmakiola.caldav
import org.xbill.DNS.Message
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.Section
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.Type
/**
* Reads SRV and TXT records out of a raw DNS answer.
*
* The lookup itself is the platform's job — on Android the system resolver,
* which follows the current network and honours Private DNS — so this only
* parses the wire format it hands back. A malformed answer reads as no records.
*/
object DnsAnswers {
const val TYPE_SRV = Type.SRV
const val TYPE_TXT = Type.TXT
fun srv(answer: ByteArray): List<SrvRecord> = records<SRVRecord>(answer)
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
fun txt(answer: ByteArray): List<String> = records<TXTRecord>(answer).flatMap { it.strings }
private inline fun <reified T> records(answer: ByteArray): List<T> = runCatching {
Message(answer).getSection(Section.ANSWER).filterIsInstance<T>()
}.getOrDefault(emptyList())
}
@@ -1,34 +0,0 @@
package de.jeanlucmakiola.caldav
import org.xbill.DNS.Lookup
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.Type
/**
* SRV/TXT lookups over dnsjava.
*
* Android exposes no usable SRV API — `DnsResolver` arrived in API 29 but is
* callback-only and does not help with the `TXT path=` half — and JNDI's DNS
* provider does not exist on Android at all. dnsjava is what DAVx5 uses.
*
* A lookup that fails returns an empty list rather than throwing: DNS being
* unavailable means "no SRV record", which is an ordinary and common answer, and
* the well-known ladder still has rungs left.
*/
class DnsJavaResolver : DnsResolver {
override fun srv(name: String): List<SrvRecord> = runCatching {
Lookup(name, Type.SRV).run()
.orEmpty()
.filterIsInstance<SRVRecord>()
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
}.getOrDefault(emptyList())
override fun txt(name: String): List<String> = runCatching {
Lookup(name, Type.TXT).run()
.orEmpty()
.filterIsInstance<TXTRecord>()
.flatMap { it.strings }
}.getOrDefault(emptyList())
}
@@ -11,18 +11,20 @@ data class SrvRecord(
val target: String,
)
/** The SRV and TXT records published under one name. */
data class DnsRecords(val srv: List<SrvRecord>, val txt: List<String>)
/**
* DNS lookups for RFC 6764. Behind an interface so the pipeline is testable
* without a network, and so the resolver can be swapped per platform.
*/
interface DnsResolver {
fun srv(name: String): List<SrvRecord>
fun txt(name: String): List<String>
/** The SRV and TXT records for [name], looked up together. */
fun lookup(name: String): DnsRecords
/** For the base-URL path, where DNS is never consulted. */
object None : DnsResolver {
override fun srv(name: String) = emptyList<SrvRecord>()
override fun txt(name: String) = emptyList<String>()
override fun lookup(name: String) = DnsRecords(emptyList(), emptyList())
}
}
@@ -112,14 +114,16 @@ object ServiceDiscovery {
val domain = domainOf(trimmed) ?: return emptyList()
val candidates = mutableListOf<Candidate>()
val srv = dns.srv("$SRV_SERVICE.$domain")
val records = dns.lookup("$SRV_SERVICE.$domain")
val srv = records.srv
.filterNot { it.target == "." || it.target.isEmpty() || it.port == 0 }
// DNS names are case-insensitive and dnsjava returns the wire case, so
// DNS names are case-insensitive and answers keep the wire case, so
// "Calendar.Google.com." would otherwise walk straight past the guard.
.filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS }
// ⚠️ RFC 2782 does not require a target to live inside the domain it
// was queried for, and this ladder is walked over plain UDP DNS with
// no DNSSEC — then walked *again* after a 401, with the
// was queried for, and the answer is not DNSSEC-validated (Private
// DNS encrypts the hop, it does not authenticate the zone) — and
// this ladder is walked *again* after a 401, with the
// authenticated client. An out-of-domain target is also one the
// credential would never be offered to, since CalDavHttp scopes it
// to the typed address's registrable domain, so following it can
@@ -131,7 +135,7 @@ object ServiceDiscovery {
// The TXT path applies to the SRV target, and to the bare domain when
// there is no SRV record — that is the GMX/Web.de shape.
val txtPath = dns.txt("$SRV_SERVICE.$domain")
val txtPath = records.txt
.firstNotNullOfOrNull { record ->
record.split(' ', ';')
.firstOrNull { it.startsWith("path=", ignoreCase = true) }
@@ -205,6 +209,19 @@ object ServiceDiscovery {
return input.toHttpUrlOrNull()
}
/**
* The email address [input] names, without a `mailto:` prefix, or null when
* it is a URL or a bare domain — the same split [candidatesFor] makes.
*/
fun emailOf(input: String): String? {
val trimmed = input.trim()
if (asBaseUrl(trimmed) != null) return null
val email = trimmed.removePrefix("mailto:").removePrefix("MAILTO:")
return email.takeIf {
it.substringBeforeLast('@', "").isNotEmpty() && domainOf(it) != null
}
}
/** The domain of an email address, a `mailto:` URI, or a bare domain. */
internal fun domainOf(input: String): String? {
val withoutScheme = input.removePrefix("mailto:").removePrefix("MAILTO:")
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.xbill.DNS.DClass
import org.xbill.DNS.Message
import org.xbill.DNS.Name
import org.xbill.DNS.Record
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.Section
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.Type
class DnsAnswersTest {
private val name = Name.fromString("_caldavs._tcp.mailbox.org.")
private fun answer(type: Int, vararg records: Record): ByteArray {
val message = Message.newQuery(Record.newRecord(name, type, DClass.IN))
records.forEach { message.addRecord(it, Section.ANSWER) }
return message.toWire()
}
@Test
fun `reads SRV records`() {
val bytes = answer(
Type.SRV,
SRVRecord(name, DClass.IN, 300, 10, 1, 443, Name.fromString("dav.mailbox.org.")),
)
assertThat(DnsAnswers.srv(bytes)).containsExactly(SrvRecord(10, 1, 443, "dav.mailbox.org"))
}
@Test
fun `reads TXT strings`() {
val bytes = answer(Type.TXT, TXTRecord(name, DClass.IN, 300, "path=/"))
assertThat(DnsAnswers.txt(bytes)).containsExactly("path=/")
}
@Test
fun `an empty or malformed answer has no records`() {
assertThat(DnsAnswers.srv(answer(Type.SRV))).isEmpty()
assertThat(DnsAnswers.srv(byteArrayOf(1, 2, 3))).isEmpty()
}
}
@@ -14,8 +14,7 @@ class ServiceDiscoveryTest {
val srv: Map<String, List<SrvRecord>> = emptyMap(),
val txt: Map<String, List<String>> = emptyMap(),
) : DnsResolver {
override fun srv(name: String) = srv[name].orEmpty()
override fun txt(name: String) = txt[name].orEmpty()
override fun lookup(name: String) = DnsRecords(srv[name].orEmpty(), txt[name].orEmpty())
}
private fun urls(input: String, dns: DnsResolver = DnsResolver.None) =
@@ -153,6 +152,15 @@ class ServiceDiscoveryTest {
assertThat(ServiceDiscovery.domainOf("example.com")).isEqualTo("example.com")
}
@Test
fun `an email is told apart from a URL and loses its mailto prefix`() {
assertThat(ServiceDiscovery.emailOf("mailto:me@example.com")).isEqualTo("me@example.com")
assertThat(ServiceDiscovery.emailOf(" me@example.com ")).isEqualTo("me@example.com")
assertThat(ServiceDiscovery.emailOf("https://me@example.com/")).isNull()
assertThat(ServiceDiscovery.emailOf("example.com")).isNull()
assertThat(ServiceDiscovery.emailOf("@example.com")).isNull()
}
@Test
fun `something that is neither an address nor a URL yields nothing`() {
assertThat(ServiceDiscovery.candidatesFor("hello")).isEmpty()