Commit Graph
177 Commits
Author SHA1 Message Date
makiolaj 55522968c8 Merge remote-tracking branch 'origin/release/v1.0.0' into feat/caldav-sync
# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt
#	app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt
#	app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt
#	app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt
#	docs/PRIVACY.md
#	gradle/libs.versions.toml
2026-09-23 13:52:33 +02:00
makiolaj 5d77fd10c9 sync: remote list create/edit/delete, sync reports, sign-in page check
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.
2026-09-23 13:46:19 +02:00
makiolaj 60a3814eb3 docs(releasing): the on-device checklist no longer fits the app
Step 1 was "launch from a clean state — the permission screen must appear",
which cannot happen on the path most people are now on. Since 1.0.0 the app
owns its store: with no OpenTasks or tasks.org installed, `autoMode()` resolves
to OWN, there is no permission to grant, and the app goes straight to the task
list. A gate that never appears is a checklist item that either gets ignored or
gets read as a failure.

Verified against this branch on a Pixel 10 (API 37): the releaseTest APK
installs, cold-starts in 549 ms with an empty crash buffer, and lands on the
task list with nothing granted.

The checklist now splits by what is installed, because the two paths verify
different things — and the provider path is where the new copy flow lives, so
it says to exercise it rather than leaving "the release's headline changes" to
stand in for it. A fresh install in OWN mode also has no lists at all, which
makes "create a task" impossible until you create one, so that is a step now.

The script's actions are unchanged: revoking both permission sets is still
right for the provider path, and revoking POST_NOTIFICATIONS still forces the
reminder onboarding either way.
2026-09-22 08:54:04 +02:00
makiolaj ee29c9bca1 test(store): assert the whole reminder, not a bare minute count
First on-device run of the instrumented suite against the branch tip: 66
tests, one failure, and it was the test that was wrong.

`alarmsRoundTripAndReplaceRatherThanAccumulate` asserted
`source.alarms()[id] == 30`. The seam stopped returning a bare `Int` in
fcee1d1, when collapsing an alarm to a minute count turned out to be what
fired an imported START-referenced reminder off DUE — `alarms()` has returned
`Map<Long, TaskReminder>` ever since. The production value was correct
(`TaskReminder(minutesBefore=30, fromStart=false)`); only the expectation was
left behind.

It compiled the whole time because Truth's `isEqualTo` takes `Any?`, so an
`Int` compared against a `TaskReminder?` is a perfectly legal call that can
only ever be false. Nothing short of running it would have found this, which
is the argument for the ROADMAP item that asked for the run.

Now asserts the whole value, so the reference is part of the contract rather
than something the test is free to ignore.

66/66 green after the fix.
2026-09-22 08:53:16 +02:00
makiolaj a6a4287000 build: pin floret-kit at v0.3.0
The submodule sat nine commits past v0.2.1, on a bare `main` commit. Naming
the pin was meant to be a fresh tag on our side; it turned out v0.3.0 had
already been cut upstream and our pin was fourteen commits behind it —
e047a2b is an ancestor of the tag, so this moves forward onto a released
version rather than sideways.

What it brings, all in `components` (core-time, core-reminders, core-locale,
core-crash and identity are untouched): a navigation slot, full-bleed content
and scrolling actions for the onboarding shell, step progress for the
onboarding scaffold, long-press and styled text on a grouped row, a per-option
summary slot on OptionPicker, AboutCard joining a grouped list, and
GroupedListInset exported.

Agendula uses none of the new surface yet, so this is a pin move rather than a
feature: nothing in `app/` changed, and nothing had to. Verified from a clean
build — 152 unit tests green, lint identical to before the bump (88 issues,
same categories), floret-kit's own test suite green, and the R8 `releaseTest`
APK assembles.

The onboarding shell is the one place to look on a device: our
`ReminderOnboardingScreen` and `OnboardingHero` build on `Onboarding.kt`,
which is where 146 of the changed lines are. It compiles unchanged, but
"compiles" and "still lays out the way it did" are different claims, and the
device pass this release already owes is where the second one gets settled.
2026-09-21 13:52:31 +02:00
makiolaj af9b2cb5c8 release: cut 1.0.0 — Agendula keeps your tasks itself
versionName 1.0.0, versionCode 10000. Merging this to main triggers
.gitea/workflows/release.yaml, which builds it, publishes to the F-Droid repo,
and mints the v1.0.0 tag — and this is the release where `prerelease` flips to
false on its own, since the pipeline derives it from MAJOR.

