Three corrections to 7fac4bd, all found in review.
Folding the origin into the match key was wrong: the REPORT body carries
only our paths, so a reply's href resolves against the collection's
post-redirect location while our stored hrefs still hold the origin we
had before it. Every resource of a redirected collection would have
landed in both buckets -- the failure the commit was meant to remove.
The key is the decoded path; the origin is checked separately, and a
reply may come from the host we asked or from the collection's own.
Two request hrefs can decode to one identity, and keeping the last of
them silently reported the other as missing and applied one row's body
to the other. Only identities naming exactly one href are matched
loosely now; where we cannot tell two spellings apart, the exact one is
the only honest answer.
And the syncer's amnesty was excused by any stray at all, though servers
volunteer siblings as a matter of course -- so a genuinely omitted
resource would never have been counted and would be re-requested for
ever. Only a stray naming the same path can be this href under a
spelling we failed to read.
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.
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.
Every non-Found outcome was collapsed into NO_CALENDARS, so a server
whose login flow named an unresolvable host told the user their account
holds no task lists. Both Failed and NotCalDav already carry the cause
discovery worked out; forward it and keep NO_CALENDARS for the outcomes
that really mean it.
This is what turned the overwrite.cli.url skew from confusing into
undiagnosable: the user retries, gets the same wrong answer, and leaves
another spent app password in their device list each time.
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.
The cleartext gate sits on the emission, but the 401 branch cached the
Basic challenge before reaching it. So after refusing a plain-HTTP
challenge the handler still believed Basic was in play: the preemptive
block is skipped because it requires both caches empty, the refusal
repeats, and the 401 after that logs "Basic credentials didn't work last
time" about a credential that never reached the wire.
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.
A row with an href but no usable ETag asks the server for one before
writing. That fetch was read with getOrNull(), so a timeout looked the
same as a server with no validator to offer: we PUT unconditionally over
whatever a concurrent edit had left there, and booked it under
unconditionalWrites, which by its own definition means the server had
none. The report asserted something false. Only Result.failure defers
now; a fetch that succeeds and yields nothing usable still writes and is
still counted.
The deferral also has to hold on the incremental path. downloadChanged
matches by href like downloadPhase does, so a row deferred mid-create --
still without an href -- is invisible to it and past its dirty-row
guard, and apply then matches by UID and overwrites the edit. The
explicit refetch list is filtered too.
deferred is set on both call sites of updateResource. It is redundant on
the direct one, where the row keeps its href, and load-bearing on the
adoption from createResource, where it does not.
A 412 on create means the name is taken, and only the UID in the body
says whether we took it ourselves on a run whose answer never arrived.
That check read the fetch with getOrNull(), so a timeout looked exactly
like "somebody else's resource" and we PUT the same body under a fresh
random name: one UID in two resources, which RFC 4791 4.1 forbids, the
task duplicated in every client, and a row whose href flips between the
two on every later sync. Only a Result.failure aborts; a fetch that
succeeded and returned nothing, or something else, still renames.
Aborting the write is not enough on its own. The row still has no href,
so downloadPhase cannot match it, its dirty-row guard cannot fire, and
apply then overwrites the local edit by UID with nothing in the report.
The href is deferred for the rest of the run so the read gives up with
the write.
Deferral is skipped rather than failed: a transport failure is nobody's
fault, and a UID-keyed quarantine count is unrefundable once it passes
the threshold, since uploadPhase then returns before anything can clear
it.
markSynced ran over every row of a resource, tombstones included, and
only ever sets href, etag and is_dirty = 0. A deleted override was left
out of the PUT body by serialize, so the write already removed it from
the server — but the row stayed behind as is_deleted = 1 with is_dirty
cleared, which no phase can reach: the ETag we recorded from our own PUT
matches the server's, so nothing re-downloads it and apply's stale
sweep never runs. The unique index on (list_id, uid, recurrence_id)
then makes re-adding that occurrence throw for good, and the view
models swallow it.
stored() now deletes those rows instead. Overrides only: markDeleted
tombstones a series master without its overrides, so a deleted master
still arrives here, and dropping it would cascade the live overrides
away via master_id. A test pins that until markDeleted is fixed.
The full run captured locals once and gave the same list to both the
download and the sweep. apply can re-point a row's href when a UID
reappears under a new filename, and purge deletes by row id, so the
sweep destroyed the row the download had just repaired: the task came
back next cycle with sortOrder 0, no colour and no parent, and
deletedLocally was reported for a task nobody deleted. Subtasks were
un-parented for good, since parent_id is ON DELETE SET_NULL and clean
children are never re-downloaded.
The listing stays pre-write; only the local side is re-read.
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.
The platform half of chunk 2; the account-add UI is 2d, since it is a design
task and the piece that needs an on-device review.
A stub ContentProvider turned out to be required and was not in the plan: a
sync adapter registers against a content authority, and we publish no provider
since :provider was deleted. Without one there is nothing for contentAuthority
to name, nothing for requestSync to address, and hasAuthorityAccess() makes
every ContentResolver sync call a silent no-op at targetSdk 34+.
- CredentialStore: Keystore AES/GCM, blob in its own DataStore file.
security-crypto is formally deprecated and terminal. Decryption failure means
re-authenticate, never a crash — including ProviderException, which is a
RuntimeException and escapes the obvious catches.
- CalDavAccounts + SyncAuthenticator: no password reaches AccountManager, which
stores them as plain TEXT. The authenticator never returns null — a null is
the protocol for "answering asynchronously", and nothing here does, so
Settings would wait forever. addAccount refuses with a readable message until
2d ships the screen, rather than opening the home screen and hanging.
- SyncAdapterService: enqueue and wait on the unique work *name*, not the
request id — enqueueUniqueWork is async so the id is unknown when the wait
starts, and under KEEP it may never exist at all. Being deduplicated is not
a failure.
- Account type and authority are per build variant, so debug and release do
not fight over ownership. SyncContractTest guards the Kotlin/resValue pair.
- The credential blob is the only thing excluded from backup: Keystore keys are
non-exportable, so a restored ciphertext can never be decrypted.
Known trade-off recorded in network_security_config.xml and SYNC-PLAN.md: the
user CA store is trusted for all traffic, which chunk 5's cert4android should
replace rather than sit beside.
The instrumented tests here compile but have not been run — device work waits
for an explicit go-ahead.
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.
JitPack-only publishing conflicts with FAIL_ON_PROJECT_REPOS, artifacts are
unsigned and not immutable, and upstream shipped two breaking majors 19 days
apart. 2.2.1 is the last OkHttp release: 3.0.0 moved to Ktor and 4.x needs
Java 21, and SYNC.md's whole auth section is written in OkHttp terms, so 4.x
would have invalidated it. A plain JVM module, not an Android library — the
tree has no Android imports and keeping it that way keeps the floret-kit
extraction a file move.
Four changes from upstream, all in dav/PROVENANCE.md:
- commons-lang3 dropped (one import, one call). The replacement requires the
whole string to be consumed, as DateUtils did: pattern 1 ends in a literal
'GMT', so SimpleDateFormat would match "...GMT+02:00" as a prefix and throw
the offset away.
- HTTP dates were parsed and formatted in the device's local zone — the GMT in
the format string is a quoted literal and timeZone was never set, so every
getlastmodified was out by the local UTC offset. Upstream tests dates
nowhere. The formatter is also no longer shared and mutable.
- dav4jvm#209: permanent redirects now reach the caller via
DavResource.permanentLocation, cleared per request so it never describes an
earlier one.
- xpp3 is compileOnly; Android supplies org.xmlpull.v1.
SYNC.md's other claimed defect, "does not follow 303", is not true of 2.2.1 —
pinned by a test rather than removed from the notes.
CI names :dav:test explicitly; testDebugUnitTest is Android-only and would
have run none of the vendored suite. lintDebug added to the per-chunk checks
after it caught a literal byte-order mark left by chunk 1.
A hand-rolled content-line model instead of ical4j: a raw (name, params,
value) tree is what the round-trip requirement wants, and a typed model
normalises away exactly what has to survive. lib-recur already does RRULE
and java.time is native at minSdk 29, so the 2.2 MB of zone data and the
registry shims buy nothing. Deviates from SYNC.md's library table — see
SYNC-PLAN.md decision 4.
- domain/ical: parser, serialiser, value codecs. No Android, no data types,
so the floret-kit extraction stays a file move.
- data/tasks/ical/VTodoMapper: VTODO <-> TaskEntity. Claims a property only
when it can reproduce it exactly; everything else round-trips verbatim
through TaskEntity.unknown_properties, which already existed at v1 — no
migration needed, and BEGIN/END lines carry the nesting the plan thought
needed a second table.
- 19 fixtures as the specification, with the canonical comparison harness
from SYNC.md: no property lost, modulo the enumerated allowlist.
- ICalendarWriter now delegates folding and escaping rather than carrying
its own copy.
VALARM ownership settled: neither side writes the other's alarms. VALARMs
round-trip in the residue, local reminders stay in task_alarms. The
setAlarm collision was the provider's; the two stores are now disjoint.
SYNC.md decides; it doesn't sequence. SYNC-PLAN.md turns its ~40 scattered
findings into five chunks — mapper, auth/discovery, engine core, incremental
sync + scheduling, hardening — each with a done-when and the traps it must
not get wrong.
Closes SYNC.md's three open questions: vendor dav4jvm/cert4android at our
Java 17 target, server-wins-and-report on 412, External mode stays.
The document was written against the vendored provider and still said the
storage question was settled that way. Owning the store answered several
of its open questions and deleted others outright, so the corrections are
marked inline the way the rest of the file marks them, rather than
quietly rewritten.
Closed: the Local->Synced migration (account_id is a nullable FK, so
attaching an account is an UPDATE), the recurring-completion model (the
store writes model (a), RECURRENCE-ID overrides sharing the master's
UID), the Auto Backup / cleanUpLists data-loss path, and the lib-recur
version trap. Phase 0's UIDs-at-creation and backup safety are shipped.
The provider-mechanism table is kept as External-mode history rather than
deleted — that code still runs in OpenTasks and tasks.org.
Effort restated: 11.5-15 weeks minus the 2.5-4 owning the store removes,
so roughly 8-11. Also states plainly at the top that no sync code exists
and that ICalendarWriter is the export half of the mapper only.
The store picker and the export screen were the two frontend surfaces
the own-store work left unbuilt, so both backends shipped unreachable.
Settings gains a Storage section holding them: a full-screen picker over
Own / an installed external provider (dimmed when none is present, named
after the provider's own app), and an export screen with a per-list tick
and the two SAF destinations, a folder or a single zip. The picker asks
for the provider's runtime permission before writing the mode, so a
denial leaves the readable store in place instead of dropping the user on
the gate; a refusal is reported with a route to app settings.
Making the mode switchable at runtime had two consequences:
- reminders are armed off whichever store was active when they were
scheduled, so a switch rebuilds the set. ReminderScheduler.sync() is
now serialised — it is a read-modify-write over ScheduledReminderStore,
and overlapping runs each wrote their own set as the whole truth
- the permission gate is the only screen an External user can reach once
their provider app stops answering, so it offers the way back to our
own store
ExportWriter no longer deletes a previous export before recreating it (a
failure in between lost both), lists the target directory once instead of
per document, and carries a typed ExportFailure so the screen can report
in the user's language rather than an exception message.
The file is machine-specific rather than anything the project depends on:
the ARM64 box64 `aapt`/`aapt2` wrappers it documents live outside the repo,
and the rest is on-device working rules. Nothing in the tree links to it.
It stays on disk and is now ignored, so it keeps working locally without
riding along in the branch.
Owning the store left a fresh install with no lists and no way to make
one, so no way to save a task. The seam gains updateList/deleteList
beside createLocalList on both paths — the External one addresses the row
as its own account's sync adapter, the only caller the provider lets
write tasklists. ListEditorSheet is the family's full-screen sheet: name
field, a 12-colour palette, and a destructive row behind a confirm when
editing. Entry points are a "New list" row under the home Lists section,
an empty state with a create button, and the home FAB switching to "New
list" while there are none. Deleting takes the list's tasks with it and
is offered only for device-only lists.
Four defects a review of the branch turned up:
- completing one occurrence closed the whole series — setCompleted wrote
the master, the row TaskDao.tasks filters on. setCompletedInstance
forks a RECURRENCE-ID override the way updateInstance does; phase 2
always specified this, only the edit half had it
- the expansion ceiling was spent on the past, so a sub-daily series
stopped expanding months before today and never reached Today or
Upcoming
- an imported START-referenced reminder fired off DUE, because the seam
collapsed alarms to a bare minute count. TaskReminder carries the
anchor now
- registerObserver bound a live flow to whichever store was active at
subscription, so a Settings store switch left every screen listening to
the store it had stopped reading
Ships the first community translations: German (98%) and Brazilian
Portuguese (100%), both from Weblate, plus the translation infrastructure
and the Codeberg forge move that had accumulated under Unreleased.
A minor bump rather than a patch: two new user-facing languages is a
feature, the way 0.3.0 was cut for Reminders.
versionCode is the derived 400 (MAJOR*10000 + MINOR*100 + PATCH), and
scripts/sync_changelog_to_fastlane.sh generated changelogs/400.txt so the
official F-Droid listing shows this version's "What's New". At 908 chars
it exceeds the script's 500-char advisory and may be truncated in-client
— consistent with past feature releases (0.2.0 ran 1811, 0.3.0 692).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146mhMLZ5AqJSZkADDc92Jz
The Weblate merge (#8) landed values-de and values-pt-rBR, but
locales_config.xml still listed only `en`. That file is the single source
of truth for which languages the app ships: AppLanguage parses it for the
in-app picker (Settings -> App language) and android:localeConfig points
the system per-app language settings at it. So both translations were
compiled into the APK and reachable by nothing — you could not pick either
one, and only a device already set to de/pt-BR would ever have seen them.
Adds the two BCP-47 tags. Note the tags are `de` and `pt-BR` while the
resource folders are values-de and values-pt-rBR; the `r` is an Android
resource-qualifier artefact and must not appear here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146mhMLZ5AqJSZkADDc92Jz
- forking an occurrence copied the master's alarm row id and hit the
primary key; replaceForTask now clears it
- a sub-second DTSTART made lib-recur emit the anchor and its truncated
self, doubling a series' first occurrence; floor to the second, which
is all RFC 5545 DATE-TIME carries
- room-testing needs kotlinx-serialization 1.8+, but consistent
resolution pinned androidTest to the app's 1.7.3
Tests: 52 pass on device.
Regression from deleting the provider. sync() gated on
providerResolver.resolve() != null, and OWN resolves to no provider by
design — so from that commit no due reminder was ever armed in what had
just become the default mode, and clearAll() cancelled any that survived
the upgrade.
The gate is now ProviderResolver.canReadStore(): OWN is always readable,
and only EXTERNAL can fail, for the two reasons it ever could. Putting
the decision on the resolver rather than inside the scheduler is what
makes it testable at all — ReminderScheduler needs Context and
AlarmManager, which is why nothing caught this.
Also brings ARCHITECTURE.md and ROADMAP.md in line with the branch: one
module, OWN/EXTERNAL, the four Room tables, expansion at read time, the
import and startup gate, and the manifest surface that no longer declares
a provider or any permission of its own.
Phase 6 of docs/OWN-STORE.md. MigrationTestHelper is wired against the
committed v1 schema, so the first real migration only has to add its own
case; the class KDoc says where it goes. app/schemas/ is added to the
androidTest assets — the schema location comes from the KSP arg, not the
Room Gradle plugin, so nothing wired the test assets automatically.
The restore tests state the WAL premise directly rather than around it: a
backup of the .db alone must lose whatever is still in the -wal, carrying
the sidecars must keep it, and checkpointing first must make the .db
alone sufficient. If the premise is wrong the first test fails instead of
passing vacuously.
Performance: 5,000 tasks and 20 FREQ=DAILY series — daily on purpose, so
the per-series occurrence cap is the case being measured — through one
full smart-list read. The ceiling is loose and the numbers are printed,
because nobody has run this on hardware yet.
Also fixes a lint error I introduced in the backup rules two commits ago.
Naming any <include> makes everything else excluded by default, so the
<exclude> for tasks.db.imported sat under no included path and
FullBackupContent rejected it — lintDebug has been failing at HEAD since,
and CI runs it.
The same defect had a second, quieter half: those explicit includes had
silently stopped DataStore being backed up at all, since it was only ever
covered by the old file's "everything by default". Settings are listed
back in explicitly.
Phase 5 of docs/OWN-STORE.md. The :provider module goes — 84 Java files,
14,555 lines, its <provider>, its two custom permissions, its 13
translated strings and its three dmfs runtime dependencies. Room has been
the default since the previous commit and every v0.3.x install has been
imported, so nothing reads it any more.
StorageMode.LOCAL is gone with it; OWN and EXTERNAL are what remain.
ProviderResolver narrows to what it was always really for — discovering
external providers — and answers null in OWN mode, where there is no
authority to resolve. Callers that need to tell that apart from "External
with nothing installed" ask mode(). ProviderStatus is unconditionally
READY in OWN mode: the permission gate only ever applied to External, and
that is now visibly true rather than a special case inside it.
A stored LOCAL is read as OWN rather than as an unparseable value. Left
to fall through to autoMode, someone who had explicitly chosen local
storage while also having OpenTasks granted would have been sent to
OpenTasks instead.
ProviderChangeReceiver's manifest filter drops our own authority — safe
now, because nothing of ours broadcasts ACTION_PROVIDER_CHANGED. In OWN
mode Room's InvalidationTracker covers foreground changes and nothing
outside the app can change our data. When SYNC.md phase 3 lands, the sync
worker must call ReminderScheduler.sync() itself; that is the replacement
for the broadcast and it belongs in the sync work.
lib-recur stays as a direct dependency and is still Apache-2.0 dmfs, so
the attribution is still owed — now as a normal third-party dependency.
provider/PROVENANCE.md is replaced by a postscript in STORAGE-DECISION.md
recording that the fork existed, why, and the one detail that still binds
us: tasks.org is DB 22 and has no is_recurring, so TaskMapper must keep
deriving recurrence from rrule/rdate.
BREAKING: the de.jeanlucmakiola.agendula.tasks authority and both custom
permissions are gone. Anyone who pointed DAVx5 or another app at that
authority loses it; External mode is the answer. Needs calling out in the
release notes.
Verified: the APK declares no ContentProvider, no custom permission and
no agendula.tasks authority, and carries no dmfs provider classes.
Phase 4 of docs/OWN-STORE.md. OneShotImport reads databases/tasks.db
directly — read-only, no provider, no ContentResolver — and writes it
into Room in one verified transaction. dmfs row ids are remapped in two
passes, because a parent can carry a higher _id than its child.
The archive happens before the import, not after, and the import always
replaces. That is what actually closes the crash window the plan's "flag
*and* rename" is meant to cover: renaming last leaves the flag unset with
tasks.db still in place, so the next launch imports a second copy. In
this order every kill point re-enters correctly.
Recurrence overrides are carried across as master_id/recurrence_id rather
than ignored. dmfs stores them as ordinary rows sharing their master's
_uid, so importing one as a second master would collide on the unique
index and abort the whole import.
autoMode now answers OWN, and a stored LOCAL reads as OWN — after the
import the dmfs file has been renamed away, so someone who chose local
storage explicitly must land on the store their data is now in.
StartupGate holds the first store read until the mode has landed and the
import has run; showing an upgrading user an empty app is the worst thing
this migration could do. The backup rules take the database with its WAL
sidecars and exclude the archive, and the app checkpoints on ON_STOP.
Joins phases 1 and 2 and covers phase 3's semantics. RoomTasksDataSource
implements all 14 seam methods; a StorageMode-routing delegate picks it
or the provider per call, since the mode is a setting the user can change
while the process lives.
There is no instances table, so a series is expanded at read time by
RecurrenceExpander and any RECURRENCE-ID override is substituted for the
occurrence it replaces. A timed series carries each occurrence's length
across; a due-anchored one has no start to offset from, so the anchor is
the due date — matching how the provider instantiated the same series.
Editing one occurrence writes a RECURRENCE-ID override sharing the
master's UID (RFC 5545 model (a)). The provider's Detaching.java forked a
brand-new task with its own UID instead — model (d), the one least
compatible with CalDAV. We inherited that without ever choosing it; this
is the choice.
TaskFormWriter states the completion rules directly instead of working
around the provider: progress and status now move together in both
directions, so a task can no longer strand itself "done at 75%".
TaskWriteMapper keeps the workarounds for External mode.
Deletes are hard when the list has no account and tombstones when it
does; master_id cascades, so a deleted series takes its overrides.
Phase 2 of docs/OWN-STORE.md, the engine half. RecurrenceExpander turns a
stored rule set into its occurrences at read time — no materialised
instances table, so none of its staleness bugs exist. Each occurrence is
returned as its RECURRENCE-ID anchor, which is what the seam now
addresses occurrences by.
Expansion is bounded two ways: the window end, and a hard occurrence
ceiling. The iterator is fast-forwarded to the window start first, so a
FREQ=MINUTELY series anchored years back doesn't scan millions of
instances to emit one.
Three things lib-recur 0.12.2 forced. RecurrenceSet.iterator injects the
start itself, so DTSTART is in the set for free and EXDATE can remove it
(RFC 5545 §3.8.5.3). Its window end is exclusive. And a floating UNTIL
against a zoned start throws, so the UNTIL's local fields are re-read in
the series zone — the vendored provider worked around the same thing via
TimeZone.getDefault(), which isn't deterministic.
Malformed RRULE/RDATE/EXDATE values are dropped, not thrown: a task with
an unparseable stored rule still has to appear.
38 tests. Multi-occurrence expansion has no provider behaviour to compare
against, so the reference is RFC 5545 directly — daily/weekly/monthly/
yearly, COUNT, UNTIL, a Europe/Berlin DST boundary, all-day series pinned
to UTC midnight, RDATE, EXDATE, and an unbounded rule hitting both bounds.
Phase 1 of docs/OWN-STORE.md. Four tables — task_lists, tasks,
task_alarms, accounts — with the indices, cascades and converters the
plan specifies, plus a DAO per table and the v1 schema JSON committed for
migration testing.
Masters and RECURRENCE-ID overrides share the tasks table, so the unique
index is on (list_id, uid, recurrence_id): an override shares its
master's UID, and a key without recurrence_id would reject exactly the
rows recurrence depends on. SQLite treats NULLs as distinct, so that
index only enforces the override half; the master half is intent, noted
where the index is declared.
PRIORITY is stored as the raw iCalendar integer rather than through the
Priority enum. Priority buckets 1..4 into HIGH, so a converter would
rewrite a server's PRIORITY:3 as 1 before it ever reached disk — the
bucketing belongs in the mapper. Status keeps its converter: that mapping
is total.
Two cascades the plan left unstated: deleting a list takes its tasks,
deleting an account only detaches its lists.
Instrumented tests cover read-back, the cascades and the unique index —
app/src/androidTest is new.
The own store expands recurrences itself, so lib-recur is a direct
dependency now rather than something :provider drags in. Still pinned at
0.12.2 — 0.16.0 removed RecurrenceSet.
scripts/make_import_fixture.py writes the tasks.db the one-shot import
will be tested against: the provider's DATABASE_VERSION 23 schema, seeded
with the cases the import has to get right (a task with no UID, a deleted
row, a recurring series, an all-day task, a subtask, an alarm property,
and a list under a real CalDAV account). The provider is being deleted, so
a fixture is the only way to keep testing against the schema it wrote.
Phase 0 of docs/OWN-STORE.md. Prepares the seam for the Room store while
the provider is still the store.
Task.id (the materialised instance row id) is gone; Task carries
occurrenceStart, its RECURRENCE-ID anchor, instead. updateInstance takes
(taskId, occurrenceStart, form) and AndroidTasksDataSource maps that back
to an instance row itself, so the provider path exercises the new
signature before Room exists.
Lazy-list keys move to Task.occurrenceKey. Two occurrences of one series
can appear in the same list once expansion is ours, and taskId alone
would collide there.
domain/Models.kt stops importing TasksContract — status, priority and
local-account constants now live in domain. StorageMode gains OWN as a
third value; LOCAL keeps meaning the dmfs provider until it is deleted.
Room 2.8.4 and room.schemaLocation added to the build.