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.
158 lines
7.6 KiB
Markdown
158 lines
7.6 KiB
Markdown
# `:dav` — vendored dav4jvm
|
||
|
||
**Upstream:** [bitfireAT/dav4jvm](https://github.com/bitfireAT/dav4jvm),
|
||
tag **2.2.1**, commit `f434c9d19b322228916c106beaebd8634b85ddb1`.
|
||
**Licence:** MPL-2.0 (`dav/LICENSE`, verbatim). Every file keeps its upstream
|
||
header, as §3.4 requires. Agendula's own code is MIT and unaffected — MPL is
|
||
file-level copyleft, which is why this lives in its own module rather than
|
||
inside `:app`.
|
||
|
||
62 source files, ~4,200 lines, plus upstream's 15 test classes.
|
||
|
||
---
|
||
|
||
## Why vendored, and why this version
|
||
|
||
`docs/SYNC-PLAN.md` decision 1 settled *vendor rather than depend*. dav4jvm is
|
||
published on **JitPack only**, which conflicts with our `FAIL_ON_PROJECT_REPOS`
|
||
policy, does not sign artifacts, and rebuilds on demand — so a coordinate is not
|
||
immutable. Upstream also shipped two breaking majors nineteen days apart
|
||
(3.0.0 on 2026-07-08, 4.0.0 on 2026-07-27).
|
||
|
||
**2.2.1 is the last OkHttp release.** 3.0.0 deleted the OkHttp package for Ktor,
|
||
and 4.x additionally requires **Java 21** bytecode while we target 17 across
|
||
`:app` and all of floret-kit. Taking 4.x would mean Ktor (~2.45 MB), the wrong
|
||
`guava` flavour, and a Java-target migration — and it would invalidate the whole
|
||
auth design in `docs/SYNC.md`, which is written in OkHttp terms throughout
|
||
(preemptive Basic via an `Interceptor`, OkHttp stripping `Authorization` on
|
||
cross-host redirects, `BasicDigestAuthHandler` because OkHttp has no Digest).
|
||
|
||
Vendoring at 2.2.1 costs us upstream's later work and makes us responsible for
|
||
this tree. What it buys: our own Java target, no JitPack, no Ktor tail, no xpp3
|
||
in the APK, and the freedom to fix the defects below rather than route around
|
||
them.
|
||
|
||
**This is a plain JVM module, not an Android library.** The upstream tree has
|
||
zero Android imports and must not gain any: `docs/SYNC.md` earmarks this layer
|
||
for floret-kit's `core-dav`, and Calendula needs the same primitives, so
|
||
extraction should stay a file move.
|
||
|
||
---
|
||
|
||
## Changes from upstream
|
||
|
||
Upstream's own test suite is vendored with the code and passes unmodified —
|
||
that is what makes these changes safe to make. Our additions live in
|
||
`LocalChangesTest`; every other test file is upstream's, untouched.
|
||
|
||
### 1. `commons-lang3` removed
|
||
|
||
`HttpUtils.kt` imported `org.apache.commons.lang3.time.DateUtils` for exactly one
|
||
call — `DateUtils.parseDate(str, locale, patterns…)`, a loop over format strings.
|
||
Replaced with that loop. The dependency is gone; the format list is byte-for-byte
|
||
upstream's, comments included.
|
||
|
||
⚠️ **The loop is not the whole of what `DateUtils` did.** It parsed with a
|
||
`ParsePosition` and rejected a pattern unless the *entire* string was consumed;
|
||
`SimpleDateFormat.parse(String)` accepts a prefix. Pattern 1 ends in the quoted
|
||
**literal** `'GMT'`, so `"Wed, 21 Oct 2015 07:28:00 GMT+02:00"` matches it as a
|
||
prefix and the offset is silently discarded — a two-hour error, and precisely the
|
||
failure change 2 exists to remove. The replacement requires full consumption.
|
||
|
||
### 2. ⚠️ HTTP dates were parsed and formatted in the device's local zone
|
||
|
||
**An upstream defect, not a porting artefact.** `httpDateFormatStr` is
|
||
`"EEE, dd MMM yyyy HH:mm:ss 'GMT'"` — the `GMT` is a **quoted literal**, so
|
||
`SimpleDateFormat` neither reads nor writes a zone from it, and
|
||
`httpDateFormat` never had `timeZone` set. So `formatDate` emitted local time
|
||
labelled `GMT`, and `parseDate` read `07:28:00 GMT` as 07:28 *local* — every
|
||
`getlastmodified` out by the device's UTC offset, in whichever direction the
|
||
user happens to live.
|
||
|
||
Upstream's `HttpUtilsTest` covers only `fileName()` and never touches dates,
|
||
which is why this survived. Fixed by forcing GMT on the formatter and on every
|
||
parse attempt; patterns carrying a real `z` still take the zone from the input,
|
||
as they must. Covered by `LocalChangesTest`.
|
||
|
||
### 3. Permanent redirects now reach the caller (`dav4jvm#209`)
|
||
|
||
`followRedirects` mutated `location` in place for every 3xx and told the caller
|
||
nothing, so a caller could not distinguish *"this resource has moved, store the
|
||
new URL"* from *"follow this once"*. DAVx5 consequently never rewrites a stored
|
||
collection URL after a 301 and re-follows it on every sync;
|
||
`docs/SYNC.md` names persisting the new URL ourselves as the fix.
|
||
|
||
Added `DavResource.permanentLocation`, set only along an unbroken chain of 301 /
|
||
308. A temporary hop ends the chain — `301 → 302` means the resource moved to the
|
||
301's target and is being served elsewhere *for now*, so persisting the 302's
|
||
target would be wrong. `location` still moves for every redirect, unchanged.
|
||
|
||
It is **cleared at the start of every request**, so it describes the request just
|
||
made and never one made earlier through the same object. `DavResource` instances
|
||
are reused, and a stale value would have the caller persist a URL that a later
|
||
`move()` already superseded.
|
||
|
||
### 4. `xpp3` is compile-time only
|
||
|
||
Upstream declares `org.ogce:xpp3` as `api`. Android ships `org.xmlpull.v1` in the
|
||
framework, so the 371 KB jar is `compileOnly` here and never reaches the APK. The
|
||
unit tests run on a plain JVM, which has no framework, so they get the real
|
||
implementation via `testImplementation`.
|
||
|
||
### 5. `httpDateFormat` is no longer a shared mutable formatter
|
||
|
||
Upstream exposed a single public `SimpleDateFormat`. It is mutable and not
|
||
thread-safe: two workers formatting a header concurrently corrupt each other
|
||
through its `Calendar`, and any caller could `setTimeZone` on it and undo change 2
|
||
for everyone else. It is now private and built per call. Nothing else in the tree
|
||
referenced it.
|
||
|
||
### 6. `<D:unauthenticated/>` is parsed instead of inferred
|
||
|
||
`CurrentUserPrincipal.Factory` read only the `<href>` child, so an
|
||
unauthenticated body (RFC 5397 §3 — a **200** whose content means the request was
|
||
not authenticated) arrived as "property present, href null" — identical to a
|
||
conformant-but-empty element, and to a server that omits the property entirely.
|
||
A caller inferring rejection from the null href therefore also fires on merely
|
||
non-conformant servers, and on a request that carried no credential at all.
|
||
|
||
The factory now makes one pass over both children (`XmlUtils.processTag` consumes
|
||
to the end tag and so cannot be called twice) and reports `unauthenticated`
|
||
explicitly. Without it, a rejected credential is indistinguishable from a
|
||
successful discovery that found nothing.
|
||
|
||
---
|
||
|
||
## Build integration
|
||
|
||
- `:dav`'s tests are a **plain JVM `test` task**. CI runs `testDebugUnitTest`,
|
||
which exists only on Android variants, so `.forgejo/workflows/ci.yaml` names
|
||
`:dav:test` explicitly. Without that the vendored suite is compiled by nobody
|
||
and run by nobody, and the safety argument above is void.
|
||
- `:app` declares `testImplementation(libs.xpp3)`. `compileOnly` is not
|
||
transitive, `:app`'s unit tests run on a plain JVM with no framework, and
|
||
`isReturnDefaultValues = true` makes android.jar's stub factory return `null` —
|
||
so anything touching `XmlUtils` would fail with an unrelated-looking NPE.
|
||
|
||
---
|
||
|
||
## What was *not* a defect
|
||
|
||
`docs/SYNC.md` lists two dav4jvm defects we would inherit. Only one of them
|
||
exists in this version:
|
||
|
||
- **"Handles 301/302/307/308 but not 303"** — does not hold for 2.2.1.
|
||
`followRedirects` gates on OkHttp's `Response.isRedirect`, which includes
|
||
`HTTP_SEE_OTHER`, and it re-sends the same method, which is what RFC 6764 §5
|
||
asks for during discovery. Pinned by a test so a future resync cannot lose it
|
||
silently.
|
||
- **`dav4jvm#209`** — real, and fixed above as change 3.
|
||
|
||
---
|
||
|
||
## Resyncing
|
||
|
||
Fetch the new tag, diff against `f434c9d`, reapply changes 1–4, run
|
||
`./gradlew :dav:test`. If upstream's suite fails, the port is wrong — that is the
|
||
entire reason it is vendored alongside the code.
|