CHANGELOG's 1.0.0 section covers the own store, list management, recurrence,
iCalendar export, the storage picker and the copy path, then the fixes to the
external-provider mode that 0.4.0 and earlier used for everything — the
reverting due times, the all-day drift, the silent save failure on tasks with a
duration, recurrence detection on tasks.org, the inert reminder field, and the
provider-failure recovery.

Nothing about the dropped authority or the custom permissions: they never
reached a release, so there is nothing for a user to have lost.

`changelogs/10000.txt` is `scripts/sync_changelog_to_fastlane.sh` output, for
the official F-Droid listing, at 471 of the 500 characters F-Droid will show —
the section is written to that budget rather than trimmed to fit it, since the
same text is the Gitea release notes. The detail behind each line is in the
commit messages, CHANGELOG's older entries stay as they were.

Still open before this merges, per docs/RELEASING.md step 4 and ROADMAP:
`./gradlew :app:connectedDebugAndroidTest` against the tip, and
`scripts/verify-release.sh` on a real device.
2026-09-21 13:43:24 +02:00
makiolaj 8dc95da01c ci: fail the build when a changelog will not fit F-Droid
`sync_changelog_to_fastlane.sh` has been printing "note: >500 chars — F-Droid
may truncate this changelog in-client" for three releases, and every release
since 0.2.0 has sailed past it: 1824, 696, 916 characters. A note nobody acts
on is not a check.

F-Droid truncates the in-client "What's New" box, so everything past the limit
is written for nobody — the reader gets a sentence cut mid-word and no way to
expand it. The limit is now a hard failure: the script exits non-zero, with a
message naming the section to shorten, and `MAX_CHARS` is a variable so the
bound lives in one place rather than being copied into the workflows.

Enforced in two places, for two different failures:

- `.forgejo/workflows/ci.yaml` runs it as an always-on guard beside the
  reproducible-release invariant, then checks `git status --porcelain` over the
  changelogs directory. That second half catches a CHANGELOG.md edit whose
  generated fastlane file was never committed — which until now degraded
  silently into the official F-Droid listing showing the *previous* version's
  notes, exactly as RELEASING.md step 3 warns. Porcelain rather than
  `git diff --exit-code`, so a brand-new file for a bumped versionCode counts
  as dirty instead of being missed as untracked.
- `.gitea/workflows/release.yaml` gets the same call in the cheap `detect`
  gate. The release job already regenerated the file, but only at step 15 of
  24 — after the build, the signing and the keystore setup. Failing in `detect`
  costs one bash invocation and publishes nothing.
2026-09-21 13:43:14 +02:00
makiolaj 3150781376 docs: say what actually shipped, and what 1.0.0 actually is
The branch's documents describe a world where the vendored provider reached
users. It never did, and several claims follow from that mistake.

`ROADMAP.md`:
- Phase 5's "**Breaking:** the authority and both custom permissions no longer
  exist — anyone who pointed DAVx5 at that authority loses it, and the release
  notes have to say so" is wrong in the way that *removes* work: they were
  added and deleted inside this unreleased cycle, so nobody could have pointed
  anything at them. The release notes must not warn about losing something that
  never shipped. The per-locale release-notes item went with it.
- "Run the instrumented suite on a device … none has ever executed" was stale:
  52 tests, 0 failures, Pixel 10 / API 36, 13 Aug. What is genuinely open is a
  re-run against the tip, since the 4 Sep commits reworked the store and added
  instrumented cases that have never run. Both now say so, with the ARM64 aapt
  exit-code trap noted where someone will hit it.
- The device-verification item described upgrading from a v0.3.2 APK with
  seeded data, which cannot be the real path. Replaced with the four cases that
  matter, including the one that only exists on a device that side-loaded a dev
  build of this branch.
- M6's Glance item claimed "deps present in build.gradle.kts" — not any more.
  Translations and the language picker shipped in 0.4.0 and are marked done.

`OWN-STORE.md` gets a correction banner over "Migrating existing users" saying
the premise is wrong, and a section for the copy that replaces it.
`STORAGE-AND-SYNC.md`'s banner said the vendored-provider decision was "made,
shipped, and then costed properly" — built, not shipped.

`PRIVACY.md` had the opposite problem: it describes CalDAV sync, Nextcloud
Login Flow v2, RFC 6764 discovery and a Keystore-held password, none of which
exist in 1.0.0 — the app holds no `INTERNET` permission at all. The permissions
section listed six it does not declare. Since it is a legal document users are
sent to from Settings → About, section 4 is now marked as describing a planned
feature, section 9 lists exactly what the manifest declares (and says what is
*not* there), and the backup and crash-report sections no longer assume network
access or sync bookkeeping. Kept forward-looking rather than cut, so it does
not have to change underneath anyone when sync lands. **Worth a read before
merging** — it is the one change here with legal weight.

