b933705c849fa7ec06538c786f6e4b21cf574bdf
19
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b933705c84 |
sync: what adding and re-adding an account actually has to do
Re-authentication reported success for work it never did. The branch took appPassword and dropped `found` and `selected` on the floor, so a user whose account had 401'd walked the whole add flow, ticked collections, and was shown Done — while no newly-ticked list was created, and a moved principal or corrected username was discarded, so a relocated account could never be repaired. It also never called accounts.add, so an account deleted in Android Settings (nothing listens for LOGIN_ACCOUNTS_CHANGED) stayed absent from Settings for ever while syncing happily via WorkManager, and every later add answered AlreadyExists. Unticked lists are still left attached, deliberately: the picker pre-ticks what is *writable*, not what this account already holds, so detaching would stop syncing a read-only share over a default nobody chose. Remove-then-re-add duplicated every list. remove() leaves them behind as device-only lists on purpose and create() always inserted, so the user got their old Personal full of tasks beside a freshly synced Personal holding the same tasks from the server — the outcome the comment next to it names as the one to avoid. A collection whose href is already on the device is re-attached now, keeping its name, colour, ordering and tasks and losing only a cursor that belonged to the account that is gone. Rollback follows: it deletes what this attempt created and lets the FK return a re-attached list to being device-only. An account in the system with no Room row could not be removed from inside the app — accounts.remove's result is ignored and find returns null while the device is locked, so the pair can come apart, and every later add then answered AlreadyExists with the accounts screen driven off Room. That state is treated as an orphan to clean up. create()'s tail is uncancellable, like remove()'s: a back gesture during "Adding the account" left the row and its lists with no credential and no system account, where the rollback never runs, needsSignIn is false so re-auth will not fire, and every retry answers AlreadyExists. remove() forgets the quarantine counts as well as the cadence cursors — same keys, same globality, and a list re-attached to a new account would otherwise inherit a resource that is skipped for ever. And NeedsAuthentication's doc claimed the caller widens a credential allowlist with its hosts. Nothing does, and nothing should without the user's say-so; it says what the list is actually for. |
||
|
|
210e394163 |
sync: close four holes in the transport and the change log
changes() dropped every per-resource status that was neither 2xx nor 404/410, so a per-object ACL answering 403 read as "unchanged" and the row kept whatever it held until the next full reconciliation noticed the ETag differed. The same reasoning error as fetch's, and the same fix shape now exists next door: it is carried as a change with no validator, which forces the multiget, and the multiget already grades a refusal. An SRV target is not required 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 authenticated client. Since the credential is scoped to the typed address's registrable domain, an out-of-domain target could only ever answer 401 anyway. Refusing it leaves the well-known ladder on the typed domain, which is where a correctly delegated install answers. CalDavHttp, three of them. Accept-Encoding: identity takes OkHttp's transparent gunzip out of the loop, so a proxy that gzips regardless handed raw deflate to the iCalendar parser and a good resource was quarantined as unreadable; the header the response carries is now honoured. Prefer was set rather than appended, so a caller's own preference was silently dropped on every write. And there was no call-level ceiling: readTimeout is per-read, so a server trickling a byte every 119 seconds held a sequential sync for the whole WorkManager window and every later collection was skipped. Three minutes is well above a full multiget batch on a slow link and well below the window — c2166b9's note said the opposite, and the revocation still sets its own budget because it needs seconds, not minutes. The KDoc claimed the handler caches which scheme worked "so the challenge is paid once", while authenticated() builds a fresh handler per call. It says what it actually does now. |
||
|
|
41ffc75fc5 |
sync: only report the address we actually replaced
Four corrections to the login-flow work earlier on this branch, all found in review. The mismatch was computed by comparing hosts exactly while reachableOrigin substitutes only across registrable domains. A server answering nc.example.com for a poll endpoint on cloud.example.com is used verbatim and correctly -- and we told the user we had replaced it because it was unreachable, blaming a setting that was right. The decision now has a name and says what it means: report a mismatch only when the address we used is not the one that was claimed. baseOf matched only the index.php spelling of the poll path, but Nextcloud drops index.php from generated routes when htaccess.IgnoreFrontController is on -- so a subdirectory install answering /nc/login/v2/poll rebuilt the root as / and lost the prefix, which is the loss that function exists to prevent. browserFailed built a fresh step and dropped the mismatch note, which is often the explanation for the failure it is replacing it with. And the note is sticky by design, but it belongs to the address that produced it: starting a new attempt now clears it, rather than carrying a claim about one server's configuration into another's. |
||
|
|
6aaa15b130 |
sync: make the add-account flow's own messages translatable
Fourteen English sentences were built in the ViewModel and rendered verbatim as fatal, Working.message and both error fields, so they shipped untranslated to every locale. Three more sites passed through whatever text the server, the repository or a Throwable produced. Seventeen in all, and three of the fourteen were added by fixes earlier on this branch -- which is the argument for a type rather than a rule. This is the defect Outcome.Cause was introduced to fix, and it only ever covered the address step. AddAccountMessage does the same for the rest: a plain Kotlin sealed type in the app, with the resource mapping beside Cause's in the screen. The state fields carry it, so a literal is now a compile error rather than something review has to catch. Nothing lints for this -- HardcodedText reads XML layout attributes and this app has none -- so the type is the only guard there is. The passthroughs get causes of their own, following the rule Outcome.Cause already states: PollResult.Failed carries RATE_LIMITED, MAINTENANCE or SERVER_ERROR, keeping the 429/503 distinction that flattening to one message would lose, and CredentialFailed carries KEYSTORE_REFUSED or NOT_SAVED. Their reason and detail stay for logs and are never shown. A server's words are untranslatable and often a bare status line; a Throwable's are worse. Twenty keys, base locale only -- Weblate owns the rest and picks them up. The four tests that asserted on English prose now assert on the message, which for the cross-domain one is stricter: it pins the host into the argument instead of anywhere in a sentence. |
||
|
|
5dfbe0fffc |
sync: hand back an app password the flow is not going to use
Nextcloud returns a minted password exactly once. Every path that left the browser flow without saving an account dropped it: discovery failing after approval, an account whose lists we cannot use, start over, the back arrow, switching to a typed password, or leaving Settings altogether. It stays valid on the server for ever, and every attempt is named "Agendula (Android)" -- so a user retrying against a misconfigured server ends up with six identical entries and no way to tell which one their working account uses. They prune nothing, or prune the wrong one. One owned field and one sink rather than a revoke per path: ten paths today, and the eleventh would be forgotten. Ownership passes to the account on Created and is released nowhere else without revoking. The sink runs on the application scope, not viewModelScope -- androidx closes that before onCleared, so a launch there never runs its body. Revocation goes through a new revokeAt, which takes the OCS root directly. ocsRootFor is principal-shaped and falls back to the bare origin, so sending a login flow's server base through it would collapse a subpath install's /nextcloud/ to / and DELETE a path that 404s -- the silent no-op that function exists to prevent. Also stops reporting a rejected credential as an empty account: a 401 after approval is either a home set outside the domain the credential is scoped to, which retrying only mints another password for, or the server having a moment. The registrable-domain check that decides this is now shared with crossDomainHint, which had been computing "com" for example.com and so never firing. |
||
|
|
c2166b944f |
sync: put the revocation's timeout on the request itself
withTimeoutOrNull around the revoke bounded nothing. The call parks on a socket read that neither coroutine cancellation nor Thread.interrupt can break, and withContext returns only when its block does -- so the deadline passed and we waited anyway, for the shared client's own budget: 30s per resolved address, doubled by the authenticator's retry, plus up to 120s of read timeout. Removing a homelab account off the VPN sat there for minutes with nothing visibly happening. Only closing the socket ends it, which is what callTimeout does. The budget moves to AppPassword.revoke, where the "best effort, must not block the removal" contract is already written down, and where it can be enforced. Not on the shared client: callTimeout covers the whole exchange including the body, and a multiget of a large list over a slow link legitimately runs long. CalDavHttpTest pins that decision. The destructive tail is now uncancellable. It spans four stores that cannot share a transaction, and the caller is a viewModelScope tied to the Settings destination, so a couple of back gestures used to kill it mid-sequence. Only the DataStore writes can observe cancellation -- every Room DAO here is blocking -- so the landing point was cadence.forget: the app password already revoked while the row survives holding it, and the account asking the user to sign in again for a credential we invalidated ourselves. Further in, the tasks are gone and the row stays. The tail is bounded and sub-second, so finishing it always beats stopping inside it. |
||
|
|
7850bfc202 |
sync: check the reply's origin instead of keying on it
Three corrections to
|
||
|
|
7fac4bd58d |
sync: match a multiget reply by the resource, not by its spelling
The request href is ours, written into the REPORT body verbatim; the response href is the server's own spelling of it. Compared as raw encodedPath, a server answering /my@dav.ics for a requested /my%40dav.ics -- the case UrlUtils.equals exists for -- puts one resource in unsolicited *and* in missing, drops its intact body, and after three such runs quarantines it out of the download for good, because nothing on that side can refund the count. Our own names never carry an @, so this is about resources other clients created, which is most of an existing collection. The key is now scheme, host, port and the *decoded* path segments. A list rather than a joined string, so /a%2Fb does not collide with /a/b; with the host, so a same-path href from elsewhere cannot supply a body for our row. The trailing slash still distinguishes, as UrlUtils.equals also refuses to normalise it. The syncer stops spending an unrefundable count on a guess: when the server both omitted hrefs we asked for and volunteered hrefs we did not, "omitted" and "we did not recognise its spelling" are indistinguishable, so those are skipped rather than failed. A wasteful re-request is recoverable; a permanently dropped task is not. |
||
|
|
730a1eb88d |
sync: tell the user which address their server got wrong
Two gaps left by
|
||
|
|
fc7e520aa6 |
sync: talk to the host that answered the poll, not the one it named
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. |
||
|
|
9f832686fb |
sync: refuse Basic over cleartext even when challenged
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. |
||
|
|
ede4205b7f |
sync: scope the credential by the public-suffix list
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. |
||
|
|
d5ce24b673 |
sync: report a refused resource instead of losing 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. |
||
|
|
b4a7fcb46e |
sync: what the on-device round found
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. |
||
|
|
28b2423ad9 |
sync(chunk 5): attribution, revocation, compliance
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. |
||
|
|
b25f8b231c |
sync(chunk 4): incremental sync and scheduling
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. |
||
|
|
b1189a4884 |
sync(chunk 3): the sync engine
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. |
||
|
|
48fc7261f0 |
sync(chunk 2d): the account-add flow
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.
|
||
|
|
da42423fe2 |
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. |