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.
This commit is contained in:
2026-09-07 16:41:42 +02:00
parent b25f8b231c
commit 28b2423ad9
25 changed files with 1147 additions and 39 deletions
+119
View File
@@ -756,6 +756,125 @@ so the follow-up branch is a file move and not a rediscovery.
**Done when:** the matrix is green where a server exists to run it against, the
attribution screen ships, and a `releaseTest` build syncs on a real device.
### What shipped, and what is still owed
**Shipped in the chunk-5 commit** — everything verifiable without a device or a
live server:
- **Attribution.** `Settings → Open source licenses`, hand-maintained. Not a
generated list: POM metadata routinely names a non-SPDX licence and points at a
URL that 404s, so generators produce confidently empty output. dav4jvm is
vendored, which makes MPL-2.0 §3.2(a) *ours* rather than a dependency's — the
row names `dav/PROVENANCE.md` alongside upstream, because §3.2(a) is about
where a recipient of the binary can obtain the source of what we modified.
- **Revocation, both directions.** A 401 escalates from the collection to the
account, marks it, **cancels its periodic work**, and stops. Cancelling is the
part that matters: Nextcloud throttles and then 429s per source IP, so a timer
on a dead app password degrades every other Nextcloud client on that network.
On removal, `DELETE /ocs/v2.php/core/apppassword` with `OCS-APIRequest: true`,
best effort — without it, uninstalling never revokes anything and the password
outlives the app in the user's device list.
- **Play compliance.** A privacy policy in `docs/PRIVACY.md`, linked **in the
app** (the half that is usually missed) and declaring *Collected, not Shared* —
"not collected" is indefensible when Play defines collection as transmission
off-device regardless of recipient. A "delete this account's tasks from this
device too" option on removal. `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` was
already absent and stays absent.
- **The server matrix**, as far as a protocol mock can carry it: Nextcloud's
`no-uid-conflict`, its trashbin 403 on a reused href, and share detection;
SOGo's second-granularity opaque tokens and its triple resource type; Radicale
advertising `sync-collection` without implementing it.
**Still owed, and blocked on things a loop cannot do:**
1. **The live matrix.** Four tests are `@Ignore`d with their reasons written out
rather than deleted: Baïkal on Digest, a `CLASS:CONFIDENTIAL` task on a shared
Nextcloud calendar, an ETag-unchanged body change on SOGo, and Nextcloud's
MKCALENDAR rate limit. Each names why a mock cannot stand in for it.
2. **`releaseTest` on device**, and the R8 outages `proguard-rules.pro` already
documents.
3. **The restore path**, which needs a restore onto a fresh device — the one
verification `SYNC.md` calls for explicitly and the one that cannot be faked.
4. **cert4android**, which replaces the current blanket user-CA trust and is a
UI-bearing dependency.
5. **Collection creation feature detection** (OPTIONS → MKCALENDAR / extended
MKCOL) and disabling the "new task list" affordance where neither exists.
Deferred because the app has no CalDAV-side list-creation UI yet, so there is
nothing to disable — it becomes real the moment that UI does.
### Found by the review, and worth naming
**⚠️ Revocation was aimed at the principal URL and revoked nothing.** The OCS
endpoint sits at the *server root*; the principal is
`…/remote.php/dav/principals/users/alice/`, so appending to it produced a path
that 404s on every server, every time — and the failure was swallowed as
best-effort. The whole feature was a no-op. The root is now derived by cutting at
`remote.php`, which is right for a subpath install too, with the origin as the
fallback.
**⚠️ Opening the app resurrected accounts a 401 had just stopped.**
`ExistingPeriodicWorkPolicy.KEEP` only keeps work that is *unfinished*, and
CANCELLED counts as finished — so `rescheduleAll()` re-enqueued the very request
the stop had removed, and put a dead app password back on a four-hour loop
against a server that throttles by source IP. Exactly what the stop exists to
prevent.
**⚠️ The cursor advanced past bodies that were never applied.** A network drop
mid-multiget set `report.failure` and returned, and the incremental path stored
the new token anyway — a permanent hole in the collection, invisible until the
next full reconciliation a day later. `runFull` had this guard from the start;
the incremental path did not.
**Stopping an account cancelled the worker doing the stopping.** `syncTrigger.cancel`
kills both unique names, one of which is the WorkSpec currently executing —
WorkManager interrupts the coroutine, so `NeedsSignIn` was never returned and the
adapter saw CANCELLED rather than FAILED. The flag now does the work: `sync()`
refuses before touching the network, and the schedule is cancelled from outside
any worker.
**"Sign in again" was a dead end.** `create` is the only path that writes a
credential and it rejected the duplicate name, so a user with a revoked app
password could only remove the account — discarding the choice to keep its lists.
It now re-authenticates in place, but *only* for an account already marked as
needing sign-in, so a healthy credential can never be silently overwritten.
**Four scalars were emitted twice.** `SEQUENCE`, `PRIORITY`, `PERCENT-COMPLETE`
and `CLASS` reach the residue verbatim when unparseable, and `write` authors them
from their columns unconditionally — so a task read from `SEQUENCE:x` round-tripped
carrying both `SEQUENCE:0` and `SEQUENCE:x`. Invisible to the corpus, which
filters `SEQUENCE` out of comparison entirely. Now suppressed, with a test that
counts occurrences rather than comparing canonically.
**A solidus-prefixed `TZID` was rejected as undefined** — the rule's own comment
says §3.2.19 exempts it. Thunderbird writes
`/mozilla.org/20050126_1/Europe/Berlin` on every zoned task, so the validator
would have quarantined every Lightning-authored task permanently.
**Two smaller ones:** a resource the query lists but the multiget will not return
was re-requested for ever with nothing counting it, and account removal blocked
on a 30-second connect timeout before anything visible happened.
## What moves to floret-kit
Recorded here so the follow-up branch is a file move rather than a rediscovery.
The rule that made it a move rather than a rewrite held: **no task-domain type
and no Android UI type crossed into the DAV or CalDAV layers.**
| Moves | Why it generalises |
|---|---|
| `:dav` (vendored dav4jvm) → `core-dav` | Not app-specific in any way; Calendula needs the identical tree |
| `:caldav` — discovery, `CalendarCollection`, `sync-collection`, quirks | Pure protocol, plain JVM, no Android types |
| `NextcloudLoginFlow` | Nothing about it is task-shaped |
| `CredentialStore`, `CalDavAccounts`, `SyncAuthenticator` | Keystore + `AccountManager` plumbing, identical for any CalDAV client |
| `LicencesScreen` / `Attribution` | Every app in the family owes the same notices |
| Stays app-local | Why |
|---|---|
| `VTodoMapper`, `CalendarResource`, `ResourceValidator` | VTODO ↔ *our* Room schema; the residue contract is ours |
| `CollectionSyncer`, `SyncReport`, conflict policy and its UI | Encodes decision 2, which is a product decision |
| `SyncEngine`, `SyncWorker`, `SyncTrigger` | Bound to our entities and our scheduling choices |
| Storage modes, External mode | Agendula-specific by construction |
---
## Definition of done, every chunk