`fastlane/.../full_description.txt` still opened with "It works directly on an
existing tasks provider (OpenTasks / tasks.org) … no own account, no own sync"
as the app's premise. That is the F-Droid listing for a release whose headline
is that it needs nothing installed. Rewritten, with the feature list and the
no-internet-permission point that is now literally true.

`README.md` and `ExportWriter`'s "ships in eleven locales" (it is three) follow.
2026-09-21 13:38:23 +02:00
makiolaj b49a8af83d chore: clear out what the own-store branch left behind, and refresh deps
Dead weight, found by reading `lintDebug` rather than by anything breaking.

Gone with the deleted `:provider` module: its entire dependency block in the
version catalog — jems, rfc5545-datetime, Robolectric, JUnit 4, Hamcrest,
Mockito — plus the comments explaining a `provider/PROVENANCE.md` that is not
there any more. Only lib-recur survived the deletion and it is now documented
where it lives. That block was also the source of most of the catalog's
"newer version available" noise.

Gone outright: `glance-appwidget` and `glance-material3`, declared but not
referenced by a single line, so they were shipping in the APK for nothing. The
WorkManager `ListenableWorker` keep rule went with them, since Glance was what
dragged WorkManager in; the RoomDatabase keep rule stays and its comment now
says why it matters *more* than it did — Room used to arrive transitively
through that chain, and is now our own task store.

Also gone: thirteen unused resources (twelve strings and a colour), which
volunteers on Weblate were translating for nothing; a `SDK_INT < O` branch that
cannot be false at minSdk 29; and the `-v26` qualifier on the mipmap folder,
unnecessary at the same minSdk.

`DemoSeeder` is injected as a `Provider` now. Its call site is
`BuildConfig.DEBUG`-gated, i.e. compile-time dead in a release build, but
injecting the instance still constructed one on every launch of the shipped
app.

`ProviderChangeReceiver` gets one intent-filter per authority rather than two
`<data>` tags in one filter. Identical behaviour — Android takes the cross
product of every data attribute in a filter — but it no longer reads as if it
might not, which is what lint's IntentFilterUniqueDataAttributes warns about.

Dependency refresh, app-level only: KSP 2.3.11, Hilt 2.60.1, lifecycle 2.11.0,
material3 1.5.0-alpha26, JUnit 6.1.3, hilt-navigation-compose 1.4.0. That last
one moved `hiltViewModel` into `androidx.hilt.lifecycle.viewmodel.compose`, so
nine call sites follow it and the artifact it now lives in is declared rather
than inherited. The floret-kit coordinates move into the catalog, version-less,
since the composite build substitutes them.

Deliberately not touched: AGP, Gradle, Kotlin, the Compose BOM and
kotlinx-* — toolchain moves that want their own change and a
reproducible-build check, not a release cut. lib-recur stays pinned at 0.12.2
(0.16.0 removed `RecurrenceSet`).
2026-09-21 13:37:49 +02:00
makiolaj 003abcce79 feat(storage): a way onto the new store, and a visible failure when there isn't
Two halves of the same problem: the migration this branch was built around
serves nobody, and when it does run and fail it says nothing.

**The copy.** `OneShotImport` reads a bundled dmfs provider's
`databases/tasks.db`, and no release ever bundled that provider —
`git tag --contains` on the commit that added `:provider` comes back empty,
because it was added and deleted inside this same unreleased cycle. Every
existing install therefore keeps its tasks in OpenTasks or tasks.org, which
`autoMode()` correctly keeps them on, and the only route to the store this
release is named after was to retype everything by hand.

`ExternalImport` copies the external provider's lists, tasks and alarms into
Room in one transaction with verified counts — the same discipline as the
legacy import, for the same reason: a partial copy is worse than none, because
the user cannot tell which half is missing. Task `uid`s survive, so these rows
can be attached to a CalDAV collection once sync lands instead of duplicating
server-side.

Offered as Settings → Storage → "Copy tasks from …", behind a confirm that
names the real task count, and only while a provider is installed and
permitted and no copy has succeeded yet. A second run would leave two of
everything: what it writes is indistinguishable from hand-typed tasks the
moment it finishes, so there is nothing to reconcile against.

One-directional on purpose. Writing a *list* into a third-party provider means
impersonating its sync adapter, and the external store is already the one that
can sync. What does not come across is documented on the class, because the
read seam is `exportTasks` and that is shaped for iCalendar: per-occurrence
overrides, EXDATE, CLASS, DURATION, the per-task timezone, and a task's exact
PRIORITY digit.

