Two gaps left by fc7e520, both found in review.
The claimed path was kept when the claimed host was replaced, though
both come from the generator we had just decided not to trust. A
subdirectory install behind a proxy reports an empty webroot from inside
the container, so /nextcloud was dropped and discovery ran against the
wrong base -- the same dead end, one level down. The origin is now
rebuilt entirely from the poll endpoint, whose own prefix is whatever
precedes index.php/login/v2/poll.
And the mismatch rode out of poll() with nowhere to go. It is carried on
the state rather than the step, because it is learned during the browser
step while the setting it blames is what the user has to go and fix
afterwards -- so it has to outlive the step that discovered it.
The poll response's `server` was taken verbatim apart from its scheme.
That field and the poll endpoint come out of different generators in
Nextcloud: the endpoint honours overwrite.cli.url, while `server` is
built from the approving request's own protocol and Host header, which
respect X-Forwarded-* only once trusted_proxies is set. So the ordinary
docker-compose-behind-nginx install answers a perfectly good
https://cloud.example.com poll endpoint with "server":
"http://nextcloud:11000" -- a name the phone cannot resolve.
Discovery then fails, the account is never created, the user is told
the account has no task lists, and the app password is already spent
and never revoked. Retrying does the same thing again and leaves
another dangling entry in their device list.
reachableOrigin keeps scheme, host and port from the endpoint we just
polled successfully whenever the claimed origin sits outside its
registrable domain, and keeps the claimed path so subdirectory installs
still work. It compares one server-emitted origin against another,
never against what the user typed, so a correctly configured proxy is
untouched and a sibling host in the same domain still passes.
Coerced, never refused, per the rule the file already states: the
credential exists by now and the 200 is spent. The mismatch rides out
on Approved so the user can be told which setting is wrong.
insecurePreemptive gated only the preemptive branch, so a plain-HTTP
server answering 401 with a Basic challenge still got the password in a
header every hop can read. The flag's name was accurate; its coverage
was not. The gate moves onto the Basic emission, covering both paths,
and the flag becomes insecureBasic.
Digest is left answered over cleartext: it never puts the password on
the wire, and refusing it would break a LAN server the day the
per-account cleartext opt-in ships.
Not reachable in the app today -- network_security_config forbids
cleartext outright and nothing passes allowCleartext -- but :caldav is a
plain JVM module meant for reuse, where neither of those holds.
Upstream's testBasic asserted this exact behaviour over http://, so it
is amended to https:// and the cleartext cases are pinned explicitly.
PROVENANCE change 9 records it as the one upstream test this port
changes rather than inherits.
The auth handler decided which hosts may receive the password by
comparing the last two labels of their names. A server at
cloud.example.co.uk therefore scoped the app password to co.uk, one at
myhome.duckdns.org to duckdns.org, and a self-hoster at 192.168.1.10 to
"any address ending .1.10". The handler sends Basic preemptively, before
any challenge, so those hosts get the password unprompted on the first
HTTPS request.
It is reachable: ServiceDiscovery accepts an SRV target outside the
domain it queried, over plain UDP DNS. Scoped correctly, an on-path
attacker needs a certificate for a name inside the victim's own
registrable domain. Scoped to co.uk, they need one for a domain they
already own.
Now topPrivateDomain(), from the list OkHttp bundles, falling back to the
exact host where there is none -- null means no restriction here, so an
IP literal or localhost must not pass one through. The handler had to
change with the caller: it re-derives the domain per request, so fixing
only the caller withholds the credential from everything.
Still trusted: two hosts under one registrable domain share an owner.
That is what iCloud's caldav/pNN-caldav split needs, and narrowing
further costs it.
A multiget answer that was neither a body nor an omission fell through
every bucket: seen, so not missing; no body, so not a resource. Nothing
counted it, the sweep left it alone because the listing still named it,
and downloadPhase asked for it again on every sync for ever -- the loop
quarantine exists to break.
FetchResult now carries `failed`, with the status the server actually
gave. Kept apart from `missing` because "refused" and "not mentioned"
are different facts: 404 and 410 are skipped, since the next listing
drops the href and the sweep purges the row, and counting them would
quarantine the resource out of that very sweep; 5xx and a bodiless
success are skipped as the server's own trouble; any other 4xx is
counted, being a judgement about this resource that repeats forever.
A per-property refusal -- a propstat with 403 around calendar-data --
is the usual shape of "you may not read this one object", and
Response.properties drops non-2xx propstats, so the code is read back
out of them rather than reported as a blank.
The two write-side guards had to learn this too. Both read a fetch that
came back Result.success as an answer, so a refusal now looked like
"somebody else's resource" (a second resource under one UID) and like
"the server has no validator" (an unconditional PUT over a concurrent
edit) -- the two things those guards were added to prevent.
Everything here came from running the account flow against a real
Nextcloud rather than from reading the code.
Discovery
- A typed bare origin now gets the RFC 6764 well-known probe. It was
returned as the only candidate, so `https://cloud.example.com` — what
people actually type — was PROPFIND'd against the web UI, answered 405,
and a working Nextcloud reported as "not a CalDAV server".
- A same-host HTTPS→HTTP redirect is put back on TLS instead of refused
(`dav` change 7). A Nextcloud behind a TLS-terminating proxy without
`overwriteprotocol` builds every redirect with http://, including the
/.well-known/caldav hop discovery depends on. Cross-host still throws.
- Outcomes carry a `Cause` the UI translates, not the server's own words.
"HTTP 405 Method Not Allowed" told someone entering an address nothing,
in a language they may not read, from outside strings.xml.
- An IPv6 origin keeps its brackets: `HttpUrl.host` returns "fd00::1", so
the rebuilt origin did not parse and a homelab address came back as
"not an address".
Login Flow v2
- The poll response's scheme is coerced, never refused. Nextcloud returns
the app password exactly once, so throwing there burned a live
credential and left it dangling in the user's device list. The host
mismatch already worked this way; the scheme now matches it.
Accounts
- The accounts screen observes Room and the sign-in state instead of
taking a snapshot, so a sync landing — or a 401 stopping an account —
reaches a screen that is already open.
- A per-account detail screen, and provider identity (`CalDavProvider`)
shared with the quirk table so one list drives both the icon and the
warning.
- The password field masks: floret-kit's `InlineTextField` gained a
visual transformation, since `KeyboardType.Password` only tells the IME
to drop suggestions.
The parts of chunk 5 that a build can verify. What is left needs a device or a
live server, and is listed in docs/SYNC-PLAN.md rather than guessed at.
- Attribution screen in Settings. dav4jvm is vendored, which makes MPL-2.0
§3.2(a) ours rather than a dependency's, so its row points at PROVENANCE.md
next to upstream. Hand-maintained: generators read POM metadata, which
routinely names a non-SPDX licence and a licence URL that 404s.
- Revocation both ways. A 401 marks the account, stops it before the next
request reaches the network, and takes it off the schedule from outside the
worker — Nextcloud throttles then 429s per source IP, so a timer on a dead
app password degrades the user's other clients. On removal, a bounded
best-effort DELETE of the app password, or uninstalling never revokes it.
- Play compliance: docs/PRIVACY.md linked in the app, declaring Collected and
not Shared; an option to delete the account's tasks from the device too;
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS confirmed absent.
- The server trap matrix as far as a protocol mock reaches, with four tests
left @Ignore'd and their reasons written out.
- docs/SYNC-PLAN.md records what moves to floret-kit, so that branch is a file
move rather than a rediscovery.
/code-review high raised 9 findings, all fixed. Three were serious: app-password
revocation was aimed at the principal URL and revoked nothing; opening the app
put accounts a 401 had stopped back on the timer, because KEEP does not keep
cancelled work; and the incremental path advanced the sync token past bodies a
failed multiget never applied. Also: four scalars were emitted twice whenever
their residue copy survived, which the round-trip corpus could not see.
Not done, and needing you: the live server matrix, releaseTest on device, the
restore-onto-a-fresh-device check, cert4android, and MKCALENDAR feature
detection. Chunk 2's on-device review is still outstanding.
RFC 6578 as an optimisation on top of chunk 3's full path, and sync that runs
by itself.
- :caldav gains sync-collection, with invalidation matched on
DAV:valid-sync-token in the body on any 4xx rather than on a status code —
400, 403, 409 and 412 are all used in the wild, and matching the status is
why Thunderbird never recovers from sabre's 403. No DAV:limit (Nextcloud
regressed it to an HTML error page); 507 on our own href is truncation.
- The engine persists the token per page, after the bodies. An iteration cap
and a no-progress guard, since the RFC never requires the token to advance.
The full path runs anyway every 24h: a token the server accepts over a
pruned change log returns 207, zero changes and no error, and the protocol
gives no other way to notice.
- The three membership traps: an unknown removed href is a no-op, a
delete-then-recreate is re-identified from the UID in the body, and a mass
removal is refused in favour of a real listing, because ACL churn looks
exactly like one.
- Scheduling is a PeriodicWorkRequest with a network constraint, expedited
only for the in-app button. getForegroundInfo is implemented unconditionally
(setExpedited falls back to a foreground service below API 31 and the
default throws) but declares no service type, which would have pulled back
the Android 15 dataSync budget and a Play video-demo requirement.
initialIncomplete turned out not to be needed: adopting a token only after a
full reconciliation completes removes the hazard it guarded, so there is
nothing to persist atomically with anything.
/code-review high raised 8 findings, all fixed. The two that mattered: the
cadence clock sat in the backed-up DataStore, so a restore would have made the
engine trust a stale token for a day — both sync-state stores now have their
own excluded file; and the incremental download path lacked the write-phase
guard, overwriting local edits that had never reached the server. Reasoning in
docs/SYNC-PLAN.md.
Chunk 2's on-device review is still outstanding; none of this has run on a
device or against a real server.
Full bidirectional sync, correct but not yet clever: calendar-query with no
time-range, calendar-multiget in batches matched against what was asked for,
conditional writes, and per-resource quarantine so one bad task cannot stop a
collection.
- :caldav gains CalendarCollection (list/fetch/create/update/delete + the
three-way 412 triage), ETag with its weak flag, vdirsyncer-style resource
names, and the RemoteCalendar seam.
- CalDavHttp now sends Accept-Encoding: identity and Prefer: handling=strict,
the two headers that keep ETags strong and our bytes unrepaired.
- :app gains CollectionSyncer (the reconciliation), SyncEngine, SyncStore,
QuarantineStore, SyncReport and a working SyncWorker, plus an in-app sync
trigger since ContentResolver.requestSync is gated at our targetSdk.
- VTimeZones closes a chunk-1 gap: the mapper emitted TZID with no VTIMEZONE
to resolve it, which handling=strict turns from a repair into a rejection.
/code-review high raised 11 findings, all fixed. The three that mattered:
an empty listing swept the whole list (a VTODO comp-filter some servers
mishandle is not proof of deletion, so an empty listing now never sweeps);
quarantine never covered creates, so a permanently rejected new task was
re-PUT forever; and a RELATED-TO deleted on the server was re-uploaded on the
next edit. Reasoning recorded in docs/SYNC-PLAN.md.
Chunk 2's on-device review is still outstanding; nothing here has been run on
a device or against a real server.
One flow, one back-stack entry, as a stepper rather than four destinations —
the steps are not independently reachable, and "back" from the browser step
abandons a server-side flow rather than popping a screen. It hangs off Settings
with the same sliding-section pattern Storage -> Export uses.
address -> discovery -> (Nextcloud browser approval | username + password)
-> pick lists -> add account
Provider warnings come before the attempt, not after: type a Fastmail or iCloud
address and the app password rule is stated while you type, which is the single
most common support ticket a CalDAV client inherits. Google is refused with the
reason. A login-flow host mismatch is shown, not refused — reverse proxies are
ordinary on self-hosted installs.
PreemptiveBasicInterceptor is deleted. The vendored BasicDigestAuthHandler
already sends Basic preemptively over HTTPS, also does Digest (Baikal defaults
to it, OkHttp has none), caches the working scheme, and scopes by registrable
domain — which is what iCloud's cross-host home set needs. Two implementations
of one job is the defect chunk 1 removed from ICalendarWriter.
⚠️ That handler compares its `domain` against the *registrable* domain, so
passing the full host meant credentials were withheld from every request to
every subdomain — i.e. every self-hosted Nextcloud, silently 401ing forever.
Pinned by CalDavHttpTest.
CalDavGateway and AccountCreator put the network and the database behind
interfaces so the sign-in state machine is testable without a server, a
database, a Keystore or an AccountManager. It had no tests, and the review
found eight issues in it.
Account creation is transactional and rolls its lists back explicitly:
account_id is ON DELETE SET NULL, so deleting the row alone leaves orphan
lists behind and every retry adds another set.
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.