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
+76
View File
@@ -0,0 +1,76 @@
# Agendula — privacy policy
_Last updated: 2026-09-07._
Agendula is a task app for Android, published by Jean-Luc Makiola. This policy
describes what happens to your data. It is short because very little happens to
it.
## The short version
Agendula has no servers. There is no Agendula account, no analytics, no
advertising, no tracking and no third-party SDK that reports anything anywhere.
Your tasks live on your device, and — only if you set that up yourself — on a
CalDAV server **you** choose.
## What is stored on your device
- Your task lists, tasks, reminders and app settings.
- If you add a CalDAV account: the server address, your username, and your
password or app password. The password is encrypted with a key held in the
Android Keystore, which cannot be exported from the device.
## What leaves your device
**Only if you add a CalDAV account**, and only to the server you entered:
- Your tasks in those lists, as iCalendar data, and the credentials needed to
authenticate.
- Requests are made over HTTPS. Cleartext HTTP is refused unless you explicitly
opt in for a specific account.
Nothing is sent anywhere else. In particular, nothing is sent to the developer.
Under Google Play's Data Safety definitions this counts as **collected** — Play
defines collection as transmitting data off the device, regardless of who
receives it — and **not shared**, because the only recipient is the server you
nominated. Data is encrypted in transit.
Your CalDAV provider has its own privacy policy, and your data on their server is
governed by it. Agendula has no relationship with them.
## Crash reports
If the app crashes, it can show you the report and ask whether to send it. It is
never sent without you choosing to send it, and you can read the whole report
first.
## Backups
If Android Auto Backup is enabled on your device, your tasks and settings may be
backed up to your own Google account. Two things are deliberately excluded: your
stored CalDAV password, and Agendula's per-device sync bookkeeping.
## Deleting your data
- **Remove a CalDAV account** from Settings → Accounts. This deletes the stored
credential and, where the server supports it, revokes the app password. Task
lists become device-only lists rather than being destroyed.
- **Remove an account and delete its local data** removes the lists and tasks as
well.
- **Uninstalling the app** removes everything Agendula stored on the device.
Deleting data from your CalDAV server is done on that server.
## Children
Agendula is not directed at children and collects nothing about anyone.
## Changes
Material changes will be noted here with a new date at the top. The history of
this file is public in the repository.
## Contact
mail@jeanlucmakiola.de
+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