Switching stores at all now asks first. Neither store hands its rows to the
other, so the app looks emptied to anyone who expected a move — the picker's
hint said so in passing, which is not where someone reads it.

**The failure.** `runIfNeeded` returned a fully-specified `ImportResult` —
`Failed(cause)`, `Imported(counts)` — and the one caller threw it away.
`StartupGate` called it inside a `runCatching` whose value it discarded, so an
upgrading user whose import failed got an empty app, no message, and their
tasks in a file only a developer could name. The class comment said as much:
"reading before the import lands shows an upgrading user an empty app, which is
the single worst thing this migration could do." It is now recorded and logged,
the completion flag deliberately left unset so the next launch retries, and
Settings → Storage offers the retry — which is what finally makes
`reimportFromArchive()` reachable from the app rather than only "by a targeted
fix release". Near-zero blast radius, given the above, but it is the one
irreversible path in the app, and the contrast was hard to defend: the export
path has a localized failure enum for a failure that costs nothing.

`@ExternalStore` binds the external source so the copy can name a store other
than the active one, and so it can be tested against a fake. Nine instrumented
tests cover the write half: id remapping with a child before its parent, the
duplicate-uid case that would otherwise abort on the unique index, the START
alarm reference, device-only lists, the empty source, a mid-read failure
rolling back with the guard left open, and preview with and without a provider.
2026-09-21 13:37:17 +02:00
makiolaj 8994dcb0aa Merge remote-tracking branch 'codeberg/main' into release/v1.0.0 2026-09-21 13:08:23 +02:00
Jean-Luc Makiola 44489c5665 privacy: point the controller contact at support@ (#13)
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 30s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Skipped
2026-09-15 21:00:52 +02:00
makiolaj 04a7137403 accounts: rebuild the add-account wizard around the provider
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/.
2026-09-09 20:58:12 +02:00
makiolaj 741c5cea68 accounts: real provider marks, from the vendors' own art
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.
2026-09-09 20:58:00 +02:00
Jean-Luc Makiolaandmakiolaj 633ec5b5d3 Move the privacy policy into the repo (#12)
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Skipped
The policy had no copy in this repository — it existed only inside the Astro page on jeanlucmakiola.de. This file becomes the single copy: the website build checks this repo out beside itself and renders `docs/PRIVACY.md` through a content collection, so the published page and the app's own documentation cannot drift. Same arrangement as calendula#293.

Shaped as the content entry the site expects: `title` / `description` / `updated` frontmatter, an HTML maintainer note that cannot render, and a body starting below the `h1` the page supplies.

The text is the published page carried over in full — controller and postal address, the two storage modes, CalDAV sync (what is stored, what is transmitted, RFC 6764 discovery, Nextcloud Login Flow v2, the user-CA trade-off), reminders and export, backups, crash reports, external links, permissions, distribution channels, deletion paths and GDPR rights.

Two things differ from the older short version that lived on `feat/caldav-sync`:

- Contact is `business@jeanlucmakiola.de`, matching the site and Calendula's policy, rather than `mail@`.
- Cleartext HTTP is described as refused outright. `CalDavDiscovery.allowCleartext` is `false` with nothing wiring it true, and `network_security_config.xml` sets `cleartextTrafficPermitted="false"`, so the previous "unless you explicitly opt in for a specific account" described a feature that does not exist. It goes back if a per-account opt-in ships.

Split out of `feat/caldav-sync` so the website change is not waiting on the whole sync branch. No issue to close — there is no open privacy/policy issue to reference.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/12
2026-09-09 16:52:17 +02:00
makiolaj 8864d38c6a docs: bring the privacy policy in sync with the published page
The published page carried sections the repo file never had — controller and
postal address, permissions, distribution channels, external links, GDPR
rights, and the CalDAV specifics (RFC 6764 discovery, Nextcloud Login Flow,
the user-CA trade-off). Since the site now renders this file, those would
have been dropped from the live page.

Two corrections while merging: contact is business@, matching the site and
Calendula's policy, and cleartext HTTP is refused outright — allowCleartext
is false with no way to turn it on, so the old "unless you opt in" was wrong.
2026-09-09 16:45:30 +02:00
makiolaj 9fb592ba51 docs: shape the policy as the content entry the site renders
Frontmatter carries the title, description and date, the body starts at
the first section rather than repeating the title as an h1, and the
maintainer note is an HTML comment — a blockquote would have rendered
"edit this in a PR" onto the published privacy page.
2026-09-09 16:14:42 +02:00
makiolaj 6f69a33514 docs: the Markdown is the policy, the site renders it
Reverses yesterday's framing. The policy is reviewed here like any other
change; the Astro page holds no prose of its own — the website build
checks this repository out beside itself and renders this file through a
content collection, so there is one copy of the text anywhere and drift is
impossible rather than merely detectable.

Both app repos are public on Codeberg, so the site needs no token to read
them and nothing has to run on the Codeberg side.
2026-09-09 16:12:00 +02:00
makiolaj a60b7236f3 docs: point at the published privacy policy
The policy is an Astro page in the website repo, not a file here — same
shape as Calendula's, which keeps no copy in its own tree at all. The
docs file says so and links both the published URL and the Astro source,
so nobody edits the wrong one; it stays the text of record only until
that page ships, since it is currently the only copy there is.

The README gains a Privacy section with the same link. It links the
published page only — the website repo is on the self-hosted Gitea and
readers of a public README cannot reach it.

Corrects ece4167's message, which said this file stays the policy's source.
2026-09-09 15:58:44 +02:00
makiolaj ece4167450 settings: the privacy row points at the site, not the repo file
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.
2026-09-09 15:44:28 +02:00
makiolaj 938103c0a9 settings: the Source row wears Codeberg's mark, not Gitea's
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.
2026-09-09 15:37:42 +02:00
makiolaj 5a79442731 settings: the hub is Calendula's hub
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.
2026-09-09 15:34:03 +02:00
makiolaj 265de47274 settings: four named groups, and every sub-screen in its own file
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.
2026-09-09 15:18:05 +02:00
makiolaj db6f2e1950 onboarding: a real first run, not one screen asking for notifications
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.
2026-09-09 13:49:18 +02:00
makiolaj bd16fc4c65 export: expressive destination buttons, and a receipt that says where it went
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.
2026-09-09 13:49:02 +02:00
makiolaj eb43f5d259 sync: a reminder set on one occurrence stays on that occurrence
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.
2026-09-09 12:21:29 +02:00
makiolaj 8154a9df36 sync: reminders survive the alarm ceiling, and a save is not undone by one
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.
2026-09-09 12:19:04 +02:00
makiolaj f7558ec181 sync: three store-side defects, and a launch loop
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.
2026-09-09 12:17:26 +02:00
makiolaj 097bc6ce9c sync: three recurrence defects the review found in the iCal layer
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.
2026-09-09 12:15:45 +02:00
makiolaj 1a0041f8fa sync: follow the redirects the JSON calls never followed, and keep partial fetches
Every client here is built with followRedirects(false), because DavResource
requires it — so the DAV calls follow by hand and the two OCS/JSON ones
followed not at all. A Nextcloud that canonicalises host or path with a
301 (apex to www, a trailing slash, a proxy) therefore turned the login
flow's start into a bare failure, its poll into an unexplained
SERVER_ERROR *after* the password was minted and the flow row deleted,
and the app-password revocation into the "attempted, and did nothing"
outcome its own KDoc exists to prevent. All three go through one
follower now, which re-issues the method and body — both routes answer
405 to a GET — bounds the hops, and applies the same downgrade rule
DavResource does.

The home set was resolved against the URL the principal PROPFIND was
aimed at rather than where it landed, though followRedirects rewrites
that location in place and probe() twenty lines above reads it back for
exactly this reason. A permanently moved principal answers a relative
href, which resolved against the old base names a path that 404s — and a
perfectly good account reports that it holds no calendars.

And a failing multiget batch discarded every batch before it: the
runCatching wrapped the whole loop rather than one request, so a 500 on
batch seven of ten threw away two hundred parsed resources and answered
Result.failure, and the collection re-downloaded everything next run. It
now keeps what it has and lets the unasked hrefs fall out as missing,
which the engine already counts rather than acting on. A run that got
nothing at all still fails.
2026-09-09 12:11:12 +02:00
makiolaj e4500e01b8 sync: three corrections from the review of this branch
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.
2026-09-09 11:57:44 +02:00
makiolaj c5e1dcfeb1 sync: a failed persist must not take the sign-in down with it
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.
2026-09-09 11:42:02 +02:00
makiolaj ead84c3195 sync: the login flow is written down before the browser gets it
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.
2026-09-09 11:39:21 +02:00
makiolaj 1119507585 sync: a device with no browser no longer strands the flow
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.
2026-09-09 11:34:50 +02:00
makiolaj b933705c84 sync: what adding and re-adding an account actually has to do
Re-authentication reported success for work it never did. The branch
took appPassword and dropped `found` and `selected` on the floor, so a
user whose account had 401'd walked the whole add flow, ticked
collections, and was shown Done — while no newly-ticked list was
created, and a moved principal or corrected username was discarded, so a
relocated account could never be repaired. It also never called
accounts.add, so an account deleted in Android Settings (nothing listens
for LOGIN_ACCOUNTS_CHANGED) stayed absent from Settings for ever while
syncing happily via WorkManager, and every later add answered
AlreadyExists. Unticked lists are still left attached, deliberately: the
picker pre-ticks what is *writable*, not what this account already
holds, so detaching would stop syncing a read-only share over a default
nobody chose.

Remove-then-re-add duplicated every list. remove() leaves them behind as
device-only lists on purpose and create() always inserted, so the user
got their old Personal full of tasks beside a freshly synced Personal
holding the same tasks from the server — the outcome the comment next to
it names as the one to avoid. A collection whose href is already on the
device is re-attached now, keeping its name, colour, ordering and tasks
and losing only a cursor that belonged to the account that is gone.
Rollback follows: it deletes what this attempt created and lets the FK
return a re-attached list to being device-only.

An account in the system with no Room row could not be removed from
inside the app — accounts.remove's result is ignored and find returns
null while the device is locked, so the pair can come apart, and every
later add then answered AlreadyExists with the accounts screen driven
off Room. That state is treated as an orphan to clean up.

create()'s tail is uncancellable, like remove()'s: a back gesture during
"Adding the account" left the row and its lists with no credential and
no system account, where the rollback never runs, needsSignIn is false
so re-auth will not fire, and every retry answers AlreadyExists.

remove() forgets the quarantine counts as well as the cadence cursors —
same keys, same globality, and a list re-attached to a new account would
otherwise inherit a resource that is skipped for ever.

And NeedsAuthentication's doc claimed the caller widens a credential
allowlist with its hosts. Nothing does, and nothing should without the
user's say-so; it says what the list is actually for.
2026-09-09 11:32:12 +02:00
makiolaj 00026e698b sync: four corrections to the add-account flow
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.
2026-09-09 11:28:08 +02:00
makiolaj 9c84b49fc9 sync: a deleted series and a deleted occurrence now reach the server
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.
2026-09-09 11:26:06 +02:00
makiolaj 80133d5cc8 sync: three defects in the vendored Digest handler
The Basic arm returned out of the challenge loop as soon as it found a
Basic challenge it had already tried. The handler is a network
interceptor, so Basic is always primed preemptively over HTTPS — the
abort therefore fired on the first 401, and a Digest challenge later in
the same header was never read. A Baikal or Apache front end offering
both and rejecting Basic at the app layer got no Digest answer at all,
and the account was marked as needing sign-in for good. Every challenge
is read before anything is decided now; giving up still happens, just
after the whole header has been looked at.

clientNonce and nonceCount sat on the companion object while SyncEngine
builds one handler per account, so two accounts syncing at once
interleaved their nc values. They are instance state now, and a new
server nonce restarts the count — RFC 7616 3.4.1 counts requests sent
with that nonce, starting at 1, and Apache's AuthDigestNcCheck answers
401 for a carried-over count.

qop values were split on "," and compared untrimmed, so
qop="auth, auth-int" quietly downgraded to auth and qop=" auth" matched
nothing — falling into the RFC 2069 branch, which emits no qop, nc or
cnonce and which an RFC 7616 server rejects outright.

Documented as changes 10-12 in PROVENANCE. The four static assignments
in upstream's digest tests now address the handler; nothing else in that
file changes.
2026-09-09 11:22:34 +02:00
makiolaj 210e394163 sync: close four holes in the transport and the change log
changes() dropped every per-resource status that was neither 2xx nor
404/410, so a per-object ACL answering 403 read as "unchanged" and the
row kept whatever it held until the next full reconciliation noticed the
ETag differed. The same reasoning error as fetch's, and the same fix
shape now exists next door: it is carried as a change with no validator,
which forces the multiget, and the multiget already grades a refusal.

An SRV target is not required to live inside the domain it was queried
for, and this ladder is walked over plain UDP DNS with no DNSSEC — then
walked again after a 401 with the authenticated client. Since the
credential is scoped to the typed address's registrable domain, an
out-of-domain target could only ever answer 401 anyway. Refusing it
leaves the well-known ladder on the typed domain, which is where a
correctly delegated install answers.

CalDavHttp, three of them. Accept-Encoding: identity takes OkHttp's
transparent gunzip out of the loop, so a proxy that gzips regardless
handed raw deflate to the iCalendar parser and a good resource was
quarantined as unreadable; the header the response carries is now
honoured. Prefer was set rather than appended, so a caller's own
preference was silently dropped on every write. And there was no
call-level ceiling: readTimeout is per-read, so a server trickling a
byte every 119 seconds held a sequential sync for the whole WorkManager
window and every later collection was skipped. Three minutes is well
above a full multiget batch on a slow link and well below the window —
c2166b9's note said the opposite, and the revocation still sets its own
budget because it needs seconds, not minutes.

The KDoc claimed the handler caches which scheme worked "so the
challenge is paid once", while authenticated() builds a fresh handler per
call. It says what it actually does now.
2026-09-09 11:20:28 +02:00
makiolaj ddcffa61b3 sync: three corrections to the reconciler, from the review pass
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.
2026-09-09 11:17:21 +02:00
makiolaj 41ffc75fc5 sync: only report the address we actually replaced
Four corrections to the login-flow work earlier on this branch, all
found in review.

The mismatch was computed by comparing hosts exactly while
reachableOrigin substitutes only across registrable domains. A server
answering nc.example.com for a poll endpoint on cloud.example.com is
used verbatim and correctly -- and we told the user we had replaced it
because it was unreachable, blaming a setting that was right. The
decision now has a name and says what it means: report a mismatch only
when the address we used is not the one that was claimed.

baseOf matched only the index.php spelling of the poll path, but
Nextcloud drops index.php from generated routes when
htaccess.IgnoreFrontController is on -- so a subdirectory install
answering /nc/login/v2/poll rebuilt the root as / and lost the prefix,
which is the loss that function exists to prevent.

browserFailed built a fresh step and dropped the mismatch note, which
is often the explanation for the failure it is replacing it with. And
the note is sticky by design, but it belongs to the address that
produced it: starting a new attempt now clears it, rather than carrying
a claim about one server's configuration into another's.
2026-09-07 23:28:08 +02:00
makiolaj 6aaa15b130 sync: make the add-account flow's own messages translatable
Fourteen English sentences were built in the ViewModel and rendered
verbatim as fatal, Working.message and both error fields, so they
shipped untranslated to every locale. Three more sites passed through
whatever text the server, the repository or a Throwable produced.
Seventeen in all, and three of the fourteen were added by fixes earlier
on this branch -- which is the argument for a type rather than a rule.

This is the defect Outcome.Cause was introduced to fix, and it only ever
covered the address step. AddAccountMessage does the same for the rest:
a plain Kotlin sealed type in the app, with the resource mapping beside
Cause's in the screen. The state fields carry it, so a literal is now a
compile error rather than something review has to catch. Nothing lints
for this -- HardcodedText reads XML layout attributes and this app has
none -- so the type is the only guard there is.

The passthroughs get causes of their own, following the rule
Outcome.Cause already states: PollResult.Failed carries RATE_LIMITED,
MAINTENANCE or SERVER_ERROR, keeping the 429/503 distinction that
flattening to one message would lose, and CredentialFailed carries
KEYSTORE_REFUSED or NOT_SAVED. Their reason and detail stay for logs and
are never shown. A server's words are untranslatable and often a bare
status line; a Throwable's are worse.

Twenty keys, base locale only -- Weblate owns the rest and picks them up.
The four tests that asserted on English prose now assert on the message,
which for the cross-domain one is stricter: it pins the host into the
argument instead of anywhere in a sentence.
2026-09-07 23:18:48 +02:00
makiolaj b7e5031777 sync: hand the password back on the retry path too
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.
2026-09-07 23:08:04 +02:00
makiolaj 5dfbe0fffc sync: hand back an app password the flow is not going to use
Nextcloud returns a minted password exactly once. Every path that left
the browser flow without saving an account dropped it: discovery failing
after approval, an account whose lists we cannot use, start over, the
back arrow, switching to a typed password, or leaving Settings
altogether. It stays valid on the server for ever, and every attempt is
named "Agendula (Android)" -- so a user retrying against a misconfigured
server ends up with six identical entries and no way to tell which one
their working account uses. They prune nothing, or prune the wrong one.

One owned field and one sink rather than a revoke per path: ten paths
today, and the eleventh would be forgotten. Ownership passes to the
account on Created and is released nowhere else without revoking. The
sink runs on the application scope, not viewModelScope -- androidx closes
that before onCleared, so a launch there never runs its body.

Revocation goes through a new revokeAt, which takes the OCS root
directly. ocsRootFor is principal-shaped and falls back to the bare
origin, so sending a login flow's server base through it would collapse
a subpath install's /nextcloud/ to / and DELETE a path that 404s -- the
silent no-op that function exists to prevent.

Also stops reporting a rejected credential as an empty account: a 401
after approval is either a home set outside the domain the credential is
scoped to, which retrying only mints another password for, or the server
having a moment. The registrable-domain check that decides this is now
shared with crossDomainHint, which had been computing "com" for
example.com and so never firing.
2026-09-07 22:59:39 +02:00
makiolaj c2166b944f sync: put the revocation's timeout on the request itself
withTimeoutOrNull around the revoke bounded nothing. The call parks on a
socket read that neither coroutine cancellation nor Thread.interrupt can
break, and withContext returns only when its block does -- so the
deadline passed and we waited anyway, for the shared client's own
budget: 30s per resolved address, doubled by the authenticator's retry,
plus up to 120s of read timeout. Removing a homelab account off the VPN
sat there for minutes with nothing visibly happening. Only closing the
socket ends it, which is what callTimeout does.

The budget moves to AppPassword.revoke, where the "best effort, must not
block the removal" contract is already written down, and where it can be
enforced. Not on the shared client: callTimeout covers the whole
exchange including the body, and a multiget of a large list over a slow
link legitimately runs long. CalDavHttpTest pins that decision.

The destructive tail is now uncancellable. It spans four stores that
cannot share a transaction, and the caller is a viewModelScope tied to
the Settings destination, so a couple of back gestures used to kill it
mid-sequence. Only the DataStore writes can observe cancellation -- every
Room DAO here is blocking -- so the landing point was cadence.forget:
the app password already revoked while the row survives holding it, and
the account asking the user to sign in again for a credential we
invalidated ourselves. Further in, the tasks are gone and the row stays.
The tail is bounded and sub-second, so finishing it always beats
stopping inside it.
2026-09-07 22:37:00 +02:00
makiolaj 7850bfc202 sync: check the reply's origin instead of keying on 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.
2026-09-07 22:26:36 +02:00
makiolaj 7fac4bd58d sync: match a multiget reply by the resource, not by its spelling
The request href is ours, written into the REPORT body verbatim; the
response href is the server's own spelling of it. Compared as raw
encodedPath, a server answering /my@dav.ics for a requested
/my%40dav.ics -- the case UrlUtils.equals exists for -- puts one
resource in unsolicited *and* in missing, drops its intact body, and
after three such runs quarantines it out of the download for good,
because nothing on that side can refund the count. Our own names never
carry an @, so this is about resources other clients created, which is
most of an existing collection.

The key is now scheme, host, port and the *decoded* path segments. A
list rather than a joined string, so /a%2Fb does not collide with /a/b;
with the host, so a same-path href from elsewhere cannot supply a body
for our row. The trailing slash still distinguishes, as UrlUtils.equals
also refuses to normalise it.

The syncer stops spending an unrefundable count on a guess: when the
server both omitted hrefs we asked for and volunteered hrefs we did not,
"omitted" and "we did not recognise its spelling" are indistinguishable,
so those are skipped rather than failed. A wasteful re-request is
recoverable; a permanently dropped task is not.
2026-09-07 22:17:06 +02:00
makiolaj 730a1eb88d sync: tell the user which address their server got wrong
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.
2026-09-07 22:09:17 +02:00
makiolaj 3a9f623d2a sync: report why discovery failed after a browser approval
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.
2026-09-07 21:59:20 +02:00
makiolaj fc7e520aa6 sync: talk to the host that answered the poll, not the one it named
The poll response's `server` was taken verbatim apart from its scheme.
That field and the poll endpoint come out of different generators in
Nextcloud: the endpoint honours overwrite.cli.url, while `server` is
built from the approving request's own protocol and Host header, which
respect X-Forwarded-* only once trusted_proxies is set. So the ordinary
docker-compose-behind-nginx install answers a perfectly good
https://cloud.example.com poll endpoint with "server":
"http://nextcloud:11000" -- a name the phone cannot resolve.

Discovery then fails, the account is never created, the user is told
the account has no task lists, and the app password is already spent
and never revoked. Retrying does the same thing again and leaves
another dangling entry in their device list.

reachableOrigin keeps scheme, host and port from the endpoint we just
polled successfully whenever the claimed origin sits outside its
registrable domain, and keeps the claimed path so subdirectory installs
still work. It compares one server-emitted origin against another,
never against what the user typed, so a correctly configured proxy is
untouched and a sibling host in the same domain still passes.

Coerced, never refused, per the rule the file already states: the
credential exists by now and the 200 is spent. The mismatch rides out
on Approved so the user can be told which setting is wrong.
2026-09-07 21:54:21 +02:00