Lists can be created, renamed and deleted on the server where it supports
MKCALENDAR or extended MKCOL. Discarded edits and quarantined tasks now
surface as sync reports. The browser sign-in step asks before opening the
server's page.
Provider, address, sign-in, lists — plus a receipt naming what it set up,
and an errand step for the services that need an app password minted
first. Picking a provider advances on the tap; a service we cannot sync
with says so on its own row and sorts to the end.
System back now steps through the flow instead of closing it, which also
means onStartOver runs on every exit: no more reopening onto the last
server's picker, and no more poll loop outliving its screen.
Split out of one 569-line file into ui/accounts/add/.
Nextcloud, Fastmail, iCloud, mailbox.org and Posteo wear their own logos
instead of one shared @ glyph across six providers. Fastmail and
mailbox.org are full-colour badges; the rest are tinted marks on the
brand disc. Anything without usable art takes the brand's initial, which
for Yandex is its own Я. Posteo's green is its real one now.
about_privacy_url was the raw docs/PRIVACY.md on Codeberg. It is
jeanlucmakiola.de/agendula/privacy now, matching Calendula's, so the row
opens a page rather than a source listing. Play wants the same URL in the
console field.
The page is not published yet; docs/PRIVACY.md stays as its source.
The row was copied from Calendula together with its ic_gitea drawable, but
about_source_url points at codeberg.org — so the icon named the software
the forge runs rather than the forge the row opens.
Simple Icons' Codeberg mark (CC0), same provenance and same single-path
shape as the one it replaces; upstream's duplicated trailing closepath is
the only edit.
Source and licence were buttons inside the About card, and the groups were
named by this app rather than by the one that already solved it. Both now
follow Calendula exactly.
The card carries the logo, name and author and nothing else, joined to a
"Support development" row below it as one grouped block — Position.Top and
Position.Bottom, so the call to action continues the container instead of
sitting inside it as a button. Source, licence, open source licenses and
the privacy policy move to a group of reference links at the foot, above
the version mark. The logo takes Calendula's 56dp chip with the 1.5x
overscan its adaptive foreground needs.
Groups are Look & behaviour / Data / App / About, and the chip accents
cycle within each group rather than colouring it uniformly, which is what
makes them a scanning aid. The four header keys are Calendula's, so the
two apps' catalogues agree.
Report a problem also picks up Calendula's crash-report hand-off: a
captured report is offered in the same dialog the next-launch prompt uses
before falling back to the issue template chooser. The reporter was
already installed here; only Settings never reached it.
The hub was a flat run of nine rows — Appearance, Task form, Reminders,
Storage, Accounts, Language, Licences, Privacy, Report a problem — with
nothing putting sync next to storage and nothing separating how the app
behaves from what the app is.
It is four named groups now, each a connected run with one accent chip
colour: Appearance (theme and colour, language), Tasks (task form,
reminders), Sync and data (accounts, storage), About (licences, privacy,
report a problem). The Appearance row takes the wording that used to be
its summary — under a header of the same name the old title said nothing,
and that string is already translated everywhere. Two new headers, so two
new strings; "Tasks" reuses the key the German and Brazilian catalogues
already carry.
And the 814-line file held the hub plus AppearanceScreen, TaskFormScreen
and RemindersScreen while Storage, Export and the account screens lived in
their own files, so where a sub-screen lived was arbitrary. Each of the
three is its own file now, the shared chips and about bits are in
SettingsCommon, and SettingsScreen keeps the hub, the section enum and the
back rules between them.
First run was a bell screen followed by an empty lists screen. It is now
welcome → reminders → an offer to connect a CalDAV account, and then
whatever that answer leaves outstanding:
yes → the add-account wizard inline, its three steps reporting their
place in the outer progress via stepOffset/totalSteps
no → a first list, then the offer to export a copy
Both end on how a list adds tasks — a preview picker over the real task row
and quick-add field, so the answer is given by looking. The list step only
appears when the user has no lists at all, which covers a server that held
no task collections and a restored backup alike; the backup step never
shows on the synced branch, where the server is the copy.
The External-store gates are rebuilt on the same shell, and their copy no
longer claims Agendula only shows tasks that OpenTasks or tasks.org stores.
The reminder step drops the same framing. The done flag keeps its old key,
so existing installs are not dragged back through it.
The two destinations were a filled Button over an OutlinedButton, and the
success line said "Exported 1 list" and nothing else.
Now they are an M3 Expressive ButtonGroup — equal-weight tiles that grow and
squeeze their neighbour on press, corners morphing square — and the outcome
carries the SAF Uri it was aimed at, so the receipt is a tinted row naming
the folder or zip, with an Open button when something on the device answers
for it. Failures get the same treatment.
Forking an occurrence copies the master's properties onto the new
override row, so the reminder was deliberately written to the master
first to carry it across — and then left there. Changing one occurrence's
reminder therefore changed the whole series', silently, and the next
occurrence inherited it too. It is put back after the fork now.
setExactAndAllowWhileIdle throws at 500 concurrent alarms per uid, which
the per-occurrence model reaches at roughly seventeen daily recurring
tasks over a thirty-day window. When it threw mid-loop store.replace
never ran, so every alarm armed on that pass went unrecorded —
uncancellable, and firing for tasks that no longer exist — and the
exception escaped into BootReceiver's goAsync(). The set is bounded
soonest-first well below the ceiling, since the far edge of the window
is what the next sync arms anyway, and a single refusal now costs that
one alarm rather than the pass.
And the reminder sync shared the write's runCatching in the edit screen,
so a scheduling failure reported a task that *was* written as unsaved.
The user taps Save again on a screen whose editingTaskId is still null
and gets a second task — the reminder would have been re-synced on the
next data change regardless. The created id is also remembered now, so a
second Save updates rather than duplicates whatever sent them back.
An override could be moved out of its series' list. updateTask applied
form.listId and form.parentId to any row, though updateInstance states
the opposite rule ten lines below — and an overridden occurrence maps
with isRecurring = false, so the repository routes it here and the edit
screen offers its list picker. list_id = B with master_id in list A is
invisible in both, since the task query skips a non-null master_id and
the override query finds no master in B, while still uploading as part of
A's resource. Both paths keep the master's list and parent now.
DatabaseCheckpoint never ran its pragma. `query` hands back a lazy
cursor and the statement is stepped on the first fill, so closing it
unread made the whole class a no-op: the -wal sidecar kept growing and
the .db stayed stale, which is exactly the restore case its KDoc says it
narrows.
And a truncated preferences_pb was a crash at every launch, in all three
stores: DataStore.data throws on collection and the collectors are root
coroutines in a scope with no handler. They replace a corrupt file with
an empty one now — settings fall back to defaults, sync state to "never
reconciled", credentials to an account asking to be signed in again, all
states the app knows how to be in. StorageModeHolder needs its own guard
either way, and specifically has to release the startup gate when it
gives up: failing quietly without it parks every observing flow on
awaitReady for ever, which is a blank app instead of a crashing one.
A DATE-valued UNTIL lost the last day of every series. The floating-UNTIL
repair rebuilt the bound from hours/minutes/seconds, all zero for a DATE,
so FREQ=DAILY;UNTIL=20250109 on a 09:00 Berlin series yielded five
occurrences ending on the 8th instead of six ending on the 9th. §3.3.10
pairs a DATE UNTIL with a DATE DTSTART; Google and Apple emit it beside a
timed one anyway, and only a device away from UTC ever saw it.
A RELATED-TO that is not a PARENT was destroyed on the next PUT. None of
them is ever claimed, so they all live in the residue, and the
contradiction test ignored RELTYPE — reading a CHILD link as a PARENT
link that disagreed with the column. It is scoped now, exactly as the
read side is.
RDATE and EXDATE lost their parameters permanently, and kept only their
first line. Both are cardinality-many — Apple writes one line per
excluded occurrence — and reading only the first left the rest out of the
column the expander works from, so a deleted occurrence reappeared in
the list and in its reminders while round-tripping to the server
perfectly out of the residue, which is what made it invisible. They are
merged now, and claimed only when no copy carries a parameter: the
column is the bare value, so claiming a TZID- or VALUE=DATE-qualified
property dropped that qualifier for good, leaving a floating EXDATE that
matched nothing and a bare eight-digit RDATE that §3.3.5 reads as a
malformed DATE-TIME — a permanent 415 under the Prefer: handling=strict
this client sends. When we do change the list ourselves, the authored
line takes the parameters the residue gives up, checked against the
value rather than copied across.
Re-authentication duplicated every list. attach() looked up rows with
account_id IS NULL, which is right for a re-add but wrong for a re-auth:
the account's own lists never matched, so signing in again after a 401
inserted a second copy of each, with no unique index on href to catch
it. The lookup now covers both, and a list already owned by this account
keeps its cursor instead of being sent through a full reconciliation for
nothing.
The poll's uncancellable window started one suspension point too late.
pollLoginFlow is a blocking execute() inside withContext, and withContext
throws on return if the job was cancelled meanwhile — so a back gesture
in that window discarded a 200 the server had already answered, along
with the only copy of a password it had already minted and deleted its
flow row for. ead84c3's reclaim cannot save that one either: the row is
gone, so a later poll can only report expiry.
And the flow record is now cleared only when the token is actually
spent. Clearing it on start over, the back arrow or a failure threw away
the one thing that could collect a password the user goes on to approve
in the browser tab we abandoned but they did not.
While proving the second of those: a server without sync-collection
reconciles in full on every run, so recording each one kept the cadence
permanently fresh and fullReconciliationDue permanently false. Harmless
for the path that reads it — the cursor is null there anyway — but it
meant ddcffa6's quarantine probe would never have fired for exactly
those servers. The mark records scheduled reconciliations now.
remember() runs in viewModelScope, where an escaping DataStore IO
exception is a crash. A flow we could not write down costs the reclaim,
not the sign-in the user is in the middle of.
Flow's own doc says to persist it before launching the browser, because
the flow outlives our process — and nothing did. The browser is a
separate task, so dying while the user approves is ordinary rather than
exotic, and it stranded a one-shot app password that nothing could then
collect or revoke: the poll token was the only way back to it and it
lived in a ViewModel field.
The token goes to the sync-state store, not the Keystore: it authorises
one poll of one flow the user is in the middle of approving, and it is
worthless past the twenty-minute window. It is cleared the moment the
flow stops mattering — spent, expired, cancelled, started over.
A genuine app open reclaims what a dead process left: poll once, and if
the user did approve, hand the password straight back. Revoked rather
than used, because the address they typed, the collections they ticked
and the account name went with the process — what is left is a live
credential in their device list under the same name as every other
attempt, which is exactly the one they cannot tell apart and so dare not
prune. Once per process, so a rotation cannot consume the one-shot 200 a
live wizard is waiting for.
The window after approval, where the password itself is only in memory,
stays open — that needs the wizard's own state to survive, which is the
same work as the wizard restructure.
Both launches can fail, and the outer catch named only
ActivityNotFoundException — so a SecurityException from a locked-down
profile escaped the LaunchedEffect and took the app down. The failure
was also ignored: onBrowserLaunched cleared openInBrowser regardless, so
the user sat on "waiting for your browser" with a spinner and no browser
for the rest of the twenty-minute window, on exactly the AOSP and
GrapheneOS devices the Custom Tabs fallback exists for.
A failed launch now says so, and the step offers the password path,
which is the only way forward on such a device.
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.
Restarting from the address step kept the previous server's credentials.
backToServer is reachable after a successful approval — a post-approval
discovery failure lands on an ordinary address step with a live Continue
button — and only onStartOver cleared the username and password. So:
approve on A, discovery fails, type B, B answers anonymously, and onSave
sees a non-blank app password and creates me@B carrying A's credential,
which is exactly what onStartOver's own doc says must not happen.
Submitting an address now clears the credential, the discovery and the
host list with it, since all three belong to the address that produced
them.
A null serverRoot silently sent no credentials. serverRootFor cannot
parse a host-with-path like cloud.example.com/nextcloud, and the typed
password was then never put on the wire — while the resulting 401 was
reported as "credentials rejected" about a password nothing had tried.
hostsNeedingAuth was refreshed on the browser path but not on the typed
one, so it held hosts from the *unauthenticated* probe. An authenticated
PROPFIND reaches further — principal, then home sets — so the
cross-domain home set that actually caused the 401 is the one most
likely to be missing, and the user got "wrong password" instead of the
diagnostic naming it.
And the poll has the same uncancellable shape the revocation had:
execute() parks on a socket read, so cancelling the job only stops the
next request. It carries its own budget now, sized for a two-second
loop rather than for a multiget.
Deleting a recurring task never sent a DELETE. markDeleted tombstoned
only the row it was handed, and master_id's CASCADE never fired because
nothing was actually deleted — so a series with any override left
LocalResource.isDeleted false, went to the upload phase, and ended
is_deleted = 1, is_dirty = 0 with a matching ETag: beyond every phase's
reach. Other clients kept the task; here it was gone. markDeleted now
tombstones the series whole, and a resource is read as deleted from its
master rather than from all of its rows, which also repairs the rows an
older version left in that state instead of leaving them stuck.
Deleting a single occurrence wrote no EXDATE. Removing the override does
not delete the occurrence, it un-overrides it — per RFC 5545 the
master's RRULE regenerates it as a plain instance, on the server and in
every other client. It was invisible locally only because the override
queries filter tombstones out. The exception is written onto the master
now and the override row is dropped, which is also what hides the
occurrence in a device-only list, where there is no tombstone to do it.
The value takes the shape the list already has: lib-recur parses the
whole EXDATE list or none of it, so a UTC date-time appended to a run of
DATEs would drop every exception the series had.
ResourceValidator gains the rule that would have named the old bug: a
body of overrides with no master describes instances of something that
is not in the resource.
The incremental path applied a page's removals before downloading its
changes, so a delete-and-recreate reported as removed one.ics + changed
two.ics purged the row before two.ics was ever fetched: the task came
back with sortOrder 0, no colour and no parent, and deletedLocally was
reported for a task nobody deleted. The same damage the sweep's
pre-download snapshot used to do, on the fast path. Bodies now go first,
after which the vacated href names nothing and the removal is the no-op
it should be — RFC 6578 reports each resource once, so a page cannot
both change and remove one href, and touched already guards what this
run wrote.
apply re-pointed a row's href in the hoisted index but left the old
entry naming those rows, so a later resource in the same batch read them
as displaced and deleted them: server holds two.ics with UID a (was at
one.ics) and one.ics now with UID b, and the rename is destroyed by the
resource that took its name. The in-memory twin of the same bug.
The download-side quarantine had no way back. At THRESHOLD the href is
stripped before the fetch, so apply — and with it succeeded — can never
run to clear the count, and a per-object ACL wrong for an afternoon hid
that task for the life of the install. The upload side is fine, since
stored and purge refund it there. The daily full reconciliation now
probes a quarantined href once: it costs one resource a day, and one
that answers clears its count.
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.
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.
5dfbe0f discarded on start over, the back arrow and leaving the screen —
but after a post-approval failure the screen shows the address field
with an error and a *Continue* button, and there is no start over to
press. So the retry the user actually makes ran a second login flow and
overwrote the first password without handing it back, which is the leak
the commit was written to close. Its test called onStartOver, an
affordance that state never offers, so it passed against a path nobody
can take.
onServerSubmitted now discards, since it begins a fresh attempt.
Three smaller ones from the same review. The revoke runs without a
catch on a scope that has no exception handler, so anything escaping
OkHttp outside AppPassword's own try would take the app down for a
courtesy call whose failures are deliberately silent. An unparseable
host counted as out-of-scope, and since a stranger is now fatal, an
IPv6 literal — which OkHttp hands back unbracketed — would strand a
homelab at [::1]; it brackets first and reads unparseable as in-scope,
so the diagnostic fails quiet rather than into a dead end. And the
one-shot 200 is recorded uncancellably: a cancellation delivered
between the server deleting the flow row and our writing the credential
down spends it with nothing left holding it.
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.
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.
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.
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.
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.
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.
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