sync(chunk 2b): CalDAV discovery and auth protocol

New :caldav module — MIT, plain JVM, api-depends on :dav. Separate from the
vendored MPL tree so the licences stay unmixed, and so "no Android types" is a
compile-time guarantee rather than a discipline. Chunk 2 split again: the
Android account layer (Keystore, AccountManager, Custom Tabs, account-add UI)
is 2c, with different verification and an on-device review.

- ServiceDiscovery: the RFC 6764 ladder. SRV priority/weight, TXT path=,
  non-443 ports, "." targets, well-known then root.
- CollectionClassifier: the two filters that are inversions of the obvious
  rule. An absent or empty supported-calendar-component-set means "supports
  everything", and classification is a positive test for CALDAV:calendar on an
  unordered set — excluding schedule-outbox would drop SOGo's main calendar.
- CalDavDiscovery: OPTIONS gate, principal, every home set, Depth-1 by name.
  A failing home set does not fail the account, and every home set failing is
  reported as an error rather than as an account with no lists.
- NextcloudLoginFlow: POST not GET, a User-Agent the user can recognise when
  revoking, 404-means-pending only, both URLs origin-checked, host mismatch
  carried rather than refused (reverse proxies are ordinary).
- PreemptiveBasicInterceptor, ServerQuirks.

dnsjava 3.6.3 (BSD-3) added: Android's DnsResolver is callback-only and cannot
do the TXT path lookup, and JNDI's DNS provider does not exist on Android.
Behind an interface, so every trap is tested with a fake and no network.

:dav gains change 6 — <D:unauthenticated/> is parsed rather than inferred from
a null href, which also fires on a merely non-conformant empty element.

52 tests here, 78 in :dav. SYNC.md's live-probed trap table is executable now.
This commit is contained in:
2026-09-04 17:36:42 +02:00
parent 5a81d3c2f5
commit da42423fe2
20 changed files with 1833 additions and 11 deletions
+33 -6
View File
@@ -57,7 +57,8 @@ Nextcloud/Radicale/Baïkal in chunk 5.
|---|---|---|---|
| 1 | **Mapper** — VTODO ↔ Room | nothing | Unknown properties survive a read-modify-write cycle |
| 2a | **Vendored DAV foundation** | nothing | Vendor a tree we can actually maintain, and know which of its defects are real |
| 2b | **Auth, discovery, account** | 2a | Never send a credential into an unvalidated redirect chain |
| 2b | **Discovery and auth protocol** | 2a | The two collection filters are inversions of the obvious rule |
| 2c | **The Android account layer** | 2b | Never send a credential into an unvalidated redirect chain |
| 3 | **Engine core** — read, write, conflicts | 1, 2 | A failed resource must not fail the collection |
| 4 | **Incremental sync + scheduling** | 3 | Never persist a sync token before the bodies it covers are applied |
| 5 | **Hardening, compliance, real servers** | 1–4 | Ship no licence violation, and no retry loop on a dead app password |
@@ -200,7 +201,32 @@ runtime dependency are gone, and `:app` links against the module.
---
## Chunk 2b — auth, discovery, account
## Chunk 2b — the discovery and auth protocol
⚠️ **Split again while building it.** The seam is the platform: everything here
is protocol, testable on a JVM against MockWebServer, and none of it touches
Android. Keystore, `AccountManager`, Custom Tabs and the account-add UI are a
different kind of work with a different kind of verification — including the
on-device review — so they are chunk 2c.
Lands in a new `:caldav` module: MIT, plain JVM, `api`-depending on `:dav`. A
separate module from the vendored MPL tree keeps the licences unmixed and makes
"no Android types" a compile-time guarantee rather than a discipline.
**One dependency added:** `dnsjava` 3.6.3 (BSD-3) for the SRV/TXT half of
RFC 6764. Android's own `DnsResolver` is callback-only and cannot do the
`TXT path=` lookup, and JNDI's DNS provider does not exist on Android at all.
It is what DAVx5 uses. Behind a `DnsResolver` interface so the pipeline is
testable without a network.
**Done when:** the trap table is executable — Posteo's SRV-only 8443, GMX's
`TXT path=`, null SRV targets, Google's dead-end record, SOGo's triple
resourcetype, Nextcloud's trashed calendar, the absent/empty component set, and
`<D:unauthenticated/>` as a 200 that means failure.
---
## Chunk 2c — the Android account layer
**Goal:** the app can add a CalDAV account and list its VTODO collections. No
syncing yet.
@@ -466,10 +492,11 @@ attribution screen ships, and a `releaseTest` build syncs on a real device.
## Definition of done, every chunk
1. It builds: `./gradlew :app:assembleDebug`.
2. Unit tests pass: `./gradlew :app:testDebugUnitTest :dav:test`. ⚠️ Name
`:dav:test` explicitly — `testDebugUnitTest` is an Android-variant task and
does not exist on a plain JVM module, so the vendored suite would otherwise be
compiled by nobody and run by nobody.
2. Unit tests pass:
`./gradlew :app:testDebugUnitTest :dav:test :caldav:test`. ⚠️ Name the plain
JVM modules explicitly — `testDebugUnitTest` is an Android-variant task and
does not exist on them, so their suites would otherwise be compiled by nobody
and run by nobody. Add each new module here **and** to CI.
3. Lint passes: `./gradlew :app:lintDebug`. Added after chunk 1 shipped a literal
byte-order mark that builds and tests both accepted and CI's lint step would
have rejected.