166 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
makiolaj 89c3a5f66e sync: do not cache a challenge we refuse to answer
The cleartext gate sits on the emission, but the 401 branch cached the
Basic challenge before reaching it. So after refusing a plain-HTTP
challenge the handler still believed Basic was in play: the preemptive
block is skipped because it requires both caches empty, the refusal
repeats, and the 401 after that logs "Basic credentials didn't work last
time" about a credential that never reached the wire.
2026-09-07 21:45:44 +02:00
makiolaj 9f832686fb sync: refuse Basic over cleartext even when challenged
insecurePreemptive gated only the preemptive branch, so a plain-HTTP
server answering 401 with a Basic challenge still got the password in a
header every hop can read. The flag's name was accurate; its coverage
was not. The gate moves onto the Basic emission, covering both paths,
and the flag becomes insecureBasic.

Digest is left answered over cleartext: it never puts the password on
the wire, and refusing it would break a LAN server the day the
per-account cleartext opt-in ships.

Not reachable in the app today -- network_security_config forbids
cleartext outright and nothing passes allowCleartext -- but :caldav is a
plain JVM module meant for reuse, where neither of those holds.

Upstream's testBasic asserted this exact behaviour over http://, so it
is amended to https:// and the cleartext cases are pinned explicitly.
PROVENANCE change 9 records it as the one upstream test this port
changes rather than inherits.
2026-09-07 21:36:58 +02:00
makiolaj ede4205b7f sync: scope the credential by the public-suffix list
The auth handler decided which hosts may receive the password by
comparing the last two labels of their names. A server at
cloud.example.co.uk therefore scoped the app password to co.uk, one at
myhome.duckdns.org to duckdns.org, and a self-hoster at 192.168.1.10 to
"any address ending .1.10". The handler sends Basic preemptively, before
any challenge, so those hosts get the password unprompted on the first
HTTPS request.

It is reachable: ServiceDiscovery accepts an SRV target outside the
domain it queried, over plain UDP DNS. Scoped correctly, an on-path
attacker needs a certificate for a name inside the victim's own
registrable domain. Scoped to co.uk, they need one for a domain they
already own.

Now topPrivateDomain(), from the list OkHttp bundles, falling back to the
exact host where there is none -- null means no restriction here, so an
IP literal or localhost must not pass one through. The handler had to
change with the caller: it re-derives the domain per request, so fixing
only the caller withholds the credential from everything.

Still trusted: two hosts under one registrable domain share an owner.
That is what iCloud's caldav/pNN-caldav split needs, and narrowing
further costs it.
2026-09-07 21:35:10 +02:00
makiolaj d5ce24b673 sync: report a refused resource instead of losing it
A multiget answer that was neither a body nor an omission fell through
every bucket: seen, so not missing; no body, so not a resource. Nothing
counted it, the sweep left it alone because the listing still named it,
and downloadPhase asked for it again on every sync for ever -- the loop
quarantine exists to break.

FetchResult now carries `failed`, with the status the server actually
gave. Kept apart from `missing` because "refused" and "not mentioned"
are different facts: 404 and 410 are skipped, since the next listing
drops the href and the sweep purges the row, and counting them would
quarantine the resource out of that very sweep; 5xx and a bodiless
success are skipped as the server's own trouble; any other 4xx is
counted, being a judgement about this resource that repeats forever.

A per-property refusal -- a propstat with 403 around calendar-data --
is the usual shape of "you may not read this one object", and
Response.properties drops non-2xx propstats, so the code is read back
out of them rather than reported as a blank.

The two write-side guards had to learn this too. Both read a fetch that
came back Result.success as an answer, so a refusal now looked like
"somebody else's resource" (a second resource under one UID) and like
"the server has no validator" (an unconditional PUT over a concurrent
edit) -- the two things those guards were added to prevent.
2026-09-07 21:26:17 +02:00
makiolaj 83b4fb8e6e sync: do not write blind when the validator fetch fails
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.
2026-09-07 21:12:04 +02:00
makiolaj 21f56a78ae sync: do not rename a create when its verification fetch fails
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.
2026-09-07 20:58:20 +02:00
makiolaj 09656f6aa7 sync: drop a deleted occurrence once its PUT lands
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.
2026-09-07 20:45:18 +02:00
makiolaj 32a461ed91 sync: sweep the local list as it is after the download
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.
2026-09-07 20:19:11 +02:00
makiolaj b4a7fcb46e sync: what the on-device round found
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.
2026-09-07 18:41:45 +02:00
makiolaj 28b2423ad9 sync(chunk 5): attribution, revocation, compliance
The parts of chunk 5 that a build can verify. What is left needs a device or a
live server, and is listed in docs/SYNC-PLAN.md rather than guessed at.

- Attribution screen in Settings. dav4jvm is vendored, which makes MPL-2.0
  §3.2(a) ours rather than a dependency's, so its row points at PROVENANCE.md
  next to upstream. Hand-maintained: generators read POM metadata, which
  routinely names a non-SPDX licence and a licence URL that 404s.
- Revocation both ways. A 401 marks the account, stops it before the next
  request reaches the network, and takes it off the schedule from outside the
  worker — Nextcloud throttles then 429s per source IP, so a timer on a dead
  app password degrades the user's other clients. On removal, a bounded
  best-effort DELETE of the app password, or uninstalling never revokes it.
- Play compliance: docs/PRIVACY.md linked in the app, declaring Collected and
  not Shared; an option to delete the account's tasks from the device too;
  REQUEST_IGNORE_BATTERY_OPTIMIZATIONS confirmed absent.
- The server trap matrix as far as a protocol mock reaches, with four tests
  left @Ignore'd and their reasons written out.
- docs/SYNC-PLAN.md records what moves to floret-kit, so that branch is a file
  move rather than a rediscovery.

/code-review high raised 9 findings, all fixed. Three were serious: app-password
revocation was aimed at the principal URL and revoked nothing; opening the app
put accounts a 401 had stopped back on the timer, because KEEP does not keep
cancelled work; and the incremental path advanced the sync token past bodies a
failed multiget never applied. Also: four scalars were emitted twice whenever
their residue copy survived, which the round-trip corpus could not see.

Not done, and needing you: the live server matrix, releaseTest on device, the
restore-onto-a-fresh-device check, cert4android, and MKCALENDAR feature
detection. Chunk 2's on-device review is still outstanding.
2026-09-07 16:41:42 +02:00
makiolaj b25f8b231c sync(chunk 4): incremental sync and scheduling
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.
2026-09-07 16:18:09 +02:00
makiolaj b1189a4884 sync(chunk 3): the sync engine
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.
2026-09-07 15:25:49 +02:00
makiolaj 48fc7261f0 sync(chunk 2d): the account-add flow
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.
2026-09-04 18:37:16 +02:00
makiolaj ec50e0998c sync(chunk 2c): Keystore credentials, AccountManager, stub sync adapter
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.
2026-09-04 18:00:07 +02:00
makiolaj da42423fe2 sync(chunk 2b): CalDAV discovery and auth protocol
New :caldav module — MIT, plain JVM, api-depends on :dav. Separate from the
vendored MPL tree so the licences stay unmixed, and so "no Android types" is a
compile-time guarantee rather than a discipline. Chunk 2 split again: the
Android account layer (Keystore, AccountManager, Custom Tabs, account-add UI)
is 2c, with different verification and an on-device review.

- ServiceDiscovery: the RFC 6764 ladder. SRV priority/weight, TXT path=,
  non-443 ports, "." targets, well-known then root.
- CollectionClassifier: the two filters that are inversions of the obvious
  rule. An absent or empty supported-calendar-component-set means "supports
  everything", and classification is a positive test for CALDAV:calendar on an
  unordered set — excluding schedule-outbox would drop SOGo's main calendar.
- CalDavDiscovery: OPTIONS gate, principal, every home set, Depth-1 by name.
  A failing home set does not fail the account, and every home set failing is
  reported as an error rather than as an account with no lists.
- NextcloudLoginFlow: POST not GET, a User-Agent the user can recognise when
  revoking, 404-means-pending only, both URLs origin-checked, host mismatch
  carried rather than refused (reverse proxies are ordinary).
- PreemptiveBasicInterceptor, ServerQuirks.

dnsjava 3.6.3 (BSD-3) added: Android's DnsResolver is callback-only and cannot
do the TXT path lookup, and JNDI's DNS provider does not exist on Android.
Behind an interface, so every trap is tested with a fake and no network.

:dav gains change 6 — <D:unauthenticated/> is parsed rather than inferred from
a null href, which also fires on a merely non-conformant empty element.

52 tests here, 78 in :dav. SYNC.md's live-probed trap table is executable now.
2026-09-04 17:36:42 +02:00
makiolaj 5a81d3c2f5 sync(chunk 2a): vendor dav4jvm 2.2.1 as :dav
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.
2026-09-04 17:12:27 +02:00
makiolaj a30114efbb sync(chunk 1): VTODO mapper with an unknown-property round-trip
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.
2026-09-04 16:45:03 +02:00
makiolaj fd6c302c17 docs(sync): split the sync work into five chunks
SYNC.md decides; it doesn't sequence. SYNC-PLAN.md turns its ~40 scattered
findings into five chunks — mapper, auth/discovery, engine core, incremental
sync + scheduling, hardening — each with a done-when and the traps it must
not get wrong.

Closes SYNC.md's three open questions: vendor dav4jvm/cert4android at our
Java 17 target, server-wins-and-report on 412, External mode stays.
2026-09-04 16:15:45 +02:00
Jean-Luc Makiola 8f735ffce7 feat: our own task store, and the frontend for it (#10)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/10
2026-09-04 15:54:34 +02:00
makiolaj fdbc236ab4 docs(sync): bring SYNC.md in line with owning the store
The document was written against the vendored provider and still said the
storage question was settled that way. Owning the store answered several
of its open questions and deleted others outright, so the corrections are
marked inline the way the rest of the file marks them, rather than
quietly rewritten.

Closed: the Local->Synced migration (account_id is a nullable FK, so
attaching an account is an UPDATE), the recurring-completion model (the
store writes model (a), RECURRENCE-ID overrides sharing the master's
UID), the Auto Backup / cleanUpLists data-loss path, and the lib-recur
version trap. Phase 0's UIDs-at-creation and backup safety are shipped.
The provider-mechanism table is kept as External-mode history rather than
deleted — that code still runs in OpenTasks and tasks.org.

Effort restated: 11.5-15 weeks minus the 2.5-4 owning the store removes,
so roughly 8-11. Also states plainly at the top that no sync code exists
and that ICalendarWriter is the export half of the mapper only.
2026-09-04 15:53:01 +02:00
makiolaj ec2e2eb59d feat(settings): storage picker and export screen
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.
2026-09-04 15:37:48 +02:00
makiolaj 9ff6027e50 chore: stop tracking CLAUDE.md
The file is machine-specific rather than anything the project depends on:
the ARM64 box64 `aapt`/`aapt2` wrappers it documents live outside the repo,
and the rest is on-device working rules. Nothing in the tree links to it.

It stays on disk and is now ignored, so it keeps working locally without
riding along in the branch.
2026-09-04 14:09:25 +02:00
makiolaj fcee1d1736 feat(lists): manage lists in the app, and fix four store defects
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
2026-09-04 13:56:52 +02:00
Jean-Luc Makiola d7131087cb Merge pull request 'release: cut 0.4.0 — ship German and Brazilian Portuguese' (#9) from release/v0.4.0 into main
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m30s
Renovate / renovate (push) Failing after 40s
2026-08-31 19:02:24 +02:00
makiolajandClaude Opus 5 dfb0a694de release: cut 0.4.0 — the first translated release
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
2026-08-31 18:27:25 +02:00
makiolajandClaude Opus 5 79970b5c65 l10n: make German and Brazilian Portuguese selectable
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
2026-08-31 18:27:14 +02:00
Jean-Luc Makiola 5b31067e43 Translations update from Weblate (#8)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/8
2026-08-31 18:19:53 +02:00
Bernardo Krzysczak c69ff049a0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (182 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/pt_BR/
2026-08-21 07:41:09 +00:00
Bernardo Krzysczak 81220d53d1 Added translation using Weblate (Portuguese (Brazil)) 2026-08-20 13:18:29 +00:00
makiolaj b3091a5aa3 Translated using Weblate (German)
Currently translated at 98.9% (180 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-18 01:04:09 +00:00
Weblate b1bde2fab5 Translated using Weblate (German)
Currently translated at 98.9% (180 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-18 01:04:09 +00:00
makiolaj abc381ca24 Translated using Weblate (German)
Currently translated at 98.8% (177 of 179 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-14 13:22:24 +00:00
Weblate 1fbd91fe82 Translated using Weblate (German)
Currently translated at 98.8% (177 of 179 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-14 13:22:24 +00:00
makiolaj c2087de639 Added translation using Weblate (German) 2026-08-14 13:22:24 +00:00
makiolaj faee90f8b1 docs: note the ARM64 box64 aapt setup and the exit-code trap it causes 2026-08-13 17:33:36 +02:00
makiolaj 90140112bb fix(store): three defects the instrumented suite found on first run
- forking an occurrence copied the master's alarm row id and hit the
  primary key; replaceForTask now clears it
- a sub-second DTSTART made lib-recur emit the anchor and its truncated
  self, doubling a series' first occurrence; floor to the second, which
  is all RFC 5545 DATE-TIME carries
- room-testing needs kotlinx-serialization 1.8+, but consistent
  resolution pinned androidTest to the app's 1.7.3

Tests: 52 pass on device.
2026-08-13 17:23:31 +02:00
makiolaj 1d4fe5b301 fix(reminders): arm reminders again in our own store
Regression from deleting the provider. sync() gated on
providerResolver.resolve() != null, and OWN resolves to no provider by
design — so from that commit no due reminder was ever armed in what had
just become the default mode, and clearAll() cancelled any that survived
the upgrade.

The gate is now ProviderResolver.canReadStore(): OWN is always readable,
and only EXTERNAL can fail, for the two reasons it ever could. Putting
the decision on the resolver rather than inside the scheduler is what
makes it testable at all — ReminderScheduler needs Context and
AlarmManager, which is why nothing caught this.

Also brings ARCHITECTURE.md and ROADMAP.md in line with the branch: one
module, OWN/EXTERNAL, the four Room tables, expansion at read time, the
import and startup gate, and the manifest surface that no longer declares
a provider or any permission of its own.
2026-08-13 16:46:31 +02:00
makiolaj 5abcbfc956 test(store): migration harness, restore path and a 5k-task check
Phase 6 of docs/OWN-STORE.md. MigrationTestHelper is wired against the
committed v1 schema, so the first real migration only has to add its own
case; the class KDoc says where it goes. app/schemas/ is added to the
androidTest assets — the schema location comes from the KSP arg, not the
Room Gradle plugin, so nothing wired the test assets automatically.

The restore tests state the WAL premise directly rather than around it: a
backup of the .db alone must lose whatever is still in the -wal, carrying
the sidecars must keep it, and checkpointing first must make the .db
alone sufficient. If the premise is wrong the first test fails instead of
passing vacuously.

Performance: 5,000 tasks and 20 FREQ=DAILY series — daily on purpose, so
the per-series occurrence cap is the case being measured — through one
full smart-list read. The ceiling is loose and the numbers are printed,
because nobody has run this on hardware yet.

Also fixes a lint error I introduced in the backup rules two commits ago.
Naming any <include> makes everything else excluded by default, so the
<exclude> for tasks.db.imported sat under no included path and
FullBackupContent rejected it — lintDebug has been failing at HEAD since,
and CI runs it.

The same defect had a second, quieter half: those explicit includes had
silently stopped DataStore being backed up at all, since it was only ever
covered by the old file's "everything by default". Settings are listed
back in explicitly.
2026-08-13 16:43:13 +02:00
makiolaj 1ed192f150 feat(store)!: delete the vendored dmfs provider
Phase 5 of docs/OWN-STORE.md. The :provider module goes — 84 Java files,
14,555 lines, its <provider>, its two custom permissions, its 13
translated strings and its three dmfs runtime dependencies. Room has been
the default since the previous commit and every v0.3.x install has been
imported, so nothing reads it any more.

StorageMode.LOCAL is gone with it; OWN and EXTERNAL are what remain.
ProviderResolver narrows to what it was always really for — discovering
external providers — and answers null in OWN mode, where there is no
authority to resolve. Callers that need to tell that apart from "External
with nothing installed" ask mode(). ProviderStatus is unconditionally
READY in OWN mode: the permission gate only ever applied to External, and
that is now visibly true rather than a special case inside it.

A stored LOCAL is read as OWN rather than as an unparseable value. Left
to fall through to autoMode, someone who had explicitly chosen local
storage while also having OpenTasks granted would have been sent to
OpenTasks instead.

ProviderChangeReceiver's manifest filter drops our own authority — safe
now, because nothing of ours broadcasts ACTION_PROVIDER_CHANGED. In OWN
mode Room's InvalidationTracker covers foreground changes and nothing
outside the app can change our data. When SYNC.md phase 3 lands, the sync
worker must call ReminderScheduler.sync() itself; that is the replacement
for the broadcast and it belongs in the sync work.

lib-recur stays as a direct dependency and is still Apache-2.0 dmfs, so
the attribution is still owed — now as a normal third-party dependency.
provider/PROVENANCE.md is replaced by a postscript in STORAGE-DECISION.md
recording that the fork existed, why, and the one detail that still binds
us: tasks.org is DB 22 and has no is_recurring, so TaskMapper must keep
deriving recurrence from rrule/rdate.

BREAKING: the de.jeanlucmakiola.agendula.tasks authority and both custom
permissions are gone. Anyone who pointed DAVx5 or another app at that
authority loses it; External mode is the answer. Needs calling out in the
release notes.

Verified: the APK declares no ContentProvider, no custom permission and
no agendula.tasks authority, and carries no dmfs provider classes.
2026-08-13 16:33:19 +02:00
makiolaj 76f9ae6780 feat(store): import the dmfs database and make Room the default
Phase 4 of docs/OWN-STORE.md. OneShotImport reads databases/tasks.db
directly — read-only, no provider, no ContentResolver — and writes it
into Room in one verified transaction. dmfs row ids are remapped in two
passes, because a parent can carry a higher _id than its child.

The archive happens before the import, not after, and the import always
replaces. That is what actually closes the crash window the plan's "flag
*and* rename" is meant to cover: renaming last leaves the flag unset with
tasks.db still in place, so the next launch imports a second copy. In
this order every kill point re-enters correctly.

Recurrence overrides are carried across as master_id/recurrence_id rather
than ignored. dmfs stores them as ordinary rows sharing their master's
_uid, so importing one as a second master would collide on the unique
index and abort the whole import.

autoMode now answers OWN, and a stored LOCAL reads as OWN — after the
import the dmfs file has been renamed away, so someone who chose local
storage explicitly must land on the store their data is now in.

StartupGate holds the first store read until the mode has landed and the
import has run; showing an upgrading user an empty app is the worst thing
this migration could do. The backup rules take the database with its WAL
sidecars and exclude the archive, and the app checkpoints on ON_STOP.
2026-08-13 16:24:19 +02:00
makiolaj 2e915da588 feat(store): implement TasksDataSource over Room
Joins phases 1 and 2 and covers phase 3's semantics. RoomTasksDataSource
implements all 14 seam methods; a StorageMode-routing delegate picks it
or the provider per call, since the mode is a setting the user can change
while the process lives.

There is no instances table, so a series is expanded at read time by
RecurrenceExpander and any RECURRENCE-ID override is substituted for the
occurrence it replaces. A timed series carries each occurrence's length
across; a due-anchored one has no start to offset from, so the anchor is
the due date — matching how the provider instantiated the same series.

Editing one occurrence writes a RECURRENCE-ID override sharing the
master's UID (RFC 5545 model (a)). The provider's Detaching.java forked a
brand-new task with its own UID instead — model (d), the one least
compatible with CalDAV. We inherited that without ever choosing it; this
is the choice.

TaskFormWriter states the completion rules directly instead of working
around the provider: progress and status now move together in both
directions, so a task can no longer strand itself "done at 75%".
TaskWriteMapper keeps the workarounds for External mode.

Deletes are hard when the list has no account and tombstones when it
does; master_id cascades, so a deleted series takes its overrides.
2026-08-13 16:16:17 +02:00
makiolaj 829a27da82 feat(recurrence): expand a series in memory over lib-recur
Phase 2 of docs/OWN-STORE.md, the engine half. RecurrenceExpander turns a
stored rule set into its occurrences at read time — no materialised
instances table, so none of its staleness bugs exist. Each occurrence is
returned as its RECURRENCE-ID anchor, which is what the seam now
addresses occurrences by.

Expansion is bounded two ways: the window end, and a hard occurrence
ceiling. The iterator is fast-forwarded to the window start first, so a
FREQ=MINUTELY series anchored years back doesn't scan millions of
instances to emit one.

Three things lib-recur 0.12.2 forced. RecurrenceSet.iterator injects the
start itself, so DTSTART is in the set for free and EXDATE can remove it
(RFC 5545 §3.8.5.3). Its window end is exclusive. And a floating UNTIL
against a zoned start throws, so the UNTIL's local fields are re-read in
the series zone — the vendored provider worked around the same thing via
TimeZone.getDefault(), which isn't deterministic.

Malformed RRULE/RDATE/EXDATE values are dropped, not thrown: a task with
an unparseable stored rule still has to appear.

38 tests. Multi-occurrence expansion has no provider behaviour to compare
against, so the reference is RFC 5545 directly — daily/weekly/monthly/
yearly, COUNT, UNTIL, a Europe/Berlin DST boundary, all-day series pinned
to UTC midnight, RDATE, EXDATE, and an unbounded rule hitting both bounds.
2026-08-13 16:09:30 +02:00
makiolaj fd8363e356 feat(store): add the Room schema, DAOs and exported schema
Phase 1 of docs/OWN-STORE.md. Four tables — task_lists, tasks,
task_alarms, accounts — with the indices, cascades and converters the
plan specifies, plus a DAO per table and the v1 schema JSON committed for
migration testing.

Masters and RECURRENCE-ID overrides share the tasks table, so the unique
index is on (list_id, uid, recurrence_id): an override shares its
master's UID, and a key without recurrence_id would reject exactly the
rows recurrence depends on. SQLite treats NULLs as distinct, so that
index only enforces the override half; the master half is intent, noted
where the index is declared.

PRIORITY is stored as the raw iCalendar integer rather than through the
Priority enum. Priority buckets 1..4 into HIGH, so a converter would
rewrite a server's PRIORITY:3 as 1 before it ever reached disk — the
bucketing belongs in the mapper. Status keeps its converter: that mapping
is total.

Two cascades the plan left unstated: deleting a list takes its tasks,
deleting an account only detaches its lists.

Instrumented tests cover read-back, the cascades and the unique index —
app/src/androidTest is new.
2026-08-13 16:09:11 +02:00
makiolaj 96a2995df4 build: add lib-recur to :app, and a dmfs v23 fixture for the import
The own store expands recurrences itself, so lib-recur is a direct
dependency now rather than something :provider drags in. Still pinned at
0.12.2 — 0.16.0 removed RecurrenceSet.

scripts/make_import_fixture.py writes the tasks.db the one-shot import
will be tested against: the provider's DATABASE_VERSION 23 schema, seeded
with the cases the import has to get right (a task with no UID, a deleted
row, a recurring series, an all-day task, a subtask, an alarm property,
and a list under a real CalDAV account). The provider is being deleted, so
a fixture is the only way to keep testing against the schema it wrote.
2026-08-13 15:58:52 +02:00
makiolaj 11b20faf82 refactor(data): address occurrences by (taskId, occurrenceStart)
Phase 0 of docs/OWN-STORE.md. Prepares the seam for the Room store while
the provider is still the store.

Task.id (the materialised instance row id) is gone; Task carries
occurrenceStart, its RECURRENCE-ID anchor, instead. updateInstance takes
(taskId, occurrenceStart, form) and AndroidTasksDataSource maps that back
to an instance row itself, so the provider path exercises the new
signature before Room exists.

Lazy-list keys move to Task.occurrenceKey. Two occurrences of one series
can appear in the same list once expansion is ours, and taskId alone
would collide there.

domain/Models.kt stops importing TasksContract — status, priority and
local-account constants now live in domain. StorageMode gains OWN as a
third value; LOCAL keeps meaning the dmfs provider until it is deleted.

Room 2.8.4 and room.schemaLocation added to the build.
2026-08-13 15:55:24 +02:00
makiolajandClaude Opus 5 8c3cbcf928 docs: fix seven defects in the own-store plan
Reviewed the plan against the code it describes. Two design holes and
five errors.

Instance identity was the real one. The plan deleted the materialised
instances table without saying what replaces the instance row id, which
TasksRepositoryImpl.updateTask passes to updateInstance and which
ListsScreen keys a lazy list by. Two occurrences of one series can
appear in the same list, so taskId alone is not unique and a hash of
(taskId, start) can collide - as a Compose key that is a visible bug.
Task.id is dropped for occurrenceStart, updateInstance takes
(taskId, occurrenceStart, form), and External mode maps back to a real
instance row with one query. This is the single seam change, and the
plan's "TasksDataSource unchanged" claim was wrong.

Local lists had no account name. TaskList.accountName is non-null,
ListsViewModel groups by it and ListsScreen renders it as a section
header, so a null account_id must still report "Local".

The unique index was wrong: overrides share their master's UID, so
unique (list_id, uid) would reject the rows the recurrence design
depends on. It needs recurrence_id in the key.

Phase 0 broke background reminders. It dropped our authority from
ProviderChangeReceiver's manifest filter while the provider was still
the store, and renamed StorageMode.LOCAL to OWN four phases before OWN
meant Room. Both moved to phase 5.

Parity against the provider was overclaimed: the provider materialises
one occurrence, so multi-occurrence expansion has nothing to compare
against and is tested against RFC 5545 directly.

The phases sum to 6.5-7 weeks, not the 6-6.5 stated, and the difference
from STORAGE-DECISION.md's 4.5-6 is now explained rather than left as a
contradiction.

Gaps closed: WAL vs Auto Backup (checkpoint on ON_STOP, sidecars in the
backup rules, tested in phase 6), cascade rules for master_id and
parent_id, Instant type converters, a rollback path that re-runs the
import from tasks.db.imported, the release note for dropping the
authority and its permissions, and ICalendarWriter.uidFor's synthesis
branch becoming External-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:30:37 +02:00
makiolajandClaude Opus 5 13cb27b2ab docs: decide to build our own store and delete the vendored provider
The vendored dmfs provider was kept on the grounds that it hands us the
sync bookkeeping for free. The phase-1 sync audit measured that
bookkeeping and found most of it broken, absent, or unusable: _DIRTY not
set on delete, no home for a per-collection sync token, read-only
collections inexpressible, ACCOUNT_TYPE write-once so enabling sync is a
full migration, and cleanUpLists able to delete a user's lists after a
backup restore. Sixteen findings are provider-imposed rather than
platform- or protocol-imposed.

Costing the alternative showed the swap is far smaller than assumed.
TasksDataSource is already a 14-method, domain-shaped interface;
exactly one file above the data layer references TasksContract. The
work is a second implementation behind an interface built for it, not a
rewrite. Against ~5 weeks to build, owning the store removes 2.5-4
weeks from the sync plan, and 8,200 of the vendored 14,555 lines are
things we would never write - 23 migrations from a 2013 schema, 798
lines of full-text search the app has zero call sites for, and 1,581
lines of a type-safe layer over ContentValues that Room deletes.

External mode (OpenTasks, tasks.org) is unaffected and keeps every
file that describes somebody else's schema.

STORAGE-DECISION.md is the reasoning; OWN-STORE.md is the architecture
and the six-phase plan. :provider stays in-tree until phase 5 so
recurrence parity can be tested against it before it goes.

Also corrected here: the provider's JVM test count (51 -> 56, measured
from the test-results XML) and a fourth site of the debunked "switching
sync on is never a migration" claim, in StorageMode.kt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:46:07 +02:00
makiolajandClaude Opus 5 98ed339346 docs: bring the docs in line with what shipped
STORAGE-AND-SYNC.md asked for a follow-up pass on ARCHITECTURE.md §7 and the
ProviderResolver KDoc, which still defined Posture B as "bundle OpenTasks and
find org.dmfs.tasks first" — the plan that was withdrawn as a dead end. That pass,
plus the status the doc left open.

ARCHITECTURE.md now describes the app as built: two modules, the storage-mode
table with the permission each needs, the autoMode rule and why it keys on
holding an external provider's permission, the two-not-three mode vocabulary, and
a manifest section that says what :provider contributes and what is deliberately
absent (GET_ACCOUNTS, INTERNET). §7 records squatting the dmfs authority as a
dead end rather than a road not yet taken, so it doesn't get re-proposed.

ROADMAP.md turns "Posture B, later" into what actually landed and lists what
didn't: the frontend surfaces, the DAVx5 issue, the sync adapter, and device
verification. Two open decisions resolved and struck through — the authority
choice, and recurrence-aware editing, which fix/provider-interaction-review made
stale.

STORAGE-AND-SYNC.md gets per-step status. Open question 3 ("does it work with no
account?") is answered, with the caveat that the test proving it is Robolectric
and skips on ARM64 — answered by construction, not yet on a device.

PLAN.md gets a banner. It's the original design document and still holds the
reasoning behind the layering, but two of its premises are overturned and it
should not be read as current.

README.md was telling users they need a tasks provider installed. They don't, and
that's the headline feature: a table of where tasks can live, that our provider
coexists with OpenTasks rather than replacing it, and that everything exports as
standard .ics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 21:32:17 +02:00
makiolajandClaude Opus 5 c5041d3f29 feat(export): write task lists out as iCalendar
Step 3 of docs/STORAGE-AND-SYNC.md. Now that our own provider holds the data in
the app's private storage, a Local-mode user's tasks exist in exactly one place
and uninstalling deletes them — on Play, where most people will never have a sync
engine, that is the majority case. So export is a v1 feature, not a nicety.

One .ics per list, because a list is a CalDAV collection and that is the unit
other clients understand; folding everything into one file would flatten the
lists away, and list membership is not recoverable from a VTODO afterwards.
ExportWriter can put them in a folder (ACTION_OPEN_DOCUMENT_TREE) or a single zip
(ACTION_CREATE_DOCUMENT). No storage permission either way — SAF hands us a Uri
the user picked.

Two things needed care:

Export reads the tasks table, not the instances view the rest of the app reads
from. In the instances view a recurring task appears once per occurrence with its
times resolved and no rule attached, so exporting from there would write the same
task fifty times and lose the RRULE that generated them.

And local tasks have no UID. The dmfs provider only lets a sync adapter assign
one, so in Local mode every task arrives with _uid null — and a VTODO without a
UID is both invalid and un-mergeable, meaning a re-imported backup would
duplicate every task rather than match it. ICalendarWriter synthesises one from
the row id, stable across exports and tagged so it is recognisable as synthetic.

Times go out in UTC rather than with a TZID. Emitting TZID obliges us to emit a
matching VTIMEZONE with its transition rules, and a TZID referencing an absent
definition is what actually breaks importers. All-day values keep VALUE=DATE, the
only form that survives a timezone change intact.

The writer is pure Kotlin with no Android in it and is covered by 40 tests —
line folding counted in octets and never splitting a UTF-8 sequence, TEXT
escaping, forward references from a subtask to a parent later in the file, and
CRLF endings. An export is only as good as its ability to be read back, and
nothing about a malformed .ics is obvious until someone needs the backup.

The SAF plumbing is marked in the storage doc as floret-kit material. Kept
app-local for now on the kit's own stated principle of not extracting before a
second consumer exists; the seam is in place, so moving it is a file move.

Backend only — no UI yet; that comes with the frontend pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 21:27:36 +02:00
makiolajandClaude Opus 5 f978c3727c feat(provider): ship our own task store, vendored under our own authority
Steps 2 and 3 of docs/STORAGE-AND-SYNC.md. Agendula stops depending on a tasks
provider app being installed: it now carries one.

The module

New :provider — the dmfs task provider 1.4.2 (Apache-2.0, DB 23), vendored
in-tree, renamed to authority de.jeanlucmakiola.agendula.tasks and permissions
de.jeanlucmakiola.agendula.permission.*. It coexists with OpenTasks and
tasks.org rather than replacing them; nothing collides with org.dmfs.*, so both
can be installed at once. The contract shape is untouched — same tables, same
columns — because that is what our data layer and every CalDAV engine already
speak. We own the namespace it lives in, not the schema.

Vendored rather than depended on because the permission names are hardcoded in
the upstream AAR's manifest and cannot be renamed in a prebuilt artifact; in-tree
also satisfies F-Droid's from-source rule. provider/PROVENANCE.md records the
upstream commit and every deviation, each marked with an AGENDULA CHANGE comment
at the site so the list and the code cannot drift apart.

The change that matters most is the account cleanup. Upstream holds GET_ACCOUNTS
and deletes any task list whose account it cannot see. We dropped that permission
— we only ever need our own accounts, which are visible without it — but an
account we cannot see is indistinguishable from one that was removed, so left
alone the provider would quietly delete synced lists. Cleanup is now restricted
to account types this package authenticates itself, which is currently none.
ProviderAccountCleanupTest pins that, and answers open question 3: the local path
works with no account present at all.

Also required by targetSdk 36, none of which upstream faced at 29:
FLAG_IMMUTABLE on the notification PendingIntent, an inexact-alarm fallback so a
revoked SCHEDULE_EXACT_ALARM cannot kill the app on a timezone change, and an
explicit android:exported on the receiver.

Storage modes

ProviderResolver gains a StorageMode: LOCAL (our provider) or EXTERNAL (an
installed one). Not a third SYNCED value — synced is LOCAL with an account
attached, which is derived state, and modelling it as a separate store would
imply switching sync on is a migration. It isn't.

When the user has not chosen, the tell is whether we already hold an external
provider's runtime permission. That permission is dangerous-level, so it can only
be there because an earlier version asked and they agreed — the signature of an
existing Posture A user, who must not be dropped onto an empty store. Fresh
installs get local-first.

hasPermission now short-circuits for our own provider: same-uid access bypasses
the check outright, so ProviderStatus.NEEDS_PERMISSION can no longer fire in
Local mode. That was the work item the storage-and-sync doc called for. The
resolver's platform calls moved behind ProviderEnvironment so the decision — the
part that loses people their data if wrong — is unit-tested on the JVM.

Verified: 51 vendored provider tests pass, app tests pass, lintDebug and
assembleDebug clean. ProviderAccountCleanupTest skips on ARM64, where Robolectric
has no SQLite backend, and runs on x86_64 CI. Not yet exercised on a device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 21:19:52 +02:00
makiolaj 3d54a896ce Merge branch 'fix/provider-interaction-review'
Corrects the tasks-provider interaction end to end (see 47cf99a). Merged as
step 1 of the storage-and-sync sequence in docs/STORAGE-AND-SYNC.md: it touches
the same permission flow that the :provider vendoring is about to change, so it
lands first.

The floret-kit submodule pointer keeps main's e047a2b, which already contains
the branch's 396e538.

# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt
2026-08-02 20:53:55 +02:00
Jean-Luc Makiola c47653c9cb Merge pull request 'Use floret-kit's shared snack chip' (#6) from feat/shared-snack-chip into main
Renovate / renovate (push) Failing after 43s
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-08-02 20:46:11 +02:00
makiolaj e639e250b7 Use floret-kit's shared snack chip
The undo chip moves into the kit so Calendula's reschedule confirmation is
the same component. Re-pins the kit onto the branch that carries it.
2026-08-02 20:45:39 +02:00
Jean-Luc Makiola 58a50512bf docs: settle the storage and sync direction (#5)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/5
2026-08-02 17:58:13 +02:00
Jean-Luc Makiola 1add1fcadb chore: re-pin floret-kit and pass allDay at the date call sites (#4)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/4
2026-08-02 17:57:58 +02:00
Jean-Luc Makiolaandmakiolaj 217d5d7afd Move the canonical forge to Codeberg, and add Renovate + Weblate (#2)
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Renovate / renovate (push) Failing after 43s
Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline. Ports the setup Calendula already runs on, adapted where Agendula genuinely differs.

**This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home.

### 1 · Workflows split by directory

Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync:

| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them |

The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API.

CI also gains three fixes: an explicit `SKIP_RE` skip-list that names the build-relevant files in the log, base-ref normalisation, and a fully-qualified `android-actions/setup-android` — Codeberg resolves bare `uses:` refs against `data.forgejo.org`, which does not carry that action.

### 2 · Three release-pipeline safety changes

- `detect` and the Renovate job get an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running these on the contributor-facing runner, without secrets.
- `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not.
- **The Codeberg publish step pushes the tag itself** instead of waiting for it to arrive by mirror. That wait was correct while Gitea mirrored *to* Codeberg; under Codeberg-canonical the mirror runs the other way and it would never resolve. Attaching the release to an already-pushed ref (no `target_commitish`) is what avoids the empty-bodied 500s, and the create call retries with backoff because Codeberg 500s on a tag it has only just received. The step stays **fail-loud**, not `continue-on-error` — it reported green through 0.2.1–0.3.2 while never once publishing, and that must not be possible again.

### 3 · Renovate

`renovate.json5` plus a Gitea-side job targeting Codeberg's API. `managerFilePatterns` covers **both** workflow directories, so the pinned Renovate image tag and the action versions in either file keep getting bumped.

Needs two new Gitea secrets: `RENOVATE_TOKEN` (Codeberg bot, repo read/write + PR scope) and `GITHUB_COM_TOKEN` (read-only github.com PAT, for changelog lookups).

### 4 · Weblate

A parity check (`scripts/check_translations.py`) runs on every PR without a path filter, so the required `Translations` status is always reported. Partial translations are expected, so `MissingTranslation` and `MissingQuantity` become informational — `ExtraTranslation` stays fatal. Agendula had no `lint` block at all, so the first locale to land would otherwise have failed the build.

**Settings → App language** now opens a picker carrying a "Help translate" header. That is why it drops floret-kit's `LanguagePickerRow` for a local row: the shared recipe has no `header` slot, and the framing is app-specific rather than a family primitive.

### 5 · Links repointed

In-app Source / License / report-issue URLs, F-Droid metadata, README (now with a Codeberg CI badge), and the docs.

`floret-kit` follows suit — `.gitmodules` points at `codeberg.org/jlmakiola/floret-kit`, so a clone no longer needs to reach the personal Gitea instance to resolve it. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases still resolve.

### 6 · Housekeeping

Drops `release-notes.md` — a release-pipeline scratch file that got committed — and gitignores the five others the release job writes into the workspace.

### Not in this PR

The Codeberg → Gitea push mirror, the Weblate component, and the Codeberg bot account (all browser-side). Until the mirror is flipped, merging this does **not** reach the Gitea runner.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/2
2026-08-01 16:34:20 +02:00
makiolajandClaude Opus 4.8 47cf99af32 fix(data): correct the tasks-provider interaction end to end
A review of every path through the OpenTasks/tasks.org ContentProvider,
prompted by edited due times reverting. Four independent defects produced that
one symptom, plus several unrelated ones alongside.

Edits reverting
- The edit form was bound from a LaunchedEffect in the nav host while its
  ViewModel survives on the back stack, and bindEdit replaced state wholesale.
  MainActivity declares no configChanges, so any Activity recreation (rotation,
  theme/font/display-size change, split-screen, unfolding) re-fired the effect
  and overwrote in-progress edits with the stored row. Guarded with a `bound`
  flag; picker state moved to rememberSaveable so an open picker also survives.

All-day handling
- All-day items are date-only in iCalendar and belong at UTC midnight with a
  null tz. The app wrote *local* midnight, so in Berlin an all-day task drifted
  back a day on every save cycle, corrupting anything synced. Rendering had the
  mirror bug, so the two cancelled out locally and hid each other.
- Toggling the all-day switch flipped the flag but left the timestamp, so an
  all-day task toggled off read back as 02:00 — another apparent "time reset".
- New domain/AllDayTime.kt owns the two conventions and the conversion between
  them; the picker, the write mapper and the toggle all go through it.

Provider write contract
- DUE and DURATION are mutually exclusive and the provider validates the merged
  row, so saving a due date onto a task that carried a duration threw
  IllegalArgumentException — the save simply failed. DURATION is now cleared
  alongside every time write.
- A recurring task's start/due are read from the instances view, and writing
  them back to tasks/<id> re-anchored the whole series. Updates now go through
  instances/<id>, where the provider forks an override instead.
- Recurrence is derived from rrule/rdate rather than the is_recurring column:
  that column only exists from OpenTasks 1.4.0 (DB 23) and is absent on
  tasks.org's bundled provider (DB 22), where it would report every recurring
  task as one-off and send its edits to the anchor.

Reminders
- The per-task Reminder field in the edit form was inert: never persisted,
  never read back, and REMINDER_WITHOUT_DUE could block a save over a value
  that was discarded regardless. Leads are now stored as Alarm property rows
  and preferred over the per-list/global setting. Written before the task
  update so a recurrence fork copies them onto the override. Note the provider
  fires nothing itself — ReminderScheduler still arms the alarm.
- Reminders were keyed by task id over rows read from the instances view, so
  .toMap() collapsed a recurring task to one arbitrary occurrence (the query is
  unsorted). Now keyed per occurrence, with request codes and intent data to
  match. Missed reminders within 6h fire once on boot instead of being dropped.

Robustness
- Four terminal `catch`es killed their upstream on the first provider failure.
  SettingsViewModel is collected in setContent above the permission gate for the
  Activity's lifetime, so a pre-grant SecurityException left the list picker
  empty until the process restarted. Replaced with capped-backoff retry.
- lazyChildren had no catch at all; an exception escaped stateIn past
  viewModelScope's SupervisorJob and crashed the process.
- Observer registration is all-or-nothing (the second register throwing leaked
  the first), ProviderChangeReceiver validates action and authority and
  debounces, and the permission gate re-checks on resume.

Also drops the unused DateTimeField composable and the stale INSTANCES
projection, which omitted the recurrence columns the mapper now depends on.

Bumps floret-kit to pick up the matching all-day formatting fix.

Verified by unit tests (43 app, 15 core-time) and a clean assembleDebug; the
provider interaction itself has not been exercised on a device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:13:26 +02:00
makiolajandClaude Opus 4.8 26628dc0bb chore: drop stray Codeberg release scratch files
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
cb-payload.json and cb-response.json are generated at runtime by the
release workflow; they were committed by accident from a local run and
still held stale v0.3.1 data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:30:13 +02:00
makiolaj d2e3832ef2 Merge branch 'chore/floret-kit-multi-reminders' 2026-07-20 21:04:05 +02:00
makiolaj 93857135b3 Merge branch 'home-today-ring-upcoming' 2026-07-20 21:04:02 +02:00
makiolajandClaude Opus 4.8 36beb2d0ad ci(release): wait for the mirrored tag, never mint it
The Codeberg publish has never once succeeded — 0.2.1, 0.2.2, 0.3.0, 0.3.1
and 0.3.2 all failed, and 0.3.0 was published by hand. Neither previous fix
could have worked, because the cause isn't in this file: ref writes to
jlmakiola/agendula on Codeberg fail. A tag push returns "cannot lock
references" and POST /tags returns an empty-bodied 500, while the identical
calls succeed on jlmakiola/calendula with the same token. Creating a release
mints a tag, so it inherited the same failure.

Attaching a release to a tag that is ALREADY there needs no ref write and
returns 201. So split the responsibility the way it should have been: the
push mirror owns delivering the tag, this step only attaches to it. Poll for
the tag, then POST with no target_commitish so the API attaches rather than
resolves a commit and mints one. If the tag never lands, fail with a pointer
at the mirror instead of trying to create it.

Drop continue-on-error. It reported green across five releases that never
published, which is how 0.3.1's crash fix reached F-Droid while the Codeberg
and Obtainium users who hit the crash got nothing.

Tested against the live API before committing (unlike its predecessors):
tag-present attaches 201, tag-absent exits 1 with the diagnostic. Note this
does not by itself restore publishing — the mirror cannot write the v0.3.2
tag either, so Codeberg must repair the repo's ref store first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:50:57 +02:00
makiolaj bc70ed3a9f release: cut 0.3.2 — get the crash fix onto Codeberg
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 11m59s
2026-07-20 19:11:55 +02:00
makiolajandClaude Opus 4.8 3f166ef5f0 release: cut 0.3.2 — get the crash fix onto Codeberg
No app-code change from 0.3.1: this exists to re-run the release pipeline
now that the Codeberg publish step is fixed. 0.3.1's APK reached F-Droid but
its Codeberg release 500'd, so anyone installing from Codeberg or Obtainium
is still on the crashing 0.3.0 — cutting 0.3.2 gets them the fix and proves
the workflow fix in the only way that counts, a real release.

Chosen over hand-patching the 0.3.1 release onto Codeberg: same outcome for
users, minus a manual APK upload, and it actually exercises the pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:11:48 +02:00
makiolaj 41bd49826a ci(release): create the Codeberg release while the tag is absent
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-07-20 19:10:56 +02:00
makiolajandClaude Opus 4.8 05c75bafa7 ci(release): create the Codeberg release while the tag is absent
Revert 4aa65ed's approach. It read the Forgejo 500 as being caused by
target_commitish, and pushed the tag to Codeberg first so the release could
attach to an existing tag without one. That inverted the actual failure:
Forgejo 500s on POST /releases precisely WHEN the tag already exists, so
pre-pushing it guarantees the error it was meant to avoid.

The second half then can't recover — a bare tag is not a release, so
GET /releases/tags 404s and the upsert has no id, which is the
"Could not resolve Codeberg release id" that ended the 0.3.1 run.

0.3.0 published fine because it POSTed while the tag was still unsynced and
let the API mint tag + release together. Do that deliberately instead of by
luck: if no release owns the tag, delete the mirrored tag, then POST with
target_commitish. Branches mirror reliably (main was already at the release
commit when 0.3.1 failed), so the commit is present and only the raced-in
tag was in the way. Deleting it is safe exactly because no release owns it.

An existing release still takes the PATCH path and skips the delete, so
re-runs never disturb something already published.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:06:36 +02:00
makiolaj cfa25b9730 release: cut 0.3.1 — fix the launch crash in minified builds (#1)
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 11m31s
2026-07-20 18:36:47 +02:00
makiolajandClaude Opus 4.8 9d7fc64b0b release: cut 0.3.1 — fix the launch crash in minified builds
R8 kept androidx.work.impl.WorkDatabase_Impl but pruned its no-arg
constructor: nothing calls it directly, Room only reaches it reflectively.
Room then threw InstantiationException, surfacing as "Failed to create an
instance of androidx.work.impl.WorkDatabase". WorkManager builds that
database from a startup ContentProvider, so 0.3.0 died before any of our
code ran — every install, every launch.

We don't depend on WorkManager directly; it arrives via Glance. AGP 9's
stricter R8 is what tipped this over, which is why 0.3.0 was the first
release to hit it.

Keep the Room no-arg constructor, and the ListenableWorker constructor
alongside it — same pruning hazard on the path WorkManager uses to
instantiate workers by name, which would have bitten once a Glance widget
update actually ran.

Reproduced the reporter's stack trace frame-for-frame on a releaseTest
build, then confirmed it launches clean afterwards. Fixes #1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:35:41 +02:00
makiolajandClaude Opus 4.8 4aa65edb45 ci(release): push tag to Codeberg before creating the release
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
The first fix didn't help: the pipeline creates the tag via the Gitea
API, and the push mirror (sync_on_commit only fires on real git pushes)
doesn't propagate an API-created tag promptly. So the Codeberg release
POST still raced the mirror and 500'd on a commit/tag Codeberg hadn't
received (0.2.1 and 0.3.0 both shipped everywhere but Codeberg).

Push the tag straight to Codeberg from the runner (guaranteed present),
then attach the release to that existing tag with no target_commitish —
which is what Forgejo 500s on. Race-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:48:29 +02:00
makiolajandClaude Opus 4.8 a9843e25b7 release: cut 0.3.0
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m27s
Ships the M5 reminders onboarding + Settings screen alongside the reworked
overview (Today progress ring, live Upcoming preview, unfurling search) and
the real launcher mark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:25:14 +02:00
makiolajandClaude Opus 4.8 5f4711dabe ui: move the action press flourish onto the shape, spin the gear
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
The press animation turned the glyph; put it on the scalloped cookie
container instead and make it stronger (scale 1→0.82, rotate 0→40°). The
glyph holds upright via a counter-rotation, so the shape spins while a
magnifier or list icon stays readable. New spinIcon opts a glyph into its own
quarter turn — the settings gear uses it, so it reads as a gear cranking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
makiolajandClaude Opus 4.8 6d3fbc05c1 home: unfurl search from a fixed search icon, drop the title
Rework the top bar into a custom row with no app title (the launcher icon
already names the app). Settings stays pinned at the right and the search
action sits just left of it; neither moves. Tapping search unfurls a pill
leftward from the magnifier (expandHorizontally anchored at the end) holding
the auto-focused query field and a clear button, with the search icon
remaining as the bar's fixed trailing icon. Results render over the home
content as you type; a blank query leaves the home screen visible. Tapping
the icon again or system back closes search; the FAB hides while searching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
makiolajandClaude Opus 4.8 721b411579 icon: centre the launcher mark on the canvas
The first pass shifted the mark off-centre. Scale 0.66 about the canvas
centre, and centre the task CARD (not the card+bloom bounding box, which the
overhanging bloom badge drags low): pivot the Y-scale at the card's centre
(y=242.76) and translate +13.24 so the card sits dead-centre, horizontally
and vertically, with the bloom badging out to the lower-right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
makiolajandClaude Opus 4.8 046b8f7e9e home: move search behind a top-bar action, not an always-on bar
The inline search bar was permanently visible. Replace it with a search
action button (a 6-sided cookie shape, sibling to the settings cookie) to
the left of settings: the search bar is absent until tapped, then opens
expanded and auto-focused, covering the home content with live results.
Back arrow or system back closes it; the FAB hides only while searching.
Pass windowInsets = 0 so the bar, already below the app bar, does not
re-apply the status-bar inset and float with a large top gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
makiolajandClaude Opus 4.8 06cc9b1c8b icon: real launcher mark — task card, check, calendula bloom
Replace the placeholder check-mark foreground with the finished agendula
mark (converted from design/icon/agendula_icon.svg): a rounded line-art task
card with a check, plus a small Calendula bloom badge in the open
bottom-right corner — the sibling of Calendula's calendar mark.

Strokes render in Calendula's off-white (#FAF6F0) over agendula's existing
plum background (#7A5C6B, the hue-rotated counterpart of Calendula's slate),
so the two apps read as a family while staying distinct. Scaled 0.66 to
match Calendula's footprint and ~2.8dp stroke weight; reused as the
<monochrome> slot for themed icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
makiolajandClaude Opus 4.8 2d366a7be3 home: inline expanding search over all tasks
Add a Material 3 SearchBar overlaying the top of the overview. Collapsed it
is a "Search tasks" bar below the title row; tapping expands it in place to
cover the home content with live results, filtering every task (open and
completed) by title, case-insensitive. The leading icon flips to a back
arrow while expanded, a clear button empties the query, and the FAB hides so
it does not float over the results. Results reuse the upcoming preview row.

ListsViewModel.Content now carries allTasks (open + completed) as the search
corpus; filtering stays in memory so the provider query is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
makiolajandClaude Opus 4.8 52aeebcb53 home: Today progress ring + live Upcoming preview
Rework the overview's 2x2 smart grid into a daily-momentum layout:

- Promote Today into a full-width hero with an M3 Expressive
  CircularWavyProgressIndicator over "x of y done" for tasks due today.
  Empty/all-done states read as a calm finished state, not a bare 0.
- Drop the white "Upcoming 0" tile (it shouted loudest while carrying the
  least) in favour of a live preview of the next few upcoming tasks, each a
  slim row with the quiet meta line and a tap-through to the task.
- Overdue + All fall back to a 2-up of the existing tonal tiles.

ListsViewModel now combines a Smart(COMPLETED) flow so the ring can count
the tasks already ticked off today (the open smart lists drop them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
makiolajandClaude Opus 4.8 8f6b85008c release: cut 0.2.2
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
Re-cut after 0.2.1's Codeberg publish failed (500 on pre-synced tag,
fixed in the previous commit). Validates the fix end to end and ships
the Codeberg direct-download channel (signed APK + SHA-256).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:14:57 +02:00
makiolajandClaude Opus 4.8 eb1e530ce7 ci(release): fix Codeberg publish 500 on pre-synced tag
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
The push mirror (sync_on_commit) syncs the tag to Codeberg before the
publish step runs, and Forgejo returns HTTP 500 on POST /releases with a
target_commitish when the tag already exists — so the release was never
created (v0.2.1 shipped to Gitea/F-Droid but not Codeberg). Only pass
target_commitish when the tag isn't mirrored yet; otherwise attach the
release to the existing tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:07:37 +02:00
makiolajandClaude Opus 4.8 7516972e9f release: cut 0.2.1
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m13s
Publish releases to the Codeberg mirror (signed APK + SHA-256 checksum)
as a direct-download channel. Bumps versionName to 0.2.1; the pipeline
mints the v0.2.1 tag on merge to main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:53:49 +02:00
makiolajandClaude Opus 4.8 7f58f81fe1 ci: adopt the modern calendula pipeline + Codeberg mirror
Port Calendula's current CI/release pipeline:

- ci.yaml: pull_request-triggered, change-scope classification
  (docs/metadata-only PRs skip the Android build but still report a
  green CI), and a reproducible-release invariant guard.
- release.yaml: the committed versionName is the source of truth — a
  bump reaching main triggers the release, which builds, signs,
  publishes to the F-Droid repo, then mints the vX.Y.Z tag + Gitea
  release and mirrors it to Codeberg with the signed APK + SHA-256
  checksum. workflow_dispatch runs the re-sign-only recovery path.
- Gitea releases are flagged as pre-releases while MAJOR is 0.
- build.gradle.kts: reproducible-release invariants (vcsInfo,
  dependenciesInfo) + a releaseTest variant for the on-device gate.
- fastlane/ becomes the single source of truth for store metadata;
  the localized F-Droid layout is generated from it at release time.
- Port scripts/, .gitea/ISSUE_TEMPLATE/, and rewrite docs/RELEASING.md
  for the versionName-in-main model; fix stale references elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:53:13 +02:00
makiolajandClaude Opus 4.8 976d496d21 reminders: adopt floret-kit multi-value override model
Bumps the floret-kit submodule to the multi-value core-reminders API
(ReminderOverride.Minutes now a List<Int>) and adapts the per-list override
plumbing + pickers to it. Agendula stays single-reminder: overrides are
one-element (or empty) lists, reminderLeadFor takes firstOrNull, and the
single-select picker wraps/unwraps listOf. On-disk format is unchanged (single
values round-trip byte-identically). Verified: :app:compileDebugKotlin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:47:50 +02:00
makiolajandClaude Opus 4.8 245f1db536 ui: move the action press flourish onto the shape, spin the gear
The press animation turned the glyph; put it on the scalloped cookie
container instead and make it stronger (scale 1→0.82, rotate 0→40°). The
glyph holds upright via a counter-rotation, so the shape spins while a
magnifier or list icon stays readable. New spinIcon opts a glyph into its own
quarter turn — the settings gear uses it, so it reads as a gear cranking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:01:07 +02:00
makiolajandClaude Opus 4.8 623e533547 home: unfurl search from a fixed search icon, drop the title
Rework the top bar into a custom row with no app title (the launcher icon
already names the app). Settings stays pinned at the right and the search
action sits just left of it; neither moves. Tapping search unfurls a pill
leftward from the magnifier (expandHorizontally anchored at the end) holding
the auto-focused query field and a clear button, with the search icon
remaining as the bar's fixed trailing icon. Results render over the home
content as you type; a blank query leaves the home screen visible. Tapping
the icon again or system back closes search; the FAB hides while searching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:00:42 +02:00
makiolajandClaude Opus 4.8 2e50356f81 icon: centre the launcher mark on the canvas
The first pass shifted the mark off-centre. Scale 0.66 about the canvas
centre, and centre the task CARD (not the card+bloom bounding box, which the
overhanging bloom badge drags low): pivot the Y-scale at the card's centre
(y=242.76) and translate +13.24 so the card sits dead-centre, horizontally
and vertically, with the bloom badging out to the lower-right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:35:03 +02:00
makiolajandClaude Opus 4.8 e6f503c02a home: move search behind a top-bar action, not an always-on bar
The inline search bar was permanently visible. Replace it with a search
action button (a 6-sided cookie shape, sibling to the settings cookie) to
the left of settings: the search bar is absent until tapped, then opens
expanded and auto-focused, covering the home content with live results.
Back arrow or system back closes it; the FAB hides only while searching.
Pass windowInsets = 0 so the bar, already below the app bar, does not
re-apply the status-bar inset and float with a large top gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:34:54 +02:00
makiolajandClaude Opus 4.8 c53511196d icon: real launcher mark — task card, check, calendula bloom
Replace the placeholder check-mark foreground with the finished agendula
mark (converted from design/icon/agendula_icon.svg): a rounded line-art task
card with a check, plus a small Calendula bloom badge in the open
bottom-right corner — the sibling of Calendula's calendar mark.

Strokes render in Calendula's off-white (#FAF6F0) over agendula's existing
plum background (#7A5C6B, the hue-rotated counterpart of Calendula's slate),
so the two apps read as a family while staying distinct. Scaled 0.66 to
match Calendula's footprint and ~2.8dp stroke weight; reused as the
<monochrome> slot for themed icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:07:25 +02:00
makiolajandClaude Opus 4.8 a8595e26b4 home: inline expanding search over all tasks
Add a Material 3 SearchBar overlaying the top of the overview. Collapsed it
is a "Search tasks" bar below the title row; tapping expands it in place to
cover the home content with live results, filtering every task (open and
completed) by title, case-insensitive. The leading icon flips to a back
arrow while expanded, a clear button empties the query, and the FAB hides so
it does not float over the results. Results reuse the upcoming preview row.

ListsViewModel.Content now carries allTasks (open + completed) as the search
corpus; filtering stays in memory so the provider query is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:07:12 +02:00
makiolajandClaude Opus 4.8 411e27659f home: Today progress ring + live Upcoming preview
Rework the overview's 2x2 smart grid into a daily-momentum layout:

- Promote Today into a full-width hero with an M3 Expressive
  CircularWavyProgressIndicator over "x of y done" for tasks due today.
  Empty/all-done states read as a calm finished state, not a bare 0.
- Drop the white "Upcoming 0" tile (it shouted loudest while carrying the
  least) in favour of a live preview of the next few upcoming tasks, each a
  slim row with the quiet meta line and a tap-through to the task.
- Overdue + All fall back to a 2-up of the existing tonal tiles.

ListsViewModel now combines a Smart(COMPLETED) flow so the ring can count
the tasks already ticked off today (the open smart lists drop them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:47:02 +02:00
makiolajandClaude Opus 4.8 b266653e4e chore: bump floret-kit submodule to latest (component superset)
CI / ci (push) Successful in 4m0s
Adopt floret-kit main @ 566caf4, picking up the grown GroupedRow / OptionPicker / CollapsingScaffold family superset. App compiles and unit tests pass against it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:13:21 +02:00
makiolajandClaude Opus 4.8 2f5012dc34 tasks: consolidate add affordance into a setting (FAB or bottom bar)
CI / ci (push) Successful in 4m8s
Removes the doubled add idiom (top inline-add bar + FAB). A new "Bottom quick-add bar" setting (default off) picks one affordance per screen: off keeps the floating New task button everywhere; on shows a quick-add field pinned to the bottom of a real list (smart lists keep the button, as they have no single target list).

The bottom field floats on the brightest container tone with a shadow so it stands off the task cards, rides above the keyboard (ime ∪ navigation-bar insets), and drops focus when the IME hides so no stray cursor lingers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:01:32 +02:00
makiolajandClaude Opus 4.8 ccc07e86c7 tasks: quiet single-line row meta, lazy subtasks in smart lists, polish
- Collapse a task row's supporting info (priority, due, subtask progress,
  list name) into one low-emphasis meta line instead of stacked filled
  chips: priority is a tinted flag, the list name carries its list colour,
  and dates are compact (formatDateTimeCompact).
- Expand subtasks in smart lists too, by lazily fetching a parent's full
  child set on demand (TasksRepository.subtasks + the view model's live
  lazyChildren), while per-list views stay query-free.
- animateItem on the rows so (un)expanding a group fades its subtasks
  in/out and slides the rows below — clean whether the children were
  already loaded or arrive a frame later.
- Sit the undo chip beside the FAB at its height rather than overlapping
  or floating above it.

Bumps the floret-kit submodule for formatDateTimeCompact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:47:50 +02:00
makiolajandClaude Opus 4.8 211450bc46 chore: bump floret-kit submodule to merged main
CI / ci (push) Successful in 7m24s
Picks up the pushed floret-kit main (the eight shared-kit commits merged with
the upstream docs/changelog/license commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:20:56 +02:00
makiolajandClaude Opus 4.8 9d134be621 nav: fix blank back-gesture preview — let navigation drive predictive back
The per-screen Modifier.predictiveBack scaled the leaving screen with a
graphicsLayer, but NavHost only composes one back-stack entry at a time, so
nothing was drawn behind the shrinking screen — the gesture revealed the white
window background instead of the previous screen.

Drive predictive back through navigation-compose instead: it composes the
destination being revealed and seeks popExit with the gesture. popExit is now a
scaleOut + fade peek, so the leaving screen shrinks to reveal the real previous
screen held static. Drops the predictiveBack modifier from the destinations and
Settings (its inner section BackHandler is unchanged). The kit's predictiveBack
stays for overlay-style screens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:15:24 +02:00
makiolajandClaude Opus 4.8 37e3c4e659 settings: use the shared AboutCard + LanguagePickerRow
Drop the in-app AboutCard, LanguageRow and languageLabel in favour of the
floret-kit recipes; the app still supplies its logo, strings, links and row
icon. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:24:17 +02:00
makiolajandClaude Opus 4.8 78632865f9 edit: use the shared OptionalFormSection in the task form
Drop the in-app copy in favour of floret-kit's. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:17:57 +02:00
makiolajandClaude Opus 4.8 e7a62e71df reminders: use the shared DialogAmountField in the custom lead editor
Replace the inline tonal amount field in the reminder picker with floret-kit's
DialogAmountField. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:08 +02:00
makiolajandClaude Opus 4.8 4c05c86e95 motion: use floret-kit's expandEnter/collapseExit for revealable sections
Replace the inline expandVertically/shrinkVertically + fade in the reminder
picker, task edit form and settings with the shared transitions — which adds
reduced-motion support and the Expressive springs. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:13:06 +02:00
makiolajandClaude Opus 4.8 37662e83bb nav: swap the full slide-back for the predictive-back peek
Back was a full-width horizontal slide-off — too heavy. Replace it with
floret-kit's Modifier.predictiveBack on each pop-able destination (task list,
detail, edit) so the leaving screen scales/rounds with the gesture and the held
screen is revealed unmoved; NavHost now does no pop animation. Settings applies
it at the hub only (enabled = section == null) so its inner section back still
returns to the hub. Forward stays a calm fade-in.

Bumps the floret-kit submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:31:20 +02:00
makiolajandClaude Opus 4.8 63c6191676 onboarding: draw the onboarding scaffold from floret-kit
Use components' OnboardingScaffold / BenefitRow / OnboardingSpace and drop the
in-app copies; keep Agendula's own SquircleHero (now in OnboardingHero.kt).
Bumps the floret-kit submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:16:56 +02:00
makiolajandClaude Opus 4.8 4994f5ec2c reminders + locale: draw prefs/reminders and language plumbing from floret-kit
Consume two newly extracted floret-kit modules and drop the in-app copies:

- core-reminders: ReminderUnit / decomposeReminderMinutes, the ReminderOverride
  model and the override codec. SettingsPrefs keeps its own DataStore wiring and
  Agendula's ':' codec dialect; ReminderFormatting/ReminderLeadPicker keep the
  app's R.string labels and presets.
- core-locale: AppLanguage. Deleted the app copy; SettingsScreen now passes
  R.xml.locales_config to supportedTags().

Bumps the floret-kit submodule to include both modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:12:50 +02:00
makiolajandClaude Opus 4.8 e4c8dbf2c1 build: drop the foojay toolchain resolver (reproducible-build safe)
CI / ci (push) Successful in 8m7s
The Gradle Java-toolchain auto-download resolver can fetch a JDK at build time,
which an offline / reproducible F-Droid build scanner rejects. It was unused —
modules set jvmTarget directly with no toolchain block — so removing it is a
no-op for the build and brings Agendula in line with floret-kit's no-resolver
rule. Clean assembleDebug verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:34:55 +02:00
makiolajandClaude Opus 4.8 069ae38b2c components batch-2: draw CollapsingScaffold + OptionPicker from floret-kit
CI / ci (push) Successful in 8m37s
Agendula now sources the collapsing settings/sub-screen scaffold and the
full-screen single-select picker (FullScreenPicker/OptionPicker) from the kit's
components module. Deletes the app copies; repoints SettingsScreen imports and
adds the FullScreenPicker import in ReminderLeadPicker. Submodule re-pinned.
ReminderFormatting stays app-local (needs a label-callback API — separate pass).

Clean composite build + tests + lintDebug + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:25:53 +02:00
makiolajandClaude Opus 4.8 a5ddf16537 components: draw the shared Compose vocabulary from floret-kit
CI / ci (push) Has been cancelled
Phase 4 (components). Agendula now sources its grouped-row primitive
(GroupedSurface/GroupedRow + Position/positionOf), InlineTextField, OptionCard,
and pastelize() from the kit's components module instead of its own ui/common
copies. These were Agendula's versions, taken as the canonical family reference
(design sign-off); Agendula keeps its own identity chips (ListColorChip/
ListNameChip task glyph, PriorityChip) which now import the shared pastelize.

- Delete app copies of GroupedList/InlineTextField/OptionCard; drop the local
  pastelize from ListChip. Repoint all imports (cross-package + same-package +
  one inline FQN) to de.jeanlucmakiola.floret.components. Submodule re-pinned.

Clean composite build + tests + lintDebug + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:09:37 +02:00
makiolajandClaude Opus 4.8 18e03e27b6 identity: draw the M3 Expressive theme factory + nav slide from floret-kit
CI / ci (push) Successful in 7m0s
Phase 3 (identity layer). AgendulaTheme is now a thin wrapper over the kit's
FloretExpressiveTheme(lightScheme, darkScheme, …): the shared mechanics
(dynamic colour, light/dark, standard motion scheme) live in floret-kit, while
Agendula keeps its own identity — the seed-derived AgendulaLight/DarkFallback
schemes and AgendulaTypography (unchanged in Color.kt/Type.kt). rememberNavSlideSpec
now comes from the kit; the app's AgendulaTransitions.kt is removed and the
NavHost import repointed. Submodule re-pinned to the identity kit commit.

Clean composite build + tests + lintDebug + debug assemble green; substitution
confirmed (de.jeanlucmakiola.floret:identity -> project :floret-kit:identity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:59:56 +02:00
makiolajandClaude Opus 4.8 152226c1b2 Phase 2: core-crash from floret-kit; complete the core-time wiring
CI / ci (push) Successful in 9m16s
Adds crash reporting to Agendula by drawing the kit's new core-crash module
(first Android-library module in floret-kit), and finishes the core-time wiring
that a Phase 0 staging slip left uncommitted on main.

core-crash (NEW capability — Agendula had none):
- CrashReporter.install() in AgendulaApp captures uncaught exceptions on-device
  (allowlist-only report, chains to the platform handler, uploads nothing).
- MainActivity routes to a standalone CrashReportActivity on a startup crash-loop,
  and surfaces a single captured crash as a dialog on next launch; markHealthy on
  resume. App label + issue-tracker URL flow in via CrashConfig.
- Thin app-side CrashReportActivity uses AgendulaTheme; the reusable machinery
  (reporter, dialog, submit, config) lives in the kit. Submodule re-pinned to the
  core-crash kit commit.

Completes Phase 0 (was only partially committed in ec7b696):
- Repoint DayWindow + Instant formatting imports to de.jeanlucmakiola.floret.time
  (the app copies were deleted in ec7b696 but the imports/includeBuild/dependency
  were never staged, leaving main non-building).
- settings.gradle.kts includeBuild("floret-kit"); app depends on core-time.

Also fixes the CI checkout YAML: the submodules block was mis-indented under
'uses:' (invalid step mapping) — dedented to a proper sibling.

Clean composite build + unit tests + lintDebug + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:24:49 +02:00
makiolajandClaude Opus 4.8 ec7b696eb9 Draw core-time from floret-kit via submodule + composite build
CI / ci (push) Failing after 2m12s
Phase 0 of the shared-kit plan: floret-kit is embedded as a git submodule and
wired in with Gradle includeBuild("floret-kit"), so the app depends on
de.jeanlucmakiola.floret:core-time and Gradle substitutes the local source
module (built from source — F-Droid-safe). Proves the submodule + composite-build
pipeline before any design layers move.

- Remove app copies of DayWindow + Instant date/time formatting (now in
  core-time); repoint imports to de.jeanlucmakiola.floret.time.
- DateTimeField gains an explicit import (was same-package).
- CI checkouts fetch submodules recursively.
- Clean composite build + unit tests + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:27:49 +02:00
makiolajandClaude Opus 4.8 76a0139fda Rename app Floret → Agendula
CI / ci (push) Successful in 8m33s
Floret is promoted to the family / design-language (shared-kit) name; the
tasks app itself becomes Agendula (de.jeanlucmakiola.agendula) — agenda
('things to be done') + Calendula's -ula, a twin of the Calendula name.

Renames the package, namespace, applicationId, rootProject.name, app_name,
FloretApp/FloretNavHost/FloretTransitions classes, theme, F-Droid metadata
dir, CI artifact name, and docs. The botanical word 'florets' is preserved in
the name-origin prose, which is rewritten to Agendula's etymology. Clean
build + unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:10:24 +02:00
makiolajandClaude Opus 4.8 d84ac60757 M5: reminders onboarding, Settings screen & per-list overrides
Reminders onboarding & polish, plus the Settings screen (landed early).

- One-time reminder onboarding gate after the provider grant
  (ReminderOnboardingScreen + OnboardingScaffold), requesting
  POST_NOTIFICATIONS on API 33+; choice recorded in prefs.
- Settings screen, structured after Calendula as a category hub with
  sliding sub-screens (CollapsingScaffold + grouped rows + full-screen
  OptionPicker): About card, Appearance (theme, dynamic colour), Task
  form (default edit-form fields, default list, add-a-subtask-row
  opt-out), Reminders, Language, and Report a problem. Reached via an
  expressive shaped action button (MaterialShapes.Cookie4Sided) on the
  lists overview. Back from a sub-screen returns to the hub.
- Reminders: master enable toggle (gates the whole engine; re-requests
  the notification permission), a default lead time with a Custom
  amount/unit editor, an Android-12 exact-alarm status row, and
  per-list overrides of the default (Inherit / None / custom lead),
  honoured by ReminderScheduler per task.
- Per-app language (AppLanguage + locales_config.xml + appcompat
  locales service); add-a-subtask-row opt-out wired into the task list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:03:26 +02:00
makiolajandClaude Opus 4.8 2c457f3915 release: 0.2.0
CI / ci (push) Successful in 6m18s
Release — F-Droid repo + Gitea release / ci (push) Successful in 7m21s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 6m14s
Promote [Unreleased] → [0.2.0] in the changelog (M2 UI + M3/M4 + fixes) and
bump the dev-default version to 0.2.0 / 200. The pushed tag drives the released
version per docs/RELEASING.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:27:06 +02:00
makiolajandClaude Opus 4.8 e0c56a73e2 M4: percent-complete, conflict-safe saves & subtask reparent
CI / ci (push) Successful in 6m49s
M3 detail/edit polish:
- "Progress" slider on the edit form writes Tasks.PERCENT_COMPLETE (clamped
  0-100, 5% detents); status stays owned by the complete toggle.
- Conflict-safe saves: updateTask re-checks the provider's last_modified against
  the value captured when the form loaded and throws TaskConflictException; the
  editor offers overwrite-or-cancel instead of clobbering an external change.

M4 subtasks (UI):
- Reparent: a "Parent task" picker files a task under any top-level task in its
  list (or "None" to promote it); candidates stay top-level to keep nesting one
  level deep. Switching list clears the now-invalid parent.
- Tapping a subtask opens its own detail (a new TaskDetail entry, own VM).

Tests: percent clamping, parent-id write, populatedFields reveal logic.
Docs: ROADMAP M2/M3/M4 reconciled to the codebase; CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:15:15 +02:00
makiolajandClaude Opus 4.8 3db553da85 detail: always show the subtasks group so add is reachable
CI / ci (push) Successful in 8m38s
Render the subtasks group on every task, not just ones that already
have children, so the inline add field is available from the start.
Hide the done/total count until there's at least one subtask.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:12 +02:00
makiolajandClaude Opus 4.8 11f9649dd7 M3: rebuild task create/edit form in Calendula's card language
Replace the boxed-text-field form with the family's tonal-card layout:
Close + filled SAVE top bar, a borderless headline title with a
list-colour accent bar, a "When" card (all-day switch + tappable
start/due rows), a tappable list card and reminder card that open
OptionCard dialogs, and segmented-button priority.

Optional Description / Priority / Reminder sections unfold from a
"More fields" picker (Calendula's disclosure): TaskFormField enum +
populatedFields() auto-reveal fields that already carry a value, and a
persisted defaultEditFields pref decides which start open (the settings
UI to edit it lands with M6). New shared InlineTextField + OptionCard;
date/time conversion helpers in DateTimeField promoted to internal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:02 +02:00
makiolajandClaude Opus 4.8 3397e57794 UI: fix subtask progress chip on the task list
Count a parent's subtask progress over the full task set in the
repository (attached to Task as subtaskTotal/subtaskDone) so completed
children are counted even on smart lists that hide closed tasks — the
chip showed "0 / 2" instead of "18 / 20". List membership (and the home
overview counts) are unchanged; only the parent's derived progress is.

Also wrap the task-row metadata in a FlowRow so the subtask chip drops
to a further line when a dated task with subtasks can't fit due + chips
on one line, rather than squeezing the trailing chip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:25:08 +02:00
makiolajandClaude Opus 4.8 24cf8fe331 nav: spring slide/fade screen transitions (Calendula feel)
Drive the NavHost with the motion scheme's fastSpatialSpec: forward fades in
over a held, static background; back (and the predictive-back drag) slides the
top screen out to the right, revealing the screen beneath unmoved — the same
overlay feel as Calendula, instead of the default navigation-compose transition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:03:45 +02:00
makiolajandClaude Opus 4.8 a19b1373a4 M2: build out task list, detail & edit screens
Replace the base scaffolds with the real Material 3 Expressive UI on the
existing M1 ViewModels.

Task list: due-date section headers (collapsible Completed), grouped tonal
rows with a list-colour accent bar, swipe-to-complete / -delete, inline add,
list-name chip and subtask "done/total" chip in mixed views (children folded
into their parent). New pure TaskSections grouping + unit test.

Detail: each fact its own tonal card with a leading icon (Calendula pattern) —
when/list/priority/progress/description; progress shows only with subtasks.
Subtasks are a connected grouped run whose first segment is the expand toggle,
with an accent-tinted inline add row; completed subtasks sink to the bottom.

Edit: title, description, list dropdown, start/due date-time + all-day,
priority segmented buttons, reminder offset, validation; IME auto-scroll.

Extract GroupedSurface from GroupedRow so the connected-row look is reusable
at any width; add ListNameChip + date-time format/picker helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:03:37 +02:00
makiolajandClaude Opus 4.8 b196d9ebe0 M2: wire navigation host + base screen scaffolds
Replace the RootScreen nav stubs with a real NavHost. Each destination
binds its existing M1 ViewModel from route args.

- ui/navigation: Dest route table (string-based; no serialization plugin)
  + FloretNavHost (lists -> task list -> detail / edit).
- Base scaffolds for TaskListScreen, TaskDetailScreen, TaskEditScreen,
  wired to their ViewModels — skeletons for the rich M2/M3 UI.
- RootScreen READY branch now hands off to FloretNavHost.
- Add navigation-compose 2.9.0; supporting strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:44:18 +02:00
426 changed files with 53230 additions and 2799 deletions
+23
View File
@@ -0,0 +1,23 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Agendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:
+24
View File
@@ -0,0 +1,24 @@
# Kept enabled so anything that doesn't fit the four templates still has a way
# in.
blank_issues_enabled: true
contact_links:
- name: Translate Agendula
url: https://weblate.dev.jeanlucmakiola.de/engage/agendula/
about: >-
Translations are managed on Weblate, not here — it owns every values-*
file, so a hand-edited translation gets overwritten on the next sync.
No coding needed: pick or request a language and translate in the browser.
- name: Contributing guide
url: https://codeberg.org/jlmakiola/agendula/src/branch/main/CONTRIBUTING.md
about: >-
Before opening a pull request: how to build (there's a submodule), where
code goes, and the one architectural rule a change is reviewed against.
- name: Sync sources and scope
url: https://codeberg.org/jlmakiola/agendula/src/branch/main/README.md
about: >-
Agendula is a front-end over the OpenTasks provider, so it works with
DAVx5, SmoothSync, DecSync and friends. Google Tasks and Microsoft To Do
are out of scope by design — check here before requesting a backend.
+27
View File
@@ -0,0 +1,27 @@
---
name: Crash report
about: Report a crash. Agendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
title: "Crash: "
labels:
- bug
- crash
- priority:high
---
<!--
Thanks for reporting a crash in Agendula!
If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content.
-->
### What happened
### Crash report
```
(paste the crash report here)
```
@@ -0,0 +1,16 @@
---
name: Feature request
about: Suggest an idea or improvement
title: ""
labels:
- feat
---
### What would you like Agendula to do?
### Why — what problem does it solve?
### Anything else
<!-- mockups, examples from other apps, alternatives you considered -->
+19
View File
@@ -0,0 +1,19 @@
---
name: Question
about: Ask how something works or get help using Agendula
title: ""
labels:
- question
---
### Your question
### What you've tried
<!-- so far, if anything -->
### Context
- Agendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:
+42
View File
@@ -0,0 +1,42 @@
<!--
Thanks for contributing to Agendula!
Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/agendula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong:
• The one architectural rule — provider column names, `TaskContract`,
`ContentResolver` and the authority string never leak above `data/tasks/`.
• Don't bump `versionName` / `versionCode`. That bump reaching `main` is what
cuts a release, so it belongs only in a release PR.
-->
### What this changes
### Why
<!-- Closes #123 — link the issue this implements or fixes. -->
### How it was tested
<!--
Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes, and for anything touching the provider
read/write paths (OpenTasks / tasks.org installed).
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
python3 scripts/check_translations.py
-->
### Checklist
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
- [ ] Provider details stay inside `data/tasks/`
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
- [ ] No `versionName` / `versionCode` bump
- [ ] No planning or design documents committed
+192
View File
@@ -0,0 +1,192 @@
name: CI
# One gate per pull request. Branch pushes no longer trigger CI on their own,
# so a change is built once on its PR (covering feature -> release/* and
# release/* -> main) instead of once per push and again on the merge to main.
# The merge itself is handled by release.yaml, which only does heavy work when
# the merge actually cuts a release.
on:
pull_request:
# Cancel superseded runs for the same PR.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# Single job named `ci` so the required "CI" status check is always reported,
# even for docs-only PRs: those just skip the Android build and the job still
# succeeds (fast green check) instead of being filtered out and leaving the
# required check pending forever.
ci:
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history so the base..HEAD diff below has a merge-base.
fetch-depth: 0
submodules: recursive
# Cheap, always-on guard: the release build must stay reproducible for the
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
# change scope so a regression can't slip through on a "docs-only" PR.
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Also cheap, also always-on. Two failures in one: the script exits
# non-zero if this version's changelog is over the character limit
# F-Droid truncates at, and the porcelain check below catches a
# CHANGELOG.md edit whose generated fastlane file was never committed —
# which used to degrade silently into "the official listing shows the
# previous version's notes".
- name: Changelog fits F-Droid, and is committed
run: |
set -e
bash scripts/sync_changelog_to_fastlane.sh
DIRTY=$(git status --porcelain fastlane/metadata/android/en-US/changelogs)
if [ -n "$DIRTY" ]; then
echo "$DIRTY"
echo "ERROR: the generated fastlane changelog is not what is committed." >&2
echo "Run scripts/sync_changelog_to_fastlane.sh and commit the result, so" >&2
echo "the official F-Droid listing shows this version's notes rather than" >&2
echo "the previous one's." >&2
exit 1
fi
# Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`.
- name: Classify change scope
id: scope
env:
# Deliberately a skip-list, not a build-list: a path nobody thought
# about defaults to building. Only paths the Gradle build provably
# never reads belong here — note that the workflows themselves, the
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^design/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
run: |
set -e
BASE="${{ github.base_ref }}"
# Normally the bare branch name; tolerate a full ref, which would
# otherwise make the merge-base lookup fail and quietly degrade this
# guard into "always build".
BASE="${BASE#refs/heads/}"
if [ -z "$BASE" ]; then
echo "No base branch on this event — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Full (not --depth=1) base fetch so the merge-base is present even when
# the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts.
git fetch --no-tags origin "$BASE"
MB=$(git merge-base "origin/$BASE" HEAD 2>/dev/null || true)
if [ -z "$MB" ]; then
# No common ancestor available — don't risk skipping the build.
echo "No merge base with origin/$BASE — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED"
RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
if [ -n "$RELEVANT" ]; then
# Naming them makes "why did my docs PR build for four minutes?"
# answerable from the log alone.
echo "Build-relevant changes:"; echo "$RELEVANT"
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "Docs/metadata-only change — skipping the Android build."
echo "code=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Java
if: steps.scope.outputs.code == 'true'
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
# setup-java and cache all exist there, but android-actions/setup-android
# does not, and the job dies with "repository not found". Gitea's instance
# defaults to GitHub, which is why this never surfaced before the split.
- name: Setup Android SDK
if: steps.scope.outputs.code == 'true'
uses: https://github.com/android-actions/setup-android@v3
with:
# Default ("tools platform-tools") drags in the Android Emulator
# (~300 MB) which the build never uses.
packages: ''
- name: Setup Android SDK cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
if: steps.scope.outputs.code == 'true'
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
if: steps.scope.outputs.code == 'true'
run: chmod +x ./gradlew
# No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only)
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
# that task would compile the vendored suite and run none of it, which is
# the whole safety argument in dav/PROVENANCE.md.
- name: Unit tests
if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest :dav:test :caldav:test
- name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug
- name: Trivy filesystem scan
if: steps.scope.outputs.code == 'true'
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | $SUDO tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | $SUDO tee /etc/apt/sources.list.d/trivy.list
$SUDO apt-get update
$SUDO apt-get install -y trivy
fi
trivy filesystem --severity HIGH,CRITICAL --exit-code 0 .
continue-on-error: true
+39
View File
@@ -0,0 +1,39 @@
name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
#
# Runs on every PR (no path filter) so the required "Translations / check"
# status is always reported — like the `ci` job. A path-filtered workflow is
# skipped on unrelated PRs and never posts its status, which leaves that
# required check pending forever and blocks the merge of any code-only PR into a
# release/* branch. The check itself is cheap and simply passes when the
# committed translations are consistent, so always running it costs nothing.
on:
pull_request:
concurrency:
group: translations-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure python3
run: |
if ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y python3
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache python3
fi
fi
python3 --version
- name: Check translation parity
run: python3 scripts/check_translations.py
-93
View File
@@ -1,93 +0,0 @@
name: CI
on:
push:
branches:
- '**'
tags-ignore:
- '**'
# Cancel superseded runs on the same branch.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
# Default ("tools platform-tools") drags in the Android Emulator
# (~300 MB) which the build never uses.
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only)
run: ./gradlew lintDebug
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK
run: ./gradlew assembleDebug
- name: Trivy filesystem scan
if: github.ref == 'refs/heads/main'
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | $SUDO tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | $SUDO tee /etc/apt/sources.list.d/trivy.list
$SUDO apt-get update
$SUDO apt-get install -y trivy
fi
trivy filesystem --severity HIGH,CRITICAL --exit-code 0 .
continue-on-error: true
+308 -176
View File
@@ -1,78 +1,144 @@
name: Release — F-Droid repo + Gitea release
name: Release — F-Droid repo + Gitea/Codeberg release
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and publishes
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing.
#
# This file lives in .gitea/workflows on purpose: Codeberg is canonical for git,
# issues, PRs and releases, but every secret (app key, F-Droid repo key, Hetzner
# credentials) lives on the self-hosted Gitea instance, and this is the only
# directory Codeberg cannot see. Contributor-triggerable work lives in
# .forgejo/workflows and references no secret. See docs/RELEASING.md.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
# without building an APK or creating a release. Used for key rotation / repo
# recovery.
on:
push:
tags:
- '*'
branches: [main]
workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false
jobs:
ci:
# Cheap gate: resolve the version from the committed build.gradle and decide
# whether this push actually cuts a new release (no tag for it yet). Keeps the
# heavy job from running on every merge to main.
detect:
# Gitea only. The workflow directory split already keeps this file invisible
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
# exists — but that only holds while .forgejo/ is non-empty. Move the last
# file out of it and Codeberg would fall back to .gitea/workflows and start
# running the release pipeline on the contributor-facing runner, with no
# secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj'
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
outputs:
is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }}
version_code: ${{ steps.v.outputs.version_code }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
submodules: recursive
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
- name: Resolve version and whether it is a new release
id: v
env:
# Tags are read from Codeberg, which is canonical — deliberately NOT
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
# source does not have. A tag minted here on Gitea is therefore wiped
# by the next sync (Codeberg does not have it yet) and only reappears
# once the tag push at the end of this workflow propagates back.
# Asking Gitea inside that window would report "no tag" for a release
# that already shipped, and cut it a second time.
# Public repo, so this read needs no token.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
#
# Anything other than a clean 200/404 is treated as fatal rather than
# as "no tag". A Codeberg outage or a network blip would otherwise
# read as absent and re-cut a release that has already shipped —
# republishing to F-Droid. Failing here is recoverable; a duplicate
# release is not.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
exit 1
;;
esac
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
# Before a single Gradle task runs: F-Droid truncates the in-client
# changelog, so an over-long one would reach users cut off mid-sentence.
# The script exits non-zero past the limit. Cheap enough to sit in the
# gate job, where failing costs nothing and publishes nothing — the step
# further down that regenerates the file for the repo would otherwise be
# the first thing to notice, after the build and the signing.
- name: Changelog fits F-Droid
if: steps.v.outputs.is_release == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# Lint already enforced on every push to main via ci.yaml.
# Release sanity only re-runs tests + a debug build to catch
# any tag-resolved drift (e.g. version code substitution issues).
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK (sanity)
run: ./gradlew assembleDebug
build-and-deploy:
needs: ci
# Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and
# re-uploads the existing index (recovery path).
release:
needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
PRERELEASE: ${{ needs.detect.outputs.prerelease }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
@@ -121,31 +187,26 @@ jobs:
$SUDO apk add --no-cache jq
fi
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch,
# not a tag) these are skipped: the job then just re-signs the existing
# index with the configured repo key and re-uploads — used for key
# rotation / repo recovery without publishing a new APK.
- name: Set version from git tag
if: startsWith(github.ref, 'refs/tags/')
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# The committed versionName is the source of truth. Pin versionCode to the
# value derived from it so the published APK's code is always
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
run: |
set -e
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${RAW_TAG#v}"
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "Version: $VERSION, VersionCode: $VERSION_CODE"
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
# Export for later steps (F-Droid changelog, mapping asset name).
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV"
# Test the exact commit being shipped (only on a real release).
- name: Unit tests
if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest
- name: Setup Android Keystore
if: startsWith(github.ref, 'refs/tags/')
if: env.IS_RELEASE == 'true'
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
@@ -160,11 +221,8 @@ jobs:
storeFile=upload-keystore.jks
EOF
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build release APK
if: startsWith(github.ref, 'refs/tags/')
if: env.IS_RELEASE == 'true'
run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools
@@ -202,8 +260,7 @@ jobs:
set -euo pipefail
# Fail loudly if the repo key is not configured. NEVER auto-generate
# one: a fresh key changes the repo fingerprint and breaks every
# user's pinned repo. (Replaces the old `fdroid update --create-key`
# path, which silently rotated the key on a wiped server.)
# user's pinned repo.
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
echo "Refusing to continue — will not auto-generate a new repo key." >&2
@@ -216,42 +273,33 @@ jobs:
mkdir -p fdroid/repo/icons
- name: Copy new APK to repo
if: startsWith(github.ref, 'refs/tags/')
if: env.IS_RELEASE == 'true'
run: |
set -e
mkdir -p fdroid/repo
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
if [ -z "$SAFE_REF_NAME" ]; then
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
fi
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/floret_${SAFE_REF_NAME}.apk"
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
- name: Copy metadata to F-Droid repo
# Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
# the commit having happened). The transform below then carries it across.
- name: Ensure this version's changelog is in the fastlane tree
if: env.IS_RELEASE == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
- name: Build F-Droid metadata from fastlane (single source of truth)
run: |
mkdir -p fdroid/metadata
cp -r fdroid-metadata/* fdroid/metadata/
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG
# section written to changelogs/<versionCode>.txt (same extraction as the
# Gitea release notes). en-US only — F-Droid falls back to it for locales
# without their own changelog. fdroid update bakes this into the index.
- name: Generate F-Droid changelog for this version
if: startsWith(github.ref, 'refs/tags/')
run: |
set -e
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > /tmp/changelog.txt
sed -i -e '/./,$!d' /tmp/changelog.txt
if [ ! -s /tmp/changelog.txt ]; then
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
fi
CL_DIR="fdroid/metadata/de.jeanlucmakiola.floret/en-US/changelogs"
mkdir -p "$CL_DIR"
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
# App-level control file (Categories/License/links) for the self-hosted
# repo's `fdroid update`.
cp fdroid-metadata/de.jeanlucmakiola.agendula.yml fdroid/metadata/
# Localized text + graphics + per-version changelogs come from the SAME
# fastlane tree the official F-Droid repo harvests from source,
# transformed into the F-Droid repo "localized" layout. One source of
# truth, both channels.
bash scripts/fastlane_to_fdroid_localized.sh \
fastlane/metadata/android \
fdroid/metadata/de.jeanlucmakiola.agendula
- name: Generate F-Droid Index
run: |
@@ -272,97 +320,47 @@ jobs:
SFTP
# Publish the signed repo/ plus metadata/ (descriptions, screenshots,
# per-version changelogs) so changelog history survives across
# releases. keystore.p12 and config.yml are NEVER uploaded, so they
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
# releases. keystore.p12 and config.yml are NEVER uploaded.
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the Gitea release (it's not an APK, so it fits the
# no-binaries rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: startsWith(github.ref, 'refs/tags/')
continue-on-error: true
# The APK is published and the index re-signed — now record the release.
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# this commit, so the tag only ever marks a fully-shipped release (and a
# failure before here leaves no tag, so re-running the workflow retries).
- name: Create tag + Gitea release
if: env.IS_RELEASE == 'true'
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
ASSET="mapping-${VERSION:-$TAG}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
# The release is created by the gitea-release job; ensure it exists
# (idempotent) so this job doesn't race it to a 404.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then
ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
"$API/releases" | jq -r '.id // empty')
fi
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
# notes. Deliberately no APK assets — distribution stays with the F-Droid
# repo; the release is the human-readable record. Gated on the tests-only
# ci job (not the deploy) so notes appear even if the F-Droid upload has
# an infrastructure hiccup.
gitea-release:
needs: ci
if: startsWith(github.ref, 'refs/tags/')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract changelog section for this tag
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${TAG#v}"
# Everything between "## [<version>]" and the next "## [" heading.
TAG="v$VERSION"
# Notes = this version's CHANGELOG section.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
# Trim leading blank lines.
sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi
echo "--- release notes ---"
cat release-notes.md
- name: Create Gitea release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
python3 - "$TAG" <<'PY' > payload.json
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": sys.argv[3] == "true",
}))
PY
# Upsert: the build-and-deploy job may have created a bare release
# first (to attach the mapping asset), so PATCH the notes if it
# exists, otherwise POST a new one. Both paths are re-run safe.
# Upsert (re-run safe): PATCH if a release for the tag already exists,
# else POST a new one (which also creates the tag at target_commitish).
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true)
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
@@ -376,6 +374,140 @@ jobs:
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)"
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the release (it's not an APK, so it fits the no-binaries
# rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# Publish the release on Codeberg, which is canonical for tags and
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
# to Gitea, but releases aren't git objects and don't sync in either
# direction — so this step pushes the tag straight to Codeberg and creates
# the release there over the API, attaching the signed APK plus a SHA-256
# checksum as the direct-download channel for users who don't want
# F-Droid. The APK is identical to the F-Droid one (same app key), so this
# adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
# cleanly if unset.
- name: Publish release to Codeberg
if: env.IS_RELEASE == 'true'
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
# 0.3.0, 0.3.1 and 0.3.2 while never once publishing, which is how a
# crash-fix release reached F-Droid but not the Codeberg/Obtainium
# users who needed it. A broken mirror must fail the release loudly.
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
SHA: ${{ github.sha }}
run: |
set -e
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="agendula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
# from Gitea (what 0.3.2 did) would wait forever. The tag this
# pipeline minted on Gitea is in fact *deleted* by the next mirror
# sync until Codeberg has it — so pushing it here is what makes it
# durable on both forges.
#
# Pushing the ref first and attaching with NO target_commitish is
# deliberate: a release POST carrying a target_commitish for a commit
# or tag Codeberg hasn't received yet is what produced the
# empty-bodied 500s. Attaching to a ref that already exists doesn't
# need the API to write one.
git tag -f "$TAG" "$SHA"
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
"refs/tags/$TAG"
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
import json, sys
tag, pre = sys.argv[1:3]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": open("release-notes.md").read(),
"draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": pre == "true",
}))
PY
# Create (or update) the release. Codeberg 500s on a POST/GET against a
# tag it has only just received — the release request outruns the
# indexing of the ref we pushed a moment ago — so a single attempt can
# fail even though the very same call succeeds seconds later. Retry
# with backoff, and PATCH in place if a release already exists (re-run
# safe, so re-running never disturbs a published release). A 5xx body
# still exits curl 0, so the loop, not `set -e`, controls the flow.
ID=""
for attempt in 1 2 3 4 5 6; do
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$EXIST" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
[ -n "$ID" ] && break
sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
+61
View File
@@ -0,0 +1,61 @@
name: Renovate
on:
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
schedule:
- cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab.
workflow_dispatch:
# Never let two Renovate runs touch the repo at once.
concurrency:
group: renovate
cancel-in-progress: false
jobs:
renovate:
# Gitea only — same guard, and the same reason, as release.yaml's `detect`:
# this file is invisible to Codeberg only while .forgejo/ is non-empty, and
# a repo-write token must never run on the contributor-facing runner.
if: github.repository_owner == 'makiolaj'
runs-on: docker
# Run the Renovate image *as* the job container and invoke the `renovate`
# binary directly. The renovatebot/github-action wrapper is a thin Node
# action that shells out to `docker run …` — it needs a Docker CLI + socket
# inside the job, which the Gitea runner's plain node container has not, so
# it died on "Unable to locate executable file: docker". Running the image
# directly drops the docker-in-docker requirement entirely.
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
container:
image: ghcr.io/renovatebot/renovate:43.232.0
steps:
- name: Run Renovate
run: renovate
env:
# Renovate targets Codeberg (canonical) while still RUNNING on the
# Gitea runner. Moving the job to Codeberg would put a repo-write
# token on the contributor-facing runner, which is exactly what the
# .forgejo/ vs .gitea/ split exists to prevent — so the token stays
# where the other secrets live and only the API calls cross over.
#
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
# pinned image ships a distinct forgejo platform module.
RENOVATE_PLATFORM: forgejo
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
# PR scope on jlmakiola/agendula.
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
# Scope to this repo only — no org-wide autodiscovery.
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_REPOSITORIES: '["jlmakiola/agendula"]'
# Commits/PRs authored as the bot, not a real maintainer. This address
# must be a verified email on the Codeberg bot account, otherwise the
# commits show up unattributed there.
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
# Read-only github.com PAT (no scopes needed). Nearly every dependency
# is *released* on GitHub, and without this, changelog/release-note
# lookups hit the 60/h anonymous rate limit and PRs arrive with an
# empty "Release Notes" section.
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
LOG_LEVEL: info
+18
View File
@@ -53,5 +53,23 @@ Thumbs.db
# F-Droid local artifacts (the pipeline generates them in CI)
/fdroid/
# Release-pipeline scratch files. release.yaml writes these into the workspace
# while cutting a release; a self-hosted runner reuses that workspace, so they
# must never end up committed (release-notes.md did, through 0.3.2).
/release-notes.md
/payload.json
/existing.json
/response.json
/cb-payload.json
/cb-response.json
# KSP
.ksp/
# Local agent notes: machine-specific build setup and on-device rules, not
# anything the project itself depends on.
/CLAUDE.md
# Scratch backlog. Says so in its own header — dumped items get turned into
# real work, not committed as a list.
/req_changes.md
+3
View File
@@ -0,0 +1,3 @@
[submodule "floret-kit"]
path = floret-kit
url = https://codeberg.org/jlmakiola/floret-kit.git
+114 -2
View File
@@ -1,11 +1,123 @@
# Changelog
All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/); the latest released git tag is
the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
[Keep a Changelog](https://keepachangelog.com/); the `versionName` committed in
`app/build.gradle.kts` is the source of truth for a release (see
`docs/RELEASING.md`), and the `vX.Y.Z` tag is minted by the pipeline.
## [Unreleased]
## [1.0.0] - 2026-09-21
### Added
- Agendula keeps your tasks itself now — nothing else to install. Manage lists
in the app, repeat tasks, and export any list as iCalendar.
- Settings → Storage picks where tasks live, and offers to copy them over from
OpenTasks or tasks.org.
### Changed
- Another task app is now optional. Already use one? Nothing changes on update.
### Fixed
- Edited due times no longer revert, all-day tasks no longer drift a day, and
per-task reminders are saved.
## [0.4.0] - 2026-08-31
### Added
- Agendula now speaks **German** and **Brazilian Portuguese**, the first
community translations. Pick one under **Settings → App language**, or in
Android's own per-app language settings; leave it on *System default* and
Agendula follows your phone. Anything not yet translated falls back to
English.
- Agendula can now be translated. Pick or request a language on Weblate and
translate in the browser — the link sits at the top of the language picker in
**Settings → App language**. Partial translations are fine; anything
untranslated falls back to English.
### Changed
- Agendula's home is now **Codeberg** (`jlmakiola/agendula`) — that's where the
source, issues, pull requests and releases live. The Source and License links
in Settings, the issue-reporting link and the F-Droid metadata all point there
now. The self-hosted Gitea instance stays as build infrastructure.
## [0.3.2] - 2026-07-20
### Fixed
- Releases reach the Codeberg download channel again. 0.3.1 published to
F-Droid but never appeared on Codeberg, so if you install from there — or
through Obtainium — this is the release that finally carries 0.3.0's
launch-crash fix. The app itself is unchanged from 0.3.1.
## [0.3.1] - 2026-07-20
### Fixed
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
release build stripped a constructor that the background-work scheduler needs
to open its database, and that happens before the app draws anything.
## [0.3.0] - 2026-07-19
### Added
- Reminders: Agendula now delivers your due reminders itself. A one-time setup
step explains this and asks for notification access, and a master switch in
Settings turns the whole thing off again.
- A Settings screen, from the gear on the overview: appearance and theme, which
fields the task form shows, your default list, and reminder defaults.
- The overview leads with Today — a progress ring showing how much of today
you've finished — followed by a live preview of what's coming up next.
- Search across every task, open or completed, from the top bar.
- A proper app icon.
### Changed
- A tidier top bar: no app title, with search and settings pinned to the right.
## [0.2.2] - 2026-07-19
### Fixed
- Release automation now reliably mirrors each release to the Codeberg mirror
(signed APK + SHA-256 checksum). The 0.2.1 attempt failed when the release tag
had already been synced to Codeberg.
## [0.2.1] - 2026-07-19
### Added
- Releases are now also published to the Codeberg mirror as a direct download:
each release carries the signed APK plus a SHA-256 checksum, for users who
don't use F-Droid.
## [0.2.0] - 2026-06-27
### Added
- M2 Material 3 Expressive UI — the app is fully navigable now: the
provider/permission onboarding gate, the lists overview (smart lists + user
lists grouped by account), and the task list (swipe-to-complete / -delete,
inline add, smart-list section headers), detail, and create/edit screens, all
wired to the M1 ViewModels.
- M3 detail/edit polish: a "Progress" slider (percent-complete, 5% detents,
written to `Tasks.PERCENT_COMPLETE`) and conflict-safe saves — `updateTask`
re-checks the provider's `last_modified` against the value captured when the
form loaded and surfaces an overwrite-or-cancel prompt instead of clobbering an
external change (e.g. a DAVx5 sync).
- M4 subtasks (UI): reparent — a full-width, searchable "Parent task" sheet on
the edit form, with candidates grouped by due-date section, files a task under
any active top-level task in its list (or "None" to promote it); switching list
clears the now-invalid parent. Tapping a subtask in the detail screen opens its
own detail, which shows a "Part of …" parent card. On the task list a parent has
a dedicated expand button that reveals its children as a nested grouped run,
ending with an inline "add a subtask" row (an opt-out toggle is planned for the
M6 settings screen).
- Priority is coloured by level (green / amber / red pastels) on the list, detail,
and edit screens.
### Fixed
- Overview open-counts now count top-level tasks only, so subtasks — and
especially open subtasks under a completed parent — no longer inflate a list's
"N open" or the smart-list counts.
- Swipe-to-delete now reveals its red background + icon as you drag (tracking the
live direction, not just the settled target), and a floating "Deleted · Undo"
chip defers the actual delete so it can be restored.
## [0.1.0] - 2026-06-18
### Added
+34 -8
View File
@@ -1,6 +1,6 @@
# Contributing to Floret
# Contributing to Agendula
Thanks for your interest in Floret — a Material 3 Expressive task app that's a
Thanks for your interest in Agendula — a Material 3 Expressive task app that's a
pure front-end over the OpenTasks `TaskContract` provider, with no own database
or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
@@ -30,11 +30,35 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
```sh
./gradlew :app:assembleDebug # build the debug APK
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintDebug # Android lint (CI runs this on every push)
./gradlew lintDebug # Android lint (CI runs this on every PR)
```
CI (`.gitea/workflows/ci.yaml`) runs lint → unit tests → debug build on every
push, so run these locally before opening a PR. Keep CI green.
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
then lint → unit tests → debug build on every pull request, so run these locally
before opening a PR. Keep CI green.
## Translations
**Never edit a `values-*/strings.xml` file in a pull request.** Translations are
owned by a self-hosted Weblate that writes to this repository directly, and a
hand-edit is overwritten on the next sync.
**[Translate Agendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
picks it up and offers it to translators. Partial translations are expected and
fine — missing keys are informational. Stale and orphaned keys are not, so run
```sh
python3 scripts/check_translations.py
```
before pushing. It reports those more clearly than lint's `MissingTranslation`
does, and it's what the `Translations` check runs on every PR.
A new language also needs one `<locale>` line in
`app/src/main/res/xml/locales_config.xml` — that file is the single source of
truth for both the in-app picker and the Android 13+ per-app language setting.
## Where to put code
@@ -75,12 +99,14 @@ push, so run these locally before opening a PR. Keep CI green.
- If your change shifts the architecture or completes a milestone, update
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
in the same PR.
- Don't bump `versionName` / `versionCode` by hand — the git tag drives those at
release time.
- Don't bump `versionName` / `versionCode` in a regular PR — the committed
`versionName` is bumped only when **cutting a release** (that bump reaching
`main` is what triggers the release; the pipeline then mints the tag). See
[`docs/RELEASING.md`](docs/RELEASING.md).
## Scope
Floret stays true to its thesis: a front-end over **open** task backends
Agendula stays true to its thesis: a front-end over **open** task backends
(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's
+73 -21
View File
@@ -1,11 +1,12 @@
<div align="center">
<h1>Floret</h1>
<h1>Agendula</h1>
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
Reads, writes, and reminds — on top of an existing tasks provider, with no own
sync stack.</p>
Keeps your tasks on your device, or on top of a tasks provider you already use.
Open standards, no account required.</p>
<a href="https://codeberg.org/jlmakiola/agendula/actions"><img src="https://codeberg.org/jlmakiola/agendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
@@ -13,30 +14,81 @@ sync stack.</p>
</div>
Floret is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula).
Where Calendula is a pure front-end over Android's `CalendarContract`, Floret is
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
database, no reinvented sync.
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula
keeps its own store, designed around RFC 5545's `VTODO` — the same tasks DAVx5
(and SmoothSync, DecSync, …) sync out of your CalDAV server. It can also read and
write a tasks provider you already have, for anyone already syncing that way.
A Calendula flower head is botanically made of many small *florets*the
individual items that make up the bloom. Floret is those items: your tasks.
The name rhymes with its sibling on purpose: **Agendula** is *agenda*Latin for
“things to be done” — given Calendula's `-ula` ending. Calendula keeps your days;
Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of
many small *florets* — so the two apps are florets of one bloom.)
> **Status: data layer done, UI in progress.** The full non-visual stack over
> the `TaskContract` provider — provider resolution, live-updating reads,
> writes, smart-list filtering, and a self-scheduled reminder engine — is built
> and unit-tested. The Material 3 Expressive screens are now being built on top,
> one at a time. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
## Where your tasks live — your choice
| | Where | Sync | Needs |
|---|---|---|---|
| **On your device** *(default)* | Agendula's own database | none yet — CalDAV sync of our own is planned | nothing. No account, no permissions, no other app |
| **In a provider you already use** | OpenTasks or tasks.org | whatever syncs it for you — DAVx5 and friends | that app installed, and its read/write permission |
Agendula's own store is an ordinary app database — nothing is published to other
apps, so there is no authority to clash over and no permission to grant. It
**coexists with OpenTasks rather than replacing it**: installing one never breaks
the other, and if you already sync through a provider, that keeps working exactly
as it did.
Switching between the two moves nothing — each store keeps its own tasks — so
Settings → Storage asks before it switches, and offers to **copy** a provider's
tasks into Agendula's own store when you want to move over. The copy is taken
once and the originals stay where they are; it is not an ongoing sync in either
direction.
Recurring tasks are expanded per RFC 5545, and everything the schema does not
model is round-tripped verbatim rather than dropped — so passing your tasks
through Agendula does not quietly lose fields a server sent.
Your tasks are exportable as standard iCalendar `.ics` files at any time, because
data you can't take with you isn't really yours.
> **Status: backend complete, UI catching up.** Storage, reads and
> writes, smart-list filtering, a self-scheduled reminder engine, and export are
> built and unit-tested. The Material 3 Expressive screens are being built on
> top, one at a time — the storage-mode picker and export screen are not there
> yet. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
> [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and
> [`docs/PLAN.md`](docs/PLAN.md) for the A-now-B-later design rationale.
> [`docs/STORAGE-AND-SYNC.md`](docs/STORAGE-AND-SYNC.md) for why storage works
> the way it does.
## Sync sources (by design)
Floret works with anything that writes to the tasks provider — **DAVx5**
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
adapter — because it builds on the provider, not on any one sync app. Google
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
In external-provider mode Agendula works with anything that writes to that provider —
**DAVx5** (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any
Android sync adapter — because it builds on the provider, not on any one sync
app. Google Tasks / Microsoft To Do are out of scope by design (proprietary; they
would mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync —
are the lane.
## Translations
Agendula ships in English so far, and would like not to. Translations are
managed on a self-hosted **Weblate**, and partial ones are fine — an
untranslated string simply falls back to English.
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
No coding needed: register on the Weblate server, pick (or request) a language,
and translate the strings in your browser. You can also reach this link in the
app from the top of **Settings → App language**.
## Privacy
No servers, no account, no analytics, no advertising, no tracking, no
third-party SDK. Your tasks stay on your device unless you add a CalDAV account
yourself, and then they go only to the server you chose — never to the
developer.
**→ [Privacy policy](https://jeanlucmakiola.de/agendula/privacy)**
## License
+127 -12
View File
@@ -16,22 +16,32 @@ val keystoreProperties = Properties().apply {
}
android {
namespace = "de.jeanlucmakiola.floret"
namespace = "de.jeanlucmakiola.agendula"
compileSdk = 37
defaultConfig {
applicationId = "de.jeanlucmakiola.floret"
applicationId = "de.jeanlucmakiola.agendula"
minSdk = 29
targetSdk = 36
// The git tag is the single source of truth for released builds: at
// release time .gitea/workflows/release.yaml derives both fields from
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 100
versionName = "0.1.0"
// These committed values ARE the source of truth for a release: merging
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
// pipeline graduates it on its own. See docs/RELEASING.md.
versionCode = 10000
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// The sync-adapter and authenticator XML descriptors cannot read
// BuildConfig, so the two identifiers they need are generated here.
// Derived from applicationId so the debug and releaseTest builds get
// their own and can be installed alongside the real app without their
// accounts colliding. Must stay in step with SyncContract.
resValue("string", "account_type", "de.jeanlucmakiola.agendula.caldav")
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
}
signingConfigs {
@@ -47,6 +57,11 @@ android {
buildTypes {
release {
// Keep release builds reproducible for F-Droid: don't let AGP embed
// build-environment git metadata (META-INF/version-control-info.textproto),
// whose `revision`/path content varies by build machine and is the only
// thing that otherwise differs from a clean from-source rebuild.
vcsInfo { include = false }
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
@@ -60,6 +75,26 @@ android {
debug {
applicationIdSuffix = ".debug"
isMinifyEnabled = false
resValue("string", "account_type", "de.jeanlucmakiola.agendula.debug.caldav")
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.debug.sync")
}
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
// and resource shrinking, but debug-signed and given its own applicationId
// suffix so it installs alongside both the production app (signed with the
// real key) and the debug build. Used to smoke-test a release candidate on
// a real device before merging to main — R8-only breakage and first-run/
// permission states don't surface in the unminified debug build, nor on a
// device that already holds the permission. Never published. See
// docs/RELEASING.md.
create("releaseTest") {
initWith(getByName("release"))
applicationIdSuffix = ".releasetest"
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
matchingFallbacks += "release"
resValue("string", "account_type", "de.jeanlucmakiola.agendula.releasetest.caldav")
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.releasetest.sync")
}
}
@@ -71,6 +106,20 @@ android {
buildFeatures {
compose = true
buildConfig = true
// The account type and sync authority are generated per variant so the
// debug and releaseTest builds do not fight the real app over ownership
// of an account type. AGP 9 requires opting in.
resValues = true
}
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
// "extra signing block" — so leaving it in blocks publishing to the official
// repo. It lives in the signing block, not the zip entries, so disabling it
// doesn't change the build output (reproducibility is unaffected).
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging {
@@ -79,12 +128,28 @@ android {
}
}
lint {
// Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on
// it. Likewise a translated <plurals> may not fill every CLDR quantity
// form its locale defines (e.g. Arabic needs "zero"); the missing form
// falls back to "other" at runtime, so MissingQuantity is informational
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer,
// translator-facing messages.
informational += listOf("MissingTranslation", "MissingQuantity")
}
testOptions {
unitTests {
all { it.useJUnitPlatform() }
isReturnDefaultValues = true
}
}
// MigrationTestHelper reads the exported schemas out of the test APK's
// assets, so app/schemas/ has to ship with the instrumented tests.
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
}
kotlin {
@@ -93,11 +158,27 @@ kotlin {
}
}
// Export each Room schema version to app/schemas/ and commit it. That JSON is
// what MigrationTestHelper reads to build an old database and migrate it, so
// without it a migration can only be tested by hand.
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
// Not a dependency we use directly — lifecycle already drags it in at 1.7.3.
// AGP's consistent resolution then pins androidTest to the app classpath, and
// room-testing's MigrationTestHelper needs 1.8+ to deserialize the exported
// schema; on 1.7.3 it dies with an AbstractMethodError. Raise it in one place.
constraints {
implementation(libs.kotlinx.serialization.json)
}
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
@@ -110,15 +191,48 @@ dependencies {
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences)
// Sync runs in WorkManager, triggered *through* the sync-adapter framework.
// hilt-work supplies the HiltWorkerFactory; its compiler generates the
// @HiltWorker plumbing.
implementation(libs.androidx.work.runtime.ktx)
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
implementation(libs.androidx.browser)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
implementation(libs.dmfs.lib.recur)
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
implementation(project(":dav"))
// Discovery, auth and Nextcloud Login Flow v2.
implementation(project(":caldav"))
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
// with no framework, and android.jar's stub factory returns null under
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
// real implementation here.
testImplementation(libs.xpp3)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.documentfile)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.floret.core.time)
implementation(libs.floret.core.reminders)
implementation(libs.floret.core.locale)
implementation(libs.floret.core.crash)
implementation(libs.floret.identity)
implementation(libs.floret.components)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
@@ -134,6 +248,7 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.truth)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
}
+9
View File
@@ -2,5 +2,14 @@
-keep class dagger.hilt.** { *; }
-keep @dagger.hilt.android.HiltAndroidApp class *
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
# nothing calls it directly Room then throws InstantiationException, reported
# as "Failed to create an instance of ...". This first bit us through a
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
# issue #1); Glance is gone and Room is now our own task store, so the rule
# matters more, not less TasksDatabase is built on the first store read.
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# Compose Compiler may keep its own; defaults are fine
-dontwarn org.jetbrains.annotations.**
@@ -0,0 +1,522 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "c94852274d874fe255ee76e1e46a3003",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principalUrl",
"columnName": "principal_url",
"affinity": "TEXT"
},
{
"fieldPath": "homeSetUrl",
"columnName": "home_set_url",
"affinity": "TEXT"
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "lastSyncAt",
"columnName": "last_sync_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSyncError",
"columnName": "last_sync_error",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "task_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER"
},
{
"fieldPath": "isVisible",
"columnName": "is_visible",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "isSynced",
"columnName": "is_synced",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "owner",
"columnName": "owner",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "is_read_only",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "ctag",
"columnName": "ctag",
"affinity": "TEXT"
},
{
"fieldPath": "syncToken",
"columnName": "sync_token",
"affinity": "TEXT"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_lists_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "listId",
"columnName": "list_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "etag",
"columnName": "etag",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "percentComplete",
"columnName": "percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "completedAt",
"columnName": "completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "classification",
"columnName": "classification",
"affinity": "INTEGER"
},
{
"fieldPath": "dtstart",
"columnName": "dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "due",
"columnName": "due",
"affinity": "INTEGER"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT"
},
{
"fieldPath": "isAllDay",
"columnName": "is_all_day",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT"
},
{
"fieldPath": "rrule",
"columnName": "rrule",
"affinity": "TEXT"
},
{
"fieldPath": "rdate",
"columnName": "rdate",
"affinity": "TEXT"
},
{
"fieldPath": "exdate",
"columnName": "exdate",
"affinity": "TEXT"
},
{
"fieldPath": "recurrenceId",
"columnName": "recurrence_id",
"affinity": "INTEGER"
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER"
},
{
"fieldPath": "parentId",
"columnName": "parent_id",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastModified",
"columnName": "last_modified",
"affinity": "INTEGER"
},
{
"fieldPath": "sequence",
"columnName": "sequence",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDeleted",
"columnName": "is_deleted",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "unknownProperties",
"columnName": "unknown_properties",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tasks_list_id_is_deleted",
"unique": false,
"columnNames": [
"list_id",
"is_deleted"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
},
{
"name": "index_tasks_parent_id",
"unique": false,
"columnNames": [
"parent_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
},
{
"name": "index_tasks_master_id_recurrence_id",
"unique": false,
"columnNames": [
"master_id",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
},
{
"name": "index_tasks_is_dirty",
"unique": false,
"columnNames": [
"is_dirty"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
},
{
"name": "index_tasks_list_id_uid_recurrence_id",
"unique": true,
"columnNames": [
"list_id",
"uid",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
}
],
"foreignKeys": [
{
"table": "task_lists",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"list_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"parent_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "task_alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minutesBefore",
"columnName": "minutes_before",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reference",
"columnName": "reference",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DUE'"
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_alarms_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"task_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c94852274d874fe255ee76e1e46a3003')"
]
}
}
Binary file not shown.
@@ -0,0 +1,123 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestName
import org.junit.runner.RunWith
/**
* The credential store, against a real Keystore.
*
* Instrumented rather than Robolectric because the thing under test *is* the
* platform: a shadowed Keystore would encrypt and decrypt happily and prove
* nothing about whether the key spec is usable for background sync.
*
* The case that matters most is the last one. A restored backup carries the
* ciphertext but not the key — Keystore keys are non-exportable — so the blob
* becomes permanently undecryptable. That must surface as "sign in again", never
* as a crash and never as a silently non-syncing account, which is why
* `backup_rules.xml` excludes this file in the first place.
*/
@RunWith(AndroidJUnit4::class)
class CredentialStoreTest {
@get:Rule val testName = TestName()
private lateinit var scope: CoroutineScope
private lateinit var dataStore: DataStore<Preferences>
private lateinit var store: CredentialStore
private val context: Context get() = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
// ⚠️ A file per test, and a scope we can cancel. DataStore's FileStorage
// keeps a process-wide set of active files and refuses a second
// connection to one ("There are multiple DataStores active for the same
// file"); the entry is released only when the owning scope's job
// completes, and the factory's default scope is never cancelled. Sharing
// one file across methods therefore fails every test after the first.
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
dataStore = PreferenceDataStoreFactory.create(scope = scope) {
context.preferencesDataStoreFile("credential_store_test_${testName.methodName}")
}
store = CredentialStore(dataStore)
}
@After
fun tearDown() {
runTest { store.clearAll() }
scope.cancel()
context.preferencesDataStoreFile("credential_store_test_${testName.methodName}").delete()
}
@Test
fun anAppPasswordRoundTrips() = runTest {
assertThat(store.put(accountId = 1L, appPassword = "s3cret-app-pw")).isTrue()
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("s3cret-app-pw"))
}
@Test
fun aNonLatin1PasswordSurvives() = runTest {
// The same charset trap the Basic interceptor has: anything that silently
// mangles "ä" produces a 401 the user reads as a wrong password.
store.put(accountId = 1L, appPassword = "pä§§wörd-🔐")
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("pä§§wörd-🔐"))
}
@Test
fun accountsDoNotShareACredential() = runTest {
store.put(1L, "first")
store.put(2L, "second")
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("first"))
assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second"))
}
@Test
fun anUnknownAccountIsAbsentRatherThanAnError() = runTest {
assertThat(store.get(99L)).isEqualTo(CredentialStore.Secret.Absent)
}
@Test
fun clearingRemovesOnlyThatAccount() = runTest {
store.put(1L, "first")
store.put(2L, "second")
store.clear(1L)
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Absent)
assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second"))
}
@Test
fun aCiphertextThisDeviceCannotDecryptMeansReAuthenticate() = runTest {
// Stands in for the restored-backup case: the blob is present and
// well-formed Base64, but was not produced by this device's key.
dataStore.edit {
it[stringPreferencesKey("caldav_app_password_1")] =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java)
}
@Test
fun aBlobThatIsNotBase64AtAllIsAlsoRecoverable() = runTest {
dataStore.edit { it[stringPreferencesKey("caldav_app_password_1")] = "not base64 !!" }
assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java)
}
}
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.R
import org.junit.Test
import org.junit.runner.RunWith
/**
* The account type and authority exist in two places that cannot see each other:
* `SyncContract`, derived from `BuildConfig.APPLICATION_ID`, and the `resValue`
* strings the XML descriptors read. Drift between them is invisible at build
* time and shows up as an account the sync framework will not trigger — the
* silent-no-op failure mode `docs/SYNC.md` warns about, with nothing in the log.
*/
@RunWith(AndroidJUnit4::class)
class SyncContractTest {
private val context: Context get() = ApplicationProvider.getApplicationContext()
@Test
fun theAccountTypeMatchesTheAuthenticatorDescriptor() {
assertThat(SyncContract.ACCOUNT_TYPE)
.isEqualTo(context.getString(R.string.account_type))
}
@Test
fun theAuthorityMatchesTheSyncAdapterDescriptor() {
assertThat(SyncContract.AUTHORITY)
.isEqualTo(context.getString(R.string.sync_authority))
}
@Test
fun theAuthorityMatchesTheStubProviderInTheManifest() {
// The provider is what makes the authority real; a mismatch here means
// requestSync addresses nothing.
val provider = context.packageManager
.resolveContentProvider(SyncContract.AUTHORITY, 0)
assertThat(provider).isNotNull()
assertThat(provider!!.name).isEqualTo(SyncStubProvider::class.java.name)
}
}
@@ -0,0 +1,290 @@
package de.jeanlucmakiola.agendula.data.tasks.legacy
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
import kotlin.time.Instant
/**
* The one-shot import, against `assets/tasks-v23.db` — the dmfs v23 fixture
* `scripts/make_import_fixture.py` seeds. Instrumented because both halves need
* a real SQLite: the source file and Room.
*/
@RunWith(AndroidJUnit4::class)
class OneShotImportTest {
@get:Rule
val temp = TemporaryFolder()
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var scope: CoroutineScope
private lateinit var prefs: DataStore<Preferences>
private lateinit var db: TasksDatabase
private lateinit var importer: OneShotImport
@Before
fun setUp() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
prefs = PreferenceDataStoreFactory.create(scope = scope) {
temp.newFile("import-${counter++}.preferences_pb").also(File::delete)
}
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
.allowMainThreadQueries()
.build()
importer = OneShotImport(context, db, prefs)
legacyFile().delete()
archiveFile().delete()
}
@After
fun tearDown() {
db.close()
scope.cancel()
legacyFile().delete()
archiveFile().delete()
}
private fun legacyFile() = context.getDatabasePath(OneShotImport.LEGACY_NAME)
private fun archiveFile() = context.getDatabasePath(OneShotImport.ARCHIVE_NAME)
/** The fixture, copied out of the test APK's assets. */
private fun fixture(target: File = temp.newFile("tasks-v23-copy.db")): File {
InstrumentationRegistry.getInstrumentation().context.assets.open(FIXTURE).use { source ->
target.outputStream().use(source::copyTo)
}
return target
}
private fun taskRows(): Map<String, TaskEntity> =
db.tasks().tasks(null, includeCompleted = true).associate { it.task.title!! to it.task }
// --- what lands -----------------------------------------------------------
@Test
fun importsEveryLiveTaskAndLeavesTheDeletedOneBehind() {
val counts = importer.importFrom(fixture())
assertThat(counts).isEqualTo(ImportCounts(lists = 3, tasks = 8, alarms = 2))
assertThat(taskRows().keys).containsExactly(
"Buy milk",
"Call the dentist",
"Gather receipts",
"Renew domain",
"Water the plants",
"Team offsite",
"Task in a hidden list",
"Ship the release",
)
}
@Test
fun importsEveryListAsADeviceOnlyListWithItsFlags() {
importer.importFrom(fixture())
val lists = db.taskLists().lists().associateBy { it.list.name }
assertThat(lists.keys).containsExactly("Personal", "Hidden list", "Work")
assertThat(lists.values.map { it.list.accountId }).containsExactly(null, null, null)
assertThat(lists.getValue("Personal").list.isVisible).isTrue()
assertThat(lists.getValue("Hidden list").list.isVisible).isFalse()
// The list that sat under a real account: still imported, owner kept.
assertThat(lists.getValue("Work").list.owner).isEqualTo("Me")
assertThat(lists.getValue("Work").list.color).isEqualTo(0xFF2244AA.toInt())
}
@Test
fun carriesTheTaskFieldsAcross() {
importer.importFrom(fixture())
val tasks = taskRows()
val milk = tasks.getValue("Buy milk")
assertThat(milk.due).isEqualTo(Instant.fromEpochMilliseconds(T0 + DAY))
assertThat(milk.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(milk.createdAt).isEqualTo(Instant.fromEpochMilliseconds(T0))
val dentist = tasks.getValue("Call the dentist")
assertThat(dentist.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(dentist.percentComplete).isEqualTo(40)
val domain = tasks.getValue("Renew domain")
assertThat(domain.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(domain.completedAt).isEqualTo(Instant.fromEpochMilliseconds(T0 - DAY))
val plants = tasks.getValue("Water the plants")
assertThat(plants.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH")
assertThat(plants.timezone).isEqualTo("Europe/Berlin")
assertThat(plants.dtstart).isEqualTo(Instant.fromEpochMilliseconds(T0))
assertThat(tasks.getValue("Team offsite").isAllDay).isTrue()
}
// --- uids -----------------------------------------------------------------
@Test
fun keepsExistingUidsAndMintsOneWhereTheLegacyRowHadNone() {
importer.importFrom(fixture())
val tasks = taskRows()
assertThat(tasks.getValue("Buy milk").uid).isEqualTo("a1b2c3d4-0000-4000-8000-000000000001")
// The external-account row's uid is what lets it be re-attached later.
assertThat(tasks.getValue("Ship the release").uid)
.isEqualTo("a1b2c3d4-0000-4000-8000-000000000009")
val minted = tasks.getValue("Call the dentist").uid
assertThat(minted).isNotEmpty()
assertThat(UUID.fromString(minted).version()).isEqualTo(4)
assertThat(tasks.values.map { it.uid }.toSet()).hasSize(tasks.size)
}
// --- the id remap ---------------------------------------------------------
@Test
fun remapsListIdsOntoTheNewRowIds() {
importer.importFrom(fixture())
val lists = db.taskLists().lists().associateBy { it.list.name }
val byList = db.tasks().tasks(null, includeCompleted = true)
.groupBy { it.task.listId }
.mapValues { (_, rows) -> rows.size }
assertThat(byList[lists.getValue("Personal").list.id]).isEqualTo(6)
assertThat(byList[lists.getValue("Hidden list").list.id]).isEqualTo(1)
assertThat(byList[lists.getValue("Work").list.id]).isEqualTo(1)
// No task kept a dmfs row id that Room never handed out.
assertThat(byList.keys).containsExactlyElementsIn(lists.values.map { it.list.id })
}
@Test
fun remapsParentIdsOntoTheNewRowIds() {
importer.importFrom(fixture())
val tasks = taskRows()
val parent = tasks.getValue("Buy milk")
val child = tasks.getValue("Gather receipts")
assertThat(child.parentId).isEqualTo(parent.id)
assertThat(db.tasks().subtasks(parent.id).map { it.task.title }).containsExactly("Gather receipts")
assertThat(tasks.values.filter { it.parentId != null }).hasSize(1)
}
// --- alarms ---------------------------------------------------------------
@Test
fun importsAlarmsAndSkipsEveryOtherProperty() {
importer.importFrom(fixture())
val tasks = taskRows()
assertThat(db.alarms().all()).hasSize(2)
val milk = db.alarms().forTask(tasks.getValue("Buy milk").id).single()
assertThat(milk.minutesBefore).isEqualTo(30)
assertThat(milk.reference).isEqualTo(AlarmReference.DUE)
assertThat(milk.message).isNull()
val release = db.alarms().forTask(tasks.getValue("Ship the release").id).single()
assertThat(release.minutesBefore).isEqualTo(1440)
assertThat(release.reference).isEqualTo(AlarmReference.DUE)
assertThat(release.message).isEqualTo("Ship it")
// The category property on task 1 is not an alarm.
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
}
// --- running it -----------------------------------------------------------
@Test
fun runIfNeededImportsArchivesTheSourceAndThenDoesNothing() = runBlocking {
fixture(legacyFile())
val first = importer.runIfNeeded()
assertThat(first).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
assertThat(legacyFile().exists()).isFalse()
assertThat(archiveFile().exists()).isTrue()
assertThat(importer.isDone.first()).isTrue()
val second = importer.runIfNeeded()
assertThat(second).isEqualTo(ImportResult.AlreadyDone)
assertThat(taskRows()).hasSize(8)
}
@Test
fun anInterruptedImportResumesFromTheArchiveWithoutDoubling() = runBlocking {
// The process dying between the commit and the flag write is the one gap
// the DataStore flag cannot cover on its own. Because the rename happens
// first and the import always replaces, the next run finds the archive and
// redoes the same work rather than importing a second copy.
fixture(legacyFile())
importer.runIfNeeded()
importer.clearCompletion()
val resumed = importer.runIfNeeded()
assertThat(resumed).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
assertThat(taskRows()).hasSize(8)
assertThat(db.taskLists().lists()).hasSize(3)
assertThat(db.alarms().all()).hasSize(2)
}
@Test
fun runIfNeededMarksItselfDoneWhenThereIsNoLegacyDatabase() = runBlocking {
assertThat(importer.runIfNeeded()).isEqualTo(ImportResult.NothingToImport)
assertThat(importer.isDone.first()).isTrue()
assertThat(taskRows()).isEmpty()
}
@Test
fun reimportFromTheArchiveReplacesRatherThanMerges() = runBlocking {
fixture(legacyFile())
importer.runIfNeeded()
val again = importer.reimportFromArchive()
assertThat(again).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
assertThat(db.taskLists().lists()).hasSize(3)
assertThat(taskRows()).hasSize(8)
assertThat(db.alarms().all()).hasSize(2)
assertThat(archiveFile().exists()).isTrue()
}
@Test
fun replacingTwiceFromTheSameFileLeavesOneCopy() {
importer.importFrom(fixture())
val counts = importer.importFrom(fixture(temp.newFile("second.db")), replaceExisting = true)
assertThat(counts).isEqualTo(ImportCounts(3, 8, 2))
assertThat(taskRows()).hasSize(8)
assertThat(db.taskLists().lists()).hasSize(3)
assertThat(db.alarms().all()).hasSize(2)
}
private companion object {
const val FIXTURE = "tasks-v23.db"
const val T0 = 1_768_467_600_000L
const val DAY = 86_400_000L
var counter = 0
}
}
@@ -0,0 +1,480 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
/**
* The seam over Room, exercised through [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] rather than the DAOs — recurrence expansion and override
* forking only exist at this level.
*/
@RunWith(AndroidJUnit4::class)
class RoomTasksDataSourceTest {
private lateinit var db: TasksDatabase
private lateinit var source: RoomTasksDataSource
private var listId = 0L
/** Truncated to the store's granularity: instants are columns of epoch millis. */
private val now get() = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds())
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
TasksDatabase::class.java,
).allowMainThreadQueries().build()
source = RoomTasksDataSource(db)
listId = source.createLocalList("Personal", 0xFF112233.toInt())
}
@After
fun tearDown() = db.close()
private fun form(
title: String = "task",
due: Instant? = null,
percentComplete: Int? = null,
) = TaskForm(title = title, listId = listId, due = due, percentComplete = percentComplete)
/** A list that belongs to an account, so writes owe a server something. */
private fun syncedList(): Long {
val accountId = db.accounts().insert(
AccountEntity(displayName = "me@example.com", username = "me"),
)
return db.taskLists().insert(
TaskListEntity(name = "Work", color = 0, accountId = accountId, href = "https://s/w/"),
)
}
/** Turns [taskId] into a weekly series anchored at [anchor]. */
private fun makeRecurring(taskId: Long, anchor: Instant, rule: String = "FREQ=WEEKLY") {
val entity = db.tasks().entity(taskId)!!
db.tasks().update(entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = rule))
}
@Test
fun createsAndReadsBackALocalList() {
val lists = source.taskLists()
assertThat(lists).hasSize(1)
assertThat(lists.single().name).isEqualTo("Personal")
// No account, so the list still has to report something the lists screen
// can group under.
assertThat(lists.single().isLocal).isTrue()
assertThat(lists.single().accountName).isEqualTo("Local")
}
@Test
fun renamesAndRecoloursAList() {
source.updateList(listId, " Errands ", 0xFF445566.toInt())
val list = source.taskLists().single()
assertThat(list.name).isEqualTo("Errands")
assertThat(list.color).isEqualTo(0xFF445566.toInt())
// Nothing to sync a device-only list to, so the edit leaves it clean.
assertThat(db.taskLists().entity(listId)!!.isDirty).isFalse()
}
@Test
fun deletingAListTakesItsTasksWithIt() {
source.insertTask(form(title = "Buy milk"))
source.insertTask(form(title = "Call the bank"))
val other = source.createLocalList("Work", 0xFF778899.toInt())
val keeper = source.insertTask(TaskForm(title = "Ship it", listId = other))
source.deleteList(listId)
assertThat(source.taskLists().map { it.id }).containsExactly(other)
assertThat(source.tasks(TaskQuery(includeCompleted = true)).map { it.taskId })
.containsExactly(keeper)
}
@Test
fun createsAndReadsBackANonRecurringTask() {
val due = now + 1.days
val id = source.insertTask(form(title = "Buy milk", due = due))
val task = source.task(id)!!
assertThat(task.taskId).isEqualTo(id)
assertThat(task.title).isEqualTo("Buy milk")
assertThat(task.due).isEqualTo(due)
assertThat(task.isRecurring).isFalse()
// A task that does not recur has no occurrence anchor, so it keys and edits
// by task id exactly as it did against the provider.
assertThat(task.occurrenceStart).isNull()
assertThat(task.occurrenceKey).isEqualTo("$id")
}
@Test
fun mintsAUidForEveryTask() {
val id = source.insertTask(form())
assertThat(db.tasks().entity(id)!!.uid).isNotEmpty()
}
@Test
fun expandsARecurringSeriesIntoManyOccurrences() {
val anchor = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, anchor)
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
// The provider materialised exactly one upcoming occurrence; we expand the
// whole window, so a weekly series yields well over a hundred.
assertThat(occurrences.size).isGreaterThan(100)
assertThat(occurrences.map { it.occurrenceStart }).containsNoDuplicates()
assertThat(occurrences.map { it.occurrenceKey }).containsNoDuplicates()
assertThat(occurrences.all { it.isRecurring }).isTrue()
// Each occurrence keeps the series' length rather than the master's dates.
val first = occurrences.minBy { it.occurrenceStart!! }
assertThat(first.due!! - first.start!!).isEqualTo(1.days)
}
@Test
fun exactlyOneOccurrenceIsTheCurrentOne() {
val id = source.insertTask(form())
makeRecurring(id, now - 30.days)
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
assertThat(occurrences.count { it.distanceFromCurrent == 0 }).isEqualTo(1)
assertThat(source.task(id)!!.distanceFromCurrent).isEqualTo(0)
}
@Test
fun editingOneOccurrenceForksARecurrenceIdOverride() {
val anchor = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, anchor)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
val override = db.tasks().override(id, target.occurrenceStart)!!
// RFC 5545's model: the override shares its master's UID — that is what
// makes it an override rather than a separate task. The dmfs provider
// detached the occurrence into a new task with its own UID instead.
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
assertThat(override.masterId).isEqualTo(id)
assertThat(override.recurrenceId).isEqualTo(target.occurrenceStart)
assertThat(override.rrule).isNull()
assertThat(override.title).isEqualTo("Water them twice")
}
@Test
fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
val before = open()
val target = before.first { it.distanceFromCurrent == 0 }
source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
// Writing the status onto the master would close the series: the master is
// the row the task query filters on, so every occurrence would vanish.
val after = open()
assertThat(after).hasSize(before.size - 1)
assertThat(after.map { it.occurrenceStart }).doesNotContain(target.occurrenceStart)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(override.rrule).isNull()
// The override stands for *that* occurrence, so it carries the
// occurrence's resolved times, not the master's anchor.
assertThat(override.dtstart).isEqualTo(target.occurrenceStart)
}
@Test
fun reopeningACompletedOccurrenceReusesItsOverride() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = listId))
.first { it.taskId == id && it.distanceFromCurrent == 0 }
source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
source.setCompletedInstance(id, target.occurrenceStart, completed = false)
assertThat(db.tasks().overrides(id)).hasSize(1)
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status)
.isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart })
.contains(target.occurrenceStart)
}
@Test
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
source.setCompletedInstance(id, now, completed = true)
assertThat(db.tasks().overrides(id)).isEmpty()
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun anOverrideReplacesOnlyItsOwnOccurrence() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
// The list holds this series alone, so no filter is needed — and none can
// be written on taskId, since the override reports its own row id.
val before = source.tasks(TaskQuery(listId = listId))
val target = before.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
val after = source.tasks(TaskQuery(listId = listId))
assertThat(after).hasSize(before.size)
val edited = after.single { it.title == "Water them twice" }
assertThat(edited.occurrenceStart).isEqualTo(target.occurrenceStart)
assertThat(after.filter { it.occurrenceStart == target.occurrenceStart }).hasSize(1)
}
/**
* An edited occurrence addresses its own row, not the master's. That is what
* sends the *next* edit down `updateTask` rather than forking a second time:
* an override carries no rule, so it reads back as non-recurring.
*/
@Test
fun anEditedOccurrenceReportsTheOverridesOwnId() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = listId)).first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
val edited = source.tasks(TaskQuery(listId = listId)).single { it.title == "Water them twice" }
val overrideId = db.tasks().override(id, target.occurrenceStart)!!.id
assertThat(edited.taskId).isEqualTo(overrideId)
assertThat(edited.taskId).isNotEqualTo(id)
assertThat(source.task(overrideId)!!.isRecurring).isFalse()
}
@Test
fun editingASeriesDoesNotReAnchorItWhenOneOccurrenceIsEdited() {
val anchor = now
val id = source.insertTask(form())
makeRecurring(id, anchor)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 2 }
source.updateInstance(id, target.occurrenceStart!!, form(due = now + 99.days))
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(anchor)
}
@Test
fun updatingANonRecurringTaskWritesThroughToItsRow() {
val id = source.insertTask(form(title = "old"))
source.updateTask(id, form(title = "new"))
assertThat(source.task(id)!!.title).isEqualTo("new")
}
@Test
fun completionTogglesTheWholeTriple() {
val id = source.insertTask(form())
source.setCompleted(id, completed = true)
val done = db.tasks().entity(id)!!
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(done.percentComplete).isEqualTo(100)
assertThat(done.completedAt).isNotNull()
source.setCompleted(id, completed = false)
assertThat(db.tasks().entity(id)!!.completedAt).isNull()
}
@Test
fun completedTasksAreExcludedUnlessAskedFor() {
val id = source.insertTask(form())
source.setCompleted(id, completed = true)
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = false))).isEmpty()
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = true))).hasSize(1)
}
@Test
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
val id = source.insertTask(form(due = now + 1.days))
// The whole reminder, not just the minute count: collapsing it to a bare
// Int is what fired an imported START-referenced alarm off DUE, and an
// alarm this seam sets from the UI is always due-referenced.
source.setAlarm(id, 30)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30))
source.setAlarm(id, 60)
assertThat(db.alarms().forTask(id)).hasSize(1)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
source.setAlarm(id, null)
assertThat(source.alarms()).doesNotContainKey(id)
}
@Test
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
val id = source.insertTask(form(due = now + 1.days))
makeRecurring(id, now)
source.setAlarm(id, 30)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form())
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
}
@Test
fun deletingATaskInALocalListRemovesItOutright() {
val id = source.insertTask(form())
source.deleteTask(id)
// No account knows about it, so there is nothing to tombstone for.
assertThat(db.tasks().entity(id)).isNull()
}
@Test
fun deletingASeriesTakesItsOverridesWithIt() {
val id = source.insertTask(form())
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
source.deleteTask(id)
assertThat(db.tasks().allOverrides(listId)).isEmpty()
}
@Test
fun aForkedOccurrenceCarriesTheSeriesReminder() {
val id = source.insertTask(form())
makeRecurring(id, now)
source.setAlarm(id, minutesBeforeDue = 30)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
// The fork copies the master's properties, which is what carries the
// reminder across — the repository is what puts the series' own back.
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
}
@Test
fun anOccurrenceCannotBeMovedOutOfItsSeriesList() {
val other = source.createLocalList("Work", 0)
val id = source.insertTask(form())
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
val override = db.tasks().override(id, target.occurrenceStart)!!
source.updateTask(override.id, TaskForm(title = "moved", listId = other))
// ⚠️ list_id = B with master_id in list A is invisible in both — the task
// query skips non-null master_id, and the override query finds no master
// in B — while still uploading as part of A's resource.
assertThat(db.tasks().entity(override.id)!!.listId).isEqualTo(listId)
assertThat(db.tasks().entity(override.id)!!.title).isEqualTo("moved")
}
@Test
fun deletingASyncedSeriesTombstonesItsOverridesToo() {
val syncedList = syncedList()
val id = source.insertTask(TaskForm(title = "Standup", listId = syncedList))
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = syncedList))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, TaskForm(title = "moved", listId = syncedList))
source.deleteTask(id)
// ⚠️ master_id cascades on delete, and a tombstone deletes nothing — so
// a master marked alone left the resource reading as partly deleted, the
// DELETE was never sent, and the task stayed on the server for ever.
val rows = db.tasks().allIn(syncedList)
assertThat(rows).hasSize(2)
assertThat(rows.all { it.isDeleted && it.isDirty }).isTrue()
}
@Test
fun deletingOneOccurrenceExceptsItOnTheMaster() {
val id = source.insertTask(form())
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
val override = db.tasks().override(id, target.occurrenceStart)!!
source.deleteTask(override.id)
// ⚠️ Dropping the override row un-overrides the occurrence, and the
// master's RRULE regenerates it. The EXDATE is the deletion.
assertThat(db.tasks().entity(override.id)).isNull()
assertThat(db.tasks().entity(id)!!.exdate).isNotEmpty()
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart })
.doesNotContain(target.occurrenceStart)
}
@Test
fun subtasksReadBackUnderTheirParent() {
val parent = source.insertTask(form(title = "Prepare invoice"))
val child = source.insertTask(form(title = "Gather receipts").copy(parentId = parent))
assertThat(source.subtasks(parent).map { it.taskId }).containsExactly(child)
}
@Test
fun exportReadsMastersNotOccurrences() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
val exported = source.exportTasks(listId)
// One row carrying the rule, not one row per occurrence with the rule lost.
assertThat(exported).hasSize(1)
assertThat(exported.single().rrule).isEqualTo("FREQ=WEEKLY")
assertThat(exported.single().uid).isNotEmpty()
}
@Test
fun insertingIntoAMissingListFails() {
val thrown = runCatching { source.insertTask(form().copy(listId = 9_999)) }.exceptionOrNull()
assertThat(thrown).isNotNull()
}
}
@@ -0,0 +1,68 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The migration harness, proven against the committed schema in `app/schemas/`.
*
* There is one schema version today, so all there is to assert is that the helper
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
* export, the assets wiring and the identity hash all line up. That is the point:
* the first real migration only has to add its own case.
*
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
* database, and add a test here shaped like this:
*
* ```
* helper.createDatabase(TEST_DB, 1).use { db ->
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
* }
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
* // read the seeded rows back — validation proves the shape, not the data
* }
* ```
*/
@RunWith(AndroidJUnit4::class)
class TasksDatabaseMigrationTest {
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
TasksDatabase::class.java,
)
@Test
fun buildsV1FromTheExportedSchema() {
helper.createDatabase(TEST_DB, 1).use { db ->
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Groceries', 0)")
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (1, 1, 'uid-1', 'Buy milk')")
db.query("SELECT title FROM tasks").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getString(0)).isEqualTo("Buy milk")
}
}
}
@Test
fun validatesV1AgainstTheExportedSchema() {
helper.createDatabase(TEST_DB, 1).close()
// No migrations to run: v1 is opened and checked against 1.json, which is
// what proves the harness rather than the schema.
helper.runMigrationsAndValidate(TEST_DB, 1, true).use { db ->
assertThat(db.version).isEqualTo(1)
}
}
private companion object {
const val TEST_DB = "migration-test.db"
}
}
@@ -0,0 +1,107 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.domain.TaskForm
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.measureTime
import kotlin.time.measureTimedValue
/**
* The plan's shape at scale: 5,000 tasks with 20 recurring series, read the way a
* smart list reads them — one `tasks(TaskQuery(includeCompleted = true))`, which
* includes expanding every series in memory.
*
* The assertion is a deliberately loose ceiling, so it catches a real regression
* rather than CI jitter; the printed numbers are what the check is actually for.
*/
@RunWith(AndroidJUnit4::class)
class TasksDatabasePerformanceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var db: TasksDatabase
private lateinit var source: RoomTasksDataSource
private var listId = 0L
@Before
fun setUp() {
delete()
db = Room.databaseBuilder(context, TasksDatabase::class.java, DB)
.allowMainThreadQueries()
.build()
source = RoomTasksDataSource(db)
listId = source.createLocalList("Everything", 0xFF112233.toInt())
}
@After
fun tearDown() {
db.close()
delete()
}
@Test
fun readsFiveThousandTasksWithTwentySeriesInsideTheBudget() {
val seeded = measureTime { seed() }
// Discard the first read: it pays for statement compilation and page cache
// warming, which a running app has already paid.
source.tasks(TaskQuery(includeCompleted = true))
val (tasks, elapsed) = measureTimedValue {
source.tasks(TaskQuery(includeCompleted = true))
}
println(
"[perf] $TASK_COUNT tasks / $SERIES_COUNT series -> ${tasks.size} occurrences " +
"in $elapsed (seed $seeded)",
)
// Expansion is bounded twice over: the read window is 1 year back and 2
// forward, and each series stops at ExpansionWindow.maxOccurrences (500),
// so the occurrence count cannot grow with the age of the series.
assertThat(tasks.size).isAtLeast(TASK_COUNT)
assertThat(elapsed.inWholeMilliseconds).isLessThan(CEILING_MILLIS)
}
private fun seed() {
val anchor = Clock.System.now() - 30.days
val ids = ArrayList<Long>(TASK_COUNT)
db.runInTransaction {
repeat(TASK_COUNT) { index ->
ids += source.insertTask(
TaskForm(title = "Task $index", listId = listId, due = anchor + index.days),
)
}
}
db.runInTransaction {
ids.take(SERIES_COUNT).forEach { id ->
val entity = db.tasks().entity(id)!!
db.tasks().update(
entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = "FREQ=DAILY"),
)
}
}
}
private fun delete() {
val base = context.getDatabasePath(DB)
base.delete()
listOf("-wal", "-shm").forEach { File(base.path + it).delete() }
}
private companion object {
const val DB = "performance-test.db"
const val TASK_COUNT = 5_000
const val SERIES_COUNT = 20
const val CEILING_MILLIS = 8_000L
}
}
@@ -0,0 +1,155 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.domain.TaskForm
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* The Auto Backup restore path, on disk.
*
* Auto Backup copies database files without checkpointing, and Room runs in WAL
* mode — so `.db` alone can be a *stale* copy of a database whose recent writes
* are still in the `-wal` sidecar. `res/xml/backup_rules.xml` carries all three
* files and [DatabaseCheckpoint] truncates the log on `ON_STOP`; this asserts
* that both of those actually do what they claim, and that neither alone is an
* assumption.
*
* A file copy of a live database stands in for the backup transport — the
* transport is what Auto Backup does to these files, and it is not what is under
* test here.
*/
@RunWith(AndroidJUnit4::class)
class TasksDatabaseRestoreTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var db: TasksDatabase
private lateinit var source: RoomTasksDataSource
private var listId = 0L
private var restored: TasksDatabase? = null
@Before
fun setUp() {
delete(LIVE)
delete(BACKUP)
db = open(LIVE)
source = RoomTasksDataSource(db)
listId = source.createLocalList("Personal", 0xFF112233.toInt())
}
@After
fun tearDown() {
restored?.close()
db.close()
delete(LIVE)
delete(BACKUP)
}
@Test
fun roomRunsInWalMode() {
// Everything below is only interesting because of this.
assertThat(journalMode()).isEqualTo("wal")
}
@Test
fun aBackupOfTheDbFileAloneLosesWhateverIsStillInTheWal() {
write("checkpointed")
checkpoint()
write("only in the wal")
backUp(withSidecars = false)
assertThat(restore()).containsExactly("checkpointed")
}
@Test
fun aBackupThatCarriesTheSidecarsKeepsTheLastWrite() {
write("checkpointed")
checkpoint()
write("only in the wal")
backUp(withSidecars = true)
assertThat(restore()).containsExactly("checkpointed", "only in the wal")
}
@Test
fun checkpointingFirstMakesTheDbFileAloneEnough() {
write("checkpointed")
checkpoint()
write("last write")
// What DatabaseCheckpoint runs on ON_STOP — the fallback for a restore
// that arrives without the sidecars.
checkpoint()
backUp(withSidecars = false)
assertThat(restore()).containsExactly("checkpointed", "last write")
}
// --- the moving parts -----------------------------------------------------
private fun open(name: String): TasksDatabase =
Room.databaseBuilder(context, TasksDatabase::class.java, name)
.allowMainThreadQueries()
.build()
private fun write(title: String) {
source.insertTask(TaskForm(title = title, listId = listId))
}
private fun journalMode(): String =
db.openHelper.writableDatabase.query("PRAGMA journal_mode").use { cursor ->
cursor.moveToFirst()
cursor.getString(0).lowercase()
}
/** [DatabaseCheckpoint]'s pragma, asserting it was not blocked by a reader. */
private fun checkpoint() {
db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor ->
cursor.moveToFirst()
assertThat(cursor.getInt(0)).isEqualTo(0)
}
}
/** Copies the live database the way Auto Backup would: no checkpoint, files as they lie. */
private fun backUp(withSidecars: Boolean) {
delete(BACKUP)
val live = context.getDatabasePath(LIVE)
val backup = context.getDatabasePath(BACKUP)
live.copyTo(backup, overwrite = true)
if (!withSidecars) return
SIDECARS.forEach { suffix ->
val from = File(live.path + suffix)
if (from.exists()) from.copyTo(File(backup.path + suffix), overwrite = true)
}
}
/** Opens the copy as a fresh install would and reports the task titles that survived. */
private fun restore(): List<String> {
restored?.close()
val database = open(BACKUP).also { restored = it }
return RoomTasksDataSource(database).tasks(TaskQuery(includeCompleted = true)).map { it.title }
}
private fun delete(name: String) {
val base = context.getDatabasePath(name)
base.delete()
SIDECARS.forEach { File(base.path + it).delete() }
}
private companion object {
const val LIVE = "restore-live.db"
const val BACKUP = "restore-backup.db"
val SIDECARS = listOf("-wal", "-shm")
}
}
@@ -0,0 +1,274 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Instant
/**
* The schema, exercised through the DAOs. Instrumented rather than JVM because
* the app's unit tests are plain JUnit 5 with no Robolectric, and Room needs a
* real SQLite.
*/
@RunWith(AndroidJUnit4::class)
class TasksDatabaseTest {
private lateinit var db: TasksDatabase
private lateinit var lists: TaskListDao
private lateinit var tasks: TaskDao
private lateinit var alarms: TaskAlarmDao
private lateinit var accounts: AccountDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
TasksDatabase::class.java,
).allowMainThreadQueries().build()
lists = db.taskLists()
tasks = db.tasks()
alarms = db.alarms()
accounts = db.accounts()
}
@After
fun tearDown() = db.close()
private fun newList(name: String = "Groceries", accountId: Long? = null): Long =
lists.insert(TaskListEntity(name = name, color = 0xFF00FF00.toInt(), accountId = accountId))
private fun newTask(
listId: Long,
uid: String = "uid-${counter++}",
title: String? = "Buy milk",
status: TaskStatus = TaskStatus.NEEDS_ACTION,
parentId: Long? = null,
masterId: Long? = null,
recurrenceId: Instant? = null,
): Long = tasks.insert(
TaskEntity(
listId = listId,
uid = uid,
title = title,
status = status,
parentId = parentId,
masterId = masterId,
recurrenceId = recurrenceId,
),
)
@Test
fun writesAndReadsAListWithItsTasks() {
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
val listId = newList(accountId = accountId)
val due = Instant.fromEpochMilliseconds(1_700_000_000_000)
val taskId = tasks.insert(
TaskEntity(
listId = listId,
uid = "uid-1",
title = "Buy milk",
description = "2%",
due = due,
priority = 3,
status = TaskStatus.IN_PROCESS,
percentComplete = 40,
),
)
val list = lists.lists().single()
assertThat(list.list.id).isEqualTo(listId)
assertThat(list.list.name).isEqualTo("Groceries")
assertThat(list.accountDisplayName).isEqualTo("Fastmail")
val row = tasks.task(taskId)!!
assertThat(row.task.title).isEqualTo("Buy milk")
assertThat(row.task.due).isEqualTo(due)
// Stored raw: an off-bucket PRIORITY must come back as it went in.
assertThat(row.task.priority).isEqualTo(3)
assertThat(row.task.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(row.task.percentComplete).isEqualTo(40)
assertThat(row.listName).isEqualTo("Groceries")
assertThat(row.accountDisplayName).isEqualTo("Fastmail")
}
@Test
fun readsTasksOfOneListAndHidesClosedOnesUnlessAsked() {
val a = newList("A")
val b = newList("B")
newTask(a, title = "open")
newTask(a, title = "done", status = TaskStatus.COMPLETED)
newTask(a, title = "cancelled", status = TaskStatus.CANCELLED)
newTask(b, title = "elsewhere")
assertThat(tasks.tasks(a, includeCompleted = false).map { it.task.title })
.containsExactly("open")
assertThat(tasks.tasks(a, includeCompleted = true)).hasSize(3)
assertThat(tasks.tasks(null, includeCompleted = true)).hasSize(4)
}
@Test
fun readsSubtasksByParent() {
val listId = newList()
val parent = newTask(listId, title = "parent")
newTask(listId, title = "child", parentId = parent)
assertThat(tasks.subtasks(parent).map { it.task.title }).containsExactly("child")
}
@Test
fun hidesTombstonesFromReadsAndExports() {
val listId = newList()
val taskId = newTask(listId)
tasks.markDeleted(taskId, Instant.fromEpochMilliseconds(1))
assertThat(tasks.tasks(listId, includeCompleted = true)).isEmpty()
assertThat(tasks.task(taskId)).isNull()
assertThat(tasks.exportTasks(listId)).isEmpty()
assertThat(tasks.entity(taskId)).isNotNull()
}
@Test
fun keepsOverridesOutOfTheMasterReads() {
val listId = newList()
val master = newTask(listId, uid = "series")
val override = newTask(
listId,
uid = "series",
masterId = master,
recurrenceId = Instant.fromEpochMilliseconds(5_000),
)
assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id })
.containsExactly(master)
assertThat(tasks.overrides(master).map { it.id }).containsExactly(override)
assertThat(tasks.allOverrides(listId).map { it.id }).containsExactly(override)
assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id)
.isEqualTo(override)
assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master)
}
// --- cascades -------------------------------------------------------------
@Test
fun deletingAListDeletesItsTasks() {
val listId = newList()
val taskId = newTask(listId)
lists.delete(listId)
assertThat(tasks.entity(taskId)).isNull()
}
@Test
fun deletingASeriesDeletesItsOverrides() {
val listId = newList()
val master = newTask(listId, uid = "series")
val override = newTask(
listId,
uid = "series",
masterId = master,
recurrenceId = Instant.fromEpochMilliseconds(5_000),
)
tasks.delete(master)
assertThat(tasks.entity(override)).isNull()
}
@Test
fun deletingAParentPromotesItsSubtasks() {
val listId = newList()
val parent = newTask(listId, title = "parent")
val child = newTask(listId, title = "child", parentId = parent)
tasks.delete(parent)
val promoted = tasks.entity(child)
assertThat(promoted).isNotNull()
assertThat(promoted!!.parentId).isNull()
}
@Test
fun deletingATaskDeletesItsAlarms() {
val listId = newList()
val taskId = newTask(listId)
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
assertThat(alarms.all()).hasSize(1)
tasks.delete(taskId)
assertThat(alarms.all()).isEmpty()
}
@Test
fun deletingAnAccountDetachesItsListsInsteadOfDeletingThem() {
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
val listId = newList(accountId = accountId)
accounts.delete(accountId)
assertThat(lists.entity(listId)!!.accountId).isNull()
}
@Test
fun replacingAnAlarmLeavesOnlyTheNewOne() {
val listId = newList()
val taskId = newTask(listId)
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 30))
assertThat(alarms.forTask(taskId).map { it.minutesBefore }).containsExactly(30)
assertThat(alarms.forTask(taskId).single().reference).isEqualTo(AlarmReference.DUE)
alarms.replaceForTask(taskId, null)
assertThat(alarms.forTask(taskId)).isEmpty()
}
// --- the unique index -----------------------------------------------------
@Test
fun anOverrideMayShareItsMastersUid() {
val listId = newList()
val master = newTask(listId, uid = "series")
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(1))
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(2))
assertThat(tasks.overrides(master)).hasSize(2)
}
@Test
fun rejectsTwoOverridesOfTheSameOccurrence() {
val listId = newList()
val master = newTask(listId, uid = "series")
val at = Instant.fromEpochMilliseconds(1)
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
val failure = runCatching {
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
}.exceptionOrNull()
assertThat(failure).isNotNull()
assertThat(failure!!.message).contains("UNIQUE")
}
@Test
fun theSameUidMayExistInAnotherList() {
val a = newList("A")
val b = newList("B")
newTask(a, uid = "shared")
newTask(b, uid = "shared")
assertThat(tasks.byUid(a, "shared")).isNotNull()
assertThat(tasks.byUid(b, "shared")).isNotNull()
}
private companion object {
var counter = 0
}
}
@@ -0,0 +1,317 @@
package de.jeanlucmakiola.agendula.data.tasks.transfer
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import java.io.File
import javax.inject.Provider
import kotlin.time.Instant
/**
* The copy out of an external provider and into Room — the upgrade path every
* released install actually needs, since no release ever bundled the provider
* `OneShotImport` reads (see `docs/OWN-STORE.md`).
*
* The source is a fake [TasksDataSource] rather than a live OpenTasks: what is
* worth testing is the write half — id remapping, uid collisions, verified
* counts, the once-only guard — and pinning that to a device with a third-party
* app installed would mean it never ran. Instrumented all the same, because the
* destination is a real Room database in a real transaction.
*/
@RunWith(AndroidJUnit4::class)
class ExternalImportTest {
@get:Rule
val temp = TemporaryFolder()
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var scope: CoroutineScope
private lateinit var prefs: DataStore<Preferences>
private lateinit var db: TasksDatabase
private lateinit var source: FakeExternalStore
private lateinit var importer: ExternalImport
@Before
fun setUp() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
prefs = PreferenceDataStoreFactory.create(scope = scope) {
temp.newFile("transfer-${counter++}.preferences_pb").also(File::delete)
}
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
.allowMainThreadQueries()
.build()
source = FakeExternalStore()
importer = ExternalImport(
external = Provider { source },
resolver = ProviderResolver(NoProviderInstalled),
database = db,
dataStore = prefs,
io = Dispatchers.IO,
)
}
@After
fun tearDown() {
db.close()
scope.cancel()
}
@Test
fun copiesListsTasksAndAlarms() = runBlocking {
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
source.tasks = mapOf(
7L to listOf(task(100, "Milk"), task(101, "Bread")),
9L to listOf(task(200, "Invoice")),
)
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
val result = importer.run()
assertThat(result).isEqualTo(
TransferResult.Copied(TransferCounts(lists = 2, tasks = 3, alarms = 1)),
)
assertThat(db.taskLists().lists().map { it.list.name })
.containsExactly("Errands", "Work")
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.title })
.containsExactly("Milk", "Bread", "Invoice")
assertThat(importer.hasRun.first()).isTrue()
}
/** Every list arrives device-only: the account belongs to the sync app. */
@Test
fun importedListsAreDeviceOnly() = runBlocking {
source.lists = listOf(list(7, "Shared", accountName = "me@example.org"))
source.tasks = mapOf(7L to listOf(task(100, "Milk")))
importer.run()
assertThat(db.taskLists().lists().single().list.accountId).isNull()
}
/** Provider row ids are the source's; Room mints its own and the link follows. */
@Test
fun remapsParentIdsOntoTheNewRowIds() = runBlocking {
source.lists = listOf(list(7, "Errands"))
// Child before parent, so a naive single pass would not find the parent.
source.tasks = mapOf(
7L to listOf(task(100, "Subtask", parentId = 200), task(200, "Parent")),
)
importer.run()
val rows = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task }
val parent = rows.single { it.title == "Parent" }
val child = rows.single { it.title == "Subtask" }
assertThat(child.parentId).isEqualTo(parent.id)
assertThat(child.parentId).isNotEqualTo(200L)
}
/**
* A `RECURRENCE-ID` override reaches the read seam as another master-shaped row
* sharing its series' uid. The unique index on (list, uid, recurrence_id)
* would reject it and take the whole copy down, so it gets a fresh uid.
*/
@Test
fun aDuplicateUidDoesNotAbortTheCopy() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(
7L to listOf(
task(100, "Weekly", uid = "shared-uid"),
task(101, "Weekly, that one week", uid = "shared-uid"),
),
)
val result = importer.run()
assertThat(result).isInstanceOf(TransferResult.Copied::class.java)
val uids = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.uid }
assertThat(uids).hasSize(2)
assertThat(uids.toSet()).hasSize(2)
assertThat(uids).contains("shared-uid")
}
/** A START-referenced reminder must not come across as a before-due one. */
@Test
fun preservesTheAlarmReference() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(7L to listOf(task(100, "Standup")))
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 10, fromStart = true))
importer.run()
val alarm = db.alarms().all().single()
assertThat(alarm.reference).isEqualTo(AlarmReference.START)
assertThat(alarm.minutesBefore).isEqualTo(10)
}
@Test
fun anEmptySourceWritesNothingAndIsNotMarkedDone() = runBlocking {
val result = importer.run()
assertThat(result).isEqualTo(TransferResult.NothingToCopy)
assertThat(db.taskLists().lists()).isEmpty()
// Still on offer: there was nothing to copy, not a copy that happened.
assertThat(importer.hasRun.first()).isFalse()
}
/** A read that blows up must leave Room exactly as it was. */
@Test
fun aFailedReadRollsBackAndLeavesTheGuardOpen() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.failOnExport = true
val result = importer.run()
assertThat(result).isInstanceOf(TransferResult.Failed::class.java)
assertThat(db.taskLists().lists()).isEmpty()
assertThat(importer.hasRun.first()).isFalse()
}
@Test
fun previewCountsWhatARunWouldWrite() = runBlocking {
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
source.tasks = mapOf(
7L to listOf(task(100, "Milk"), task(101, "Bread")),
9L to listOf(task(200, "Invoice")),
)
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
// preview() resolves the provider itself, so it needs one to be installed.
val withProvider = ExternalImport(
external = Provider { source },
resolver = ProviderResolver(OpenTasksInstalledAndGranted),
database = db,
dataStore = prefs,
io = Dispatchers.IO,
)
assertThat(withProvider.preview())
.isEqualTo(TransferCounts(lists = 2, tasks = 3, alarms = 1))
}
@Test
fun previewIsNullWithoutAReadableProvider() = runBlocking {
assertThat(importer.preview()).isNull()
}
// --- fixtures --------------------------------------------------------------
private fun list(id: Long, name: String, accountName: String = "Device") = TaskList(
id = id,
name = name,
color = 0xFF7E57C2.toInt(),
accountName = accountName,
accountType = "org.dmfs.account.LOCAL",
isSynced = true,
isVisible = true,
owner = null,
)
private fun task(
id: Long,
title: String,
uid: String? = "uid-$id",
parentId: Long? = null,
) = ExportTask(
taskId = id,
uid = uid,
title = title,
description = null,
location = null,
url = null,
priority = Priority.NONE,
status = TaskStatus.NEEDS_ACTION,
percentComplete = null,
start = null,
due = Instant.fromEpochMilliseconds(1_800_000_000_000),
isAllDay = false,
completedAt = null,
created = null,
lastModified = null,
rrule = null,
rdate = null,
parentId = parentId,
)
private companion object {
var counter = 0
}
}
/** Only the three reads the copy makes; everything else is out of scope. */
private class FakeExternalStore : TasksDataSource {
var lists: List<TaskList> = emptyList()
var tasks: Map<Long, List<ExportTask>> = emptyMap()
var alarms: Map<Long, TaskReminder> = emptyMap()
var failOnExport = false
override fun taskLists(): List<TaskList> = lists
override fun exportTasks(listId: Long): List<ExportTask> {
if (failOnExport) error("provider went away mid-read")
return tasks[listId].orEmpty()
}
override fun alarms(): Map<Long, TaskReminder> = alarms
override fun tasks(query: TaskQuery): List<Task> = unused()
override fun task(taskId: Long): Task? = unused()
override fun subtasks(parentTaskId: Long): List<Task> = unused()
override fun insertTask(form: TaskForm): Long = unused()
override fun updateTask(taskId: Long, form: TaskForm) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
override fun deleteTask(taskId: Long) = unused()
override fun createLocalList(name: String, color: Int): Long = unused()
override fun updateList(listId: Long, name: String, color: Int) = unused()
override fun deleteList(listId: Long) = unused()
override fun registerObserver(onChange: () -> Unit): AutoCloseable = unused()
private fun unused(): Nothing = error("the copy does not call this")
}
/** No tasks provider on the device: `preview()` has nothing to read. */
private object NoProviderInstalled : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = null
override fun isGranted(permission: String): Boolean = false
override fun appLabel(packageName: String): String? = null
}
private object OpenTasksInstalledAndGranted : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? =
"org.dmfs.tasks".takeIf { authority == "org.dmfs.tasks" }
override fun isGranted(permission: String): Boolean = permission.startsWith("org.dmfs.permission.")
override fun appLabel(packageName: String): String = "OpenTasks"
}
+125 -7
View File
@@ -2,14 +2,43 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Tasks provider access. Both permission sets are declared; the active one
<!-- External tasks-provider access, for StorageMode.EXTERNAL only. Both
permission sets are declared, since the manifest is static; the active one
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
runtime by the permission flow. Both are dangerous-level. -->
runtime by the permission flow, and only once the user has actually
selected External mode. Both are dangerous-level.
StorageMode.OWN needs nothing here: it is a Room database in our own data
directory. Agendula publishes no ContentProvider and declares no
permissions of its own. -->
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
but it shows in F-Droid's permission diff, so declare it deliberately
rather than letting it appear from nowhere.
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
(whose failure mode is a fatal RemoteServiceException) and a Play
requirement for a video demo.
Two more permissions appear in the merged manifest without being
declared here, and both come from work-runtime: WAKE_LOCK, and
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
API 31 WorkManager implements expedited work with a foreground service,
and minSdk is 29, so it is load-bearing for the "Sync now" button.
Removing it with tools:node="remove" would break expedited work on
exactly the older devices that need it most. Noted because it shows in
F-Droid's permission diff and would otherwise look unexplained. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Exact due-time reminders: USE_EXACT_ALARM on 33+, SCHEDULE on 31-32. -->
@@ -27,18 +56,26 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<!-- Custom Tabs provider detection. Without this entry it silently finds
nothing on API 30+, and the Nextcloud login flow falls back to an
external browser for no visible reason. -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application
android:name=".FloretApp"
android:name=".AgendulaApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Floret"
android:theme="@style/Theme.Agendula"
tools:targetApi="35">
<activity
android:name=".MainActivity"
@@ -51,6 +88,14 @@
</intent-filter>
</activity>
<!-- Standalone crash-report surface; MainActivity routes here on a
startup crash-loop. Not exported, kept out of recents. -->
<activity
android:name=".ui.crash.CrashReportActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask" />
<!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
<receiver
android:name=".data.reminders.DueReminderReceiver"
@@ -65,17 +110,90 @@
</intent-filter>
</receiver>
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
Targets both known authorities; the host must be static. -->
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
tasks while Agendula is backgrounded. External mode only: in OWN mode
nothing outside the app can change our data, and Room's
InvalidationTracker covers our own writes.
An intent-filter host must be a literal, so both external authorities
are listed — one filter each. Two <data> tags in a single filter would
mean the same thing (Android takes the cross product of every data
attribute in a filter), but reads as if it might not, which is what
lint's IntentFilterUniqueDataAttributes warns about. -->
<receiver
android:name=".data.reminders.ProviderChangeReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PROVIDER_CHANGED" />
<data android:scheme="content" android:host="org.tasks.opentasks" />
<data android:scheme="content" android:host="org.dmfs.tasks" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PROVIDER_CHANGED" />
<data android:scheme="content" android:host="org.tasks.opentasks" />
</intent-filter>
</receiver>
<!-- Sync plumbing. The stub provider exists only to give the sync
adapter an authority to register against: Agendula publishes no real
ContentProvider since :provider was deleted, and without an authority
ContentService.hasAuthorityAccess() makes every ContentResolver sync
call a silent no-op at targetSdk >= 34. -->
<provider
android:name=".data.sync.SyncStubProvider"
android:authorities="${applicationId}.sync"
android:exported="false"
android:syncable="true" />
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
<service
android:name=".data.sync.AuthenticatorService"
android:exported="true"
android:permission="android.permission.ACCOUNT_MANAGER">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".data.sync.SyncAdapterService"
android:exported="true"
android:permission="android.permission.BIND_SYNC_ADAPTER">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
</service>
<!-- WorkManager's on-demand initialisation. Removing the default
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
@HiltWorker workers can take injected dependencies. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- Persists the per-app language on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
</manifest>
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.agendula
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.Configuration
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
/**
* Application entry point. Registered as android:name=".AgendulaApp". Besides
* Hilt init, it kicks off a reminder re-sync on launch (independent of any UI),
* so alarms reflect tasks synced while the app was closed.
*/
@HiltAndroidApp
class AgendulaApp : Application(), Configuration.Provider {
/**
* Lets `@HiltWorker` workers take injected dependencies. The manifest removes
* WorkManager's default initializer so this one is used instead; without both
* halves, a worker with a constructor argument fails to instantiate at
* runtime rather than at build time.
*/
@Inject lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
// Install first thing so startup crashes are captured too (privacy-
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(
this,
CrashConfig(
appLabel = getString(R.string.app_name),
newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_url),
issueTitle = getString(R.string.crash_report_issue_title),
),
)
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
val scheduler = entryPoint.reminderScheduler()
// Mirror the stored storage mode into ProviderResolver and import a
// v0.3.x install's tasks, both before anything reads a store.
val startupGate = entryPoint.startupGate()
val scope = entryPoint.applicationScope()
// An alarm is armed off whichever store was active when it was scheduled,
// so a switch has to rebuild the set. Armed only once startup's own
// null -> stored transition is past, which the launch sync below covers.
val started = AtomicBoolean(false)
entryPoint.providerResolver().onModeChanged {
if (started.get()) scope.launch { runCatching { scheduler.sync() } }
}
startupGate.start()
ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint())
scope.launch {
// Wait for the stored mode and the import to land first. Rescheduling
// alarms against whichever store autoMode happens to pick would arm
// them off the wrong one — or off an empty one, mid-import.
runCatching {
startupGate.awaitReady()
started.set(true)
scheduler.sync()
}
}
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppEntryPoint {
fun reminderScheduler(): ReminderScheduler
fun startupGate(): StartupGate
fun providerResolver(): ProviderResolver
@ApplicationScope
fun applicationScope(): CoroutineScope
fun databaseCheckpoint(): DatabaseCheckpoint
}
}
@@ -0,0 +1,161 @@
package de.jeanlucmakiola.agendula
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Provider
/**
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
* (replaceable) functional scaffold over the real data layer. Task-detail intent
* routing for reminder taps lands with the full UI.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// A Provider, not the seeder itself: the call site below is dead in a release
// build (`BuildConfig.DEBUG` is a compile-time false), and injecting the
// instance would still construct one on every launch of the shipped app.
@Inject lateinit var demoSeeder: Provider<DemoSeeder>
@Inject lateinit var accounts: AccountRepository
@Inject lateinit var syncTrigger: SyncTrigger
@Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
private var pendingCrashReport by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// If the app keeps crashing as it starts, the main UI can't be trusted
// to come up. Route to the standalone report screen instead of
// re-entering the crashing graph.
if (CrashReporter.isCrashLoop(this)) {
startActivity(Intent(this, CrashReportActivity::class.java))
finish()
return
}
enableEdgeToEdge()
// Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
// Sync hard on app open: the periodic worker's interval is a floor, and
// in the `rare` and `restricted` App Standby buckets it may not have run
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
// schedule lost to "clear app data" or to a restore.
//
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
// so onCreate runs again on every rotation, theme switch, locale change
// and font-scale change — each of which would otherwise start a fresh
// network sync the moment the previous one finished.
if (savedInstanceState == null) {
lifecycleScope.launch {
runCatching {
accounts.rescheduleAll()
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
// A login flow the previous process died in the middle of.
// Its password, if the user approved, exists nowhere else.
pendingLoginFlows.reclaim()
}
}
}
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
// local (non-syncing) demo list once; no-op without the extra.
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
lifecycleScope.launch { runCatching { demoSeeder.get().seed() } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (ui.settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
RootScreen(modifier = Modifier.fillMaxSize())
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this@MainActivity, report)
CrashReporter.clearReport(this@MainActivity)
pendingCrashReport = null
},
onDismiss = {
CrashReporter.dismissPrompt(this@MainActivity)
pendingCrashReport = null
},
)
}
}
}
}
override fun onResume() {
super.onResume()
// A successful start breaks any loop; reset the timing trail so a later
// ordinary crash isn't mistaken for a loop.
CrashReporter.markHealthy(this)
}
companion object {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
private const val EXTRA_SEED = "agendula_seed"
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
fun taskIntent(context: Context, taskId: Long): Intent =
Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Just opens the app.
*
* What the sync notice taps into. Settings → Accounts is where it
* would rather go, and it will once routing lands — the same work
* [taskIntent] is waiting on — but a notification that opens the app
* is still the difference between the user hearing about a discarded
* edit and not.
*/
fun openIntent(context: Context): Intent =
Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
@@ -1,10 +1,10 @@
package de.jeanlucmakiola.floret.data.demo
package de.jeanlucmakiola.agendula.data.demo
import de.jeanlucmakiola.floret.data.di.IoDispatcher
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.DayWindow
import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -16,10 +16,10 @@ import kotlin.time.Instant
/**
* Debug-only one-shot sample data. Creates a **local, device-only** list
* ("Floret Demo") so nothing syncs to a CalDAV server and fills it with
* ("Agendula Demo") so nothing syncs to a CalDAV server and fills it with
* tasks spread across overdue / today / upcoming / no-date / completed so every
* smart list shows content. Idempotent: skips if the demo list already exists.
* Triggered from [de.jeanlucmakiola.floret.MainActivity] only in debug builds.
* Triggered from [de.jeanlucmakiola.agendula.MainActivity] only in debug builds.
*/
@Singleton
class DemoSeeder @Inject constructor(
@@ -47,14 +47,14 @@ class DemoSeeder @Inject constructor(
repository.createTask(TaskForm(title = "Gather receipts", listId = listId, parentId = invoiceId))
repository.createTask(TaskForm(title = "Book train tickets", listId = listId, due = at(te + 6 * day), priority = Priority.LOW))
repository.createTask(TaskForm(title = "Read Compose 1.5 release notes", listId = listId))
repository.createTask(TaskForm(title = "Sketch the Floret app icon", listId = listId))
repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId))
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
repository.setCompleted(done, completed = true)
repository.setCompleted(done, occurrenceStart = null, completed = true)
}
private companion object {
const val DEMO_LIST = "Floret Demo"
const val DEMO_LIST = "Agendula Demo"
const val DEMO_COLOR = 0xFF7A5C6B.toInt()
}
}
@@ -0,0 +1,174 @@
package de.jeanlucmakiola.agendula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Provider
import javax.inject.Singleton
/**
* ⚠️ Every one of these needs a corruption handler, and without one a truncated
* `preferences_pb` is a **crash at every launch**: `DataStore.data` throws
* `CorruptionException` on collection, and the collectors here are root
* coroutines in a scope with no handler. A file half-written by a kill during
* `edit` is the ordinary way to get one.
*
* Starting empty is the only recovery available and it is a mild one: the
* settings store falls back to defaults, the sync-state store to "never
* reconciled", and the credential store to accounts that ask to be signed in
* again — all states the app already knows how to be in, unlike a launch loop.
*/
private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "agendula_prefs",
corruptionHandler = replaceCorrupted(),
)
/** See [CredentialsDataStore] for why this is a separate file. */
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
name = CREDENTIALS_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/** See [SyncStateDataStore] for why this is a separate file. */
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
name = SYNC_STATE_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
*/
const val CREDENTIALS_DATASTORE = "agendula_credentials"
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
*/
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
@Module
@InstallIn(SingletonComponent::class)
abstract class DataBindModule {
@Binds
@Singleton
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
@Binds
@Singleton
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
@Binds
@Singleton
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
@Binds
@Singleton
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
@Binds
@Singleton
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
// Deliberately unqualified-free of the routing above: this is the external
// store itself, for the one caller that has to read it while another store is
// the active one.
@Binds
@Singleton
@ExternalStore
abstract fun bindExternalTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
}
@Module
@InstallIn(SingletonComponent::class)
object DataProvideModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.agendulaDataStore
@Provides
@Singleton
@CredentialsDataStore
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.credentialsDataStore
@Provides
@Singleton
@SyncStateDataStore
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.syncStateDataStore
@Provides
@Singleton
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
Room.databaseBuilder(context, TasksDatabase::class.java, TasksDatabase.NAME)
// Room's default, stated rather than assumed: Auto Backup copies files
// without checkpointing, so a `-wal` sidecar can hold writes the
// backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.build()
/**
* The active store, chosen by [StorageMode].
*
* Resolved per injection point rather than bound once, because the mode is a
* user setting that [de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder]
* can change while the process lives. Both implementations are singletons, so
* this picks between two long-lived objects rather than building either.
*/
@Provides
@Singleton
fun provideTasksDataSource(
resolver: ProviderResolver,
room: Provider<RoomTasksDataSource>,
external: Provider<AndroidTasksDataSource>,
): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external)
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Singleton
@ApplicationScope
fun provideApplicationScope(): CoroutineScope =
// SupervisorJob so one failing collector can't take the others down with it.
CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
@@ -0,0 +1,64 @@
package de.jeanlucmakiola.agendula.data.di
import javax.inject.Qualifier
/** Marks the IO [kotlinx.coroutines.CoroutineDispatcher] for provider access. */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
/**
* Marks the process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that
* outlives any screen and has nothing to be cancelled by, such as keeping the
* selected storage mode mirrored out of DataStore. It is never cancelled, so
* don't launch anything unbounded in it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope
/**
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
*
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
* `datastore/`, and a restored ciphertext is permanently undecryptable because
* Keystore keys are non-exportable. Its own file is what lets the backup rules
* exclude the credentials and nothing else — `docs/SYNC.md` is explicit that
* excluding the whole database or all of DataStore would trade a latent bug for
* a live one.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class CredentialsDataStore
/**
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
* counters and the full-reconciliation clock.
*
* Its own file for the same reason the credentials have one: Auto Backup
* includes `datastore/`, and every value in here is a statement about *this*
* device's conversation with a server. Restored onto a new install they are all
* lies, and two of them are dangerous — a restored "reconciled recently" makes
* the engine trust a sync token for another day, which is precisely the silently
* pruned change log the full path exists to catch, and a restored quarantine
* count silently skips resources that were never tried here.
*
* Not user data, so nothing is lost by excluding it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncStateDataStore
/**
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
* whichever store the active mode selects.
*
* Only the one-time copy into our own store needs to name a store this way;
* everything else goes through the routed source and must keep doing so. Having
* it as a binding rather than depending on the concrete class is also what lets
* that copy be tested against a fake.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ExternalStore
@@ -0,0 +1,133 @@
package de.jeanlucmakiola.agendula.data.export
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.inject.Inject
import javax.inject.Singleton
/** Where an export ended up, for the UI to report. */
data class ExportResult(val fileCount: Int, val taskListNames: List<String>)
/**
* Why an export failed, as a value rather than a message: the UI is translated
* (see `res/xml/locales_config.xml`), so the wording has to come from a string
* resource rather than being built here.
*/
enum class ExportFailure {
FOLDER_UNAVAILABLE,
FOLDER_NOT_WRITABLE,
CANNOT_CREATE_FILE,
LOST_ACCESS,
WRITE_FAILED,
}
/** The export could not be written. */
class ExportFailedException(
val failure: ExportFailure,
cause: Throwable? = null,
) : IOException(failure.name, cause)
/**
* Writes [ExportDocument]s to a user-chosen location through the Storage Access
* Framework.
*
* No storage permission anywhere: SAF hands us a `Uri` the user picked
* themselves, which is both the modern approach and the only one that still works
* on scoped storage. The caller owns launching `ACTION_CREATE_DOCUMENT` (for
* [writeZip]) or `ACTION_OPEN_DOCUMENT_TREE` (for [writeToTree]) and passes the
* result here.
*
* Marked in `docs/STORAGE-AND-SYNC.md` as floret-kit material — the plumbing is
* not task-domain and Calendula will want the same thing. Kept app-local for now
* on the kit's own stated principle of not extracting until a second consumer
* actually exists; the seam is here, so moving it later is a file move.
*/
@Singleton
class ExportWriter @Inject constructor(
@ApplicationContext private val context: Context,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Writes every document into [treeUri], a directory the user picked.
*
* A same-named file is truncated and rewritten in place rather than deleted
* and recreated: SAF would otherwise append " (1)" and turn the folder into
* an unusable pile of snapshots, and a delete that is not followed by a
* successful create loses the previous export outright.
*
* The directory is listed once. `DocumentFile.findFile` queries the whole
* tree per call, so looking each name up in the loop is one full
* cross-process directory scan per list.
*/
suspend fun writeToTree(treeUri: Uri, documents: List<ExportDocument>): ExportResult =
withContext(io) {
runCatching {
val tree = DocumentFile.fromTreeUri(context, treeUri)
?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE)
if (!tree.canWrite()) throw ExportFailedException(ExportFailure.FOLDER_NOT_WRITABLE)
val existing = tree.listFiles().associateBy { it.name }
documents.forEach { document ->
val file = existing[document.fileName]
?: tree.createFile(MIME_ICALENDAR, document.fileName)
?: throw ExportFailedException(ExportFailure.CANNOT_CREATE_FILE)
write(file.uri, document.content)
}
}.getOrElse { throw asExportFailure(it) }
ExportResult(documents.size, documents.map { it.fileName })
}
/**
* Writes every document into a single zip at [target].
*
* The one-file form, for sharing or for a backup the user filed somewhere
* themselves — one attachment rather than one per list.
*/
suspend fun writeZip(target: Uri, documents: List<ExportDocument>): ExportResult =
withContext(io) {
runCatching {
context.contentResolver.openOutputStream(target, "wt")?.use { raw ->
ZipOutputStream(raw.buffered()).use { zip ->
documents.forEach { document ->
zip.putNextEntry(ZipEntry(document.fileName))
zip.write(document.content)
zip.closeEntry()
}
}
} ?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
}.getOrElse { throw asExportFailure(it) }
ExportResult(documents.size, documents.map { it.fileName })
}
private fun write(target: Uri, bytes: ByteArray) {
runCatching {
// "wt" truncates. Without it a shorter export leaves the tail of the
// previous, longer one behind and produces a corrupt file.
context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) }
?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
}.getOrElse { throw asExportFailure(it) }
}
private fun asExportFailure(cause: Throwable): Throwable = when (cause) {
is ExportFailedException -> cause
// A SAF grant can be revoked between the picker and the write (the volume
// was unmounted, the provider's process died, the user cleared the grant).
is SecurityException -> ExportFailedException(ExportFailure.LOST_ACCESS, cause)
is IOException -> ExportFailedException(ExportFailure.WRITE_FAILED, cause)
else -> cause
}
private companion object {
const val MIME_ICALENDAR = "text/calendar"
}
}
@@ -0,0 +1,76 @@
package de.jeanlucmakiola.agendula.data.export
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
import de.jeanlucmakiola.agendula.domain.export.ExportList
import de.jeanlucmakiola.agendula.domain.export.ICalendarWriter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Turns the user's task lists into `.ics` documents.
*
* Export is a v1 feature rather than a nicety because of where the data now
* lives: our own provider is inside the app's private storage, so in Local mode a
* user's tasks exist in exactly one place and uninstalling deletes them. On Play,
* where most people will never have a sync engine, that is the majority case.
*
* **One document per list**, because a list is a CalDAV collection and that is the
* unit every other client understands. Bundling everything into a single file
* would flatten the lists away, and list membership is not recoverable from a
* VTODO afterwards.
*/
@Singleton
class TaskExporter @Inject constructor(
private val dataSource: TasksDataSource,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Serialises [listIds] — every visible list when null.
*
* A list with no tasks still produces a document. An empty `.ics` is a real
* answer ("this list is empty"), whereas a missing file is indistinguishable
* from the export having gone wrong.
*/
suspend fun export(listIds: Set<Long>? = null): List<ExportDocument> = withContext(io) {
dataSource.taskLists()
.filter { listIds == null || it.id in listIds }
.map { list ->
val document = ExportList(
listId = list.id,
name = list.name,
accountName = list.accountName,
tasks = dataSource.exportTasks(list.id),
)
ExportDocument(
fileName = fileNameFor(list.name, list.id),
content = ICalendarWriter.write(document).toByteArray(Charsets.UTF_8),
)
}
}
companion object {
/**
* A file name derived from the list name, safe on every filesystem the
* user might pick through SAF (including FAT32 on an SD card).
*
* The list id is appended rather than trusted to be redundant: two lists on
* different accounts may share a name, and two exports landing on the same
* file would silently lose one of them.
*/
fun fileNameFor(listName: String, listId: Long): String {
val safe = listName
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '-' }
.joinToString("")
.trim('-')
.take(60)
.ifBlank { "list" }
return "$safe-$listId.ics"
}
}
}
@@ -0,0 +1,159 @@
package de.jeanlucmakiola.agendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK }
data class Settings(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
/** The list a new task defaults to; `null` = first available. */
val defaultListId: Long? = null,
/** Default minutes before due to remind; 0 = at due time. */
val reminderLeadMinutes: Int = 0,
/** Master switch for due reminders; off clears every scheduled alarm. */
val remindersEnabled: Boolean = true,
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
val showAddSubtaskRow: Boolean = true,
/**
* Add affordance for a list: `false` = the floating "New task" button (opens
* the editor); `true` = a quick-add bar pinned to the bottom of a real list.
* Smart lists always use the button (they have no single list to add into).
*/
val bottomAddBar: Boolean = false,
/**
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
* overrides the global default (an empty list = no reminder); absent =
* inherit. Agendula offers a single reminder, so each override is a
* one-element (or empty) list.
*/
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(),
) {
/** The lead time for a task in [listId]: its override if set, else the global default. */
fun reminderLeadFor(listId: Long): Int? =
perListReminderOverride.reminderLeadsFor(listId, listOf(reminderLeadMinutes)).firstOrNull()
}
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@Singleton
class SettingsPrefs @Inject constructor(
private val dataStore: DataStore<Preferences>,
) {
val settings: Flow<Settings> = dataStore.data.map { p ->
Settings(
themeMode = p[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
?: ThemeMode.SYSTEM,
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(),
)
}
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
}
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
/**
* Which task store backs the app, or `null` while the user has not chosen —
* which is the normal state, since most people never open Settings.
*
* Kept out of [Settings] on purpose. Everything in there is a rendering
* preference collected by the UI; this one selects an authority in the data
* layer, is read on paths that must not wait for a whole settings object, and
* `null` genuinely means "undecided" rather than "default" — the difference
* matters, because undecided is what lets `ProviderResolver.autoMode` keep an
* upgrading Posture A user pointed at the provider that holds their data.
*/
val storageMode: Flow<StorageMode?> = dataStore.data.map { p ->
when (val stored = p[STORAGE_MODE]) {
null -> null
// 0.3.x's value for the bundled dmfs provider. That store is gone and
// its data was imported into OWN, so read it as OWN rather than
// letting it fall through to autoMode — someone who chose local
// storage explicitly would otherwise be sent to an external provider.
"LOCAL" -> StorageMode.OWN
else -> runCatching { StorageMode.valueOf(stored) }.getOrNull()
}
}
suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name }
/**
* One-time first-run gate; false until the flow has been walked through.
*
* The key still says `reminder_onboarding_done` — it gated a single reminder
* step before the flow grew around it, and renaming it would drag every
* existing install back through onboarding.
*/
val onboardingDone: Flow<Boolean> = dataStore.data.map { it[ONBOARDING_DONE] ?: false }
suspend fun setOnboardingDone() = dataStore.edit { it[ONBOARDING_DONE] = true }
suspend fun setRemindersEnabled(enabled: Boolean) = dataStore.edit { it[REMINDERS_ENABLED] = enabled }
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
suspend fun setBottomAddBar(enabled: Boolean) = dataStore.edit { it[BOTTOM_ADD_BAR] = enabled }
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
current.applyReminderOverride(listId, override)
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
}
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
private companion object {
val THEME_MODE = stringPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
val ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
val STORAGE_MODE = stringPreferencesKey("storage_mode")
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
}
}
/**
* Agendula's stored dialect for the per-list override map: `id:minutes` entries
* joined by `;` (the family default). Fixed at release — don't change without a
* data migration.
*/
private val reminderCodec = ReminderOverrideCodec.DEFAULT
@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.reminders
package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
@@ -1,13 +1,16 @@
package de.jeanlucmakiola.floret.data.reminders
package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -21,6 +24,7 @@ class DueReminderReceiver : BroadcastReceiver() {
@Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var notifier: TaskNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -30,6 +34,7 @@ class DueReminderReceiver : BroadcastReceiver() {
val pending = goAsync()
scope.launch {
try {
if (!settingsPrefs.settings.first().remindersEnabled) return@launch
val task = runCatching { dataSource.task(taskId) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task)
} finally {
@@ -39,9 +44,16 @@ class DueReminderReceiver : BroadcastReceiver() {
}
companion object {
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID"
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
fun intent(context: Context, taskId: Long): Intent =
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)
/**
* [triggerAt] rides in the intent *data*, not just an extra: PendingIntent
* identity ignores extras, so two occurrences of the same recurring task
* would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT.
*/
fun intent(context: Context, taskId: Long, triggerAt: Long): Intent =
Intent(context, DueReminderReceiver::class.java)
.setData("agendula://reminder/$taskId/$triggerAt".toUri())
.putExtra(EXTRA_TASK_ID, taskId)
}
}
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Re-syncs reminders whenever the tasks provider changes — covers external sync
* (DAVx5 pulling new/edited tasks) while Agendula isn't in the foreground. The
* manifest filter targets both known authorities; best-effort (the in-app
* ContentObserver covers the foreground case regardless).
*/
@AndroidEntryPoint
class ProviderChangeReceiver : BroadcastReceiver() {
@Inject lateinit var scheduler: ReminderScheduler
@Inject lateinit var providerResolver: ProviderResolver
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onReceive(context: Context, intent: Intent) {
// The receiver has to stay exported to hear the provider's broadcast, and
// the sender holds no permission we could require — so validate the
// broadcast itself. Without this, any installed app can spam a full
// re-sync (an unbounded provider read) by firing a matching intent.
if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return
val authority = providerResolver.resolve()?.authority ?: return
if (intent.data?.host != authority) return
// External sync can fire these in bursts; one re-sync per burst is plenty.
val now = SystemClock.elapsedRealtime()
synchronized(Companion) {
if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return
lastSyncAt = now
}
val pending = goAsync()
scope.launch {
try {
scheduler.sync()
} finally {
pending.finish()
}
}
}
private companion object {
const val MIN_SYNC_INTERVAL_MS = 10_000L
@Volatile
var lastSyncAt = -MIN_SYNC_INTERVAL_MS
}
}
@@ -0,0 +1,179 @@
package de.jeanlucmakiola.agendula.data.reminders
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
* and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run
* on app start, on boot, on a store switch, and on an external provider change;
* it diffs against [ScheduledReminderStore] so only changed alarms move.
*/
@Singleton
class ReminderScheduler @Inject constructor(
@ApplicationContext private val context: Context,
private val dataSource: TasksDataSource,
private val settingsPrefs: SettingsPrefs,
private val store: ScheduledReminderStore,
private val providerResolver: ProviderResolver,
@IoDispatcher private val io: CoroutineDispatcher,
) {
private val syncLock = Mutex()
/**
* Diff the armed alarms against the store and move only what changed.
*
* Serialised: the diff is a read-modify-write over [ScheduledReminderStore],
* and callers overlap (a store switch fires this while the launch sync may
* still be running). Two interleaved runs would each write their own set as
* the whole truth, leaving the other's alarms armed but unrecorded — never
* cancelled, and firing against the wrong store's task ids.
*/
suspend fun sync() = withContext(io) { syncLock.withLock { syncLocked() } }
private suspend fun syncLocked() {
val settings = settingsPrefs.settings.first()
// Gate on whether the store is readable, not on whether a provider
// resolves: our own store deliberately resolves to no provider, so the
// latter clears every reminder in the default mode.
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
clearAll()
return
}
val now = System.currentTimeMillis()
val horizon = now + WINDOW_MS
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return }
// One reminder per *occurrence*: a recurring series yields a row per
// occurrence, all sharing a taskId, so this is a Set rather than a
// taskId-keyed Map — keying by task would collapse a daily recurring task
// down to one arbitrary reminder.
// Per-task leads. One query for all of them.
val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() }
val desired = tasks
.filter { !it.isClosed && it.due != null }
.mapNotNull { task ->
// A reminder set on the task itself wins; otherwise the task's list
// may override the global lead, or opt out entirely (override =
// null), in which case it gets no reminder at all.
val reminder = perTask[task.taskId]
val lead = reminder?.minutesBefore
?: settings.reminderLeadFor(task.listId)
?: return@mapNotNull null
// A stored reminder says what it counts back from. Ours are always
// before due, but an imported dmfs alarm or another client's can be
// before *start* — firing those off the due date is silently wrong
// for every task whose start and due differ.
val anchor = if (reminder?.fromStart == true) task.start ?: task.due!! else task.due!!
ScheduledReminder(
taskId = task.taskId,
triggerAt = anchor.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L,
)
}
// The lower bound trails `now` so a reminder missed while the device was
// off still fires once on boot instead of being silently dropped —
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
// already armed stays armed (the diff below), so it can't re-fire.
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon }
// ⚠️ Bounded, soonest first. Android 12+ throws at 500 concurrent
// exact alarms per app, and the per-occurrence model reaches that
// with about seventeen daily recurring tasks over this window. What
// falls off the end is the far edge of a thirty-day horizon, which
// the next sync arms as it comes closer; what an exception would
// cost is every alarm this pass touched.
.sortedBy { it.triggerAt }
.take(MAX_ALARMS)
.toSet()
val previous = store.all()
(previous - desired).forEach { cancel(it) }
// ⚠️ Only what was actually armed. A throw mid-loop used to skip the
// write below entirely, so every alarm set on that pass went unrecorded
// — uncancellable, and firing for tasks that no longer exist — and the
// exception escaped into BootReceiver's goAsync().
val armed = (desired - previous).filter { schedule(it) }
store.replace(desired.intersect(previous) + armed)
}
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getBroadcast(
context,
reminder.requestCode,
DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt),
flags,
)
}
/** @return whether the alarm is now armed, and so worth recording. */
private fun schedule(reminder: ScheduledReminder): Boolean {
val triggerAt = reminder.triggerAt
val pi = pendingIntent(reminder, create = true) ?: return false
val am = alarmManager()
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
return try {
if (canExact) {
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
} else {
am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi)
}
true
} catch (_: IllegalStateException) {
// The concurrent-alarm ceiling, which [MAX_ALARMS] keeps us under —
// but the count is per uid and nothing here owns all of it.
false
} catch (_: SecurityException) {
// Exact-alarm permission revoked between the check and the call.
false
}
}
private fun cancel(reminder: ScheduledReminder) {
pendingIntent(reminder, create = false)?.let {
alarmManager().cancel(it)
it.cancel()
}
}
private suspend fun clearAll() {
store.all().forEach { cancel(it) }
store.replace(emptySet())
}
private companion object {
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
/** How long after its trigger a missed reminder is still worth firing. */
const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
/**
* Alarms this app will hold at once.
*
* Android 12+ throws at 500 per uid. Well under it, because the count is
* per *uid* and this is not the only thing in the process that can arm
* one — and because the alarms nearest in time are the ones that matter,
* while the far edge of the window is re-armed by the next sync.
*/
const val MAX_ALARMS = 400
}
}
@@ -0,0 +1,50 @@
package de.jeanlucmakiola.agendula.data.reminders
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so
* the trigger time is part of the identity — keying by task alone would collapse
* a daily task down to a single reminder.
*/
data class ScheduledReminder(val taskId: Long, val triggerAt: Long) {
/**
* Request code for this alarm's PendingIntent. Derived from both fields so
* sibling occurrences don't share (and overwrite) one alarm slot.
*/
val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode()
}
/**
* Remembers which task reminders are currently armed, so [ReminderScheduler] can
* diff against a fresh computation and touch only the alarms that changed.
* Persisted in DataStore as a set of `taskId|trigger` strings.
*/
@Singleton
class ScheduledReminderStore @Inject constructor(
private val dataStore: DataStore<Preferences>,
) {
suspend fun all(): Set<ScheduledReminder> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
val parts = entry.split('|')
val id = parts.getOrNull(0)?.toLongOrNull()
val at = parts.getOrNull(1)?.toLongOrNull()
if (id != null && at != null) ScheduledReminder(id, at) else null
}.toSet()
suspend fun replace(scheduled: Set<ScheduledReminder>) {
dataStore.edit { prefs ->
prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet()
}
}
private companion object {
val KEY = stringSetPreferencesKey("scheduled_reminders")
}
}
@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.reminders
package de.jeanlucmakiola.agendula.data.reminders
import android.Manifest
import android.annotation.SuppressLint
@@ -12,9 +12,9 @@ import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.floret.MainActivity
import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Task
import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.Instant as JInstant
import java.time.ZoneId
@@ -79,7 +79,6 @@ class TaskNotifier @Inject constructor(
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
@@ -0,0 +1,446 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.TaskCollection
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* The one thing the sign-in flow needs from [AccountRepository].
*
* A seam, so the flow's state machine can be tested without a database, a
* Keystore or an `AccountManager` — the three things that make the rest of this
* class Android-only.
*/
interface AccountCreator {
suspend fun create(
displayName: String,
username: String,
appPassword: String,
found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>,
): AccountRepository.Outcome
}
/**
* Turns a finished sign-in into an account that exists in all three places it
* has to: the Room `accounts` row, the encrypted credential, and the
* system-visible `AccountManager` entry.
*
* The order matters. The Room row comes first because its id keys the
* credential, and the system account comes last because it is the one thing a
* user can see — an entry in Settings for an account whose credential failed to
* store would be a sync that silently never works.
*/
@Singleton
class AccountRepository @Inject constructor(
private val database: TasksDatabase,
private val credentials: CredentialStore,
private val accounts: CalDavAccounts,
private val syncTrigger: SyncTrigger,
private val cadence: SyncCadenceStore,
private val accountState: AccountStateStore,
private val quarantine: QuarantineStore,
private val notices: SyncNoticeStore,
private val collectionSupport: CollectionSupportStore,
private val gateway: CalDavGateway,
@IoDispatcher private val io: CoroutineDispatcher,
) : AccountCreator {
/** What went wrong, in words a user can act on. */
sealed interface Outcome {
data class Created(val accountId: Long) : Outcome
data object AlreadyExists : Outcome
data class CredentialFailed(val cause: Cause, val detail: String = "") : Outcome
/**
* Why an account could not be saved, in a form the UI can translate.
*
* ⚠️ The UI renders *this*, never [CredentialFailed.detail] — which is
* a `Throwable.message` and so an untranslated, often unreadable string.
*/
enum class Cause {
/** The Keystore refused to hold the password. */
KEYSTORE_REFUSED,
/** Anything else that stopped the write. */
NOT_SAVED,
}
}
suspend fun all(): List<AccountEntity> = withContext(io) { database.accounts().all() }
/** The accounts, observed, so a sync landing updates a screen that is open. */
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
/**
* Creates an account and the task lists the user chose.
*
* [selected] is a subset of what discovery found; a collection the user did
* not tick is simply not created, and can be added later without touching
* anything else — `task_lists.account_id` is a nullable FK, so attaching is
* an `UPDATE`.
*/
override suspend fun create(
displayName: String,
username: String,
appPassword: String,
found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>,
): Outcome = withContext(io) {
// Both stores, not just one. There is no unique index on
// accounts.display_name and nothing prunes Room when the system account
// disappears, so "removed from system Settings, re-added here" would
// otherwise leave a second Room row and a duplicate of every list.
val systemAccount = accounts.find(displayName)
val existing = database.accounts().all().firstOrNull { it.displayName == displayName }
// ⚠️ Re-authentication, not a duplicate. An account stopped by a 401 has
// no other way back: `create` is the only path that writes a credential,
// and refusing it here left the user with "that account is already set
// up" and no option but to remove the account — discarding the choice to
// keep its lists attached. Narrow on purpose: a *healthy* account of the
// same name is still a duplicate, so this can never silently overwrite a
// working credential.
if (existing != null && accountState.needsSignIn(existing.id)) {
return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected)
}
if (existing != null) return@withContext Outcome.AlreadyExists
// ⚠️ In the system, not in Room: an orphan, not a duplicate. `remove()`
// ignores whether the AccountManager entry actually went (and `find`
// returns null while the device is locked), so this state is reachable —
// and refusing here left the user with an account that cannot be removed
// from inside the app at all, since the accounts screen is driven off
// Room. Clearing it up is kinder than refusing for ever.
systemAccount?.let { accounts.remove(it) }
// ⚠️ Uncancellable as a whole. The row, its lists, the credential and
// the system entry are four stores that cannot share a transaction, and
// the caller is a viewModelScope tied to the Settings destination — a
// back gesture during "Adding the account" would otherwise leave the row
// and its lists with no credential and no system account: the rollback
// never runs, `needsSignIn` is false so re-auth will not fire, and every
// retry answers AlreadyExists. `remove()` documents the same hazard.
withContext(NonCancellable) {
val inserted = mutableListOf<Long>()
val accountId = database.runInTransaction<Long> {
val id = database.accounts().insert(
AccountEntity(
displayName = displayName,
// Persist where a 301/308 actually put us — dav4jvm#209 exists
// precisely so this is knowable, and re-following the redirect
// on every sync is what not persisting it costs.
principalUrl = (found.movedTo ?: found.principal).toString(),
// The principal's own home set. `resolve("./")` on a collection
// URL is a no-op — CalDAV hrefs already end in "/" — so the
// old version stored the first collection's own URL, and that
// collection may not even be from the account's own home set.
homeSetUrl = found.homeSets.firstOrNull()?.toString(),
username = username,
),
)
inserted += attach(id, selected)
id
}
if (!credentials.put(accountId, appPassword)) {
// Never leave a half-made account behind: without a credential it
// would sit in Settings failing to sync with nothing to explain it.
rollback(accountId, inserted)
return@withContext Outcome.CredentialFailed(
Outcome.Cause.KEYSTORE_REFUSED,
"the device keystore would not store the password",
)
}
if (!accounts.add(displayName, accountId)) {
credentials.clear(accountId)
rollback(accountId, inserted)
return@withContext Outcome.AlreadyExists
}
// On the schedule from the moment it exists, and syncing immediately —
// an account that shows up empty until the first periodic window looks
// broken.
syncTrigger.schedule(displayName)
syncTrigger.enqueue(displayName)
Outcome.Created(accountId)
}
}
/**
* Points [selected] at [accountId], re-attaching what a previous removal
* left behind rather than inserting a second copy.
*
* ⚠️ `remove()` leaves the lists as device-only ones on purpose, so a plain
* insert gives the user their old "Personal" full of tasks *and* a freshly
* synced "Personal" holding the same tasks from the server. The row keeps
* its name, colour, ordering and tasks — the user's, not the server's — and
* loses only its cursor, because the account it was reconciled against is
* gone.
*
* ⚠️ On a re-authentication this also has to see the account's *own* lists,
* not just detached ones — the re-auth path walks the same picker, so a
* lookup that missed them would insert a second copy of every list the
* account already syncs. On a create the id was minted a statement earlier,
* so nothing is attached to it yet and only detached rows can match.
*
* @return the ids of the lists this call *created*, which are the only ones
* a rollback may delete.
*/
private fun attach(accountId: Long, selected: Set<TaskCollection>): List<Long> {
val known = database.taskLists().attachable(accountId).associateBy { it.href }
val inserted = mutableListOf<Long>()
selected.forEach { collection ->
val href = collection.url.toString()
val current = known[href]
if (current == null) {
inserted += database.taskLists().insert(
TaskListEntity(
name = collection.displayName ?: collection.url.pathSegments
.lastOrNull { it.isNotEmpty() }
.orEmpty(),
color = collection.color ?: DEFAULT_LIST_COLOR,
accountId = accountId,
isReadOnly = collection.readOnly,
href = href,
),
)
} else {
// Changing hands, as opposed to re-authenticating the account
// that already owns it: a cursor from the previous owner says
// nothing about this one, while the current owner's is still
// good and throwing it away costs a full reconciliation.
val changingHands = current.accountId != accountId
database.taskLists().update(
current.copy(
accountId = accountId,
isReadOnly = collection.readOnly,
syncToken = current.syncToken.takeUnless { changingHands },
ctag = current.ctag.takeUnless { changingHands },
),
)
}
}
return inserted
}
/**
* Replaces the credential of an account the server had stopped accepting.
*
* The tasks stay exactly as they are — the password was the only thing that
* went stale. Everything the user was asked for on the way here is applied
* all the same:
*
* ⚠️ This branch used to take [appPassword] and drop [found] and [selected]
* on the floor. The user walked the whole add flow, ticked collections, and
* was shown Done — while no newly-ticked list was created, no unticked one
* was detached, and a moved principal or a corrected username was discarded,
* so a relocated account could never be repaired. It also never called
* `accounts.add`, so an account the user had 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.
*/
private suspend fun reauthenticate(
existing: AccountEntity,
displayName: String,
username: String,
appPassword: String,
found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>,
): Outcome {
val accountId = existing.id
if (!credentials.put(accountId, appPassword)) {
return Outcome.CredentialFailed(
Outcome.Cause.KEYSTORE_REFUSED,
"the device keystore would not store the password",
)
}
withContext(NonCancellable) {
database.runInTransaction {
database.accounts().update(
existing.copy(
// Where discovery just found it, which is the only way a
// principal that has moved can ever be corrected.
principalUrl = (found.movedTo ?: found.principal).toString(),
homeSetUrl = found.homeSets.firstOrNull()?.toString()
?: existing.homeSetUrl,
username = username,
),
)
attach(accountId, selected)
}
// ⚠️ Ticked lists are attached; unticked ones are left alone. The
// picker pre-ticks everything *writable*, not everything already
// attached, so detaching what is unticked would silently stop
// syncing a read-only share the account has synced for months —
// over a default the user never chose. Detaching belongs here the
// day the picker knows what this account already holds.
// The Room row is the account as far as this app is concerned, so a
// missing system entry is re-registered rather than left behind.
if (accounts.find(displayName) == null) accounts.add(displayName, accountId)
accountState.setNeedsSignIn(accountId, false)
database.accounts().recordSync(accountId, at = null, error = null)
syncTrigger.schedule(displayName)
syncTrigger.enqueue(displayName)
}
return Outcome.Created(accountId)
}
/**
* Undoes a half-made account.
*
* ⚠️ The lists this attempt *created* have to go explicitly.
* `task_lists.account_id` is `ON DELETE SET NULL` — deliberately, so
* removing a working account never destroys tasks — which means deleting the
* account row alone would leave a set of empty device-only lists behind.
*
* ⚠️ And only those. A list [attach] re-attached was already on the device
* and holds the user's tasks; `SET NULL` returns it to being device-only,
* which is exactly where it came from.
*/
private fun rollback(accountId: Long, inserted: List<Long>) = database.runInTransaction {
inserted.forEach { database.taskLists().delete(it) }
database.accounts().delete(accountId)
}
/**
* Puts every existing account back on the periodic schedule.
*
* Cheap and idempotent — `KEEP` means an already-scheduled account is left
* exactly as it is — so calling it on app open costs nothing and repairs the
* one case WorkManager cannot: a schedule lost to "clear app data" or to a
* restore onto a device that never ran the account-add flow.
*/
suspend fun rescheduleAll() = withContext(io) {
val stopped = accountState.needingSignIn()
database.accounts().all().forEach { account ->
// ⚠️ A stopped account must not come back on the timer. `KEEP` only
// keeps work that is unfinished, and CANCELLED counts as finished —
// so rescheduling would re-enqueue the very request a 401 removed,
// and the next app open would put a dead app password back on a
// four-hour loop against a server that throttles by IP.
//
// This is also where the cancellation happens at all: the engine
// cannot cancel from inside the worker it is running in.
if (account.id in stopped) {
syncTrigger.cancel(account.displayName)
} else {
syncTrigger.schedule(account.displayName)
}
}
}
/** The accounts a caller may sync right now — stopped ones excluded. */
suspend fun syncable(): List<AccountEntity> = withContext(io) {
val stopped = accountState.needingSignIn()
database.accounts().all().filterNot { it.id in stopped }
}
/**
* Removes an account and everything that keys off it.
*
* The lists are **not** deleted: `task_lists.account_id` is `ON DELETE SET
* NULL`, so they become device-only lists. Removing an account is not an
* instruction to destroy the tasks it held.
*/
suspend fun remove(accountId: Long, displayName: String, deleteLocalData: Boolean = false) =
withContext(io) {
syncTrigger.cancel(displayName)
revokeAppPassword(accountId)
// ⚠️ Uncancellable from here. Everything below is destructive and
// spread over four stores that cannot share a transaction, and the
// caller is a viewModelScope tied to the Settings destination — the
// user taps Remove, the screen slides away, and a couple of back
// gestures kill the scope mid-sequence. Only the DataStore writes can
// observe cancellation (every Room DAO here is blocking), so the
// realistic landing point is `cadence.forget`: the app password is
// already revoked server-side while the row survives holding it, and
// the account reads "sign in again" for a credential we ourselves
// invalidated. Land further in and the tasks are gone with the row
// still there. The tail is three DataStore writes, two deletes and an
// AccountManager call — bounded and sub-second, so finishing it is
// strictly better than stopping anywhere inside it.
withContext(NonCancellable) {
// The lists survive as device-only lists, so their cursors must
// not: a re-added account would otherwise inherit a "reconciled
// recently" that was true of a different account's data.
val listIds = database.taskLists().syncedForAccount(accountId)
.map { it.id }
.toSet()
cadence.forget(listIds)
// ⚠️ And the quarantine counts, which are keyed the same way and
// are just as global. A list re-attached to a new account would
// otherwise inherit them, and a resource already at THRESHOLD is
// skipped for ever — it never succeeds, so it never clears.
quarantine.forget(listIds)
// Play's Account Deletion policy does not apply to us — there is
// no Agendula account to delete — but "I want it gone from this
// device too" is a reasonable thing to want, and it is the only
// way to get the tasks off the device without also uninstalling.
if (deleteLocalData) database.taskLists().deleteForAccount(accountId)
accountState.setNeedsSignIn(accountId, false)
// Keyed by account id, exactly like the flag above, and just as
// orphaned once the row goes: ids are AUTOINCREMENT so they are
// never reused, but nothing would ever read or clear these again.
notices.dismiss(accountId)
// The same reasoning, for what the server said it would let us
// create: keyed by an id nothing will ever mention again.
collectionSupport.forget(accountId)
credentials.clear(accountId)
database.accounts().delete(accountId)
accounts.find(displayName)?.let { accounts.remove(it) }
}
}
/**
* Best effort, and before the credential is cleared — it is the credential.
*
* A failure here is never allowed to stop the removal: the user asked for the
* account to go, and a server that is unreachable, or was never a Nextcloud,
* is not a reason to keep it.
*/
private suspend fun revokeAppPassword(accountId: Long) {
val account = database.accounts().account(accountId) ?: return
val username = account.username ?: return
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return
val password = (credentials.get(accountId) as? CredentialStore.Secret.Present)?.value
?: return
// ⚠️ The budget lives on the request itself, in AppPassword.revoke.
// Wrapping this in withTimeoutOrNull only *looked* bounded: the call
// parks on a socket read that no cancellation can break, and withContext
// returns when its block does, so the deadline passed and we waited
// anyway — minutes, on a multi-homed host that stalls.
try {
gateway.revokeAppPassword(
CalDavGateway.Credentials(username, password, origin),
)
} catch (_: CancellationException) {
// Deliberately swallowed. If the caller went away mid-revoke we still
// want the removal to finish rather than stop half-done; the tail
// below runs uncancellable for the same reason.
}
}
private companion object {
/** M3 primary-ish blue; the user recolours a list from its own screen. */
const val DEFAULT_LIST_COLOR = 0xFF4C6FFF.toInt()
}
}
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which accounts the server has stopped accepting.
*
* Separate from `accounts.last_sync_error` because the two mean different
* things to the user and to the engine: an error is "this did not work, we will
* try again", while this is "**we have stopped trying** and only you can change
* that". Conflating them is how a client ends up retrying a revoked app password
* on a timer.
*
* Lives with the other per-device sync state, and is therefore excluded from
* backup — see [SyncStateDataStore]. That is also correct on its own terms: the
* credential does not survive a restore either, so a restored "needs sign-in" is
* at best redundant and at worst stale.
*/
@Singleton
class AccountStateStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
suspend fun needingSignIn(): Set<Long> = observeNeedingSignIn().first()
/** Observed, so a 401 during a background sync reaches an open screen. */
fun observeNeedingSignIn(): Flow<Set<Long>> = dataStore.data.map { prefs ->
prefs[KEY].orEmpty().mapNotNull { it.toLongOrNull() }.toSet()
}
suspend fun needsSignIn(accountId: Long): Boolean = accountId in needingSignIn()
suspend fun setNeedsSignIn(accountId: Long, needed: Boolean) {
dataStore.edit { prefs ->
val current = prefs[KEY].orEmpty().toMutableSet()
if (needed) current += accountId.toString() else current -= accountId.toString()
prefs[KEY] = current
}
}
private companion object {
val KEY = stringSetPreferencesKey("accounts_needing_sign_in")
}
}
@@ -0,0 +1,103 @@
package de.jeanlucmakiola.agendula.data.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.os.Bundle
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
/**
* Identifiers shared between Kotlin and the two XML descriptors.
*
* Derived from `applicationId`, so the debug and `releaseTest` builds get their
* own account type and authority and can be installed alongside the real app
* without their accounts colliding. ⚠️ `res/xml/authenticator.xml` and
* `res/xml/sync_adapter.xml` cannot read `BuildConfig`, so they use string
* resources generated by `resValue` in `app/build.gradle.kts` — the two must be
* changed together.
*/
object SyncContract {
val ACCOUNT_TYPE: String = BuildConfig.APPLICATION_ID + ".caldav"
val AUTHORITY: String = BuildConfig.APPLICATION_ID + ".sync"
}
/**
* Agendula's CalDAV accounts, as the system sees them.
*
* The Room `accounts` table is the source of truth for everything about an
* account; this is only the system-visible half — the entry in Settings, and the
* handle the sync framework needs to trigger us.
*/
@Singleton
class CalDavAccounts @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val accountManager get() = AccountManager.get(context)
fun all(): List<Account> =
accountManager.getAccountsByType(SyncContract.ACCOUNT_TYPE).toList()
fun find(name: String): Account? = all().firstOrNull { it.name == name }
/**
* Registers [name] with the system and turns sync on for it.
*
* No password is handed to `AccountManager`: it stores them as plain `TEXT`.
* The app password goes to [CredentialStore], keyed by the Room account id.
*
* @return false if an account with this name already exists
*/
fun add(name: String, roomAccountId: Long): Boolean {
val account = Account(name, SyncContract.ACCOUNT_TYPE)
val userData = Bundle().apply { putString(KEY_ROOM_ACCOUNT_ID, roomAccountId.toString()) }
if (!accountManager.addAccountExplicitly(account, null, userData)) return false
// All three are among the calls that return silently with no registered
// sync adapter — see SyncAdapterService. They work because we register one.
ContentResolver.setIsSyncable(account, SyncContract.AUTHORITY, 1)
ContentResolver.setSyncAutomatically(account, SyncContract.AUTHORITY, true)
return true
}
/**
* The Room account id for [account], or null.
*
* ⚠️ `getUserData` returns null while the device is locked, so a
* boot-triggered sync has to wait for unlock rather than treat this as
* "account gone".
*/
fun roomAccountId(account: Account): Long? =
accountManager.getUserData(account, KEY_ROOM_ACCOUNT_ID)?.toLongOrNull()
/**
* Removes the system-visible account.
*
* `removeAccountExplicitly` works because we own the account type. The Room
* row and the credential are removed by [AccountRepository]; pruning must be
* driven by this call and by `AccountManager`'s account-removed broadcast,
* never by "absent from the visible set" — `getAccountsByType` returns
* nothing while the device is locked, and treating that as removal is how a
* restore silently deletes the user's lists.
*/
fun remove(account: Account): Boolean = accountManager.removeAccountExplicitly(account)
fun requestSync(account: Account) {
ContentResolver.requestSync(
account,
SyncContract.AUTHORITY,
Bundle().apply {
putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
},
)
}
private companion object {
const val KEY_ROOM_ACCOUNT_ID = "roomAccountId"
}
}
@@ -0,0 +1,127 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.caldav.AppPassword
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.DnsJavaResolver
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* The network side of adding an account, behind one interface.
*
* It exists so the sign-in state machine can be tested. That machine decides
* which of five outcomes leads where, when a one-shot app password is spent, and
* which host a credential is scoped to — all of which are exactly the sort of
* thing that goes wrong quietly, and none of which should require a server to
* exercise.
*/
interface CalDavGateway {
/** Discovery against [target], optionally carrying credentials. */
suspend fun discover(target: String, credentials: Credentials? = null): CalDavDiscovery.Outcome
/** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */
suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow?
suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult
/**
* Hands an app password back to the server, best effort.
*
* Without it, uninstalling never revokes anything — the credential we minted
* outlives the app in the user's device list.
*/
suspend fun revokeAppPassword(credentials: Credentials): Boolean
/**
* The same, for a password the login flow just minted.
*
* ⚠️ Separate because [Credentials.origin] means something different here:
* the *server root* the flow reported, not a principal URL. Sending it
* through [revokeAppPassword] would derive the OCS root as though it were a
* principal, and a subpath install's `https://host/nextcloud/` would collapse
* to `https://host/` — a DELETE that 404s on every one of them.
*/
suspend fun revokeIssuedAppPassword(credentials: Credentials): Boolean
/** Credentials, and the origin whose registrable domain they are scoped to. */
data class Credentials(val username: String, val password: String, val origin: HttpUrl)
}
@Singleton
class OkHttpCalDavGateway @Inject constructor(
@IoDispatcher private val io: CoroutineDispatcher,
) : CalDavGateway {
/**
* Becomes the app password's **name** in Nextcloud's Settings → Security →
* Devices & sessions. OkHttp's default would show `okhttp/4.12.0`, leaving
* the user unable to tell what to revoke — which defeats the whole point of
* using an app password.
*/
private val userAgent = "Agendula (Android)"
override suspend fun discover(
target: String,
credentials: CalDavGateway.Credentials?,
): CalDavDiscovery.Outcome = withContext(io) {
val client = credentials?.let {
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
} ?: CalDavHttp.anonymous(userAgent)
CalDavDiscovery(client, DnsJavaResolver()).discover(target)
}
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
withContext(io) {
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent)
.start(server, now())
.getOrNull()
}
/**
* ⚠️ The budget lives on the request, as it does for the revocation.
* `execute()` parks on a socket read that no cancellation can break, so the
* four places that cancel the poll job only stop the *next* request — and
* the shared client's ceiling is sized for a multiget, not for a two-second
* poll loop against a server that answered a moment ago.
*/
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult =
withContext(io) {
val client = CalDavHttp.anonymous(userAgent).newBuilder()
.callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
NextcloudLoginFlow(client, userAgent).poll(flow, now())
}
override suspend fun revokeAppPassword(
credentials: CalDavGateway.Credentials,
): Boolean = withContext(io) {
val client = CalDavHttp.authenticated(
userAgent, credentials.username, credentials.password, credentials.origin,
)
AppPassword.revoke(client, credentials.origin)
}
override suspend fun revokeIssuedAppPassword(
credentials: CalDavGateway.Credentials,
): Boolean = withContext(io) {
val client = CalDavHttp.authenticated(
userAgent, credentials.username, credentials.password, credentials.origin,
)
AppPassword.revokeAt(client, credentials.origin)
}
private fun now() = System.currentTimeMillis() / 1000
private companion object {
/** One poll of a 2s loop. Long enough for a homelab, short enough to cancel. */
const val POLL_TIMEOUT_SECONDS = 15L
}
}
@@ -0,0 +1,72 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.CollectionSupport
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* What each account's home set answered to OPTIONS, last time we asked.
*
* ⚠️ A cache, never the answer. `docs/SYNC-PLAN.md` chunk 5 asks for the "new
* task list" affordance to be *hidden* where neither MKCALENDAR nor extended
* MKCOL exists, and a picker that has to make a network round trip before it can
* draw a row is a picker that stutters — so the last answer is what it draws
* with, and [RemoteListRepository] re-asks before it actually writes. A server
* that gained the capability in an upgrade, or lost it in a config change, is
* then wrong for exactly one glance rather than for ever.
*/
@Singleton
class CollectionSupportStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
suspend fun get(accountId: Long): CollectionSupport =
decode(dataStore.data.first()[KEY].orEmpty())[accountId] ?: CollectionSupport.NONE
/** Asks [ask], records what it said, and hands it back. */
suspend fun refresh(accountId: Long, ask: () -> CollectionSupport): CollectionSupport {
val answer = ask()
dataStore.edit { prefs ->
// Re-read inside `edit`, which DataStore serialises: two accounts
// can be asked at once and a snapshot taken outside would drop one.
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
current[accountId] = answer
prefs[KEY] = current.map { (id, support) -> encode(id, support) }.toSet()
}
return answer
}
suspend fun forget(accountId: Long) {
dataStore.edit { prefs ->
prefs[KEY] = decode(prefs[KEY].orEmpty())
.filterKeys { it != accountId }
.map { (id, support) -> encode(id, support) }
.toSet()
}
}
private fun encode(accountId: Long, support: CollectionSupport): String =
"$accountId|${support.mkCalendar}|${support.extendedMkCol}"
private fun decode(entries: Set<String>): Map<Long, CollectionSupport> =
entries.mapNotNull { entry ->
val parts = entry.split('|')
if (parts.size != FIELDS) return@mapNotNull null
val accountId = parts[0].toLongOrNull() ?: return@mapNotNull null
accountId to CollectionSupport(
mkCalendar = parts[1].toBooleanStrictOrNull() ?: return@mapNotNull null,
extendedMkCol = parts[2].toBooleanStrictOrNull() ?: return@mapNotNull null,
)
}.toMap()
private companion object {
val KEY = stringSetPreferencesKey("collection_support")
const val FIELDS = 3
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
package de.jeanlucmakiola.agendula.data.sync
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.agendula.data.di.CredentialsDataStore
import kotlinx.coroutines.flow.first
import java.io.IOException
import java.security.GeneralSecurityException
import java.security.ProviderException
import java.security.KeyStore
import java.util.Base64
import javax.crypto.AEADBadTagException
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* App passwords, encrypted with a hardware-backed Keystore key.
*
* `androidx.security:security-crypto` is **formally deprecated and terminal** —
* deprecated at 1.1.0-alpha07, shipped deprecated in stable 1.1.0, with release
* notes saying there will be no further releases — and its successor
* `datastore-tink` is alpha. So: Keystore `AES/GCM/NoPadding` directly, blob in
* DataStore.
*
* Be honest about what this buys. `AccountManager` stores passwords as plain
* `TEXT` — there is no encryption or hashing anywhere in AOSP — so file-based
* encryption plus a same-signature check is the whole boundary there. That is
* DAVx5's posture and it is defensible, but it is not secure storage. This is
* better, and the difference is worth the ~80 lines.
*
* Three deliberate non-choices:
* - `setUserAuthenticationRequired` is left at its default of `false`. Requiring
* a device unlock per decryption makes background sync impossible.
* - `setUnlockedDeviceRequired` is **not** set, for the same reason.
* - A failure to decrypt is *never* a crash. It means re-authenticate.
*/
@Singleton
class CredentialStore @Inject constructor(
@CredentialsDataStore private val dataStore: DataStore<Preferences>,
) {
/** What came back for an account. */
sealed interface Secret {
data class Present(val value: String) : Secret
data object Absent : Secret
/**
* The ciphertext exists but can no longer be decrypted, so the only
* recovery is to sign in again.
*
* Reached by a restored backup (Keystore keys are non-exportable, so a
* restored blob is permanently undecryptable — which is why the blob is
* excluded from backup), by the key being invalidated when the user
* changes their lock screen, or by corruption.
*/
data class Unrecoverable(val reason: String) : Secret
}
/**
* Stores [appPassword] for [accountId].
*
* @return false when the Keystore could not be used at all. A wedged or
* degraded keystore throws [ProviderException], which is a `RuntimeException`
* and would otherwise take down the account-add flow — the same "never
* crash over this" rule [get] follows.
*/
suspend fun put(accountId: Long, appPassword: String): Boolean = try {
val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key()) }
// The IV travels with the ciphertext. GCM requires a unique IV per
// encryption under the same key; letting the provider generate it is the
// only way to be sure of that.
val payload = cipher.iv + cipher.doFinal(appPassword.toByteArray(Charsets.UTF_8))
dataStore.edit { it[keyFor(accountId)] = Base64.getEncoder().encodeToString(payload) }
true
} catch (e: GeneralSecurityException) {
false
} catch (e: ProviderException) {
false
} catch (e: IOException) {
false
}
suspend fun get(accountId: Long): Secret {
val stored = dataStore.data.first()[keyFor(accountId)] ?: return Secret.Absent
return try {
val payload = Base64.getDecoder().decode(stored)
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
init(
Cipher.DECRYPT_MODE,
key(),
GCMParameterSpec(TAG_BITS, payload, 0, IV_BYTES),
)
}
Secret.Present(
String(
cipher.doFinal(payload, IV_BYTES, payload.size - IV_BYTES),
Charsets.UTF_8,
),
)
} catch (e: KeyPermanentlyInvalidatedException) {
// The lock screen changed, or the key was otherwise invalidated.
Secret.Unrecoverable(e.message ?: "the encryption key was invalidated")
} catch (e: AEADBadTagException) {
// Wrong key or tampered ciphertext — the restored-backup case.
Secret.Unrecoverable(e.message ?: "the stored credential could not be decrypted")
} catch (e: GeneralSecurityException) {
Secret.Unrecoverable(e.message ?: "the stored credential could not be read")
} catch (e: IllegalArgumentException) {
// Not valid Base64 at all — a truncated or hand-edited blob.
Secret.Unrecoverable(e.message ?: "the stored credential is malformed")
} catch (e: ProviderException) {
// ⚠️ AndroidKeyStore signals keystore-level failure ("Keystore
// operation failed", "Failed to load key") with this — a
// RuntimeException, so none of the catches above match it. On a
// device with a degraded keystore it would crash the sync worker
// instead of prompting a re-authentication.
Secret.Unrecoverable(e.message ?: "the device keystore is unavailable")
} catch (e: IOException) {
// KeyStore.load declares it.
Secret.Unrecoverable(e.message ?: "the device keystore could not be opened")
}
}
suspend fun clear(accountId: Long) {
dataStore.edit { it.remove(keyFor(accountId)) }
}
/** Every stored credential. Used when the last account goes away. */
suspend fun clearAll() {
dataStore.edit { it.clear() }
}
private fun keyFor(accountId: Long) = stringPreferencesKey("caldav_app_password_$accountId")
private fun key(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply {
init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
// Not calling setUserAuthenticationRequired /
// setUnlockedDeviceRequired is the point — see the class doc.
.build(),
)
}.generateKey()
}
private companion object {
const val KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "agendula.caldav.credentials"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_BYTES = 12
const val TAG_BITS = 128
}
}
@@ -0,0 +1,145 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* Where a started login flow is written down, so it can outlive this process.
*
* A seam for the same reason [AccountCreator] and [CalDavGateway] are: the flow
* that decides *when* a one-shot password stops being ours has to be testable
* without a DataStore.
*/
interface LoginFlowRecord {
/** Called **before** the browser is handed the URL. */
suspend fun remember(flow: NextcloudLoginFlow.Flow)
/** The flow is over, however it ended. */
suspend fun forget()
}
/**
* The Nextcloud login flow that is currently out at a browser.
*
* ⚠️ [NextcloudLoginFlow.Flow]'s own doc says to persist it **before** launching
* the browser, because the flow outlives our process — and it did not. The
* browser is a separate task, so process death while the user is approving is
* ordinary rather than exotic, and it stranded a one-shot app password that
* nothing could then collect *or* revoke: the flow's poll token was the only way
* back to it, and it lived in a ViewModel field.
*
* The token is not a credential. It authorises exactly one poll of one flow the
* user is in the middle of approving, and it is useless past the twenty-minute
* window — so it belongs in the sync-state store rather than the Keystore.
*
* ⚠️ What this does **not** cover is the window *after* approval, where the
* password itself lives only in memory. That needs the wizard's own state to
* survive, which is a different piece of work.
*/
@Singleton
class PendingLoginFlowStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
private val gateway: CalDavGateway,
) : LoginFlowRecord {
private val lock = Mutex()
private var reclaimed = false
/** Records [flow] so a process that dies mid-approval can still finish with it. */
override suspend fun remember(flow: NextcloudLoginFlow.Flow) {
dataStore.edit { prefs ->
prefs[LOGIN_URL] = flow.loginUrl.toString()
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
prefs[POLL_TOKEN] = flow.pollToken
prefs[DEADLINE] = flow.deadlineEpochSeconds
}
}
/** The flow is finished, one way or another. */
override suspend fun forget() {
dataStore.edit { prefs ->
prefs.remove(LOGIN_URL)
prefs.remove(POLL_ENDPOINT)
prefs.remove(POLL_TOKEN)
prefs.remove(DEADLINE)
}
}
/**
* Collects and hands back a password nobody is left to own.
*
* Revoked rather than used: the address the user typed, the collections they
* ticked and the account name are all gone with the process, so there is
* nothing to finish. What is left is a live app password in the user's
* device list, under the same name as every other attempt — which is exactly
* what they cannot tell apart, and so dare not prune.
*
* ⚠️ **Once per process.** This activity is recreated on every rotation,
* theme switch and locale change, and a second run against a flow the *live*
* wizard is still polling would consume its one-shot 200 and revoke the
* password it was about to be handed. A flow remembered after this has run
* belongs to a wizard that is alive to finish it.
*/
suspend fun reclaim() {
lock.withLock {
if (reclaimed) return
reclaimed = true
}
val flow = pending() ?: return
when (val result = gateway.pollLoginFlow(flow)) {
is NextcloudLoginFlow.PollResult.Approved -> {
// Cleared first: a revocation that fails must not leave a token
// that would be polled again, and the 200 is already spent.
forget()
gateway.revokeIssuedAppPassword(
CalDavGateway.Credentials(
username = result.credentials.loginName,
password = result.credentials.appPassword,
origin = result.credentials.server,
),
)
}
is NextcloudLoginFlow.PollResult.Expired -> forget()
// Still inside the window, or the server had a moment. Either way
// the token is still worth something, so it is left for the next
// open; a poll past the deadline answers Expired and clears it.
NextcloudLoginFlow.PollResult.Pending,
is NextcloudLoginFlow.PollResult.Failed,
-> Unit
}
}
private suspend fun pending(): NextcloudLoginFlow.Flow? {
val prefs = dataStore.data.first()
val endpoint = prefs[POLL_ENDPOINT]?.toHttpUrlOrNull() ?: return null
val token = prefs[POLL_TOKEN] ?: return null
val deadline = prefs[DEADLINE] ?: return null
return NextcloudLoginFlow.Flow(
loginUrl = prefs[LOGIN_URL]?.toHttpUrlOrNull() ?: endpoint,
pollEndpoint = endpoint,
pollToken = token,
deadlineEpochSeconds = deadline,
)
}
private companion object {
val LOGIN_URL = stringPreferencesKey("login_flow_url")
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
val DEADLINE = longPreferencesKey("login_flow_deadline")
}
}
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* How many times each resource has failed, and therefore which ones to skip.
*
* ⚠️ Deliberately **not** a backoff. A backoff assumes the failure is transient
* and asks "how long until I try again"; the failures that matter here are
* permanent — a body sabre answers 415 for, a contradictory `RRULE`/`EXDATE`
* pair Nextcloud answers 500 for forever, a 507 the spec forbids retrying at
* all. The question worth asking is "how many times before I leave this one
* alone and finish the collection", and the answer is [THRESHOLD].
*
* Counts are cleared the moment a resource succeeds, so a genuinely transient
* failure costs nothing beyond the runs it actually failed in.
*/
@Singleton
class QuarantineStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
/** Current failure counts, keyed by [key]. */
suspend fun counts(): Map<String, Int> = decode(dataStore.data.first()[KEY].orEmpty())
private fun decode(entries: Set<String>): Map<String, Int> = entries.mapNotNull { entry ->
val separator = entry.lastIndexOf(COUNT_SEPARATOR)
if (separator <= 0) return@mapNotNull null
val count = entry.substring(separator + 1).toIntOrNull() ?: return@mapNotNull null
entry.substring(0, separator) to count
}.toMap()
/**
* Applies one account's changes without disturbing anyone else's.
*
* ⚠️ Not a whole-map replace. The counts are global — keyed by list, not by
* account — while `SyncWorker`'s uniqueness is only *per account*, so two
* accounts can sync at once. Each would snapshot the same global map and the
* later writer would discard the other's increments and resurrect the
* counters it had cleared. Re-reading inside `edit`, which DataStore
* serialises, keeps the read-modify-write atomic.
*
* @param updates counts to set, replacing any current value for those keys.
* @param cleared keys to remove outright, whatever they currently hold.
*/
suspend fun merge(updates: Map<String, Int>, cleared: Set<String>) {
dataStore.edit { prefs ->
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
current -= cleared
current += updates.filterValues { it > 0 }
prefs[KEY] = current
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
.toSet()
}
}
/**
* Forgets every count belonging to [listIds].
*
* ⚠️ The keys are global, exactly like the cadence cursors cleared beside
* them. A list detached from a removed account and re-attached to a new one
* would otherwise inherit its old counters — and a resource already at
* [THRESHOLD] is skipped for ever, since a quarantined resource never
* succeeds and so never clears.
*/
suspend fun forget(listIds: Set<Long>) {
if (listIds.isEmpty()) return
val prefixes = listIds.map { "$it|" }
dataStore.edit { prefs ->
val current = decode(prefs[KEY].orEmpty())
.filterKeys { key -> prefixes.none(key::startsWith) }
prefs[KEY] = current
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
.toSet()
}
}
companion object {
/**
* Attempts before a resource is left alone.
*
* Three rather than one: a 502 from a reverse proxy mid-restart and a
* permanently malformed body arrive as the same outcome, and burning two
* extra runs is cheaper than quarantining a resource that would have
* worked.
*/
const val THRESHOLD = 3
fun key(listId: Long, href: String) = "$listId|$href"
private const val COUNT_SEPARATOR = '#'
private val KEY = stringSetPreferencesKey("sync_quarantine")
}
}
@@ -0,0 +1,308 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.CollectionAdmin
import de.jeanlucmakiola.caldav.CollectionOutcome
import de.jeanlucmakiola.caldav.CollectionSupport
import de.jeanlucmakiola.caldav.DavCollectionAdmin
import de.jeanlucmakiola.caldav.ResourceNames
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Singleton
/**
* Task lists that live on a server: making them, renaming them, recolouring
* them and deleting them.
*
* ⚠️ Every write here is **server first, Room second**, and that ordering is the
* whole design. The other way round gives the user a list that exists on their
* phone and nowhere else, and nothing to tell them so — `task_lists.is_dirty`
* was already set by a rename and read by nobody, which is precisely that
* failure with the evidence discarded. A refused write leaves the local row
* exactly as it was, so what is on screen is what is on the server.
*
* Device-only lists are not this class's business: they have no href, no
* account and nothing to ask permission of. [de.jeanlucmakiola.agendula.data.tasks.TasksRepository]
* keeps them.
*/
@Singleton
class RemoteListRepository @Inject constructor(
private val database: TasksDatabase,
private val credentials: CredentialStore,
private val support: CollectionSupportStore,
private val syncTrigger: SyncTrigger,
private val cadence: SyncCadenceStore,
private val notices: SyncNoticeStore,
private val quarantine: QuarantineStore,
private val accountState: AccountStateStore,
private val resolver: ProviderResolver,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/** Why a collection write did not happen, in a form the UI can translate. */
sealed interface Outcome {
data object Done : Outcome
/** The server said no, and will say no again. */
data class Refused(val code: Int) : Outcome
/** The server could not be reached. Worth another try. */
data object Unreachable : Outcome
/** This account cannot make collections at all — iCloud, Posteo, Google. */
data object Unsupported : Outcome
/** Ours is a read-only share; the write belongs to whoever owns it. */
data object ReadOnly : Outcome
/** The account is gone, stopped, or has no credential we can decrypt. */
data object NoAccount : Outcome
/**
* The server answered something this call cannot make sense of.
*
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
* update and delete, so each of them has branches the other's method
* can return and its own cannot — and mapping those to [Unreachable]
* told someone sitting on wifi that they were offline. Unreachable is a
* claim about the network, and this is not one.
*/
data object Unexpected : Outcome
}
/**
* The accounts a new list may be created on, freshest answer first.
*
* ⚠️ Re-asked rather than cached for ever. `CollectionSupportStore` holds
* the last answer so a picker can draw immediately, but a server that gained
* the capability in an upgrade — or lost it in a config change — must be
* able to say so, and the only moment that costs nothing is while the user
* is looking at the picker.
*/
suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
// ⚠️ Empty in External mode, whatever the accounts table holds. The
// lists on screen then come from a third-party provider, so a row
// inserted into ours would exist, sync, and be visible to nobody.
if (resolver.mode() != StorageMode.OWN) return@withContext emptyList()
val stopped = accountState.needingSignIn()
val candidates = database.accounts().all().filter {
it.homeSetUrl?.toHttpUrlOrNull() != null && it.id !in stopped
}
// ⚠️ Together, not one after another. Each probe is a blocking OPTIONS,
// so three accounts with one server on a slow link held the "Where" row
// off the sheet for the sum of all three — with the sheet already drawn.
coroutineScope {
candidates.map { account -> async { account to supportFor(account) } }
.awaitAll()
.filter { (_, support) -> support.canCreate }
.map { (account, _) -> account }
}
}
/**
* Makes a collection on [accountId]'s home set and a row pointing at it.
*
* @return the new list's local id, or why there is none.
*/
suspend fun create(
accountId: Long,
name: String,
color: Int,
): Outcome = withContext(io) {
// ⚠️ Re-checked here, not only in `creatableAccounts`. The picker's list
// is a StateFlow that outlives one opening of the sheet, so a mode that
// flips between the list being built and Save being tapped would
// otherwise create the collection on the server and file the row in a
// store External mode never reads.
if (resolver.mode() != StorageMode.OWN) return@withContext Outcome.NoAccount
val account = database.accounts().account(accountId) ?: return@withContext Outcome.NoAccount
val homeSet = account.homeSetUrl?.toHttpUrlOrNull()
?: return@withContext Outcome.NoAccount
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
val capabilities = support.refresh(accountId) { admin.support(homeSet) }
if (!capabilities.canCreate) return@withContext Outcome.Unsupported
// ⚠️ A second attempt, but only for the one refusal a different name
// can fix. Nextcloud's trashbin *renames* a deleted collection rather
// than removing it, so re-creating under a segment used before answers
// 403 for ever — and "Shopping" is exactly the name someone deletes and
// remakes. Retrying anything else spends a second authenticated write
// that will fail the same way, and worse: a 401 is a second hit on the
// brute-force counter, and a 507 retried reports the wrong code back,
// since the caller only ever sees the *last* attempt's.
val first = ResourceNames.forCollection(name)
var created = admin.create(homeSet, first, name, color, capabilities)
if (created is CollectionOutcome.Refused && created.code in NAME_REFUSALS) {
created = admin.create(homeSet, ResourceNames.randomCollection(), name, color, capabilities)
}
when (created) {
is CollectionOutcome.Created -> {
// ⚠️ Uncancellable. The collection exists on the server from
// here on, and a cancellation between that and the row would
// leave one the app has no record of and no way to reach —
// visible only on the next full account re-add.
withContext(NonCancellable) {
database.taskLists().insert(
TaskListEntity(
name = name,
color = color,
accountId = accountId,
href = created.url.toString(),
),
)
}
// The server has it and we do not; a sync is how the two agree
// on a ctag and a token rather than reconciling in full later.
syncTrigger.enqueue(account.displayName, expedited = true)
Outcome.Done
}
is CollectionOutcome.Refused -> Outcome.Refused(created.code)
is CollectionOutcome.Failed -> Outcome.Unreachable
CollectionOutcome.Unsupported -> Outcome.Unsupported
CollectionOutcome.Updated -> Outcome.Unexpected
}
}
/**
* Renames and recolours [listId] on the server, then locally.
*
* ⚠️ Refuses a read-only collection rather than discovering it at write
* time. A share the owner has made read-only answers 403 to a PROPPATCH, and
* a row that has already been renamed locally by then reads as a rename that
* worked and then quietly reverted on the next sync.
*/
suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
val account = list.accountId?.let { database.accounts().account(it) }
?: return@withContext Outcome.NoAccount
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
when (val outcome = admin.updateProperties(url, displayName = name, color = color)) {
CollectionOutcome.Updated -> {
withContext(NonCancellable) {
// Read again inside the write: a sync running alongside this
// may have refreshed the ACL flag or the cursor, and writing
// back the entity we read before the network call would
// revert it.
val current = database.taskLists().entity(listId) ?: return@withContext
database.taskLists().update(
// isDirty stays false: the server already has this. The
// flag existed for a PROPPATCH that never happened.
current.copy(name = name, color = color, isDirty = false),
)
}
Outcome.Done
}
is CollectionOutcome.Refused -> Outcome.Refused(outcome.code)
is CollectionOutcome.Failed -> Outcome.Unreachable
is CollectionOutcome.Created, CollectionOutcome.Unsupported -> Outcome.Unexpected
}
}
/**
* Deletes [listId] on the server, then on the device.
*
* ⚠️ The one write where "already gone" is success — [CollectionAdmin.delete]
* grades 404 and 410 that way — because otherwise a collection someone
* removed from another client leaves a row here that nothing can get rid of.
*/
suspend fun delete(listId: Long): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
val account = list.accountId?.let { database.accounts().account(it) }
?: return@withContext Outcome.NoAccount
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
when (val outcome = admin.delete(url)) {
CollectionOutcome.Updated -> {
withContext(NonCancellable) {
// `tasks.list_id` is ON DELETE CASCADE, so the tasks go with
// it — which is what was just done on the server.
database.taskLists().delete(listId)
// And the per-list state keyed off it, exactly as removing an
// account clears its lists': the ids are AUTOINCREMENT so
// nothing would ever read these again. The notices go by
// *name*, which is how they are keyed — a discarded-edit
// notice would otherwise name a list that no longer exists
// until the user tapped "Got it".
forgetPerListState(listId)
list.accountId?.let { notices.forgetList(it, list.name) }
}
Outcome.Done
}
is CollectionOutcome.Refused -> Outcome.Refused(outcome.code)
is CollectionOutcome.Failed -> Outcome.Unreachable
is CollectionOutcome.Created, CollectionOutcome.Unsupported -> Outcome.Unexpected
}
}
private suspend fun supportFor(account: AccountEntity): CollectionSupport {
val homeSet = account.homeSetUrl?.toHttpUrlOrNull() ?: return CollectionSupport.NONE
val admin = adminFor(account) ?: return CollectionSupport.NONE
return support.refresh(account.id) { admin.support(homeSet) }
}
/**
* Null when the account has no credential we can use — a stopped account, or
* a restore.
*
* ⚠️ The stopped check is the same one `SyncEngine.sync` makes before it
* touches the network, and for the same reason: Nextcloud's brute-force
* protection throttles and then **429s per source IP**, so spending a
* request on a credential we already know the server rejects lands on the
* user's *other* clients. Opening the "new list" sheet must not do that any
* more than a timer may.
*/
private suspend fun adminFor(account: AccountEntity): CollectionAdmin? {
if (accountState.needsSignIn(account.id)) return null
val username = account.username ?: return null
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
?: return null
return DavCollectionAdmin(client(username, password, origin))
}
private fun client(username: String, password: String, origin: HttpUrl): OkHttpClient =
CalDavHttp.authenticated(USER_AGENT, username, password, origin)
private suspend fun forgetPerListState(listId: Long) {
val ids = setOf(listId)
cadence.forget(ids)
quarantine.forget(ids)
}
private companion object {
/** The same agent the sync and the add flow use, so the server names us once. */
const val USER_AGENT = "Agendula (Android)"
/**
* Refusals a different path segment can get past, and only those.
*
* 403 is Nextcloud's trashbin still holding the name; 405 is a server
* answering "already a collection there". Everything else — 401, 409,
* 423, 507 — means the same thing under any name.
*/
val NAME_REFUSALS = setOf(403, 405)
}
}
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.agendula.data.sync
import android.accounts.Account
import android.app.Service
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.os.Bundle
import android.os.IBinder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.minutes
/**
* The sync adapter whose entire job is to start a WorkManager job and wait.
*
* DAVx5's own comment describes the same design: *"We use the sync adapter
* framework only for the trigger, actual syncing is implemented with
* WorkManager."*
*
* ⚠️ Registering this is **not optional decoration**. `docs/SYNC.md` establishes
* that `ContentService.hasAuthorityAccess()` gates `requestSync`,
* `setSyncAutomatically`, `addPeriodicSync`, `setIsSyncable`, `getSyncStatus` and
* seven more behind a compat change that is on for targetSdk ≥ 34 — which we
* are. With no sync adapter registered for our authority, every one of those
* calls **returns silently**: no exception, no log, and it passes on a
* Robolectric shadow. The visible result is an account permanently reading "Sync
* off for all items" with a greyed-out "Sync now", and it is documented on no
* Android behaviour-changes page.
*
* The greying-out is why the app ships its own sync button regardless:
* `enabledSyncNowMenu()` needs at least one checked authority switch, and ours
* is `userVisible="false"`.
*/
class SyncAdapterService : Service() {
private val adapter by lazy { CalDavSyncAdapter(applicationContext) }
override fun onBind(intent: Intent?): IBinder = adapter.syncAdapterBinder
}
private class CalDavSyncAdapter(context: Context) :
AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true) {
override fun onPerformSync(
account: Account,
extras: Bundle,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult,
) {
val workManager = WorkManager.getInstance(context)
val uniqueName = SyncTrigger(context).enqueue(account.name)
// Block this thread until the work reaches a terminal state. The framework
// treats onPerformSync returning as "the sync is done", so returning early
// would make every sync look instantaneous and defeat the back-off it
// applies on failure. runBlocking is fine here: onPerformSync is already
// called on a background thread the framework owns.
//
// ⚠️ Watch the **unique work name**, not the request id. enqueueUniqueWork
// is asynchronous — the WorkSpec row is not written by the time the next
// line runs — so a flow keyed on the id emits null for an unknown id and
// the wait returns immediately, having waited for nothing. And under
// KEEP, when a run is already in flight, our request is never enqueued at
// all and its id stays unknown forever. Keying on the name handles both:
// it waits for whichever run is actually happening.
val infos = runCatching {
runBlocking {
withTimeoutOrNull(WORKER_TIMEOUT_MINUTES.minutes) {
workManager.getWorkInfosForUniqueWorkFlow(uniqueName)
.first { infos -> infos.isNotEmpty() && infos.all { it.state.isFinished } }
}
}
}.getOrNull()
// Counted as a soft error: the engine's own per-collection and
// per-resource isolation decides what is actually fatal, and telling the
// framework otherwise would have it back off the whole account. Being
// deduplicated by KEEP is *not* a failure — the sync is happening, this
// trigger simply joined the one already running.
val timedOut = infos == null
val failed = infos?.any { it.state == WorkInfo.State.FAILED } == true
if (timedOut || failed) syncResult.stats.numIoExceptions++
}
private companion object {
/** DAVx5 uses the same ceiling; an ordinary worker is documented for < 10 min. */
const val WORKER_TIMEOUT_MINUTES = 10L
}
}
@@ -0,0 +1,109 @@
package de.jeanlucmakiola.agendula.data.sync
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
/**
* The account authenticator.
*
* Agendula holds no auth tokens — a CalDAV account is a username and an app
* password, and the password lives in [CredentialStore], not here.
* `AccountManager` stores passwords as plain `TEXT`; there is no encryption or
* hashing anywhere in AOSP, so nothing secret is handed to it.
*
* `docs/SYNC.md` corrects the reason this exists. It is **not** required by any
* provider — that argument was circular. The real reasons: a stable account
* identity a third-party engine could address, presence in system Settings, and
* the sync framework as a change trigger.
*/
class SyncAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
/**
* ⚠️ Refuses until the account-add UI exists (`SYNC-PLAN.md` chunk 2d).
*
* The authenticator service is exported and registered, so Settings →
* Accounts → Add account lists Agendula **today**. Handing back an intent to
* a screen that does not yet handle [ACTION_ADD_ACCOUNT] would open the
* ordinary home screen while Settings waits forever on a response nothing
* answers. A refusal the user can read is strictly better than a hang; 2d
* replaces this with the real intent and answers [response].
*/
override fun addAccount(
response: AccountAuthenticatorResponse?,
accountType: String?,
authTokenType: String?,
requiredFeatures: Array<out String>?,
options: Bundle?,
): Bundle = unsupported("Add a CalDAV account from inside Agendula, under Settings")
override fun editProperties(
response: AccountAuthenticatorResponse?,
accountType: String?,
): Bundle = Bundle()
/**
* ⚠️ Never `null`. `AbstractAccountAuthenticator.Transport` reads a null
* return as "I will answer asynchronously via the response", and nothing here
* ever does — the caller's `AccountManagerFuture` would never complete.
*/
override fun confirmCredentials(
response: AccountAuthenticatorResponse?,
account: Account?,
options: Bundle?,
): Bundle = unsupported("Agendula does not confirm credentials from the system UI")
/** No token type: this is Basic/Digest against a CalDAV server. */
override fun getAuthToken(
response: AccountAuthenticatorResponse?,
account: Account?,
authTokenType: String?,
options: Bundle?,
): Bundle = unsupported("Agendula accounts do not use auth tokens")
override fun getAuthTokenLabel(authTokenType: String?): String? = null
/** Never `null`, for the reason given on [confirmCredentials]. */
override fun updateCredentials(
response: AccountAuthenticatorResponse?,
account: Account?,
authTokenType: String?,
options: Bundle?,
): Bundle = unsupported("Re-authenticate from inside Agendula, under Settings")
override fun hasFeatures(
response: AccountAuthenticatorResponse?,
account: Account?,
features: Array<out String>?,
): Bundle = Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) }
private fun unsupported(message: String) = Bundle().apply {
putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
putString(AccountManager.KEY_ERROR_MESSAGE, message)
}
companion object {
/** Sent to `MainActivity` when the system asks us to add an account (chunk 2d). */
const val ACTION_ADD_ACCOUNT = "de.jeanlucmakiola.agendula.ADD_ACCOUNT"
}
}
/**
* Binds [SyncAuthenticator] for the system.
*
* Exported and guarded by `android.permission.ACCOUNT_MANAGER` — note that
* `android.permission.ACCOUNT_AUTHENTICATOR`, which the obvious guess would
* reach for, **does not exist**.
*/
class AuthenticatorService : Service() {
private val authenticator by lazy { SyncAuthenticator(this) }
override fun onBind(intent: Intent?): IBinder? = authenticator.iBinder
}
@@ -0,0 +1,92 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
/**
* When each collection was last reconciled against a full listing.
*
* ⚠️ This is the mitigation for the one RFC 6578 failure that has no signal at
* all: a token the server still accepts, over a change log it has already
* pruned, answers `207` with zero changes and no error. Nothing in the protocol
* distinguishes that from "nothing happened". The only defence is to stop
* trusting the token periodically and diff a real listing — so the full path is
* a permanent safety net, not a fallback, and this is its clock.
*
* Kept out of Room deliberately: it is scheduling bookkeeping, not user data,
* and it must never be part of a backup that could restore a stale "we checked
* recently" into a fresh install.
*/
@Singleton
class SyncCadenceStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
/**
* Last *scheduled* full reconciliation per list id.
*
* ⚠️ Not "the last time a full listing was read". A collection whose server
* has no `sync-collection` support reads one on every run, and recording
* each would keep this permanently fresh — so nothing hung off the periodic
* mark would ever come due again.
*/
suspend fun lastFullSync(): Map<Long, Instant> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
val separator = entry.lastIndexOf(SEPARATOR)
if (separator <= 0) return@mapNotNull null
val id = entry.substring(0, separator).toLongOrNull() ?: return@mapNotNull null
val at = entry.substring(separator + 1).toLongOrNull() ?: return@mapNotNull null
id to Instant.fromEpochSeconds(at)
}.toMap()
/** Merges, rather than replacing, so concurrent accounts do not erase each other. */
suspend fun record(reconciled: Map<Long, Instant>) {
if (reconciled.isEmpty()) return
dataStore.edit { prefs ->
val current = prefs[KEY].orEmpty()
.mapNotNull { entry ->
val separator = entry.lastIndexOf(SEPARATOR)
if (separator <= 0) null else entry.substring(0, separator) to entry
}
.toMap()
.toMutableMap()
reconciled.forEach { (id, at) ->
current["$id"] = "$id$SEPARATOR${at.epochSeconds}"
}
prefs[KEY] = current.values.toSet()
}
}
/** Forgets a list, so a re-added account starts from a full reconciliation. */
suspend fun forget(listIds: Set<Long>) {
if (listIds.isEmpty()) return
dataStore.edit { prefs ->
prefs[KEY] = prefs[KEY].orEmpty().filterNot { entry ->
entry.substringBefore(SEPARATOR).toLongOrNull() in listIds
}.toSet()
}
}
companion object {
/**
* How long a sync token is trusted before a full listing is diffed anyway.
*
* Long enough that the incremental path still carries almost every sync,
* short enough that a silently pruned change log is a day's divergence
* rather than an indefinite one.
*/
val FULL_RECONCILIATION_INTERVAL: Duration = 24.hours
private const val SEPARATOR = '@'
private val KEY = stringSetPreferencesKey("sync_last_full")
}
}
@@ -0,0 +1,240 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.CalendarCollection
import de.jeanlucmakiola.caldav.RemoteCalendar
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* Syncs one account: every list it owns, against the collection each points at.
*
* ⚠️ **A failed collection must not fail the account.** One revoked share, one
* calendar the server 500s on, must not stop the other four from syncing — so
* every collection's outcome is a [SyncReport] rather than an exception, and the
* account's own result is the list of them.
*/
@Singleton
class SyncEngine @Inject constructor(
private val database: TasksDatabase,
private val store: RoomSyncStore,
private val credentials: CredentialStore,
private val quarantine: QuarantineStore,
private val cadence: SyncCadenceStore,
private val accountState: AccountStateStore,
private val notices: SyncNoticeStore,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/** Why an account could not be synced at all, as opposed to one of its lists. */
sealed interface Result {
data class Synced(
val reports: List<SyncReport>,
/**
* What this run destroyed or gave up on that was not already on
* record — the caller's cue to say so out loud.
*/
val notices: List<SyncNotice> = emptyList(),
) : Result
/** The credential is gone or undecryptable: only re-authentication helps. */
data class NeedsSignIn(val reason: String) : Result
data class Misconfigured(val reason: String) : Result
}
suspend fun sync(accountName: String): Result = withContext(io) {
val account = database.accounts().all().firstOrNull { it.displayName == accountName }
?: return@withContext Result.Misconfigured("no such account: $accountName")
// ⚠️ Before anything reaches the network. A periodic request that outlives
// the stop — or a manual trigger on a stopped account — must not spend a
// request on a credential we already know the server rejects: Nextcloud
// throttles then 429s per source IP, and that lands on the user's other
// clients rather than on us.
if (accountState.needsSignIn(account.id)) {
return@withContext Result.NeedsSignIn("waiting for you to sign in again")
}
val username = account.username
?: return@withContext fatal(account.id, Result.Misconfigured("account has no username"))
val origin = account.principalUrl?.toHttpUrlOrNull()
?: return@withContext fatal(
account.id,
Result.Misconfigured("account has no principal URL"),
)
val password = when (val secret = credentials.get(account.id)) {
is CredentialStore.Secret.Present -> secret.value
CredentialStore.Secret.Absent -> {
stopForSignIn(account.id, "no stored password")
return@withContext Result.NeedsSignIn("no stored password")
}
is CredentialStore.Secret.Unrecoverable -> {
stopForSignIn(account.id, secret.reason)
return@withContext Result.NeedsSignIn(secret.reason)
}
}
val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin)
val reports = syncCollections(account) { url -> CalendarCollection(client, url) }
// ⚠️ Before the auth check, not after it. A 401 on one collection does
// not un-discard an edit another collection already destroyed, and
// returning NeedsSignIn past this point would drop the record of it.
// Outside `syncCollections` because that is driven without a network by
// the reconciliation tests, which have nothing to say about notices.
val fresh = notices.record(
accountId = account.id,
at = kotlin.time.Clock.System.now(),
reports = reports,
titles = quarantinedTitles(reports),
)
if (reports.any { it.authFailure }) {
// ⚠️ Stop the account rather than let the schedule keep trying.
// Nextcloud throttles and then 429s **per source IP**, so a timer on a
// dead app password degrades every other Nextcloud client on the
// user's network — and there is nothing here to retry: the fix is a
// sign-in only the user can perform.
stopForSignIn(account.id, "the server rejected the credentials")
return@withContext Result.NeedsSignIn("the server rejected the credentials")
}
accountState.setNeedsSignIn(account.id, false)
Result.Synced(reports, fresh)
}
/**
* The local title of each quarantined resource, by href.
*
* ⚠️ Resolved here rather than left to the store, which has no database.
* Without it the user is told "a task has stopped syncing" over a row
* reading `a1f9c3e2-….ics` — the opaque blob `SyncNoticeStore` refuses to
* show for a discarded edit, and unactionable for exactly the same reason.
* A resource we have never stored has no title to find, and its filename is
* then genuinely all there is.
*/
private fun quarantinedTitles(reports: List<SyncReport>): Map<String, String> =
reports.filter { it.quarantined.isNotEmpty() }
.flatMap { report ->
val wanted = report.quarantined.mapTo(mutableSetOf()) { it.href }
store.rowsIn(report.listId)
.filter { it.href in wanted && !it.title.isNullOrBlank() }
.map { it.href!! to it.title!! }
}
.toMap()
/**
* Records why the account could not be synced at all.
*
* ⚠️ Without this the row keeps its old `lastSyncAt`, and the accounts screen
* goes on reporting "synced 5 minutes ago" for an account whose credential
* can no longer be decrypted — the silent failure the account layer exists to
* avoid.
*/
/**
* Marks an account as stopped until the user signs in again.
*
* ⚠️ It does **not** cancel the work, even though stopping the timer is the
* whole point — because this runs *inside* `SyncWorker`, and one of the two
* unique names it would cancel is the WorkSpec currently executing us.
* WorkManager would interrupt the coroutine, so `Result.NeedsSignIn` would
* never be returned and the adapter would see CANCELLED rather than FAILED.
*
* The flag does the work instead: [sync] refuses before touching the network,
* so a firing that survives costs nothing, and [AccountRepository.rescheduleAll]
* cancels the schedule from outside any worker.
*/
private suspend fun stopForSignIn(accountId: Long, reason: String) {
accountState.setNeedsSignIn(accountId, true)
database.accounts().recordSync(accountId, at = null, error = reason)
}
private fun fatal(accountId: Long, result: Result): Result {
val reason = when (result) {
is Result.NeedsSignIn -> result.reason
is Result.Misconfigured -> result.reason
is Result.Synced -> return result
}
database.accounts().recordSync(accountId, at = null, error = reason)
return result
}
/** Split out from [sync] so the reconciliation can be driven without a network. */
internal suspend fun syncCollections(
account: AccountEntity,
remoteFor: (HttpUrl) -> RemoteCalendar,
): List<SyncReport> {
val lists = database.taskLists().syncedForAccount(account.id)
val listIds = lists.map { it.id }.toSet()
// ⚠️ Only this account's keys are written back. The counts are global
// while the worker's uniqueness is only per account, so replacing the
// whole map would discard a concurrently syncing account's increments and
// resurrect the counters it had cleared.
val before = quarantine.counts()
val counts = before.toMutableMap()
val syncer = CollectionSyncer(store)
val now = kotlin.time.Clock.System.now()
val lastFull = cadence.lastFullSync()
// Never reconciled, or the token has been trusted long enough.
val due = lists.associate { list ->
val since = lastFull[list.id]
list.id to (since == null || now - since >= SyncCadenceStore.FULL_RECONCILIATION_INTERVAL)
}
val reports = lists.map { list ->
val url = list.href?.toHttpUrlOrNull()
?: return@map SyncReport(list.id, list.name, failure = "list has no collection URL")
syncer.sync(
list = list,
remote = remoteFor(url),
quarantine = counts,
fullReconciliationDue = due[list.id] == true,
)
}
// ⚠️ Only the runs that were *due*. A server without `sync-collection`
// reconciles in full every single time, so recording each one kept the
// clock permanently fresh and `fullReconciliationDue` permanently false
// — which costs nothing on that path, since the cursor is null anyway,
// but silently disables everything else hung off the periodic mark. The
// download-side quarantine probe is the one that matters: for exactly
// those servers it would never have fired.
cadence.record(
reports.filter { it.reconciledInFull && it.failure == null && due[it.listId] == true }
.associate { it.listId to now },
)
fun mine(key: String) = key.substringBefore('|').toLongOrNull() in listIds
quarantine.merge(
updates = counts.filterKeys(::mine),
cleared = before.keys.filter(::mine).filterNot { it in counts }.toSet(),
)
database.accounts().recordSync(
accountId = account.id,
at = now,
error = reports.mapNotNull { it.failure }.firstOrNull(),
)
return reports
}
private companion object {
/**
* Matches what the account-add flow signed in with, so Nextcloud's
* Settings → Security → Devices & sessions keeps naming the app password
* after the app rather than after OkHttp.
*/
const val USER_AGENT = "Agendula (Android)"
}
}
@@ -0,0 +1,129 @@
package de.jeanlucmakiola.agendula.data.sync
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import javax.inject.Inject
import javax.inject.Singleton
/**
* Tells the user what a background sync destroyed or gave up on.
*
* ⚠️ A notification, and not only a row on the accounts screen. Sync runs on a
* four-hour timer while the app is closed, so a surface the user has to go and
* look at means the discarded edit is discovered — if ever — days later, next to
* a task that quietly says something else than what they typed. The account
* screen keeps the detail; this is what makes them go there.
*
* Its own channel, at `IMPORTANCE_LOW`: it is a report rather than an alarm, and
* it must be silenceable without taking due-task reminders with it.
*/
@Singleton
class SyncNoticeNotifier @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
@SuppressLint("MissingPermission")
fun post(accountName: String, notices: List<SyncNotice>) {
if (notices.isEmpty() || !canPost()) return
ensureChannel()
val discarded = notices.count { it.kind == SyncNotice.Kind.DISCARDED_EDIT }
val quarantined = notices.size - discarded
// ⚠️ A discarded edit outranks a quarantine even when there are more
// quarantines, and the collapsed line says so. They are not equivalent:
// an edit that lost is work already destroyed and unrecoverable, while a
// quarantined task is a condition that persists and clears itself. The
// big text below lists both, in full, whichever headline was chosen.
val title = if (discarded > 0) {
context.resources.getQuantityString(
R.plurals.sync_notice_discarded_title, discarded, discarded,
)
} else {
context.resources.getQuantityString(
R.plurals.sync_notice_quarantined_title, quarantined, quarantined,
)
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(context.getString(R.string.sync_notice_body, accountName))
.setStyle(NotificationCompat.BigTextStyle().bigText(summaryOf(notices)))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setAutoCancel(true)
.setContentIntent(
PendingIntent.getActivity(
context,
accountName.hashCode(),
MainActivity.openIntent(context),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
.build()
// Tagged by account, so a second account's news replaces nothing.
NotificationManagerCompat.from(context).notify(accountName, NOTIFICATION_ID, notification)
}
/**
* The first few, by name.
*
* ⚠️ A count on its own is unactionable — "3 edits were replaced" leaves the
* user to guess which three, across every list they own. The names are the
* only part that makes the account screen worth opening.
*/
private fun summaryOf(notices: List<SyncNotice>): String {
val named = notices.take(SUMMARY_LIMIT).joinToString("\n") { notice ->
val subject = notice.subject.ifBlank { context.getString(R.string.task_untitled) }
context.getString(R.string.sync_notice_line, subject, notice.listName)
}
val rest = notices.size - SUMMARY_LIMIT
return if (rest > 0) {
named + "\n" + context.resources.getQuantityString(R.plurals.sync_notice_more, rest, rest)
} else {
named
}
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.sync_notice_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply { description = context.getString(R.string.sync_notice_channel_desc) },
)
}
private companion object {
const val CHANNEL_ID = "sync_notices"
const val NOTIFICATION_ID = 2
/** Enough to recognise the work; the screen has the rest. */
const val SUMMARY_LIMIT = 5
}
}
@@ -0,0 +1,265 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/**
* One thing a sync did that the user would not otherwise find out about.
*
* ⚠️ `docs/SYNC-PLAN.md` decision 2 is **server wins, local edit discarded**, and
* [SyncReport]'s own doc says the report is "the other half of the decision, not
* a nice-to-have". Until this existed the other half was a `Log.i` — the edit was
* gone, nothing in `ui/` read `discardedEdits`, and from where the user sits that
* is indistinguishable from the app losing their work.
*/
data class SyncNotice(
val accountId: Long,
val listName: String,
val kind: Kind,
/** The task's title for a discarded edit, the resource's name for a quarantine. */
val subject: String,
/**
* What makes this notice distinct from another about a different task.
*
* ⚠️ Carried but never shown. These are stored as a `Set<String>`, and two
* discarded edits from one run share an account, a list, a cause and a
* timestamp — so two *untitled* tasks, or two both called "Milk", encoded
* identically and one of them silently vanished. The notification counted
* two and the screen listed one. The UID is the only thing that tells them
* apart, and it is exactly what must not reach the user: opaque text chosen
* by whoever created the task.
*/
val key: String,
/** Why the edit lost. Null for a quarantine, which has no such choice behind it. */
val cause: DiscardedEdit.Cause?,
val at: Instant,
) {
enum class Kind { DISCARDED_EDIT, QUARANTINED }
}
/**
* What the last syncs destroyed or gave up on, per account, until it is read.
*
* The two kinds keep different company, which is why they are written
* differently:
*
* - A **discarded edit** is news. It happened once, it cannot be undone, and a
* later clean sync does not make it untrue — so it accumulates and is cleared
* only by the user acknowledging it. Replacing the set every run would let a
* quiet sync an hour later erase the one thing worth saying.
* - A **quarantined resource** is a standing condition: one task has stopped
* syncing while the rest of its list is fine. It is re-reported on every run
* for as long as it holds, so the account's set of them is *replaced* each
* time — which is also how it clears itself the moment the resource starts
* working again.
*
* Lives with the other per-device sync state, and is therefore excluded from
* backup — see [SyncStateDataStore]. Correct on its own terms too: a restored
* device has not discarded anything.
*/
@Singleton
class SyncNoticeStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
/** Observed, so a background sync's news reaches a screen that is already open. */
fun observeAll(): Flow<List<SyncNotice>> = dataStore.data.map { prefs ->
prefs[KEY].orEmpty().mapNotNull(::decode).sortedByDescending { it.at }
}
/**
* Folds one account's run into the store.
*
* @return only what is **new**, which is what a notification may be posted
* for. A quarantine already on record is a condition the user has already
* been told about, and re-announcing it on every four-hour run would train
* them to ignore the one that matters.
*/
/**
* @param titles the local title of each quarantined resource, by href.
* ⚠️ Not optional decoration. Without it the row read
* `a1f9c3e2-….ics`, which is the opaque blob this file refuses to show
* for a discarded edit — and "a task has stopped syncing" that does not
* say which task is the very failure the feature exists to fix. Absent
* only for a resource we never stored, where the filename is genuinely
* all there is.
*/
suspend fun record(
accountId: Long,
at: Instant,
reports: List<SyncReport>,
titles: Map<String, String> = emptyMap(),
): List<SyncNotice> {
val discarded = reports.flatMap { report ->
report.discardedEdits.map { edit ->
SyncNotice(
accountId = accountId,
listName = report.listName,
kind = SyncNotice.Kind.DISCARDED_EDIT,
// The UID is not shown to anyone: it is opaque text chosen by
// whoever created the task, routinely a bare hex blob.
subject = edit.title.orEmpty(),
key = edit.uid,
cause = edit.cause,
at = at,
)
}
}
// ⚠️ Only the ones that have actually stopped. Below the threshold the
// resource is still being retried, and "one of your tasks has stopped
// syncing" would be untrue of a single 502 from a proxy mid-restart.
val quarantined = reports.flatMap { report ->
report.quarantined
.filter { it.failures >= QuarantineStore.THRESHOLD }
.map { resource ->
SyncNotice(
accountId = accountId,
listName = report.listName,
kind = SyncNotice.Kind.QUARANTINED,
subject = titles[resource.href] ?: resource.href.substringAfterLast('/'),
key = resource.href,
cause = null,
at = at,
)
}
}
// ⚠️ Nothing to say is the overwhelmingly common case — most syncs
// discard nothing and quarantine nothing — and a DataStore edit rewrites
// and fsyncs the whole file. Skipped only when there is also nothing on
// record to clear, or a recovered resource would keep its notice for ever.
if (discarded.isEmpty() && quarantined.isEmpty() && !hasRecord(accountId)) {
return emptyList()
}
var added = emptyList<SyncNotice>()
dataStore.edit { prefs ->
// ⚠️ Re-read inside `edit`, which DataStore serialises. The set is
// global while `SyncWorker`'s uniqueness is only per account, so two
// accounts can be folding in at once and a snapshot taken outside
// would discard the other's.
val current = prefs[KEY].orEmpty().mapNotNull(::decode)
val others = current.filter { it.accountId != accountId }
val keptDiscards = current.filter {
it.accountId == accountId && it.kind == SyncNotice.Kind.DISCARDED_EDIT
}
val standing = current.filter {
it.accountId == accountId && it.kind == SyncNotice.Kind.QUARANTINED
}
added = discarded + quarantined.filterNot { fresh ->
standing.any { it.key == fresh.key }
}
// Newest first, then capped: an account that has been failing for a
// week must not grow this without bound, and the oldest news is the
// least actionable.
val kept = (discarded + keptDiscards).sortedByDescending { it.at }.take(MAX_PER_ACCOUNT)
// ⚠️ Capped as well, and the class doc used to claim it did not need
// to be. "Bounded by the collection" is only true of a healthy one:
// a server answering 415 to four hundred resources puts four hundred
// entries in one preference key, rewritten on every run — and the
// account screen renders them into a plain scrolling column.
val standingNow = quarantined.take(MAX_PER_ACCOUNT)
val updated = (others + kept + standingNow).map(::encode).toSet()
// ⚠️ Only when it differs. A DataStore edit rewrites and fsyncs the
// whole file, and an account holding one un-dismissed notice would
// otherwise pay that on every four-hour sync until the user tapped
// "Got it" — which is the cost the fast path above claims to avoid.
if (updated != prefs[KEY]) prefs[KEY] = updated
}
return added
}
private suspend fun hasRecord(accountId: Long): Boolean =
dataStore.data.first().let { prefs ->
prefs[KEY].orEmpty().mapNotNull(::decode).any { it.accountId == accountId }
}
/**
* Forgets one list's notices, for a list that has just been deleted.
*
* By name, because that is how they are keyed — there is no list id in a
* notice, and by the time this is called the row it would have named is
* already gone.
*/
suspend fun forgetList(accountId: Long, listName: String) {
dataStore.edit { prefs ->
val kept = prefs[KEY].orEmpty()
.mapNotNull(::decode)
.filterNot { it.accountId == accountId && it.listName == listName }
.map(::encode)
.toSet()
if (kept != prefs[KEY]) prefs[KEY] = kept
}
}
/** The user has read them. */
suspend fun dismiss(accountId: Long) {
dataStore.edit { prefs ->
prefs[KEY] = prefs[KEY].orEmpty()
.mapNotNull(::decode)
.filterNot { it.accountId == accountId }
.map(::encode)
.toSet()
}
}
/**
* ⚠️ Base64 around the free text, not a delimiter and a hope. A list is
* named by its owner and a task is titled by its author, so both can hold
* any character at all — including whatever separator looked safe.
*/
private fun encode(notice: SyncNotice): String = listOf(
notice.accountId.toString(),
notice.kind.name,
notice.cause?.name.orEmpty(),
notice.at.toEpochMilliseconds().toString(),
base64(notice.listName),
base64(notice.subject),
base64(notice.key),
).joinToString(SEPARATOR)
private fun decode(entry: String): SyncNotice? {
val parts = entry.split(SEPARATOR)
if (parts.size != FIELDS) return null
val accountId = parts[0].toLongOrNull() ?: return null
val kind = SyncNotice.Kind.entries.firstOrNull { it.name == parts[1] } ?: return null
val at = parts[3].toLongOrNull() ?: return null
return SyncNotice(
accountId = accountId,
listName = unBase64(parts[4]) ?: return null,
kind = kind,
subject = unBase64(parts[5]) ?: return null,
key = unBase64(parts[6]) ?: return null,
cause = DiscardedEdit.Cause.entries.firstOrNull { it.name == parts[2] },
at = Instant.fromEpochMilliseconds(at),
)
}
private fun base64(value: String): String =
Base64.getUrlEncoder().encodeToString(value.toByteArray(Charsets.UTF_8))
private fun unBase64(value: String): String? = runCatching {
String(Base64.getUrlDecoder().decode(value), Charsets.UTF_8)
}.getOrNull()
private companion object {
val KEY = stringSetPreferencesKey("sync_notices")
/** Not present in URL-safe Base64, nor in a decimal or an enum name. */
const val SEPARATOR = "|"
const val FIELDS = 7
/** Discarded edits, per account. Quarantines are bounded by the collection. */
const val MAX_PER_ACCOUNT = 50
}
}
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.agendula.data.sync
/**
* What a sync did, and — the part that matters — what it destroyed.
*
* ⚠️ `docs/SYNC-PLAN.md` decision 2 is **server wins, local edit discarded**.
* That policy terminates, which is why it was chosen over forking under a new
* UID, but on its own it is indistinguishable from data loss: the user's edit is
* gone and nothing said so. The report is the other half of the decision, not a
* nice-to-have — [discardedEdits] is why this type exists.
*/
data class SyncReport(
val listId: Long,
val listName: String,
val downloaded: Int = 0,
val uploaded: Int = 0,
val deletedRemotely: Int = 0,
val deletedLocally: Int = 0,
/** Local edits thrown away because the server's copy was newer. */
val discardedEdits: List<DiscardedEdit> = emptyList(),
/** Resources the collection gave up on, so the rest of it could finish. */
val quarantined: List<QuarantinedResource> = emptyList(),
/**
* Writes sent without `If-Match` because the server offers no usable
* validator. Not an error, but the one case where a concurrent edit can be
* overwritten without us noticing, so it is said out loud.
*/
val unconditionalWrites: Int = 0,
/**
* Whether this run reconciled against a full listing rather than a change log.
*
* ⚠️ Tracked because a token the server accepts over a change log it has
* already pruned returns 207, zero changes and no error — RFC 6578 gives no
* signal for it at all. The only mitigation is to reconcile in full on a slow
* cadence regardless of the token, which means knowing when we last did.
*/
val reconciledInFull: Boolean = false,
/**
* Why the change-log path was abandoned, on a run the full path then
* completed.
*
* Not a [failure]: the collection is reconciled and the user has nothing to
* act on. Kept because a server that rejects `sync-collection` every time
* will do it again, and that is worth seeing in a log without it becoming an
* error in the UI.
*/
val incrementalNote: String? = null,
/**
* The server refused our credentials.
*
* ⚠️ Escalates to the whole account and stops it, unlike every other failure
* here. Nextcloud's brute-force protection throttles and then **429s per
* source IP**, so a client that keeps retrying a dead app password on a timer
* takes the user's *other* Nextcloud clients down with it, on that network,
* and looks from the outside like we broke their server. There is nothing to
* retry anyway: only the user can fix it.
*/
val authFailure: Boolean = false,
/** Set when the collection failed as a whole. The account keeps going. */
val failure: String? = null,
) {
val hadWork: Boolean
get() = downloaded > 0 || uploaded > 0 || deletedRemotely > 0 || deletedLocally > 0
}
/** One local edit that lost to the server. */
data class DiscardedEdit(
val uid: String,
val title: String?,
val cause: Cause,
) {
enum class Cause {
/** The server's copy changed after we last read it. */
SERVER_NEWER,
/** The task was deleted on the server while it was edited here. */
DELETED_ON_SERVER,
/** Deleted here, but changed on the server after that. The delete lost. */
DELETE_LOST,
}
}
/**
* A resource the collection stopped trying.
*
* ⚠️ Quarantine is a **counter, not a backoff**. A single HTTP 400 on one
* resource has halted all of a user's calendar sync in DAVx5 for weeks; the
* failure has to be contained to the resource that caused it, and the rest of
* the collection has to complete.
*/
data class QuarantinedResource(
val href: String,
val reason: String,
val failures: Int,
)
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import javax.inject.Inject
/**
* The database, as [CollectionSyncer] needs it.
*
* A seam, and the reason is the same one that put [CalDavGateway] in front of
* discovery: the reconciliation above this interface is where local edits are
* discarded, tombstones swept and conflicts resolved, and every one of those is
* a decision that should be provable without a device. Room's test double is
* Robolectric plus an in-memory database; this is eight methods.
*/
interface SyncStore {
/** Every row in a list, **tombstones included**. */
fun rowsIn(listId: Long): List<TaskEntity>
fun insert(row: TaskEntity): Long
fun update(row: TaskEntity)
fun deleteAll(taskIds: List<Long>)
/**
* Records href and ETag on a resource's rows, clearing `is_dirty`.
*
* The caller excludes any row it left out of the body — that row is deleted,
* not marked synced.
*/
fun markSynced(taskIds: List<Long>, href: String?, eTag: String?)
fun setParent(taskId: Long, parentId: Long?)
/** The master row for a UID — the one with no `RECURRENCE-ID`. */
fun masterByUid(listId: Long, uid: String): TaskEntity?
fun row(taskId: Long): TaskEntity?
/**
* One column, deliberately. A whole-entity update would carry the row as it
* looked when the sync started and revert anything the user changed while it
* ran.
*/
fun setListReadOnly(listId: Long, readOnly: Boolean)
/** The RFC 6578 cursor. Null resets the collection to a full reconciliation. */
fun setSyncToken(listId: Long, token: String?)
}
class RoomSyncStore @Inject constructor(
private val database: TasksDatabase,
) : SyncStore {
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
override fun insert(row: TaskEntity) = database.tasks().insert(row)
override fun update(row: TaskEntity) {
database.tasks().update(row)
}
override fun deleteAll(taskIds: List<Long>) {
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
}
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
if (taskIds.isNotEmpty()) database.tasks().markSynced(taskIds, href, eTag)
}
override fun setParent(taskId: Long, parentId: Long?) {
database.tasks().setParent(taskId, parentId)
}
override fun masterByUid(listId: Long, uid: String) = database.tasks().byUid(listId, uid)
override fun row(taskId: Long) = database.tasks().entity(taskId)
override fun setListReadOnly(listId: Long, readOnly: Boolean) {
database.taskLists().setReadOnly(listId, readOnly)
}
override fun setSyncToken(listId: Long, token: String?) {
database.taskLists().setSyncToken(listId, token)
}
}
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
/**
* A `ContentProvider` that stores nothing.
*
* It exists because **a sync adapter is registered against a content
* authority**, and Agendula publishes no provider — `:provider` was deleted when
* we took our own Room store (`docs/OWN-STORE.md`). Without an authority there
* is nothing for `<sync-adapter android:contentAuthority>` to name, nothing for
* `ContentResolver.requestSync` to address, and nothing for system Settings to
* render a sync switch against.
*
* `docs/SYNC.md` establishes that the sync-adapter registration is not optional:
* `ContentService.hasAuthorityAccess()` gates `requestSync`,
* `setSyncAutomatically`, `addPeriodicSync`, `setIsSyncable` and seven more
* behind a compat change that is **on for targetSdk ≥ 34**, and with nothing
* registered every one of those calls returns silently — no exception, no log,
* and it passes on a Robolectric shadow. This provider is the cheapest way to
* hold up the other end of that requirement.
*
* Not exported, and every method is a no-op. Real data lives in Room.
*/
class SyncStubProvider : ContentProvider() {
override fun onCreate() = true
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
) = 0
}
@@ -0,0 +1,123 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
/**
* Starts a sync for one account.
*
* Shared by the sync adapter and by the app's own "sync now", because the app
* cannot rely on the system trigger: ⚠️ `ContentService.hasAuthorityAccess()`
* gates `requestSync` behind a compat change that is on at targetSdk ≥ 34, and
* our authority is `userVisible="false"`, so Settings greys "Sync now" out. The
* in-app button enqueues the work directly and is unaffected.
*/
@Singleton
class SyncTrigger @Inject constructor(
@ApplicationContext private val context: Context,
) {
/**
* Starts a sync now.
*
* @param expedited for a trigger the user is looking at. ⚠️ Paired with
* `RUN_AS_NON_EXPEDITED_WORK_REQUEST`, which is not optional: the expedited
* quota is per-app and exhaustible, and the alternative policy
* (`DROP_WORK_REQUEST`) silently discards the sync the user just asked for.
* Never set from a background trigger — a boot receiver spending the quota
* leaves none for the button.
* @return the unique work name, which the caller may wait on.
*/
fun enqueue(accountName: String, expedited: Boolean = false): String {
val uniqueName = SyncWorker.uniqueNameFor(accountName)
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(inputFor(accountName))
.setConstraints(NETWORK)
.apply {
if (expedited) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
}
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
uniqueName,
// KEEP, not REPLACE: a periodic trigger arriving while a manual sync
// is mid-flight must not cancel it and lose the cursor.
ExistingWorkPolicy.KEEP,
request,
)
return uniqueName
}
/**
* Puts the account on the periodic schedule.
*
* A plain `PeriodicWorkRequest` and no foreground service, deliberately —
* see [SyncWorker]. WorkManager restores its own schedule after a reboot, so
* nothing has to re-arm this from `BOOT_COMPLETED`; that matters because
* Android 15 forbids starting a `dataSync` foreground service from boot, and
* a design that needed one would have no way to run at all.
*
* ⚠️ Be honest about the cadence in the UI. [INTERVAL] is a floor, not a
* promise: in the `rare` and `restricted` App Standby buckets network access
* is off entirely, and the genuine worst case is once overnight.
*/
fun schedule(accountName: String) {
val request = PeriodicWorkRequestBuilder<SyncWorker>(
INTERVAL.inWholeMinutes, TimeUnit.MINUTES,
FLEX.inWholeMinutes, TimeUnit.MINUTES,
)
.setInputData(inputFor(accountName))
.setConstraints(NETWORK)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
periodicNameFor(accountName),
// UPDATE would restart the interval on every app launch, so a device
// that is opened often would never reach the end of one.
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
/** Takes a removed account off the schedule. */
fun cancel(accountName: String) {
WorkManager.getInstance(context).apply {
cancelUniqueWork(periodicNameFor(accountName))
cancelUniqueWork(SyncWorker.uniqueNameFor(accountName))
}
}
private fun inputFor(accountName: String) =
Data.Builder().putString(SyncWorker.KEY_ACCOUNT_NAME, accountName).build()
private companion object {
val INTERVAL: Duration = 4.hours
/** The tail of each interval the system may run us in. */
val FLEX: Duration = 1.hours
/**
* Sync needs a network, and saying so lets WorkManager run us the moment
* connectivity returns rather than on the next interval.
*/
val NETWORK: Constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
fun periodicNameFor(accountName: String) = "caldav-sync-periodic:$accountName"
}
}
@@ -0,0 +1,115 @@
package de.jeanlucmakiola.agendula.data.sync
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import de.jeanlucmakiola.agendula.R
/**
* Where sync actually happens.
*
* Two things about this worker are decided already. It is a
* **`CoroutineWorker` with no foreground service**: an ordinary
* worker is documented for under 10 minutes, and escalating to `setForeground`
* pulls in `FOREGROUND_SERVICE_DATA_SYNC`, the Android 15 six-hours-per-24
* `dataSync` budget whose failure mode is a fatal `RemoteServiceException`, and
* a Play requirement for a video demo per declared FGS type. And it must be
* **chunked and resumable** — the sync cursor is persisted per collection so a
* killed worker resumes rather than restarts, because under WorkManager process
* death mid-sync is routine rather than exotic.
*/
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted parameters: WorkerParameters,
private val engine: SyncEngine,
private val noticeNotifier: SyncNoticeNotifier,
) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
val accountName = inputData.getString(KEY_ACCOUNT_NAME) ?: return Result.failure()
return when (val outcome = engine.sync(accountName)) {
is SyncEngine.Result.Synced -> {
// ⚠️ Success even when collections failed. A retry re-runs the
// whole account, and WorkManager's backoff would then punish the
// four healthy collections for the one that 500s — while the
// failing one is already contained by its own quarantine counter.
outcome.reports.forEach { report ->
if (report.failure != null || report.hadWork) Log.i(TAG, report.toString())
}
// ⚠️ Said out loud, not only logged. `SyncReport`'s own doc
// calls the report "the other half" of server-wins, and this
// worker runs on a four-hour timer with the app closed — so a
// log line is the same as saying nothing. Only what is new: the
// store has already dropped whatever the user has been told.
noticeNotifier.post(accountName, outcome.notices)
Result.success()
}
// Only the user can fix this, and retrying costs them Nextcloud's
// per-IP brute-force throttle — which takes their *other* clients
// down with it.
is SyncEngine.Result.NeedsSignIn -> Result.failure()
is SyncEngine.Result.Misconfigured -> Result.failure()
}
}
/**
* ⚠️ Implemented **unconditionally**, even though this worker never asks to
* run in the foreground.
*
* `setExpedited` falls back to a foreground service below API 31, and
* WorkManager calls this to build it. The default implementation throws
* `IllegalStateException`, so a worker that only ever runs expedited on
* modern devices crashes on every device running API 29 or 30 — which we
* support. It is never actually shown above API 30.
*/
override suspend fun getForegroundInfo(): ForegroundInfo {
val manager = applicationContext.getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
applicationContext.getString(R.string.sync_notification_channel),
NotificationManager.IMPORTANCE_LOW,
),
)
val notification: Notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(applicationContext.getString(R.string.sync_notification_title))
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
// ⚠️ **No `foregroundServiceType`.** Declaring `dataSync` is what drags in
// `FOREGROUND_SERVICE_DATA_SYNC`, the Android 15 six-hours-per-24 budget
// whose failure mode is a fatal `RemoteServiceException`, and a Play
// requirement for a video demo per declared type — the whole tail this
// worker exists to avoid. It is not needed either: above API 30
// `setExpedited` uses an expedited job and never calls this at all, and
// types only became mandatory at API 34.
return ForegroundInfo(NOTIFICATION_ID, notification)
}
companion object {
/** One in-flight sync per account, so a manual trigger cannot pile up. */
fun uniqueNameFor(accountName: String) = "caldav-sync:$accountName"
const val KEY_ACCOUNT_NAME = "accountName"
private const val TAG = "SyncWorker"
private const val CHANNEL_ID = "sync"
private const val NOTIFICATION_ID = 4001
}
}
@@ -0,0 +1,299 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/**
* The only class that knows about the ContentResolver, [TasksContract] and the
* active authority. Everything else works through [TasksDataSource] / domain
* models, so swapping the provider (Posture B) never reaches above this file.
*/
@Singleton
class AndroidTasksDataSource @Inject constructor(
@ApplicationContext private val context: Context,
private val providerResolver: ProviderResolver,
) : TasksDataSource {
private val resolver: ContentResolver get() = context.contentResolver
private fun authority(): String =
providerResolver.resolve()?.authority ?: throw ProviderUnavailableException()
private fun taskUri(authority: String, taskId: Long): Uri =
ContentUris.withAppendedId(TasksContract.tasksUri(authority), taskId)
// --- reads ----------------------------------------------------------------
override fun taskLists(): List<TaskList> {
val uri = TasksContract.listsUri(authority())
val sort = "${Lists.ACCOUNT_NAME}, ${Lists.NAME}"
return resolver.query(uri, TaskProjections.LISTS, null, null, sort)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
} ?: emptyList()
}
override fun tasks(query: TaskQuery): List<Task> {
val clauses = mutableListOf<String>()
val args = mutableListOf<String>()
query.listId?.let {
clauses += "${Tasks.LIST_ID} = ?"
args += it.toString()
}
if (!query.includeCompleted) clauses += "${Tasks.IS_CLOSED} = 0"
val selection = clauses.takeIf { it.isNotEmpty() }?.joinToString(" AND ")
return queryInstances(selection, args.takeIf { it.isNotEmpty() }?.toTypedArray())
}
override fun task(taskId: Long): Task? {
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
return rows.firstOrNull { it.distanceFromCurrent == 0 } ?: rows.firstOrNull()
}
override fun subtasks(parentTaskId: Long): List<Task> =
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
val uri = TasksContract.instancesUri(authority())
// projection = null (all columns): the instances view differs across
// provider versions (e.g. tasks.org's bundled OpenTasks has no
// `is_recurring` / `distance_from_current`). The by-name mapper reads
// whatever is present and nulls the rest; the repository re-sorts, so we
// don't depend on a provider sort column either.
return resolver.query(uri, null, selection, args, null)
?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
} ?: emptyList()
}
override fun exportTasks(listId: Long): List<ExportTask> {
// projection = null for the same reason queryInstances uses it: the tasks
// table's shape varies across provider versions, and the by-name mapper
// reads what's there.
val uri = TasksContract.tasksUri(authority())
return resolver.query(
uri,
null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
// the user is concerned, so exporting it would resurrect deleted tasks
// in the backup.
"${Tasks.LIST_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(listId.toString()),
null,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList()
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id)
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
?: throw TaskWriteFailedException("insert task")
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
}
override fun updateTask(taskId: Long, form: TaskForm) {
val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
}
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
}
/**
* The provider's instance row id for one occurrence.
*
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
* through the instances URI still needs the row id, so it is looked up here
* rather than carried around above the data layer. Selection is on `task_id`
* only — the anchor is matched in Kotlin because the column that holds it
* (`instance_original_time`) is missing on older provider schemas, where a
* WHERE clause naming it would throw instead of falling back.
*/
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
val uri = TasksContract.instancesUri(authority())
val selection = "${Instances.TASK_ID} = ?"
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
val reader = CursorColumnReader(c)
while (c.moveToNext()) {
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
return@use reader.getLong(Tasks.ID)
}
}
null
}
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
val uri = TasksContract.propertiesUri(authority())
// Replace rather than update: the provider's AlarmHandler re-validates the
// whole row on every update, so a partial edit throws — and delete+insert
// means we never have to track property_id.
resolver.delete(
uri,
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE),
)
if (minutesBeforeDue != null) {
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
?: throw TaskWriteFailedException("set alarm for task $taskId")
}
}
override fun alarms(): Map<Long, TaskReminder> {
val uri = TasksContract.propertiesUri(authority())
val projection = arrayOf(
Properties.TASK_ID,
TasksContract.Alarm.MINUTES_BEFORE,
TasksContract.Alarm.REFERENCE,
)
return resolver.query(
uri,
projection,
"${Properties.MIMETYPE} = ?",
arrayOf(TasksContract.Alarm.MIMETYPE),
null,
)?.use { c ->
val reader = CursorColumnReader(c)
buildMap {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID)
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
val reference = reader.getInt(TasksContract.Alarm.REFERENCE)
if (id != null && minutes != null) {
put(
id,
TaskReminder(
minutesBefore = minutes,
fromStart = reference == TasksContract.Alarm.REFERENCE_START,
),
)
}
}
}
} ?: emptyMap()
}
override fun setCompleted(taskId: Long, completed: Boolean) {
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
}
/**
* Through the instances URI, which is what makes the provider fork an override
* rather than close the series. No instance row for the anchor means the task
* is not a series after all — the plain write is then the right one.
*/
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: return setCompleted(taskId, completed)
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
}
override fun deleteTask(taskId: Long) {
resolver.delete(taskUri(authority(), taskId), null, null)
}
override fun createLocalList(name: String, color: Int): Long {
val uri = TasksContract.asSyncAdapter(
TasksContract.listsUri(authority()),
TasksContract.LOCAL_ACCOUNT_NAME,
TasksContract.LOCAL_ACCOUNT_TYPE,
)
val values = TaskWriteMapper.localListValues(name, color)
val result = resolver.insert(uri, values.toContentValues())
?: throw TaskWriteFailedException("create local list")
return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id")
}
override fun updateList(listId: Long, name: String, color: Int) {
val values = TaskWriteMapper.listValues(name, color)
val rows = resolver.update(listSyncUri(listId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update list $listId")
}
override fun deleteList(listId: Long) {
val rows = resolver.delete(listSyncUri(listId), null, null)
if (rows == 0) throw TaskWriteFailedException("delete list $listId")
}
/**
* A list row addressed as its own account's sync adapter — the provider only
* lets that caller write the `tasklists` table, and the account has to be the
* row's own (the params are matched against it, not merely accepted).
*/
private fun listSyncUri(listId: Long): Uri {
val authority = authority()
val uri = TasksContract.listUri(authority, listId)
val account = resolver.query(uri, arrayOf(Lists.ACCOUNT_NAME, Lists.ACCOUNT_TYPE), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0).orEmpty() to c.getString(1).orEmpty() else null }
?: throw TaskWriteFailedException("list $listId not found")
return TasksContract.asSyncAdapter(uri, account.first, account.second)
}
// --- observation ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
val provider = providerResolver.resolve() ?: return AutoCloseable { }
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) = onChange()
}
// Register both or neither: if the second call throws, the first
// registration would otherwise leak (no AutoCloseable was handed back yet).
try {
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
} catch (e: RuntimeException) {
runCatching { resolver.unregisterContentObserver(observer) }
throw e
}
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
}
private fun Map<String, Any?>.toContentValues(): ContentValues {
val cv = ContentValues(size)
for ((key, value) in this) when (value) {
null -> cv.putNull(key)
is Long -> cv.put(key, value)
is Int -> cv.put(key, value)
is Boolean -> cv.put(key, if (value) 1 else 0)
is String -> cv.put(key, value)
else -> cv.put(key, value.toString())
}
return cv
}
}
@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks
package de.jeanlucmakiola.agendula.data.tasks
import android.database.Cursor
@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks
package de.jeanlucmakiola.agendula.data.tasks
/** No tasks provider (OpenTasks / tasks.org) is installed on the device. */
class ProviderUnavailableException :
@@ -7,3 +7,10 @@ class ProviderUnavailableException :
/** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) :
RuntimeException("Task write failed: $operation")
/**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
* it, so saving would clobber that change. The UI offers to overwrite or reload.
*/
class TaskConflictException(taskId: Long) :
RuntimeException("Task $taskId changed since it was loaded")
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import javax.inject.Provider
import kotlin.time.Instant
/**
* Routes every call to the store [StorageMode] selects.
*
* Per-call rather than bound once: the mode is a setting the user can change
* while the process lives, and [StorageModeHolder] pushes the new value into
* [ProviderResolver] without rebuilding the object graph. Both delegates are
* singletons, so this chooses between two existing objects.
*
* Room versus a third-party ContentProvider — nothing else.
*/
class ModeRoutingTasksDataSource(
private val resolver: ProviderResolver,
private val room: Provider<RoomTasksDataSource>,
private val external: Provider<AndroidTasksDataSource>,
) : TasksDataSource {
private fun active(): TasksDataSource =
when (resolver.mode()) {
StorageMode.OWN -> room.get()
StorageMode.EXTERNAL -> external.get()
}
override fun taskLists(): List<TaskList> = active().taskLists()
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
override fun task(taskId: Long): Task? = active().task(taskId)
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
active().updateInstance(taskId, occurrenceStart, form)
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
override fun exportTasks(listId: Long): List<ExportTask> = active().exportTasks(listId)
override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed)
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) =
active().setCompletedInstance(taskId, occurrenceStart, completed)
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color)
override fun deleteList(listId: Long) = active().deleteList(listId)
/**
* Unlike every other method here, an observer is registered once and then
* *held* — so it cannot be routed per call, and would otherwise stay bound to
* whichever store was active when the flow started. Switching stores in
* Settings would then leave every open screen listening to the store it is no
* longer reading from.
*
* So the registration moves with the mode, and the switch itself counts as a
* change: the data underneath every live flow has just been replaced.
*/
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
val lock = Any()
var closed = false
var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull()
val modeHandle = resolver.onModeChanged {
synchronized(lock) {
if (!closed) {
handle?.let { runCatching { it.close() } }
handle = runCatching { active().registerObserver(onChange) }.getOrNull()
}
}
onChange()
}
return AutoCloseable {
synchronized(lock) {
closed = true
modeHandle.close()
handle?.let { runCatching { it.close() } }
handle = null
}
}
}
}
@@ -0,0 +1,46 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* The two platform facts [ProviderResolver] needs, behind an interface.
*
* Same seam the data source uses, for the same reason: which store a returning
* user lands on is decided by [ProviderResolver.autoMode], getting it wrong shows
* them an empty app, and that decision is worth testing on the JVM rather than
* only on a device. Everything Android-shaped lives here so the logic above stays
* plain Kotlin.
*/
interface ProviderEnvironment {
/** The package declaring [authority], or `null` when nothing on the device does. */
fun packageDeclaring(authority: String): String?
/** Whether this app currently holds [permission]. */
fun isGranted(permission: String): Boolean
/** [packageName]'s own app name, or null when it cannot be read. */
fun appLabel(packageName: String): String?
}
@Singleton
class AndroidProviderEnvironment @Inject constructor(
@ApplicationContext private val context: Context,
) : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? =
context.packageManager.resolveContentProvider(authority, 0)?.packageName
override fun isGranted(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
override fun appLabel(packageName: String): String? = runCatching {
val pm = context.packageManager
pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
}.getOrNull()
}
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.agendula.data.tasks
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.retryWhen
private const val BASE_RETRY_MS = 1_000L
private const val MAX_RETRY_MS = 30_000L
/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */
private fun retryDelayMs(attempt: Long): Long =
(BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS)
/**
* Recover a provider-backed flow without killing it.
*
* Provider reads fail for reasons that resolve on their own: the read permission
* isn't granted yet (first launch collects before the permission gate), or the
* provider app is mid-update. A terminal `catch` swallows the failure *and*
* cancels the upstream, so the flow never produces again — the screen stays empty
* until the process restarts, even after the user grants the permission.
*
* This emits [fallback] instead and keeps retrying with a capped backoff, so the
* collector recovers on its own once the provider becomes readable.
*/
fun <T> Flow<T>.recoveringFromProviderFailure(fallback: () -> T): Flow<T> =
retryWhen { _, attempt ->
emit(fallback())
delay(retryDelayMs(attempt))
true
}
@@ -0,0 +1,147 @@
package de.jeanlucmakiola.agendula.data.tasks
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
import javax.inject.Singleton
/**
* An external tasks provider Agendula can talk to. Every candidate runs the same
* dmfs `TaskProvider`, so the [TasksContract] columns apply to either.
*/
data class TaskProvider(
val authority: String,
val readPermission: String,
val writePermission: String,
val packageName: String? = null,
)
/**
* Discovers the *external* tasks providers (OpenTasks, tasks.org) that
* [StorageMode.EXTERNAL] can be pointed at.
*
* This used to be the A/B seam between an external provider and one Agendula
* bundled itself. That second half is gone: [StorageMode.OWN] is a Room database
* with no authority, no ContentResolver and nothing to permit, so there is
* nothing here for it to resolve. See `docs/OWN-STORE.md`.
*
* Which store is active comes from [storageMode]; how that gets decided when the
* user has not chosen is [autoMode].
*/
@Singleton
class ProviderResolver @Inject constructor(
private val environment: ProviderEnvironment,
) {
private val modeListeners = CopyOnWriteArrayList<() -> Unit>()
/**
* The user's explicit choice, or `null` while they have not made one (which is
* the normal state — most people never open Settings). Kept as a plain field
* rather than read from DataStore on demand because [resolve] is called from
* synchronous data-source code on every query, including from the main thread
* via `providerStatus()`. [StorageModeHolder] owns keeping it current.
*
* Assigning a *different* mode notifies [onModeChanged]: every live store
* observer is bound to one store and has to be moved across.
*/
@Volatile
var storageMode: StorageMode? = null
set(value) {
val changed = field != value
field = value
if (changed) modeListeners.forEach { it() }
}
/**
* Observe switches between stores. Fires on the thread that set [storageMode]
* — [StorageModeHolder]'s collector — so listeners must be cheap and must not
* block.
*/
fun onModeChanged(listener: () -> Unit): AutoCloseable {
modeListeners += listener
return AutoCloseable { modeListeners -= listener }
}
/** The active store, resolving the undecided case through [autoMode]. */
fun mode(): StorageMode = storageMode ?: autoMode()
/**
* The provider to query, or `null` — either because [StorageMode.OWN] is
* active and there is no provider involved at all, or because
* [StorageMode.EXTERNAL] is and none is installed. Callers that need to tell
* those apart ask [mode].
*/
fun resolve(): TaskProvider? = when (mode()) {
StorageMode.OWN -> null
StorageMode.EXTERNAL -> resolveExternal()
}
/**
* What to use when the user has not chosen — and the one piece of real
* judgement in this class, because getting it wrong loses people their data.
*
* Ranking our own store first unconditionally would be wrong: someone who
* has been using Agendula over OpenTasks since 0.3.x would update, land on an
* empty database, and reasonably conclude their tasks were deleted.
*
* So the tell is **whether we already hold an external provider's runtime
* permission**. That is a dangerous permission — it can only be there because
* a previous version asked and the user agreed, which is precisely the
* definition of "this person is an existing Posture A user". A fresh install
* never holds it, and gets our own store.
*
* Deliberately cheap and synchronous: a PackageManager lookup and a permission
* check, no database probe. Settings overrides it either way.
*/
fun autoMode(): StorageMode {
val external = resolveExternal()
return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.OWN
}
/** The first installed external candidate, or `null` when none is present. */
fun resolveExternal(): TaskProvider? {
for (candidate in EXTERNAL_CANDIDATES) {
val packageName = environment.packageDeclaring(candidate.authority) ?: continue
return candidate.copy(packageName = packageName)
}
return null
}
fun hasPermission(provider: TaskProvider): Boolean =
environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission)
/**
* Whether the active store can be read at all.
*
* [StorageMode.OWN] always can — it is our own database, with nothing to
* install and nothing to grant. Only [StorageMode.EXTERNAL] can be
* unreadable. Callers that gate on `resolve() != null` instead get this wrong
* the moment OWN is active, because OWN resolves to no provider by design.
*/
fun canReadStore(): Boolean = when (mode()) {
StorageMode.OWN -> true
StorageMode.EXTERNAL -> resolveExternal()?.let(::hasPermission) == true
}
companion object {
/**
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
* `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*`
* (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`.
* OpenTasks is listed first as the canonical authority; on a device with
* only one installed, order is moot.
*/
val EXTERNAL_CANDIDATES: List<TaskProvider> = listOf(
TaskProvider(
authority = "org.dmfs.tasks",
readPermission = "org.dmfs.permission.READ_TASKS",
writePermission = "org.dmfs.permission.WRITE_TASKS",
),
TaskProvider(
authority = "org.tasks.opentasks",
readPermission = "org.tasks.permission.READ_TASKS",
writePermission = "org.tasks.permission.WRITE_TASKS",
),
)
}
}
@@ -0,0 +1,46 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* The work that has to finish before anything reads a task store: the stored
* [StorageMode] has to reach [ProviderResolver], and a v0.3.x install's tasks
* have to be imported out of the dmfs provider's file into Room.
*
* Both are startup races with the same shape. Reading before the mode lands
* answers from `autoMode()` instead of the user's choice; reading before the
* import lands shows an upgrading user an empty app, which is the single worst
* thing this migration could do.
*/
@Singleton
class StartupGate @Inject constructor(
private val storageModeHolder: StorageModeHolder,
private val oneShotImport: OneShotImport,
@ApplicationScope private val scope: CoroutineScope,
) {
private val ready = CompletableDeferred<Unit>()
/** Call once, from `Application.onCreate`. */
fun start() {
storageModeHolder.start()
scope.launch {
// Opens the gate even on failure: a store that cannot be imported is
// still better shown empty than not shown at all, and the source file
// is left where it was either way.
runCatching {
storageModeHolder.awaitReady()
oneShotImport.runIfNeeded()
}
ready.complete(Unit)
}
}
suspend fun awaitReady() = ready.await()
}
@@ -0,0 +1,26 @@
package de.jeanlucmakiola.agendula.data.tasks
/**
* Which task store backs the app — the user's choice, per `docs/OWN-STORE.md`.
*
* Only two values, though `docs/STORAGE-AND-SYNC.md` describes three modes.
* **Synced is not a third store**: it is [OWN] with an account attached to a
* list, which is derived state rather than something the user picks. Attaching
* one is a plain `UPDATE task_lists SET account_id = ?` — not the full data
* migration it was under the dmfs provider, whose `ACCOUNT_TYPE` was write-once.
*/
enum class StorageMode {
/**
* Agendula's own Room database. The default, and always available: there is
* no authority, no ContentResolver and no permission to grant.
*/
OWN,
/**
* A tasks provider app already on the device (OpenTasks, tasks.org), synced by
* whatever that provider's engine is — DAVx5 and friends. Still fully
* supported, now a choice rather than the only way. Requires that provider's
* runtime read/write permissions.
*/
EXTERNAL,
}
@@ -0,0 +1,64 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Mirrors the stored [StorageMode] into [ProviderResolver].
*
* The resolver is consulted synchronously from every data-source call and from
* `providerStatus()` on the main thread, so it cannot read DataStore itself.
* This is the one component that bridges the two: it collects the preference for
* the life of the process and pushes each value across.
*
* [awaitReady] exists for the startup race. Until the first DataStore emission
* arrives the resolver's mode is `null` and `ProviderResolver.autoMode` answers
* instead — fine as a steady state, wrong for a user who explicitly chose the
* other mode. Anything that touches the provider before the UI is up (the launch
* reminder re-sync, notably) should wait rather than risk reading the wrong
* store and rescheduling every alarm off it.
*/
@Singleton
class StorageModeHolder @Inject constructor(
private val prefs: SettingsPrefs,
private val resolver: ProviderResolver,
@ApplicationScope private val scope: CoroutineScope,
) {
private val firstValue = CompletableDeferred<Unit>()
/** Starts mirroring. Idempotent in effect; call once, from `Application.onCreate`. */
fun start() {
scope.launch {
try {
prefs.storageMode.collect { mode ->
resolver.storageMode = mode
firstValue.complete(Unit)
}
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
// ⚠️ Two failures in one, and the second is the worse. This is a
// root coroutine in a scope with no exception handler, so a read
// that throws — a corrupt file the handler could not replace, a
// filesystem that will not answer — takes the app down at every
// launch. And releasing the gate is not optional either:
// `awaitReady` is awaited before anything touches the provider,
// so failing quietly without it parks every observing flow for
// ever, which is a blank app rather than a crashing one.
// `autoMode` answers from here on, which is the steady state for
// a user who never chose.
firstValue.complete(Unit)
}
}
}
/** Suspends until the stored mode has been applied at least once. */
suspend fun awaitReady() = firstValue.await()
}
@@ -0,0 +1,119 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlin.time.Instant
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
object TaskMapper {
fun task(r: ColumnReader): Task {
fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
val rowId = r.getLong(Tasks.ID) ?: 0L
// Derived from the rule columns rather than the `is_recurring` column
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
// absent on tasks.org's bundled provider (DB 22), where reading it
// would silently report every recurring task as one-off — and route
// its edits onto the series anchor.
val recurring = r.getString(Tasks.RRULE) != null ||
r.getString(Tasks.RDATE) != null ||
r.getBoolean(Instances.IS_RECURRING)
return Task(
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
title = r.getString(Tasks.TITLE).orEmpty(),
description = r.getString(Tasks.DESCRIPTION),
location = r.getString(Tasks.LOCATION),
url = r.getString(Tasks.URL),
priority = priorityFromICal(r.getInt(Tasks.PRIORITY)),
status = statusFromInt(r.getInt(Tasks.STATUS)),
percentComplete = r.getInt(Tasks.PERCENT_COMPLETE),
start = instant(Instances.INSTANCE_START),
due = instant(Instances.INSTANCE_DUE),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
timeZone = r.getString(Tasks.TZ),
completedAt = instant(Tasks.COMPLETED),
listColor = r.getInt(Tasks.LIST_COLOR) ?: 0,
taskColor = r.getInt(Tasks.TASK_COLOR),
listName = r.getString(Tasks.LIST_NAME),
accountName = r.getString(Tasks.ACCOUNT_NAME),
parentId = r.getLong(Tasks.PARENT_ID),
isRecurring = recurring,
occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED),
)
}
/**
* The occurrence's `RECURRENCE-ID` anchor.
*
* `instance_original_time` is the provider's own name for it and is set on
* every occurrence of a recurring task, so it is read first. It is absent on
* older provider schemas, where the fallbacks reconstruct the same value: a
* DTSTART-anchored series instantiates each occurrence at its start, and a
* series carrying only DUE anchors on the due date instead.
*/
fun occurrenceAnchor(r: ColumnReader): Instant? =
(
r.getLong(Instances.INSTANCE_ORIGINAL_TIME)
?: r.getLong(Instances.INSTANCE_START)
?: r.getLong(Instances.INSTANCE_DUE)
)?.let { Instant.fromEpochMilliseconds(it) }
/**
* Maps a row of the **`tasks` table** — a master task, not an occurrence.
*
* Export reads there rather than from `instances` on purpose: in the instances
* view a recurring task appears once per occurrence with its times already
* resolved and no rule attached, so exporting from it would write the same
* task many times over and drop the RRULE that produced them. Here each task
* appears exactly once, carrying the rule itself.
*/
fun exportTask(r: ColumnReader): ExportTask {
fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
return ExportTask(
taskId = r.getLong(Tasks.ID) ?: 0L,
uid = r.getString(Tasks.UID),
title = r.getString(Tasks.TITLE).orEmpty(),
description = r.getString(Tasks.DESCRIPTION),
location = r.getString(Tasks.LOCATION),
url = r.getString(Tasks.URL),
priority = priorityFromICal(r.getInt(Tasks.PRIORITY)),
status = statusFromInt(r.getInt(Tasks.STATUS)),
percentComplete = r.getInt(Tasks.PERCENT_COMPLETE),
// The task's own columns, not the instance view's resolved ones.
start = instant(Tasks.DTSTART),
due = instant(Tasks.DUE),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
completedAt = instant(Tasks.COMPLETED),
created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED),
rrule = r.getString(Tasks.RRULE),
rdate = r.getString(Tasks.RDATE),
parentId = r.getLong(Tasks.PARENT_ID)?.takeIf { it > 0 },
)
}
fun taskList(r: ColumnReader): TaskList = TaskList(
id = r.getLong(Lists.ID) ?: 0L,
name = r.getString(Lists.NAME).orEmpty(),
color = r.getInt(Lists.COLOR) ?: 0,
accountName = r.getString(Lists.ACCOUNT_NAME).orEmpty(),
accountType = r.getString(Lists.ACCOUNT_TYPE).orEmpty(),
isSynced = r.getBoolean(Lists.SYNC_ENABLED),
isVisible = r.getBoolean(Lists.VISIBLE),
owner = r.getString(Lists.OWNER),
)
}
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
object TaskProjections {
val LISTS: Array<String> = arrayOf(
Lists.ID,
Lists.NAME,
Lists.COLOR,
Lists.VISIBLE,
Lists.SYNC_ENABLED,
Lists.OWNER,
Lists.ACCOUNT_NAME,
Lists.ACCOUNT_TYPE,
)
// No `instances` projection on purpose: that read passes `projection = null`
// (all columns), because the view's shape differs across provider versions —
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
// here would drift out of sync with the by-name mapper and quietly drop
// columns it depends on. See AndroidTasksDataSource.queryInstances.
}
@@ -0,0 +1,122 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Instant
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
private fun Instant.forAllDay(allDay: Boolean): Instant =
if (!allDay) this
else Instant.fromEpochMilliseconds(
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
)
/**
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
* so it unit-tests on the JVM; [AndroidTasksDataSource] converts the map to
* ContentValues. `null` values are written as SQL NULL (clears the column).
*/
object TaskWriteMapper {
fun taskValues(form: TaskForm, tzId: String): Map<String, Any?> = buildMap {
put(Tasks.TITLE, form.title.trim())
put(Tasks.LIST_ID, form.listId)
put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
put(Tasks.PRIORITY, form.priority.toICal())
// Keep completion in sync with progress whenever the form carries a
// percent (the Progress field was used). The provider already auto-
// completes at 100% but won't reopen below it — left to itself it strands
// a task "done at 75%" — so we set status both ways. COMPLETED is only
// cleared on reopen; at 100% we leave it for the provider to fill/keep so
// re-saving a finished task doesn't churn its completion timestamp. When
// no percent is present the standalone complete toggle stays authoritative.
val percent = form.percentComplete?.coerceIn(0, 100)
put(Tasks.PERCENT_COMPLETE, percent)
when {
percent == null -> Unit
percent >= 100 -> put(Tasks.STATUS, TasksContract.STATUS_COMPLETED)
percent > 0 -> {
put(Tasks.STATUS, TasksContract.STATUS_IN_PROCESS)
put(Tasks.COMPLETED, null)
}
else -> {
put(Tasks.STATUS, TasksContract.STATUS_NEEDS_ACTION)
put(Tasks.COMPLETED, null)
}
}
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
// All-day tasks are date-only in iCalendar. The provider reads them back
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
// remaining date against UTC — so a local-midnight instant lands on the
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
// DUE and DURATION are mutually exclusive. The provider's Validating
// processor evaluates the *merged* row (supplied values over the stored
// ones), so writing DUE onto a task that already carries a DURATION throws
// "Only one of DUE or DURATION must be supplied." Clear it alongside.
put(Tasks.DURATION, null)
put(Tasks.PARENT_ID, form.parentId)
// The provider treats a null tz as local time; set it explicitly for
// timed tasks so the stored instant is unambiguous across zones.
val timed = !form.isAllDay && (form.start != null || form.due != null)
put(Tasks.TZ, if (timed) tzId else null)
}
/**
* Values for an update through the *instances* URI (a recurring occurrence).
* The provider clones the row into an override and strips list/recurrence
* fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than
* written and silently ignored — moving one occurrence between lists or
* parents isn't a thing the override model expresses.
*/
fun instanceValues(form: TaskForm, tzId: String): Map<String, Any?> =
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
if (completed) {
mapOf(
Tasks.STATUS to TasksContract.STATUS_COMPLETED,
Tasks.PERCENT_COMPLETE to 100,
Tasks.COMPLETED to nowMillis,
)
} else {
mapOf(
Tasks.STATUS to TasksContract.STATUS_NEEDS_ACTION,
Tasks.PERCENT_COMPLETE to null,
Tasks.COMPLETED to null,
)
}
/**
* A reminder for [taskId], as an Alarm property row. The provider's validator
* requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every
* write, so all three are always present.
*/
fun alarmValues(taskId: Long, minutesBeforeDue: Int): Map<String, Any?> = mapOf(
Properties.TASK_ID to taskId,
Properties.MIMETYPE to Alarm.MIMETYPE,
Alarm.MINUTES_BEFORE to minutesBeforeDue,
Alarm.REFERENCE to Alarm.REFERENCE_DUE,
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
)
/** The user-owned columns of a list — what an edit is allowed to change. */
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
Lists.NAME to name.trim(),
Lists.COLOR to color,
)
fun localListValues(name: String, color: Int): Map<String, Any?> = listValues(name, color) + mapOf(
Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
Lists.VISIBLE to 1,
Lists.SYNC_ENABLED to 1,
)
}
@@ -1,9 +1,10 @@
package de.jeanlucmakiola.floret.data.tasks
package de.jeanlucmakiola.agendula.data.tasks
import android.net.Uri
import androidx.core.net.toUri
/**
* The subset of the OpenTasks `TaskContract` that Floret uses, vendored as
* The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as
* Kotlin constants. Derived from `org.dmfs.tasks.contract.TaskContract`
* (Apache-2.0, dmfs GmbH):
* https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java
@@ -25,7 +26,7 @@ object TasksContract {
const val PARAM_ACCOUNT_TYPE = "account_type"
const val LOAD_PROPERTIES = "load_properties"
/** Account used for local, device-only task lists Floret owns. */
/** Account used for local, device-only task lists Agendula owns. */
const val LOCAL_ACCOUNT_NAME = "Local"
const val LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL"
@@ -66,6 +67,9 @@ object TasksContract {
const val IS_ALLDAY = "is_allday"
const val TZ = "tz"
const val RRULE = "rrule"
const val RDATE = "rdate"
/** Set on an override row — the master occurrence this one replaces. */
const val ORIGINAL_INSTANCE_ID = "original_instance_id"
const val PARENT_ID = "parent_id"
const val SORTING = "sorting"
const val CREATED = "created"
@@ -85,7 +89,7 @@ object TasksContract {
* The `instances` view one row per task occurrence, with recurrence
* resolved and absolute [INSTANCE_START]/[INSTANCE_DUE] timestamps. Inherits
* every [Tasks] column (title, status, ) plus the instance-only columns
* below. This is Floret's read source.
* below. This is Agendula's read source.
*/
object Instances {
const val PATH = "instances"
@@ -96,8 +100,59 @@ object TasksContract {
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
const val DISTANCE_FROM_CURRENT = "distance_from_current"
const val IS_RECURRING = "is_recurring"
/**
* The occurrence's `RECURRENCE-ID` the time this occurrence was
* instantiated at, before any override moved it. Set on every occurrence
* of a recurring task, which is what makes it the occurrence's identity.
*/
const val INSTANCE_ORIGINAL_TIME = "instance_original_time"
}
/** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */
object Properties {
const val PATH = "properties"
const val PROPERTY_ID = "property_id"
const val TASK_ID = "task_id"
const val MIMETYPE = "mimetype"
}
/**
* An alarm property row a per-task reminder lead.
*
* Storage and sync format *only*: the provider fires nothing (its alarm
* scheduling is commented out and the internal `alarms` table is never
* populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler]
* still arms the real AlarmManager alarm. Writing it here is what makes the
* lead survive a sync and show up in other OpenTasks clients.
*
* The columns are the generic `dataN` slots; the meanings below are the
* Alarm property's contract for them.
*/
object Alarm {
const val MIMETYPE = "vnd.android.cursor.item/alarm"
/** `data0` — minutes from the reference date; positive means *before* it. */
const val MINUTES_BEFORE = "data0"
/** `data1` — which date to count from. */
const val REFERENCE = "data1"
/** `data2` — optional message shown with the alarm. */
const val MESSAGE = "data2"
/** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */
const val ALARM_TYPE = "data3"
const val REFERENCE_DUE = 1
const val REFERENCE_START = 2
/** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */
const val TYPE_MESSAGE = 1
}
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
// --- status values (TaskColumns.STATUS_*) --------------------------------
const val STATUS_NEEDS_ACTION = 0
const val STATUS_IN_PROCESS = 1
@@ -107,10 +162,21 @@ object TasksContract {
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (iCalendar). */
const val PRIORITY_NONE = 0
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
fun authorityUri(authority: String): Uri = "content://$authority".toUri()
fun listsUri(authority: String): Uri = "content://$authority/${Lists.PATH}".toUri()
fun listUri(authority: String, listId: Long): Uri =
"content://$authority/${Lists.PATH}/$listId".toUri()
fun tasksUri(authority: String): Uri = "content://$authority/${Tasks.PATH}".toUri()
fun instancesUri(authority: String): Uri = "content://$authority/${Instances.PATH}".toUri()
/**
* A single occurrence. Updating through this URI is how a *recurring* task is
* edited: the provider clones the row into an override task
* (`original_instance_id` set, recurrence fields stripped) instead of moving
* the series anchor, which is what writing to `tasks/<id>` would do.
*/
fun instanceUri(authority: String, instanceId: Long): Uri =
"content://$authority/${Instances.PATH}/$instanceId".toUri()
/** Append the sync-adapter params required to write local-account rows. */
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
@@ -0,0 +1,100 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import kotlin.time.Instant
/**
* A stored reminder: how long before, and what it counts back from.
*
* [fromStart] matters because both stores can hold a `START`-referenced alarm —
* the dmfs import preserves one, and an external provider's other clients write
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
* to a number here is what silently fired those reminders off the wrong anchor.
*/
data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false)
/** What to fetch from the provider. Smart-list date logic is applied above this. */
data class TaskQuery(
val listId: Long? = null,
val includeCompleted: Boolean = false,
)
/**
* Domain-shaped, synchronous seam over the tasks ContentResolver. Returns parsed
* lists so [TasksRepositoryImpl] can be tested with a fake on the JVM. All Cursor
* and ContentObserver handling lives in [AndroidTasksDataSource].
*/
interface TasksDataSource {
fun taskLists(): List<TaskList>
fun tasks(query: TaskQuery): List<Task>
fun task(taskId: Long): Task?
fun subtasks(parentTaskId: Long): List<Task>
fun insertTask(form: TaskForm): Long
fun updateTask(taskId: Long, form: TaskForm)
/**
* Update a single occurrence of a recurring task, addressed by the task row and
* the occurrence's `RECURRENCE-ID` anchor ([Task.occurrenceStart]). The store
* forks an override rather than moving the series anchor — which is what
* [updateTask] would do, since a recurring task's start/due are the
* occurrence's resolved times.
*
* Addressing by `(taskId, occurrenceStart)` rather than by a materialised
* instance row id keeps this seam independent of any one store's row
* numbering; External mode maps it back to an instance row itself.
*/
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
/**
* Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm
* property row. The provider never fires it — [de.jeanlucmakiola.agendula
* .data.reminders.ReminderScheduler] does — but persisting it here is what
* syncs the lead and shares it with other OpenTasks clients.
*/
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
/** Every task's reminder, by task id. One query, for the scheduler. */
fun alarms(): Map<Long, TaskReminder>
/**
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
* not occurrences — see [TaskMapper.exportTask] for why that distinction
* matters. Excludes rows the provider has flagged deleted-but-unsynced.
*/
fun exportTasks(listId: Long): List<de.jeanlucmakiola.agendula.domain.export.ExportTask>
fun setCompleted(taskId: Long, completed: Boolean)
/**
* Complete (or reopen) **one occurrence** of a recurring task, addressed the
* same way [updateInstance] is. Ticking a series through [setCompleted] would
* close the master row, which takes every past and future occurrence out of
* every list at once.
*
* Implementations fall back to [setCompleted] when the row turns out not to
* be a series master — an override, or a plain task the caller happened to
* hand an anchor for — so the routing above cannot get this wrong.
*/
fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean)
fun deleteTask(taskId: Long)
fun createLocalList(name: String, color: Int): Long
/** Rename and recolour [listId]. */
fun updateList(listId: Long, name: String, color: Int)
/**
* Delete [listId] **and the tasks in it** — `tasks.list_id` cascades on the
* Room path, and the provider does the same on the External one.
*
* Only ever called for a local, device-only list: a collection that belongs
* to an account is the server's to remove, and neither store expresses a
* collection tombstone yet. The UI gates on [TaskList.isLocal]; this seam
* does not re-check it.
*/
fun deleteList(listId: Long)
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
fun registerObserver(onChange: () -> Unit): AutoCloseable
}
@@ -0,0 +1,62 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
/**
* The single entry point the UI layer uses. Flows re-emit automatically when the
* provider changes (our writes *and* external sync like DAVx5), via the
* data source's ContentObserver.
*/
interface TasksRepository {
fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>>
/**
* The direct children of [parentId], re-emitting live like the other flows.
* Lets a smart-list view pull a parent's full subtask set on demand — those
* children are usually filtered out of the list's own (date-based) query.
*/
fun subtasks(parentId: Long): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long
/**
* Overwrite [taskId] with [form]. When [expectedLastModified] is non-null, the
* task's current `last_modified` is re-checked first and a
* [TaskConflictException] is thrown if it differs — i.e. something changed it
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
*/
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
/**
* Complete or reopen a task. Pass the occurrence's [Task.occurrenceStart] so a
* recurring series forks a `RECURRENCE-ID` override for that one occurrence
* instead of closing the whole series; `null` completes the row itself.
*/
suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean)
suspend fun deleteTask(taskId: Long)
/**
* The per-task reminder lead in minutes before due, or `null` if the task has
* none (in which case the list's / global setting applies). Read when the edit
* form loads so saving can't silently drop it.
*/
suspend fun reminderFor(taskId: Long): Int?
suspend fun createLocalList(name: String, color: Int): Long
suspend fun updateList(listId: Long, name: String, color: Int)
/** Deletes the list **and its tasks**. Local lists only — see [TasksDataSource.deleteList]. */
suspend fun deleteList(listId: Long)
/** Synchronous snapshot for the permission/onboarding gate. */
fun providerStatus(): ProviderStatus
}
@@ -0,0 +1,181 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSorting
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Instant
@Singleton
class TasksRepositoryImpl @Inject constructor(
private val dataSource: TasksDataSource,
private val providerResolver: ProviderResolver,
private val startupGate: StartupGate,
@IoDispatcher private val io: CoroutineDispatcher,
) : TasksRepository {
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
dataSource.subtasks(parentId)
.filter { it.taskId != parentId }
.sortedWith(TaskSorting.DEFAULT)
}
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
dataSource.task(taskId)?.let { task ->
TaskDetail(
task = task,
subtasks = dataSource.subtasks(taskId).filter { it.taskId != taskId },
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
)
}
}
private fun loadTasks(filter: TaskFilter): List<Task> {
val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
// smart list's own open/closed semantics. We over-read on purpose so a
// parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
}
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = dataSource.tasks(query)
// (done, total) of direct children per parent, over the unfiltered set.
val progress: Map<Long, Pair<Int, Int>> = all
.filter { (it.parentId ?: 0L) > 0L }
.groupingBy { it.parentId!! }
.fold(0 to 0) { (done, total), t ->
(if (t.isCompleted) done + 1 else done) to (total + 1)
}
return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
} ?: task
}
.sortedWith(TaskSorting.DEFAULT)
}
override suspend fun createTask(form: TaskForm): Long =
withContext(io) {
val id = dataSource.insertTask(form)
form.reminderMinutesBeforeDue?.let { dataSource.setAlarm(id, it) }
id
}
override suspend fun reminderFor(taskId: Long): Int? =
withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() }
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
withContext(io) {
// Re-read just before writing: it settles the conflict check *and* tells
// us which URI to write through.
val current = dataSource.task(taskId)
// Conflict-safe overwrite: bail if the provider's last_modified moved
// since the form captured it (external sync / another app). A null
// baseline means "force / overwrite anyway".
if (expectedLastModified != null) {
val seen = current?.lastModified
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
}
// A recurring task's start/due are one occurrence's resolved times, so
// writing them back to the task row would re-anchor the whole series.
// Going through the occurrence forks an override instead.
val occurrence = current?.takeIf { it.isRecurring }?.occurrenceStart
if (occurrence == null) {
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
dataSource.updateTask(taskId, form)
return@withContext
}
// ⚠️ Written to the series, and then put back. Forking an occurrence
// copies the task's *current* properties onto the new override row,
// so setting the alarm beforehand is the only way to carry it
// across — and leaving it there would silently give every other
// occurrence in the series the reminder the user set on one of them.
val seriesReminder = dataSource.alarms()[taskId]?.minutesBefore
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
try {
dataSource.updateInstance(taskId, occurrence, form)
} finally {
dataSource.setAlarm(taskId, seriesReminder)
}
}
override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) =
withContext(io) {
if (occurrenceStart != null) {
dataSource.setCompletedInstance(taskId, occurrenceStart, completed)
} else {
dataSource.setCompleted(taskId, completed)
}
}
override suspend fun deleteTask(taskId: Long) =
withContext(io) { dataSource.deleteTask(taskId) }
override suspend fun createLocalList(name: String, color: Int): Long =
withContext(io) { dataSource.createLocalList(name, color) }
override suspend fun updateList(listId: Long, name: String, color: Int) =
withContext(io) { dataSource.updateList(listId, name, color) }
override suspend fun deleteList(listId: Long) =
withContext(io) { dataSource.deleteList(listId) }
override fun providerStatus(): ProviderStatus {
// Our own store is always ready: it ships with the app, needs no provider
// and no grant. The permission gate only ever applied to External mode —
// now that is visibly true rather than a special case inside it.
if (providerResolver.mode() == StorageMode.OWN) return ProviderStatus.READY
val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
else ProviderStatus.NEEDS_PERMISSION
}
/**
* Emits an initial load, then re-loads on every store change. The observer
* callback (main thread) only pokes a conflated channel; the actual blocking
* query runs on [io].
*/
private fun <T> observing(load: () -> T): Flow<T> = callbackFlow {
// Nothing reads a store before the stored mode has landed and a v0.3.x
// install has been imported — otherwise the first emission comes from the
// wrong store, or from an empty one.
startupGate.awaitReady()
val ticks = Channel<Unit>(Channel.CONFLATED)
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
ticks.trySend(Unit) // prime the initial emission
val job = launch {
ticks.consumeEach { send(load()) }
}
awaitClose {
handle.close()
job.cancel()
ticks.close()
}
}.flowOn(io)
}
@@ -0,0 +1,56 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.VTimeZones
/**
* One CalDAV resource: the `VCALENDAR` wrapper around the `VTODO`s that share a
* `UID`.
*
* ⚠️ A resource is **not** a task. RFC 4791 §4.1 requires every component in one
* resource to share a UID, which makes a recurring task and all of its
* `RECURRENCE-ID` overrides exactly one resource — and makes two unrelated tasks
* in one resource unuploadable. The engine works in resources; the mapper works
* in components.
*/
object CalendarResource {
const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN"
const val VERSION = "2.0"
/** Wraps [vtodos] with the `VTIMEZONE`s their `TZID` parameters reference. */
fun build(vtodos: List<ICalComponent>): ICalComponent {
val zones = vtodos
.flatMap { VTimeZones.forComponent(it) }
.distinctBy { it.property("TZID")?.value }
return ICalComponent(
name = "VCALENDAR",
properties = listOf(
ICalProperty("VERSION", emptyList(), VERSION),
ICalProperty("PRODID", emptyList(), PRODUCT_ID),
),
// Zones first: a server that streams the object as it parses has the
// definition before the reference.
components = zones + vtodos,
)
}
fun serialize(vtodos: List<ICalComponent>): String =
ICalSerializer.serialize(build(vtodos))
/**
* The `VCALENDAR`s in a downloaded body.
*
* More than one is malformed but does happen; the caller decides what to do
* about it rather than having the decision made here by a parser.
*/
fun parse(text: String): List<ICalComponent> =
ICalParser.parseAll(text).filter { it.name.equals("VCALENDAR", ignoreCase = true) }
/** Every `VTODO` across [calendars], in document order. */
fun todosIn(calendars: List<ICalComponent>): List<ICalComponent> =
calendars.flatMap { it.components("VTODO") }
}
@@ -0,0 +1,107 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
/**
* Refuses a resource before it is uploaded.
*
* ⚠️ This exists because **sabre answers 415 for things ordinary UI actions
* produce**, and a 415 is not recoverable by retrying: the row stays dirty, the
* next sync sends the same bytes, and the user sees a task that never leaves the
* device with no explanation. Catching it here turns a permanent silent failure
* into one message naming the field.
*
* Every rule below is a real sabre rejection, not defensive tidiness.
*/
object ResourceValidator {
/** Why a resource cannot be uploaded, in words that name the offending field. */
@JvmInline
value class Rejection(val reason: String)
/** Null when [calendar] may be uploaded. */
fun validate(calendar: ICalComponent): Rejection? {
// An object carrying METHOD is an iTIP *message*, not calendar data.
// RFC 4791 §4.1 forbids it outright in a calendar object resource.
calendar.property("METHOD")?.let {
return Rejection("carries METHOD:${it.value}, which makes it a scheduling message")
}
val todos = calendar.components("VTODO")
if (todos.isEmpty()) return Rejection("contains no VTODO")
// ⚠️ Mixed component types in one resource. A VEVENT that arrived in the
// same body and was never modelled must not be re-emitted next to a
// VTODO — RFC 4791 §4.1 allows only one component type per resource.
val foreign = calendar.components
.map { it.name.uppercase() }
.filterNot { it == "VTODO" || it == "VTIMEZONE" }
.distinct()
if (foreign.isNotEmpty()) {
return Rejection("mixes VTODO with ${foreign.joinToString(", ")}")
}
// ⚠️ Overrides with no master. A `RECURRENCE-ID` names an instance *of*
// a series, so a body holding only overrides describes instances of
// something that is not in the resource — RFC 4791 §4.1 asks for the
// recurring component and its overridden instances, not the instances
// alone. This is the shape a partly tombstoned series used to serialise
// to, and no server rejects it in a way that names the cause.
if (todos.all { it.property("RECURRENCE-ID") != null }) {
return Rejection("holds overridden instances but not the task they override")
}
// One resource, one UID — the constraint that makes "fork the conflicting
// edit into the same resource" impossible, and the one servers enforce.
val uids = todos.map { it.property("UID")?.value.orEmpty() }.distinct()
if (uids.size > 1) return Rejection("holds ${uids.size} different UIDs")
if (uids.singleOrNull().isNullOrBlank()) return Rejection("has no UID")
// ⚠️ A TZID without a leading solidus must reference a VTIMEZONE in the
// same object (RFC 5545 §3.2.19). We regenerate definitions from
// `java.time`, which cannot resolve a non-IANA id — a Windows zone name
// that arrived from another client and survives in the residue produces a
// reference with nothing behind it. `Prefer: handling=strict` turns that
// from a server-side repair into a rejection, so it is caught by name
// here instead of as an unexplained 415.
val defined = calendar.components("VTIMEZONE")
.mapNotNull { it.property("TZID")?.value }
.toSet()
val undefined = todos.flatMap(::tzidsIn)
// ⚠️ The solidus form is exempt, and the rule above says so: §3.2.19
// requires a local VTIMEZONE only for a TZID *without* a leading
// solidus, because the prefix marks a globally defined identifier.
// Thunderbird writes `/mozilla.org/20050126_1/Europe/Berlin` on every
// zoned task, `java.time` cannot resolve it, and rejecting it would
// quarantine every Lightning-authored task permanently.
.filterNot { it.startsWith('/') }
.filterNot { it in defined }
.distinct()
if (undefined.isNotEmpty()) {
return Rejection("references the unknown time zone ${undefined.first()}")
}
return todos.firstNotNullOfOrNull(::validateTodo)
}
/**
* ⚠️ The per-component rules are [VTodoMapper.validate]'s, not a second copy.
*
* Two value-type tests that disagree is worse than one: `ResourceValidator`
* originally tested only `VALUE=DATE`, while the mapper also treats a bare
* eight-digit value as a DATE, so a residue `DTSTART:20260101` beside an
* authored `DUE;VALUE=DATE:20260102` was rejected as a mismatch and never
* left the device.
*/
private fun validateTodo(todo: ICalComponent): Rejection? {
if (todo.property("DUE") != null && todo.property("DURATION") != null) {
// RFC 5545 §3.6.2: DUE and DURATION are mutually exclusive.
return Rejection("has both DUE and DURATION")
}
return VTodoMapper.validate(todo).firstOrNull()?.let(::Rejection)
}
private fun tzidsIn(component: ICalComponent): List<String> =
component.properties.mapNotNull { it.param("TZID")?.takeIf(String::isNotBlank) } +
component.components.flatMap(::tzidsIn)
}
@@ -0,0 +1,547 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParam
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import java.time.ZoneId
import kotlin.time.Clock
import kotlin.time.Instant
/**
* VTODO ↔ [TaskEntity].
*
* ## The residue
*
* Everything the mapper does not claim — unknown properties, unknown parameters,
* `VALARM`s, whole unknown sub-components — is serialised verbatim into
* [TaskEntity.unknownProperties] and re-emitted on write. RFC 5545 §3.1 requires
* it ("Applications MUST preserve the value data for x-name and iana-token
* values that they don't recognize"), and failing it destroys other people's
* data invisibly — invisible in our own UI precisely because we are the client
* that does not understand the property.
*
* ## What "claimed" means, and why it is narrow
*
* The mapper claims a property only when it can **reproduce it exactly** from
* its columns. Everything else stays in the residue and round-trips untouched:
*
* - a value it cannot parse or that is out of range (`PRIORITY:11`,
* `PERCENT-COMPLETE:abc`, `STATUS:X-DEFERRED`, `SEQUENCE:x`) — clamping or
* defaulting these would be a silent rewrite of somebody's data;
* - a time it can read but not reproduce — a floating stamp, or a `TZID` this
* device's tzdb has never heard of. The column still gets a best-effort
* instant so the UI has something to show;
* - a `DTSTART`/`DUE` pair that disagrees on value type or timezone, where
* authoring both from one `is_all_day` flag and one `timezone` column would
* destroy the odd one out.
*
* ## Residue eviction
*
* A suppressed property is only suppressed while it still *agrees* with its
* column. [contradictsResidue] compares the two on write: if the user has since
* edited that field, the stale residue copy is evicted and the column is
* authored. Without this, editing the due date of a task imported with a
* floating `DUE` would silently do nothing on the server.
*
* ## Alarms
*
* `VALARM`s round-trip in the residue and are never authored here. Local
* reminders live in `task_alarms` and are never serialised. The two stores are
* disjoint, so neither can destroy the other — which is what `docs/SYNC.md`'s
* `setAlarm` collision was actually about, and it does not survive into the
* own-store world. Merging them is a later decision, not a silent one.
*/
object VTodoMapper {
/**
* DAVx5's limit, and ours for the same two reasons: Android's `CursorWindow`
* row cap, and `CALDAV:max-resource-size`, whose violation is a failed PUT.
*/
const val MAX_RESIDUE_BYTES = 25 * 1024
/**
* Cardinality-one properties the mapper authors from a column, and therefore
* must not author while the residue still holds the original.
*/
private val SUPPRESSED_BY_RESIDUE = setOf(
"DTSTART", "DUE", "COMPLETED", "RECURRENCE-ID", "CREATED", "LAST-MODIFIED",
"STATUS", "RELATED-TO",
// ⚠️ The scalars belong here too, and their absence was invisible: the
// round-trip corpus filters SEQUENCE out of comparison entirely, so a
// task read from `SEQUENCE:x` was re-emitted carrying *both* `SEQUENCE:0`
// and `SEQUENCE:x` with nothing to catch it. `write` always authors
// SEQUENCE, so the duplicate is unconditional — and under
// `Prefer: handling=strict` sabre will not quietly repair it.
"SEQUENCE", "PRIORITY", "PERCENT-COMPLETE", "CLASS",
// Parameterised copies stay in the residue verbatim; authoring the
// column beside them would emit the same exclusions twice.
"RDATE", "EXDATE",
)
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
data class Mapped(
val entity: TaskEntity,
/** `RELATED-TO;RELTYPE=PARENT`, for the caller to resolve to a row id. */
val parentUid: String?,
/** Set when the source carried no `UID` and the caller must mint one. */
val uidWasMissing: Boolean,
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
val droppedResidue: Boolean,
)
// ---------------------------------------------------------------- read
fun read(vtodo: ICalComponent, listId: Long = 0L): Mapped {
// Claims are tracked by index, not by value: two identical content lines
// are two pieces of data, and claiming one must not swallow the other on
// the way into the residue.
val claimed = mutableSetOf<Int>()
fun claim(property: ICalProperty?) {
property?.let { p ->
vtodo.properties.indexOfFirst { it === p }.takeIf { it >= 0 }?.let(claimed::add)
}
}
/** Claims [property] only if [value] could be read from it. */
fun <T> take(property: ICalProperty?, value: T?): T? =
value?.also { claim(property) }
/**
* Every `RDATE` / `EXDATE` copy's values, merged.
*
* ⚠️ Both are cardinality-**many**, and Apple Calendar writes one line
* per excluded occurrence. 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
* back to the server perfectly out of the residue, which is what made it
* invisible.
*
* ⚠️ Claimed only when no copy carries a parameter. The column is the
* bare value, so claiming a `TZID=`- or `VALUE=DATE`-qualified property
* would drop that qualifier for good: the property never reaches the
* residue, and `write` re-emits it naked. A floating EXDATE stops
* matching the instance it excluded, and a bare eight-digit RDATE is
* read as a malformed DATE-TIME (§3.3.5) — a permanent 415 under the
* `Prefer: handling=strict` this client sends.
*/
fun dateList(vtodo: ICalComponent, name: String, claim: (ICalProperty?) -> Unit): String? {
val copies = vtodo.properties(name)
if (copies.isEmpty()) return null
if (copies.all { it.params.isEmpty() }) copies.forEach(claim)
return copies.joinToString(",") { it.value.trim() }.takeIf { it.isNotEmpty() }
}
val uidProperty = vtodo.property("UID")
// Claimed even when empty: an empty UID must not reach the residue, or
// write would emit the caller's minted UID alongside the empty one.
val uid = take(uidProperty, uidProperty?.value?.trim()).orEmpty()
val dtstart = readTime(vtodo, "DTSTART")
val due = readTime(vtodo, "DUE")
// A task's primary stamp is DUE; DTSTART only decides when there is no
// DUE. Deriving one all-day flag from "either is a DATE" turns a
// date/date-time pair into two DATEs and destroys the time half.
val allDay = if (due.present) due.isDate else dtstart.isDate
val timezone = dtstart.tzid ?: due.tzid
// Claim a stamp only when the single is_all_day flag and the single
// timezone column can reproduce it. Otherwise it stays in the residue and
// is re-emitted exactly as it arrived.
if (dtstart.reproducible(allDay, timezone)) claim(dtstart.property)
if (due.reproducible(allDay, timezone)) claim(due.property)
val recurrenceId = readTime(vtodo, "RECURRENCE-ID")
if (recurrenceId.reproducible(allDay, timezone)) claim(recurrenceId.property)
// These three are UTC-only on the way out, so a zoned or date-valued one
// is not reproducible.
val completed = readTime(vtodo, "COMPLETED")
if (completed.isUtcDateTime) claim(completed.property)
val created = readTime(vtodo, "CREATED")
if (created.isUtcDateTime) claim(created.property)
val lastModified = readTime(vtodo, "LAST-MODIFIED")
if (lastModified.isUtcDateTime) claim(lastModified.property)
val statusProperty = vtodo.property("STATUS")
val status = take(
statusProperty,
when (statusProperty?.value?.trim()?.uppercase()) {
"NEEDS-ACTION" -> TaskStatus.NEEDS_ACTION
"IN-PROCESS" -> TaskStatus.IN_PROCESS
"COMPLETED" -> TaskStatus.COMPLETED
"CANCELLED" -> TaskStatus.CANCELLED
else -> null
},
) ?: TaskStatus.NEEDS_ACTION
val percentProperty = vtodo.property("PERCENT-COMPLETE")
val percent = take(
percentProperty,
percentProperty?.value?.trim()?.toIntOrNull()?.takeIf { it in 0..100 },
)
// 0 = undefined, 1 = highest, 9 = lowest. Stored raw: bucketing on the way
// in would rewrite a server's PRIORITY:3 as 1 and lose it on write-back.
val priorityProperty = vtodo.property("PRIORITY")
val priority = take(
priorityProperty,
priorityProperty?.value?.trim()?.toIntOrNull()?.takeIf { it in 0..9 },
) ?: PRIORITY_NONE
val classProperty = vtodo.property("CLASS")
val classification = take(
classProperty,
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
)
val sequenceProperty = vtodo.property("SEQUENCE")
val sequence = take(
sequenceProperty,
sequenceProperty?.value?.trim()?.toIntOrNull()?.takeIf { it >= 0 },
) ?: 0
// RELTYPE defaults to PARENT (§3.2.15), and §3.8.4.5 reads backwards to
// most implementers: the *referencing* component is the subordinate one,
// so this points at our parent.
//
// Deliberately **not** claimed. TaskEntity holds only a local `parent_id`,
// so a parent that is not in this store — not fetched yet, in another
// collection, deleted locally — would leave nothing to write back and the
// relationship would be destroyed. Keeping it in the residue means the
// link survives even when the parent row does not.
val parentUid = vtodo.properties("RELATED-TO")
.firstOrNull { (it.param("RELTYPE") ?: "PARENT").equals("PARENT", ignoreCase = true) }
?.value?.trim()?.takeIf { it.isNotEmpty() }
val entity = TaskEntity(
listId = listId,
uid = uid,
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
// URI, not TEXT — escaping it would corrupt a query string.
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
status = status,
percentComplete = percent,
completedAt = completed.instant,
priority = priority,
classification = classification,
dtstart = dtstart.instant,
due = due.instant,
duration = take(vtodo.property("DURATION"), vtodo.property("DURATION")?.value?.trim()),
isAllDay = allDay,
timezone = timezone,
rrule = take(vtodo.property("RRULE"), vtodo.property("RRULE")?.value?.trim()),
rdate = dateList(vtodo, "RDATE", ::claim),
exdate = dateList(vtodo, "EXDATE", ::claim),
recurrenceId = recurrenceId.instant,
createdAt = created.instant,
lastModified = lastModified.instant,
// The Organizer's revision counter (§3.8.7.4) — preserved verbatim,
// never ours to bump.
sequence = sequence,
)
// DTSTAMP is regenerated on every serialisation and carries no state, so
// it is dropped rather than stored. LAST-MODIFIED, which does carry
// state, is a column above — conflating the two makes every sync look
// like an edit.
val residueProperties = vtodo.properties
.filterIndexed { index, _ -> index !in claimed }
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
val residueText = ICalSerializer.serializeProperties(residueProperties) +
ICalSerializer.serializeAll(vtodo.components)
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
return Mapped(
entity = entity.copy(
unknownProperties = residueText.takeIf { it.isNotEmpty() && !tooLarge },
),
parentUid = parentUid,
uidWasMissing = uid.isEmpty(),
droppedResidue = tooLarge,
)
}
// --------------------------------------------------------------- write
/**
* Serialises [entity] back to a VTODO.
*
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
* [now] is the `DTSTAMP`.
*/
fun write(
entity: TaskEntity,
parentUid: String? = null,
now: Instant = Clock.System.now(),
): ICalComponent {
val residue = parseResidue(entity.unknownProperties)
val keptResidue =
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) }
val suppressed = keptResidue
.map { it.name.uppercase() }
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
val properties = mutableListOf<ICalProperty>()
fun add(name: String, value: String?, vararg params: ICalParam) {
if (value == null || name.uppercase() in suppressed) return
properties += ICalProperty(name, params.toList(), value)
}
fun addTime(name: String, instant: Instant?) {
if (instant == null || name in suppressed) return
properties += timeProperty(name, instant, entity)
}
add("UID", entity.uid)
add("DTSTAMP", ICalValues.formatDateTime(now, null))
add("SEQUENCE", entity.sequence.toString())
add("SUMMARY", entity.title?.let(ICalValues::escapeText))
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
add("LOCATION", entity.location?.let(ICalValues::escapeText))
add("URL", entity.url)
add("STATUS", entity.status.toICalName())
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
// §3.8.2.1: COMPLETED MUST be UTC — no TZID, no floating, no DATE.
add("COMPLETED", entity.completedAt?.let { ICalValues.formatDateTime(it, null) })
add("PRIORITY", entity.priority.takeIf { it != PRIORITY_NONE }?.toString())
add("CLASS", entity.classification?.let { CLASS_NAMES.getOrNull(it) })
addTime("DTSTART", entity.dtstart)
addTime("DUE", entity.due)
add("DURATION", entity.duration)
add("RRULE", entity.rrule)
// ⚠️ Authored only when the residue's copies contradicted the column —
// i.e. we changed the exclusions ourselves. The parameters come from the
// copy the residue is giving up, so an authored value keeps the TZID the
// list was written in; with no copy to take them from, a date-shaped
// value still needs its VALUE=DATE, since §3.3.5's default is DATE-TIME
// and a bare eight-digit value is malformed.
dateList("RDATE", entity.rdate, residue, suppressed, properties)
dateList("EXDATE", entity.exdate, residue, suppressed, properties)
addTime("RECURRENCE-ID", entity.recurrenceId)
add("CREATED", entity.createdAt?.let { ICalValues.formatDateTime(it, null) })
add("LAST-MODIFIED", entity.lastModified?.let { ICalValues.formatDateTime(it, null) })
if (parentUid != null && "RELATED-TO" !in suppressed) {
properties += ICalProperty("RELATED-TO", listOf(ICalParam("RELTYPE", "PARENT")), parentUid)
}
return ICalComponent(
name = "VTODO",
properties = properties + keptResidue,
components = residue.components,
)
}
private fun dateList(
name: String,
value: String?,
residue: ICalComponent,
suppressed: Set<String>,
into: MutableList<ICalProperty>,
) {
if (value == null || name in suppressed) return
val inherited = residue.properties(name).firstOrNull { it.params.isNotEmpty() }?.params
into += ICalProperty(name, paramsFor(value, inherited.orEmpty()), value)
}
/**
* The parameters an authored list may carry, given what is in it.
*
* ⚠️ Checked against the value rather than copied across. §3.3.5 forbids
* `TZID` on a `…Z` value and sabre answers 415 for the pair, and a
* date-shaped value needs `VALUE=DATE` or it is read as a malformed
* DATE-TIME. A `TZID` inherited from a residue copy therefore only survives
* where the value is still a local wall time.
*
* What this cannot repair is an `EXDATE` whose zone differs from `DTSTART`'s:
* one `timezone` column describes the task, so a value appended here is
* written in *that* zone. Legal, and vanishingly rare — every client that
* writes both writes them alike.
*/
private fun paramsFor(value: String, inherited: List<ICalParam>): List<ICalParam> {
val values = value.split(',').map(String::trim).filter(String::isNotEmpty)
if (values.isEmpty()) return emptyList()
if (values.none { it.contains('T') }) return listOf(ICalParam("VALUE", "DATE"))
if (values.any { it.endsWith("Z") }) return emptyList()
return inherited.filter { it.name.equals("TZID", ignoreCase = true) }
}
/**
* True when a suppressing residue property no longer describes what its
* column holds — i.e. the user has edited that field since it was imported,
* so the stale copy must go and the column must be authored instead.
*/
private fun contradictsResidue(
property: ICalProperty,
residue: ICalComponent,
entity: TaskEntity,
parentUid: String?,
): Boolean {
// Cardinality-many and list-valued: the column carries every copy's
// values merged, so it contradicts only when that whole set has changed
// — which is what deleting an occurrence does, and nothing else here
// does. All the copies are then dropped together and one authored line
// carries the lot, parameters included.
fun listChanged(column: String?): Boolean {
fun split(text: String?) =
text.orEmpty().split(',').map(String::trim).filter(String::isNotEmpty)
return split(column) != residue.properties(property.name).flatMap { split(it.value) }
}
fun sameInstant(column: Instant?) =
ICalValues.readInstant(ICalValues.parseTime(property)) == column
return when (property.name.uppercase()) {
"DTSTART" -> !sameInstant(entity.dtstart)
"DUE" -> !sameInstant(entity.due)
"COMPLETED" -> !sameInstant(entity.completedAt)
"RECURRENCE-ID" -> !sameInstant(entity.recurrenceId)
"RDATE" -> listChanged(entity.rdate)
"EXDATE" -> listChanged(entity.exdate)
"CREATED" -> !sameInstant(entity.createdAt)
"LAST-MODIFIED" -> !sameInstant(entity.lastModified)
// The residue only ever holds a STATUS we could not read, which left
// the column at its NEEDS-ACTION fallback. Anything else means the
// user has since set a real status.
"STATUS" -> entity.status != TaskStatus.NEEDS_ACTION
// A null parentUid is "the parent is not in this store", not "there is
// no parent" — dropping the link there would destroy a relationship
// over a row we simply have not fetched.
//
// ⚠️ Scoped to RELTYPE=PARENT, exactly as the read side is. Nothing
// here models CHILD or SIBLING, and no RELATED-TO is ever claimed,
// so every one of them lives in the residue — where an unscoped
// comparison read a CHILD link as "a PARENT link that disagrees with
// the column" and dropped it on the next PUT.
"RELATED-TO" ->
(property.param("RELTYPE") ?: "PARENT").equals("PARENT", ignoreCase = true) &&
parentUid != null && property.value.trim() != parentUid
// Each of these reaches the residue only when it could not be parsed,
// which left the column at its fallback. A column that has since moved
// off that fallback is the user's edit, and it wins.
"PRIORITY" -> entity.priority != PRIORITY_NONE
"PERCENT-COMPLETE" -> entity.percentComplete != null
"CLASS" -> entity.classification != null
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
// so the column stays at its fallback and the residue always wins.
else -> false
}
}
/**
* A `VALARM` with `TRIGGER;RELATED=END` needs `DUE`, or `DTSTART` plus
* `DURATION` (§3.8.6.3). Clearing the due date on a task that has an
* end-relative reminder produces a resource the server rejects permanently —
* and it is reachable from ordinary UI actions, so it is checked before PUT
* rather than discovered as a 415.
*/
fun validate(vtodo: ICalComponent): List<String> {
val problems = mutableListOf<String>()
val due = vtodo.property("DUE")
val dtstart = vtodo.property("DTSTART")
val duration = vtodo.property("DURATION")
if (due != null && dtstart != null) {
val start = ICalValues.readInstant(ICalValues.parseTime(dtstart))
val end = ICalValues.readInstant(ICalValues.parseTime(due))
// sabre answers 415 for both of these, not a 4xx that names them.
if (start != null && end != null && end < start) problems += "DUE precedes DTSTART"
if (isDateValue(dtstart) != isDateValue(due)) {
problems += "DTSTART and DUE disagree on value type"
}
}
if (due == null && (dtstart == null || duration == null)) {
val endRelative = vtodo.components("VALARM").any { alarm ->
alarm.property("TRIGGER")?.param("RELATED").equals("END", ignoreCase = true)
}
if (endRelative) problems += "TRIGGER;RELATED=END with neither DUE nor DTSTART+DURATION"
}
if (vtodo.property("METHOD") != null) problems += "METHOD is not allowed on a stored resource"
return problems
}
// -------------------------------------------------------------- helpers
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
private class TimeRead(
val property: ICalProperty?,
val instant: Instant?,
val tzid: String?,
val isDate: Boolean,
val representable: Boolean,
) {
val present get() = property != null
/** True when one `is_all_day` flag and one `timezone` column reproduce it. */
fun reproducible(allDay: Boolean, timezone: String?) =
present && representable && isDate == allDay && tzid == timezone
/** True when it is a UTC date-time — the only form we author for these. */
val isUtcDateTime get() = present && representable && !isDate && tzid == null
}
private fun readTime(vtodo: ICalComponent, name: String): TimeRead {
val property = vtodo.property(name) ?: return TimeRead(null, null, null, false, false)
return when (val value = ICalValues.parseTime(property)) {
is ICalValues.TimeValue.Date -> TimeRead(property, value.instant, null, true, true)
is ICalValues.TimeValue.Timed -> TimeRead(property, value.instant, value.tzid, false, true)
// Readable but not reproducible: the column gets the best-effort
// instant, the property round-trips from the residue verbatim.
is ICalValues.TimeValue.Unrepresentable ->
TimeRead(property, value.instant, null, false, false)
}
}
private fun timeProperty(name: String, instant: Instant, entity: TaskEntity): ICalProperty {
if (entity.isAllDay) {
return ICalProperty(
name,
listOf(ICalParam("VALUE", "DATE")),
ICalValues.formatDate(instant),
)
}
// Resolve the zone once. Letting the parameter and the value each decide
// separately produces TZID on a `…Z` value, which §3.3.5 forbids and
// sabre answers 415 for.
val tzid = entity.timezone?.takeIf { runCatching { ZoneId.of(it) }.isSuccess }
val params = if (tzid == null) emptyList() else listOf(ICalParam("TZID", tzid))
return ICalProperty(name, params, ICalValues.formatDateTime(instant, tzid))
}
/** The same DATE test [ICalValues.parseTime] applies, so the two cannot disagree. */
private fun isDateValue(property: ICalProperty): Boolean =
ICalValues.parseTime(property) is ICalValues.TimeValue.Date
private fun ICalProperty.text(): String = ICalValues.unescapeText(value)
private fun parseResidue(text: String?): ICalComponent {
if (text.isNullOrEmpty()) return ICalComponent("VTODO")
// The residue is stored as bare properties followed by whole sub-component
// blocks, so it parses as the body of a VTODO with the wrapper restored.
return runCatching {
ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n")
}.getOrElse { ICalComponent("VTODO") }
}
private fun TaskStatus.toICalName(): String = when (this) {
TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION"
TaskStatus.IN_PROCESS -> "IN-PROCESS"
TaskStatus.COMPLETED -> "COMPLETED"
TaskStatus.CANCELLED -> "CANCELLED"
}
}
@@ -0,0 +1,412 @@
package de.jeanlucmakiola.agendula.data.tasks.legacy
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.tasks.CursorColumnReader
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/** How much one import moved. */
data class ImportCounts(val lists: Int, val tasks: Int, val alarms: Int)
/** The outcome of [OneShotImport.runIfNeeded] or [OneShotImport.reimportFromArchive]. */
sealed interface ImportResult {
/** The DataStore flag was already set; nothing was read. */
data object AlreadyDone : ImportResult
/** No legacy database on disk — a fresh install, or one already archived. */
data object NothingToImport : ImportResult
data class Imported(val counts: ImportCounts) : ImportResult
/** Nothing landed: the transaction rolled back and the source is untouched. */
data class Failed(val cause: Throwable) : ImportResult
}
/**
* Moves a v0.3.x install's tasks out of the bundled dmfs provider's SQLite file
* and into Room, once (`docs/OWN-STORE.md`, "Migrating existing users").
*
* The file is opened read-only and directly — no provider, no ContentResolver —
* so this keeps working after `:provider` is deleted. Everything lands in one
* Room transaction with verified counts, so a failure leaves Room exactly as it
* was and the source file exactly where it was.
*
* dmfs accounts are not carried over: every list is imported as a device-only
* list (`account_id IS NULL`), including one that sat under a real account —
* only reachable if the user had pointed DAVx5 at our authority. Their task
* `uid`s are preserved, which is what lets those rows be re-attached to an
* account once sync lands.
*/
@Singleton
class OneShotImport @Inject constructor(
@ApplicationContext private val context: Context,
private val database: TasksDatabase,
private val dataStore: DataStore<Preferences>,
) {
/** Whether the import has run. Set before the rename, so both guards hold. */
val isDone: Flow<Boolean> = dataStore.data.map { it[IMPORT_DONE] ?: false }
/**
* Whether the last attempt failed and the archived source is still sitting
* there unread.
*
* Recorded because the alternative is what this class used to do: return an
* [ImportResult.Failed] that every caller dropped on the floor, leaving an
* upgrading user with an empty app, no message, and their tasks in a file
* only a developer could find. Settings → Storage offers the retry.
*/
val lastAttemptFailed: Flow<Boolean> = dataStore.data.map { it[IMPORT_FAILED] ?: false }
/**
* Steps 18 of the plan: archive `databases/tasks.db`, import it, record
* completion. Safe to call on every launch.
*
* The archive happens *before* the import, and the import always replaces, so
* that every point this can be killed at re-enters correctly:
*
* - killed after the rename, before the import — the next run finds the
* archive, imports it, and nothing is lost;
* - killed after the import commits, before the flag is written — the next
* run truncates and re-imports the same archive, so the result is the same
* rather than doubled.
*
* Renaming last would leave that second window open: the flag would be unset
* and `tasks.db` still in place, and the next launch would import it a second
* time on top of the first. That is the window the plan's "guarded by a
* DataStore flag *and* by the rename" is meant to close, and only this order
* actually closes it.
*/
suspend fun runIfNeeded(): ImportResult = withContext(Dispatchers.IO) {
if (isDone.first()) return@withContext ImportResult.AlreadyDone
val source = archivedSource() ?: run {
markDone()
return@withContext ImportResult.NothingToImport
}
val counts = runCatching { importFrom(source, replaceExisting = true) }
.getOrElse { return@withContext recordFailure(it) }
markDone()
ImportResult.Imported(counts)
}
/**
* The rollback path: re-run against the archived `tasks.db.imported`,
* truncating the Room tables first so a second attempt replaces rather than
* merges. Reached by a targeted fix release, not by the app on its own.
*/
suspend fun reimportFromArchive(): ImportResult = withContext(Dispatchers.IO) {
val source = archivedSource() ?: return@withContext ImportResult.NothingToImport
val counts = runCatching { importFrom(source, replaceExisting = true) }
.getOrElse { return@withContext recordFailure(it) }
markDone()
ImportResult.Imported(counts)
}
/**
* The legacy database as `tasks.db.imported`, archiving it first if it is
* still under its live name. `null` when there is nothing to import.
*/
private fun archivedSource(): File? {
val archive = context.getDatabasePath(ARCHIVE_NAME)
if (archive.exists()) return archive
val live = context.getDatabasePath(LEGACY_NAME)
if (!live.exists()) return null
return if (archive(live)) archive else live
}
/** Clears the completion flag so [runIfNeeded] will import again. */
suspend fun clearCompletion() {
dataStore.edit { it.remove(IMPORT_DONE) }
}
/**
* Steps 26 against an arbitrary dmfs database: read it read-only, then write
* everything in one Room transaction whose counts are verified before it
* commits. Blocking — call it off the main thread.
*/
fun importFrom(source: File, replaceExisting: Boolean = false): ImportCounts {
val snapshot = SQLiteDatabase.openDatabase(source.path, null, SQLiteDatabase.OPEN_READONLY)
.use(::read)
return database.runInTransaction<ImportCounts> {
if (replaceExisting) truncate()
val baseline = tableCounts()
val written = write(snapshot)
verify(written, baseline)
written
}
}
// --- reading the dmfs file ------------------------------------------------
private fun read(db: SQLiteDatabase): LegacySnapshot {
val lists = mutableListOf<LegacyList>()
db.rawQuery("SELECT * FROM Lists ORDER BY _id", null).use { cursor ->
val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) {
val id = r.getLong("_id") ?: continue
lists += LegacyList(
id = id,
entity = TaskListEntity(
name = r.getString("list_name").orEmpty(),
color = r.getInt("list_color") ?: 0,
accountId = null,
isVisible = r.getBoolean("visible"),
isSynced = r.getBoolean("sync_enabled"),
owner = r.getString("list_owner"),
),
)
}
}
val rows = mutableListOf<LegacyTaskRow>()
db.rawQuery("SELECT * FROM Tasks WHERE _deleted IS NULL OR _deleted = 0 ORDER BY _id", null)
.use { cursor ->
val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) {
val id = r.getLong("_id") ?: continue
rows += LegacyTaskRow(
id = id,
listId = r.getLong("list_id") ?: continue,
parentId = r.getLong("parent_id"),
masterId = r.getLong("original_instance_id"),
recurrenceId = r.instant("original_instance_time"),
entity = TaskEntity(
listId = 0,
uid = r.getString("_uid") ?: UUID.randomUUID().toString(),
title = r.getString("title"),
description = r.getString("description"),
location = r.getString("location"),
url = r.getString("url"),
color = r.getInt("task_color"),
status = statusFromInt(r.getInt("status")),
percentComplete = r.getInt("percent_complete"),
completedAt = r.instant("completed"),
priority = r.getInt("priority") ?: PRIORITY_NONE,
classification = r.getInt("class"),
dtstart = r.instant("dtstart"),
due = r.instant("due"),
duration = r.getString("duration"),
isAllDay = r.getBoolean("is_allday"),
timezone = r.getString("tz"),
rrule = r.getString("rrule"),
rdate = r.getString("rdate"),
exdate = r.getString("exdate"),
createdAt = r.instant("created"),
lastModified = r.instant("last_modified"),
),
)
}
}
val alarms = mutableListOf<LegacyAlarm>()
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null)
.use { cursor ->
val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) {
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
val taskId = r.getLong("task_id") ?: continue
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
alarms += LegacyAlarm(
taskId = taskId,
minutesBefore = minutes,
reference = if (r.getString("data1")?.trim() == REFERENCE_START) {
AlarmReference.START
} else {
AlarmReference.DUE
},
message = r.getString("data2"),
)
}
}
return LegacySnapshot(lists, rows, alarms)
}
// --- writing into Room ----------------------------------------------------
/**
* dmfs `list_id`, `parent_id` and `original_instance_id` are old row ids, and
* Room mints its own on insert, so every one of them is remapped through the
* ids the inserts hand back. Tasks are inserted with their links cleared and
* a second pass sets them, because a parent may be a higher `_id` than its
* child.
*/
private fun write(snapshot: LegacySnapshot): ImportCounts {
val listDao = database.taskLists()
val taskDao = database.tasks()
val alarmDao = database.alarms()
val listIds = snapshot.lists.associate { it.id to listDao.insert(it.entity) }
// A task whose list is missing is already invisible in dmfs — its tasks
// view inner-joins Lists — so dropping it loses nothing the user could see.
val importable = snapshot.tasks.filter { it.listId in listIds }
val importableIds = importable.mapTo(mutableSetOf()) { it.id }
val taskIds = mutableMapOf<Long, Long>()
val inserted = mutableListOf<Pair<LegacyTaskRow, TaskEntity>>()
val seen = mutableSetOf<Triple<Long, String, Instant?>>()
for (row in importable) {
val listId = listIds.getValue(row.listId)
val overrides = row.masterId != null && row.masterId in importableIds
val recurrenceId = row.recurrenceId.takeIf { overrides }
// A duplicate (list, uid, recurrence) would abort the whole import on
// the unique index; a fresh uid costs the row nothing it still has.
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
?: UUID.randomUUID().toString()
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
val newId = taskDao.insert(entity)
taskIds[row.id] = newId
inserted += row to entity.copy(id = newId)
}
for ((row, entity) in inserted) {
val parentId = row.parentId?.let(taskIds::get)
val masterId = row.masterId?.let(taskIds::get)
if (parentId == null && masterId == null) continue
taskDao.update(entity.copy(parentId = parentId, masterId = masterId))
}
var alarmCount = 0
for (alarm in snapshot.alarms) {
val taskId = taskIds[alarm.taskId] ?: continue
alarmDao.insert(
TaskAlarmEntity(
taskId = taskId,
minutesBefore = alarm.minutesBefore,
reference = alarm.reference,
message = alarm.message,
),
)
alarmCount++
}
return ImportCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount)
}
private fun verify(written: ImportCounts, before: ImportCounts) {
val after = tableCounts()
check(after.lists - before.lists == written.lists) {
"list count mismatch: ${after.lists - before.lists} != ${written.lists}"
}
check(after.tasks - before.tasks == written.tasks) {
"task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}"
}
check(after.alarms - before.alarms == written.alarms) {
"alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}"
}
}
/** Dropping the lists takes their tasks and alarms with them, by cascade. */
private fun truncate() {
val listDao = database.taskLists()
listDao.lists().forEach { listDao.delete(it.list.id) }
}
private fun tableCounts() = ImportCounts(
lists = count("task_lists"),
tasks = count("tasks"),
alarms = count("task_alarms"),
)
private fun count(table: String): Int =
database.query("SELECT COUNT(*) FROM $table", null).use {
if (it.moveToFirst()) it.getInt(0) else 0
}
// --- the source file ------------------------------------------------------
/**
* Renames the dmfs file, sidecars included, to `tasks.db.imported`. Never
* deletes it: for one release it is the only way back if the import turns out
* to be wrong on someone's device.
*/
private fun archive(source: File): Boolean {
val target = File(source.parentFile, ARCHIVE_NAME)
if (!source.renameTo(target)) return false
for (suffix in SIDECARS) {
val sidecar = File(source.path + suffix)
if (sidecar.exists()) sidecar.renameTo(File(target.path + suffix))
}
return true
}
/**
* Leaves a breadcrumb the UI can act on, and one in logcat for a bug report.
* The completion flag is deliberately *not* set: the next launch retries on
* its own, and the archived source is still where it was.
*/
private suspend fun recordFailure(cause: Throwable): ImportResult.Failed {
Log.e(TAG, "Importing the legacy task database failed; source left in place", cause)
dataStore.edit { it[IMPORT_FAILED] = true }
return ImportResult.Failed(cause)
}
private suspend fun markDone() {
dataStore.edit {
it[IMPORT_DONE] = true
it.remove(IMPORT_FAILED)
}
}
private fun CursorColumnReader.instant(name: String): Instant? =
getLong(name)?.let(Instant::fromEpochMilliseconds)
companion object {
const val LEGACY_NAME = "tasks.db"
const val ARCHIVE_NAME = "tasks.db.imported"
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
private const val REFERENCE_START = "2"
private val SIDECARS = listOf("-journal", "-wal", "-shm")
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
private val IMPORT_FAILED = booleanPreferencesKey("legacy_import_failed")
private const val TAG = "OneShotImport"
}
}
private class LegacySnapshot(
val lists: List<LegacyList>,
val tasks: List<LegacyTaskRow>,
val alarms: List<LegacyAlarm>,
)
private class LegacyList(val id: Long, val entity: TaskListEntity)
private class LegacyTaskRow(
val id: Long,
val listId: Long,
val parentId: Long?,
val masterId: Long?,
val recurrenceId: Instant?,
val entity: TaskEntity,
)
private class LegacyAlarm(
val taskId: Long,
val minutesBefore: Int,
val reference: AlarmReference,
val message: String?,
)
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
/** Reads and writes over `accounts`. */
@Dao
interface AccountDao {
@Query("SELECT * FROM accounts ORDER BY display_name")
fun all(): List<AccountEntity>
/**
* The same rows, observed.
*
* ⚠️ A one-shot read cannot show a sync: the worker writes `last_sync_at`
* from a background thread minutes after the button was pressed, so a screen
* holding a snapshot goes on saying "never synced" until the user leaves and
* comes back. Room's invalidation tracker is what closes that gap.
*/
@Query("SELECT * FROM accounts ORDER BY display_name")
fun observeAll(): Flow<List<AccountEntity>>
@Query("SELECT * FROM accounts WHERE id = :accountId")
fun account(accountId: Long): AccountEntity?
@Insert
fun insert(account: AccountEntity): Long
@Update
fun update(account: AccountEntity)
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
fun recordSync(accountId: Long, at: Instant?, error: String?)
@Query("DELETE FROM accounts WHERE id = :accountId")
fun delete(accountId: Long): Int
}
@@ -0,0 +1,38 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.TypeConverter
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.statusFromInt
import de.jeanlucmakiola.agendula.domain.toInt
import kotlin.time.Instant
/**
* Storage encodings for the entity types SQLite has no column type for. Time is
* epoch millis; [TaskStatus] goes through the `domain` mappers so that numbering
* keeps its single home.
*
* `PRIORITY` deliberately has no converter — it is stored as the raw iCalendar
* integer, because [de.jeanlucmakiola.agendula.domain.Priority] is a lossy
* bucketing and a converter would apply it before the value reaches disk.
*/
object Converters {
@TypeConverter
fun instantToMillis(value: Instant?): Long? = value?.toEpochMilliseconds()
@TypeConverter
fun instantFromMillis(value: Long?): Instant? = value?.let(Instant::fromEpochMilliseconds)
@TypeConverter
fun statusToInt(value: TaskStatus): Int = value.toInt()
@TypeConverter
fun statusFrom(value: Int): TaskStatus = statusFromInt(value)
@TypeConverter
fun alarmReferenceToString(value: AlarmReference): String = value.name
@TypeConverter
fun alarmReferenceFrom(value: String): AlarmReference =
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
}
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Folds the write-ahead log back into the database file when the app goes to the
* background.
*
* Room runs in WAL mode, and Auto Backup copies files without checkpointing — so
* a `-wal` sidecar can hold writes the backed-up `.db` does not. The backup rules
* carry all three files, which already makes a restore consistent; this narrows
* the window further by ensuring the `.db` alone is usually current, which is what
* a restore onto a device that drops the sidecars falls back to.
*/
@Singleton
class DatabaseCheckpoint @Inject constructor(
private val database: TasksDatabase,
@ApplicationScope private val scope: CoroutineScope,
) : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
scope.launch(Dispatchers.IO) {
runCatching {
// ⚠️ Stepped, not merely compiled. `query` hands back a lazy
// cursor and the statement runs on the first fill — closing it
// unread made this whole class a no-op, so the sidecar kept
// growing and the `.db` stayed stale, which is precisely the
// restore case above.
database.openHelper.writableDatabase
.query("PRAGMA wal_checkpoint(TRUNCATE)")
.use { it.moveToFirst() }
}
}
}
}
@@ -0,0 +1,222 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* A CalDAV account. Empty until sync lands (`docs/SYNC.md` phase 2), but the FK
* from [TaskListEntity] exists from v1 so turning sync on never needs a
* migration. The app password is never stored here — Keystore only.
*/
@Entity(tableName = "accounts")
data class AccountEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "display_name") val displayName: String,
@ColumnInfo(name = "principal_url") val principalUrl: String? = null,
@ColumnInfo(name = "home_set_url") val homeSetUrl: String? = null,
@ColumnInfo(name = "username") val username: String? = null,
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
)
/**
* A task list. [accountId] is nullable: `NULL` is a device-only list, and
* attaching one to an account later is a plain `UPDATE` rather than a data
* migration.
*
* Deleting an account detaches its lists (`SET NULL`) instead of deleting them,
* for the same reason [TaskEntity.parentId] does — removing an account is not
* an instruction to destroy the tasks it held.
*/
@Entity(
tableName = "task_lists",
foreignKeys = [
ForeignKey(
entity = AccountEntity::class,
parentColumns = ["id"],
childColumns = ["account_id"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [Index(value = ["account_id"])],
)
data class TaskListEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "name") val name: String,
/** ARGB. */
@ColumnInfo(name = "color") val color: Int,
@ColumnInfo(name = "account_id") val accountId: Long? = null,
@ColumnInfo(name = "is_visible", defaultValue = "1") val isVisible: Boolean = true,
@ColumnInfo(name = "is_synced", defaultValue = "1") val isSynced: Boolean = true,
/** CalDAV owner display name. */
@ColumnInfo(name = "owner") val owner: String? = null,
@ColumnInfo(name = "is_read_only", defaultValue = "0") val isReadOnly: Boolean = false,
/** User ordering. */
@ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0,
/** Collection URL, relative to the account root. */
@ColumnInfo(name = "href") val href: String? = null,
@ColumnInfo(name = "ctag") val ctag: String? = null,
/** RFC 6578 sync token, per collection. */
@ColumnInfo(name = "sync_token") val syncToken: String? = null,
/**
* Unused, and kept only because dropping a column costs a migration.
*
* ⚠️ It was set by a rename and read by nobody. A collection's name and
* colour are now written server-first by
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], so there is
* no local edit left waiting to be pushed — and a flag that means "owed to
* the server" while nothing ever pays it is worse than no flag at all.
*/
@ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false,
)
/**
* A task. Series masters *and* `RECURRENCE-ID` overrides live in this table; an
* override is a row with [recurrenceId] set and [masterId] pointing at its
* master, sharing the master's [uid].
*
* [masterId] and [parentId] are different things: [parentId] is task hierarchy
* (`RELATED-TO;RELTYPE=PARENT`), [masterId] is recurrence. A row can carry both.
*/
@Entity(
tableName = "tasks",
foreignKeys = [
ForeignKey(
entity = TaskListEntity::class,
parentColumns = ["id"],
childColumns = ["list_id"],
onDelete = ForeignKey.CASCADE,
),
// Deleting a series takes its overrides with it — they would otherwise be
// unreachable rows that still sync.
ForeignKey(
entity = TaskEntity::class,
parentColumns = ["id"],
childColumns = ["master_id"],
onDelete = ForeignKey.CASCADE,
),
// Deleting a parent promotes its subtasks to top level rather than
// destroying work the user did not ask to lose.
ForeignKey(
entity = TaskEntity::class,
parentColumns = ["id"],
childColumns = ["parent_id"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [
Index(value = ["list_id", "is_deleted"]),
Index(value = ["parent_id"]),
Index(value = ["master_id", "recurrence_id"]),
Index(value = ["is_dirty"]),
// An override shares its master's UID, so (list_id, uid) alone would
// reject the very rows recurrence depends on. With recurrence_id NULL on
// the master and set on each override this reads as: one master and at
// most one override per occurrence, per UID, per list. Note SQLite treats
// NULLs as distinct in a unique index, so the master half is a statement
// of intent, not an enforced constraint.
Index(value = ["list_id", "uid", "recurrence_id"], unique = true),
],
)
data class TaskEntity(
// identity
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "list_id") val listId: Long,
/** RFC 4122 UUID, minted at creation in every mode, synced or not. */
@ColumnInfo(name = "uid") val uid: String,
@ColumnInfo(name = "href") val href: String? = null,
@ColumnInfo(name = "etag") val etag: String? = null,
// content
@ColumnInfo(name = "title") val title: String? = null,
@ColumnInfo(name = "description") val description: String? = null,
@ColumnInfo(name = "location") val location: String? = null,
@ColumnInfo(name = "url") val url: String? = null,
/** ARGB override for the list colour. */
@ColumnInfo(name = "color") val color: Int? = null,
// state
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
@ColumnInfo(name = "percent_complete") val percentComplete: Int? = null,
@ColumnInfo(name = "completed_at") val completedAt: Instant? = null,
/**
* Raw iCalendar `PRIORITY`: 0 none, 1 highest, 9 lowest. Stored unbucketed —
* [de.jeanlucmakiola.agendula.domain.Priority] folds 14 into HIGH, so
* converting on the way *in* would rewrite a server's `PRIORITY:3` as `1` and
* lose it on the next round-trip. The bucketing belongs to the mapper, which
* is where the UI needs it.
*/
@ColumnInfo(name = "priority", defaultValue = "0") val priority: Int = PRIORITY_NONE,
/** RFC 5545 `CLASS`: 0 public, 1 private, 2 confidential. */
@ColumnInfo(name = "classification") val classification: Int? = null,
// time
@ColumnInfo(name = "dtstart") val dtstart: Instant? = null,
@ColumnInfo(name = "due") val due: Instant? = null,
/** RFC 5545 `DURATION`, verbatim. Mutually exclusive with [due]. */
@ColumnInfo(name = "duration") val duration: String? = null,
@ColumnInfo(name = "is_all_day", defaultValue = "0") val isAllDay: Boolean = false,
@ColumnInfo(name = "timezone") val timezone: String? = null,
// recurrence
@ColumnInfo(name = "rrule") val rrule: String? = null,
@ColumnInfo(name = "rdate") val rdate: String? = null,
@ColumnInfo(name = "exdate") val exdate: String? = null,
/** This row's `RECURRENCE-ID` anchor; `NULL` on a master. */
@ColumnInfo(name = "recurrence_id") val recurrenceId: Instant? = null,
/** The series this row overrides; `NULL` on a master. */
@ColumnInfo(name = "master_id") val masterId: Long? = null,
// hierarchy
@ColumnInfo(name = "parent_id") val parentId: Long? = null,
@ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0,
// audit
@ColumnInfo(name = "created_at") val createdAt: Instant? = null,
@ColumnInfo(name = "last_modified") val lastModified: Instant? = null,
@ColumnInfo(name = "sequence", defaultValue = "0") val sequence: Int = 0,
// sync
@ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false,
/** Tombstone: deleted locally, still owed to a server. */
@ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false,
/**
* Raw unfolded iCalendar lines of every property we do not model, re-emitted
* verbatim on write so a round-trip cannot silently lose a field.
*/
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
)
/** What [TaskAlarmEntity.minutesBefore] counts back from. */
enum class AlarmReference { DUE, START }
/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */
@Entity(
tableName = "task_alarms",
foreignKeys = [
ForeignKey(
entity = TaskEntity::class,
parentColumns = ["id"],
childColumns = ["task_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index(value = ["task_id"])],
)
data class TaskAlarmEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "task_id") val taskId: Long,
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
@ColumnInfo(name = "message") val message: String? = null,
)
@@ -0,0 +1,26 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.ColumnInfo
import androidx.room.Embedded
/**
* A list plus its account's display name, which the domain
* [de.jeanlucmakiola.agendula.domain.TaskList] carries and groups by.
* `null` means a device-only list.
*/
data class TaskListRow(
@Embedded val list: TaskListEntity,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
)
/**
* A task plus the three columns of its list the domain
* [de.jeanlucmakiola.agendula.domain.Task] carries, so reading a screenful is
* one query rather than one per list.
*/
data class TaskRow(
@Embedded val task: TaskEntity,
@ColumnInfo(name = "list_name") val listName: String,
@ColumnInfo(name = "list_color") val listColor: Int,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
)
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.LocalAccount
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import kotlin.time.Instant
/** Account type reported for a list attached to one of ours. */
const val CALDAV_ACCOUNT_TYPE = "caldav"
/** Maps Room rows to domain models. Pure + testable, like [de.jeanlucmakiola.agendula.data.tasks.TaskMapper]. */
object RoomTaskMapper {
fun taskList(row: TaskListRow): TaskList = TaskList(
id = row.list.id,
name = row.list.name,
color = row.list.color,
// TaskList.accountName is non-null and the lists screen groups by it, so a
// list with no account still has to report something to group under.
accountName = row.accountDisplayName ?: LocalAccount.NAME,
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
isSynced = row.list.isSynced,
isVisible = row.list.isVisible,
owner = row.list.owner,
accountId = row.list.accountId,
isReadOnly = row.list.isReadOnly,
)
/**
* One occurrence of [row]. [occurrenceStart] is the occurrence's
* `RECURRENCE-ID` anchor and `null` for a task that does not recur;
* [start] / [due] are that occurrence's resolved times.
*/
fun task(
row: TaskRow,
occurrenceStart: Instant? = null,
start: Instant? = row.task.dtstart,
due: Instant? = row.task.due,
distanceFromCurrent: Int? = null,
): Task = Task(
taskId = row.task.id,
listId = row.task.listId,
title = row.task.title.orEmpty(),
description = row.task.description,
location = row.task.location,
url = row.task.url,
priority = priorityFromICal(row.task.priority),
status = row.task.status,
percentComplete = row.task.percentComplete,
start = start,
due = due,
isAllDay = row.task.isAllDay,
timeZone = row.task.timezone,
completedAt = row.task.completedAt,
listColor = row.listColor,
taskColor = row.task.color,
listName = row.listName,
accountName = row.accountDisplayName ?: LocalAccount.NAME,
parentId = row.task.parentId,
isRecurring = row.task.isRecurring,
occurrenceStart = occurrenceStart,
distanceFromCurrent = distanceFromCurrent,
created = row.task.createdAt,
lastModified = row.task.lastModified,
)
fun exportTask(task: TaskEntity): ExportTask = ExportTask(
taskId = task.id,
uid = task.uid,
title = task.title.orEmpty(),
description = task.description,
location = task.location,
url = task.url,
priority = priorityFromICal(task.priority),
status = task.status,
percentComplete = task.percentComplete,
start = task.dtstart,
due = task.due,
isAllDay = task.isAllDay,
completedAt = task.completedAt,
created = task.createdAt,
lastModified = task.lastModified,
rrule = task.rrule,
rdate = task.rdate,
parentId = task.parentId?.takeIf { it > 0 },
)
}
/** A row carries a recurrence rule if it has an `RRULE` or an `RDATE`. */
val TaskEntity.isRecurring: Boolean
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/**
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
* dmfs provider instantiated the same series.
*/
val TaskEntity.recurrenceAnchor: Instant?
get() = dtstart ?: due
/** The rule set of this series, or `null` when it does not recur. */
fun TaskEntity.recurrenceSpec(): RecurrenceSpec? {
if (!isRecurring) return null
val anchor = recurrenceAnchor ?: return null
return RecurrenceSpec(
rrule = rrule,
rdate = rdate,
exdate = exdate,
anchor = anchor,
isAllDay = isAllDay,
timeZone = timezone,
)
}
@@ -0,0 +1,329 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.InvalidationTracker
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import java.time.ZoneId
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
/** How far either side of now a series is expanded. */
private val WINDOW_BACK = 365.days
private val WINDOW_FORWARD = 730.days
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
/**
* [TasksDataSource] over Agendula's own Room store.
*
* The one structural difference from [de.jeanlucmakiola.agendula.data.tasks
* .AndroidTasksDataSource]: there is no materialised instances table, so a
* recurring series is expanded here, at read time, by [RecurrenceExpander].
* Nothing above this cares — the repository already filters and sorts in Kotlin.
*/
@Singleton
class RoomTasksDataSource @Inject constructor(
private val database: TasksDatabase,
) : TasksDataSource {
private val clock: Clock = Clock.System
private val tasks get() = database.tasks()
private val lists get() = database.taskLists()
private val alarms get() = database.alarms()
// --- reads ----------------------------------------------------------------
override fun taskLists(): List<TaskList> = lists.lists().map(RoomTaskMapper::taskList)
override fun tasks(query: TaskQuery): List<Task> {
val now = clock.now()
val overrides = tasks.allOverrides(query.listId).groupBy { it.masterId }
return tasks.tasks(query.listId, query.includeCompleted)
.flatMap { occurrencesOf(it, overrides[it.task.id].orEmpty(), now) }
.filter { query.includeCompleted || !it.isClosed }
}
override fun task(taskId: Long): Task? {
val row = tasks.task(taskId) ?: return null
// An override row is one occurrence in its own right; it names the
// occurrence it replaces rather than expanding to a series.
row.task.recurrenceId?.let { return RoomTaskMapper.task(row, occurrenceStart = it) }
val now = clock.now()
val occurrences = occurrencesOf(row, tasks.overrides(taskId), now)
return occurrences.firstOrNull { it.distanceFromCurrent == 0 } ?: occurrences.firstOrNull()
}
override fun subtasks(parentTaskId: Long): List<Task> {
val now = clock.now()
return tasks.subtasks(parentTaskId)
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
}
override fun exportTasks(listId: Long): List<ExportTask> =
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
override fun alarms(): Map<Long, TaskReminder> =
alarms.all().associate {
it.taskId to TaskReminder(
minutesBefore = it.minutesBefore,
fromStart = it.reference == AlarmReference.START,
)
}
/**
* Every occurrence of [row] inside the expansion window, with any
* `RECURRENCE-ID` override substituted for the occurrence it replaces.
*
* A non-recurring task is its own single occurrence and carries a null
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
*/
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
val window = ExpansionWindow(
from = now - WINDOW_BACK,
until = now + WINDOW_FORWARD,
pivot = now,
)
val anchors = RecurrenceExpander.expand(spec, window)
if (anchors.isEmpty()) return emptyList()
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
val byAnchor = overrides.associateBy { it.recurrenceId }
return anchors.mapIndexedNotNull { index, anchor ->
val override = byAnchor[anchor]
if (override != null) {
RoomTaskMapper.task(
row = row.copy(task = override),
occurrenceStart = anchor,
start = override.dtstart,
due = override.due,
distanceFromCurrent = distances[index],
)
} else {
val (start, due) = occurrenceTimes(row.task, anchor)
RoomTaskMapper.task(
row = row,
occurrenceStart = anchor,
start = start,
due = due,
distanceFromCurrent = distances[index],
)
}
}
}
/**
* One occurrence's resolved start and due. A timed series keeps each
* occurrence's duration; a due-anchored one has no start to offset from, so
* the anchor *is* the due date.
*/
private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
if (master.dtstart == null) return null to anchor
val length = master.due?.let { it - master.dtstart }
return anchor to length?.let { anchor + it }
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone())
return tasks.insert(entity)
}
override fun updateTask(taskId: Long, form: TaskForm) {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
// ⚠️ An override's list and parent are the master's, the rule
// [updateInstance] states and this path has to keep. An overridden
// occurrence maps with `isRecurring = false`, so the repository routes
// it here and the edit screen offers its list picker — and a row that
// took `list_id = B` while its `master_id` stayed in list A is invisible
// in both (the task query skips non-null `master_id`, and the override
// query finds no master in B) while still uploading as part of A's
// resource.
tasks.update(
if (current.masterId == null) {
edited
} else {
edited.copy(listId = current.listId, parentId = current.parentId)
},
)
}
/**
* Writes one occurrence as a `RECURRENCE-ID` override — RFC 5545's model, and
* what every other CalDAV client expects to receive. The dmfs provider
* detached the occurrence into a brand-new task with its own UID instead,
* which is the model least compatible with sync; the override shares its
* master's UID, which is exactly what makes it an override.
*/
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
val now = clock.now()
val existing = tasks.override(taskId, occurrenceStart)
if (existing != null) {
// The same rule the fork below applies: the list and parent are the
// master's, whatever the form was carrying.
val edited = TaskFormWriter.apply(existing, form, now, zone())
tasks.update(edited.copy(listId = master.listId, parentId = master.parentId))
return
}
val (start, due) = occurrenceTimes(master, occurrenceStart)
val fork = TaskFormWriter.apply(
newOverride(master, taskId, occurrenceStart, start, due),
form,
now,
zone(),
)
// The list and parent come from the master: moving one occurrence between
// lists or parents is not something the override model expresses.
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) }
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
alarms.replaceForTask(
taskId,
minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) },
)
}
override fun setCompleted(taskId: Long, completed: Boolean) {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
}
/**
* Ticking one occurrence forks a `RECURRENCE-ID` override carrying the
* completion — the same model [updateInstance] writes. Writing the status onto
* the master instead would close the series: the master is what
* [TaskDao.tasks] filters on, so every occurrence, past and future, would
* leave every list at once.
*/
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete instance $taskId")
// Not a series master — an override, or a plain task the caller handed an
// anchor for. Either way this row *is* the occurrence.
if (master.recurrenceSpec() == null) return setCompleted(taskId, completed)
val now = clock.now()
tasks.override(taskId, occurrenceStart)?.let {
tasks.update(TaskFormWriter.completed(it, completed, now))
return
}
val (start, due) = occurrenceTimes(master, occurrenceStart)
val fork = TaskFormWriter.completed(newOverride(master, taskId, occurrenceStart, start, due), completed, now)
val id = tasks.insert(fork)
alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) }
}
/**
* A blank override row for one occurrence of [master]: same UID (that is what
* makes it an override rather than a separate task), the series fields
* stripped, and no `href`/`etag` because the server has never seen it.
*/
private fun newOverride(
master: TaskEntity,
masterId: Long,
occurrenceStart: Instant,
start: Instant?,
due: Instant?,
): TaskEntity = master.copy(
id = 0,
masterId = masterId,
recurrenceId = occurrenceStart,
dtstart = start,
due = due,
rrule = null,
rdate = null,
exdate = null,
href = null,
etag = null,
)
/**
* Hard delete for a row no server knows about, tombstone for one that is
* still owed to a collection — and an `EXDATE` when what is being deleted is
* a single occurrence of a series.
*
* ⚠️ Removing an 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 on every other client. The exception has to be written
* down on the master, which is also what keeps the occurrence hidden in a
* device-only list, where there is no tombstone to hide it.
*/
override fun deleteTask(taskId: Long) {
val current = tasks.entity(taskId) ?: return
val listAccount = lists.entity(current.listId)?.accountId
val masterId = current.masterId
val occurrence = current.recurrenceId
if (masterId != null && occurrence != null) {
tasks.entity(masterId)?.let { master ->
tasks.update(
TaskFormWriter.excepting(
master,
occurrence,
clock.now(),
zone(),
// A device-only list owes nobody a PUT.
dirty = listAccount != null,
),
)
}
// The master's EXDATE *is* the deletion, so the override has no job
// left. Leaving a tombstone behind would upload a body with the
// occurrence merely absent, which says the opposite.
tasks.delete(taskId)
return
}
if (listAccount == null) tasks.delete(taskId) else tasks.markDeleted(taskId, clock.now())
}
override fun createLocalList(name: String, color: Int): Long =
lists.insert(TaskListEntity(name = name.trim(), color = color))
/**
* ⚠️ Device-only lists. An account-backed collection is renamed through
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], which sends
* the PROPPATCH first and writes here only once the server has taken it.
*
* This used to set `is_dirty` for an account list instead, on the theory
* that a later sync would push it. Nothing ever read that flag: the rename
* never left the phone, and the next run that re-read the collection's
* `displayname` quietly put the old name back.
*/
override fun updateList(listId: Long, name: String, color: Int) {
val current = lists.entity(listId) ?: throw TaskWriteFailedException("update list $listId")
lists.update(current.copy(name = name.trim(), color = color))
}
/** `tasks.list_id` is `ON DELETE CASCADE`, so the list's tasks go with it. */
override fun deleteList(listId: Long) = lists.delete(listId)
// --- observation ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
override fun onInvalidated(tables: Set<String>) = onChange()
}
database.invalidationTracker.addObserver(observer)
return AutoCloseable { database.invalidationTracker.removeObserver(observer) }
}
private fun zone(): String = ZoneId.systemDefault().id
}
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
/** Reads and writes over `task_alarms`. */
@Dao
interface TaskAlarmDao {
/** Every reminder in the store, for one scheduler pass. */
@Query("SELECT * FROM task_alarms")
fun all(): List<TaskAlarmEntity>
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId")
fun forTask(taskId: Long): List<TaskAlarmEntity>
@Insert
fun insert(alarm: TaskAlarmEntity): Long
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
fun deleteForTask(taskId: Long): Int
/**
* Set the task's only reminder, or clear it with `null`. The row that lands is
* always a new one — the id is cleared so an alarm lifted off another task
* (forking an occurrence copies the master's) inserts instead of colliding.
*/
@Transaction
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) {
deleteForTask(taskId)
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
}
}
@@ -0,0 +1,190 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* Reads and writes over `tasks`.
*
* Reads split masters from overrides on purpose: [tasks] returns the rows a
* recurrence expander expands (a non-recurring task is its own single
* occurrence), and [allOverrides] / [overrides] return the
* `RECURRENCE-ID` rows that replace individual occurrences. Nothing here
* expands anything — that is phase 2's job, in Kotlin.
*/
@Dao
interface TaskDao {
// --- reads ----------------------------------------------------------------
/**
* Master (and non-recurring) rows, optionally narrowed to one list. Closed
* tasks — `COMPLETED` and `CANCELLED` — are excluded unless
* [includeCompleted]; tombstones always are.
*/
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color,
a.display_name AS account_display_name
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
WHERE t.is_deleted = 0
AND t.master_id IS NULL
AND (:listId IS NULL OR t.list_id = :listId)
AND (:includeCompleted = 1 OR t.status NOT IN (2, 3))
"""
)
fun tasks(listId: Long?, includeCompleted: Boolean): List<TaskRow>
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color,
a.display_name AS account_display_name
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
WHERE t.id = :taskId AND t.is_deleted = 0
"""
)
fun task(taskId: Long): TaskRow?
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color,
a.display_name AS account_display_name
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
WHERE t.parent_id = :parentTaskId AND t.is_deleted = 0 AND t.master_id IS NULL
"""
)
fun subtasks(parentTaskId: Long): List<TaskRow>
@Query("SELECT * FROM tasks WHERE id = :taskId")
fun entity(taskId: Long): TaskEntity?
/**
* Every override, optionally narrowed to one list — read alongside [tasks] so
* expansion can replace the occurrences they override in one pass rather than
* querying per series.
*/
@Query(
"""
SELECT * FROM tasks
WHERE master_id IS NOT NULL AND is_deleted = 0
AND (:listId IS NULL OR list_id = :listId)
"""
)
fun allOverrides(listId: Long?): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0")
fun overrides(masterId: Long): List<TaskEntity>
@Query(
"SELECT * FROM tasks WHERE master_id = :masterId AND recurrence_id IS :recurrenceId AND is_deleted = 0"
)
fun override(masterId: Long, recurrenceId: Instant?): TaskEntity?
@Query("SELECT * FROM tasks WHERE list_id = :listId AND uid = :uid AND recurrence_id IS :recurrenceId")
fun byUid(listId: Long, uid: String, recurrenceId: Instant? = null): TaskEntity?
/** Masters only, tombstones excluded — what an `.ics` export writes. */
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
fun exportTasks(listId: Long): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
fun dirty(): List<TaskEntity>
/**
* Every row in a list, tombstones included.
*
* Sync needs the tombstones: a row with `is_deleted = 1` is a DELETE the
* server is still owed, and a query that filters them out is a client that
* resurrects deleted tasks on the next download.
*/
@Query("SELECT * FROM tasks WHERE list_id = :listId")
fun allIn(listId: Long): List<TaskEntity>
// --- writes ---------------------------------------------------------------
@Insert
fun insert(task: TaskEntity): Long
@Update
fun update(task: TaskEntity): Int
@Query(
"""
UPDATE tasks SET status = :status, percent_complete = :percentComplete,
completed_at = :completedAt, last_modified = :lastModified, is_dirty = :dirty
WHERE id = :taskId
"""
)
fun setCompletion(
taskId: Long,
status: TaskStatus,
percentComplete: Int?,
completedAt: Instant?,
lastModified: Instant?,
dirty: Boolean,
): Int
/** Hard delete. Used when the row was never on a server. */
@Query("DELETE FROM tasks WHERE id = :taskId")
fun delete(taskId: Long): Int
/**
* Tombstone, for a row a server still knows about — and for the whole series
* when [taskId] is a master.
*
* ⚠️ `master_id` cascades on *delete*, and a tombstone deletes nothing, so
* tombstoning the master alone left its overrides live. The resource then
* read as partly deleted: `LocalResource.isDeleted` is `rows.all { … }`, so
* it went to the upload phase instead of the delete phase, no DELETE was
* ever sent, and the master ended `is_deleted = 1, is_dirty = 0` with a
* matching ETag — beyond the reach of every phase. Other clients kept the
* task; here it was gone.
*/
@Query(
"""
UPDATE tasks SET is_deleted = 1, is_dirty = 1, last_modified = :at
WHERE id = :taskId OR master_id = :taskId
"""
)
fun markDeleted(taskId: Long, at: Instant?): Int
/**
* Re-points a subtask at its parent.
*
* Separate from [update] because `RELATED-TO` arrives as a UID and can only
* be resolved to a row id once every row of the sync is present — and
* resolving it must not disturb `is_dirty`, which a whole-entity update
* would.
*/
@Query("UPDATE tasks SET parent_id = :parentId WHERE id = :taskId")
fun setParent(taskId: Long, parentId: Long?): Int
/** Hard delete of a whole resource's rows — a master and its overrides. */
@Query("DELETE FROM tasks WHERE id IN (:taskIds)")
fun deleteAll(taskIds: List<Long>): Int
/**
* Records where a resource lives and which version we hold.
*
* Applied to a resource's rows at once, because a master and its
* `RECURRENCE-ID` overrides share one href and one ETag — they are one file
* on the server. A tombstoned override is excluded by the caller: it was
* left out of the body, so the caller deletes the row instead.
*
* `is_dirty` is cleared **explicitly** rather than left to a default: a
* downstream write that leaves the flag set uploads what was just
* downloaded, which is how a sync loop starts.
*/
@Query("UPDATE tasks SET href = :href, etag = :etag, is_dirty = 0 WHERE id IN (:taskIds)")
fun markSynced(taskIds: List<Long>, href: String?, etag: String?): Int
}
@@ -0,0 +1,148 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Instant
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
internal fun Instant.forAllDay(allDay: Boolean): Instant =
if (!allDay) this
else Instant.fromEpochMilliseconds(
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
)
/**
* Applies a [TaskForm] to a [TaskEntity]. Pure, so the semantics below are
* testable on the JVM without a database.
*
* This is the Room counterpart of
* [de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper], which stays for
* External mode. It is a separate object rather than a shared one because most
* of what that mapper does is work around the provider — clearing `DURATION`
* because the provider validates a merged row, writing `STATUS` both ways
* because the provider auto-completes at 100% but will not reopen below it. Here
* those rules are ours to state directly.
*/
object TaskFormWriter {
/** A brand-new task. [uid] is minted by the caller and never null. */
fun newTask(form: TaskForm, uid: String, now: Instant, tzId: String): TaskEntity =
apply(
TaskEntity(listId = form.listId, uid = uid, createdAt = now),
form,
now,
tzId,
)
/** [current] with [form] applied. Identity, recurrence and sync columns are left alone. */
fun apply(current: TaskEntity, form: TaskForm, now: Instant, tzId: String): TaskEntity {
val percent = form.percentComplete?.coerceIn(0, 100)
val timed = !form.isAllDay && (form.start != null || form.due != null)
return current.copy(
listId = form.listId,
title = form.title.trim(),
description = form.description?.trim()?.ifBlank { null },
priority = form.priority.toICal(),
percentComplete = percent,
status = statusFor(percent, current.status),
completedAt = completedAtFor(percent, current, now),
dtstart = form.start?.forAllDay(form.isAllDay),
due = form.due?.forAllDay(form.isAllDay),
// DUE and DURATION are mutually exclusive (RFC 5545 §3.6.2).
duration = null,
isAllDay = form.isAllDay,
timezone = if (timed) tzId else null,
parentId = form.parentId?.takeIf { it > 0 },
lastModified = now,
isDirty = true,
)
}
/** The completion triple, for the standalone complete toggle. */
fun completed(current: TaskEntity, completed: Boolean, now: Instant): TaskEntity = current.copy(
status = if (completed) TaskStatus.COMPLETED else TaskStatus.NEEDS_ACTION,
percentComplete = if (completed) 100 else null,
completedAt = if (completed) now else null,
lastModified = now,
isDirty = true,
)
/**
* [master] with [occurrence] added to its `EXDATE` — how a single occurrence
* of a series is deleted.
*
* ⚠️ Removing the override row is not a deletion. RFC 5545 reads an absent
* `RECURRENCE-ID` component as "not overridden", so the master's `RRULE`
* regenerates that instance; only an `EXDATE` takes it out of the set.
*
* @param floatingZone what a series with no `TZID` means by its wall times —
* the same zone [de.jeanlucmakiola.agendula.domain.recurrence
* .RecurrenceExpander] resolves it in.
*/
fun excepting(
master: TaskEntity,
occurrence: Instant,
now: Instant,
floatingZone: String,
dirty: Boolean,
): TaskEntity {
val existing = master.exdate?.trim()?.ifEmpty { null }
val value = exceptionValue(master, occurrence, existing, floatingZone)
if (existing != null && value in existing.split(',').map(String::trim)) return master
return master.copy(
exdate = if (existing == null) value else "$existing,$value",
lastModified = now,
isDirty = master.isDirty || dirty,
)
}
/**
* ⚠️ Written in the shape the list is already in, not in ours.
*
* `EXDATE` is stored as the bare property value, so a list is a run of one
* value type — and lib-recur parses the whole list or none of it. Appending
* a UTC `…Z` to a run of `DATE`s would drop every exception the series
* already had, this one included.
*/
private fun exceptionValue(
master: TaskEntity,
occurrence: Instant,
existing: String?,
floatingZone: String,
): String {
val sample = existing?.substringBefore(',')?.trim()
return when {
sample == null ->
if (master.isAllDay) ICalValues.formatDate(occurrence)
else ICalValues.formatDateTime(occurrence, null)
!sample.contains('T') -> ICalValues.formatDate(occurrence)
sample.endsWith("Z") -> ICalValues.formatDateTime(occurrence, null)
// Local wall time: the TZID parameter was dropped on the way in, so
// the series' own zone is what those values mean.
else -> ICalValues.formatDateTime(occurrence, master.timezone ?: floatingZone)
}
}
/**
* A form carrying no percent leaves status alone — the standalone toggle stays
* authoritative. Otherwise progress and status move together in both
* directions, which is the asymmetry the provider never had: it auto-completed
* at 100% but would not reopen below it, stranding a task "done at 75%".
*/
private fun statusFor(percent: Int?, current: TaskStatus): TaskStatus = when {
percent == null -> current
percent >= 100 -> TaskStatus.COMPLETED
percent > 0 -> TaskStatus.IN_PROCESS
else -> TaskStatus.NEEDS_ACTION
}
private fun completedAtFor(percent: Int?, current: TaskEntity, now: Instant): Instant? = when {
percent == null -> current.completedAt
percent >= 100 -> current.completedAt ?: now
else -> null
}
}
@@ -0,0 +1,110 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
/** Reads and writes over `task_lists`. Synchronous, like the seam above it. */
@Dao
interface TaskListDao {
@Query(
"""
SELECT l.*, a.display_name AS account_display_name
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
ORDER BY a.display_name, l.sort_order, l.name
"""
)
fun lists(): List<TaskListRow>
@Query(
"""
SELECT l.*, a.display_name AS account_display_name
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
WHERE l.id = :listId
"""
)
fun list(listId: Long): TaskListRow?
@Query("SELECT * FROM task_lists WHERE id = :listId")
fun entity(listId: Long): TaskListEntity?
@Query("SELECT COUNT(*) FROM task_lists WHERE id = :listId")
fun exists(listId: Long): Int
@Insert
fun insert(list: TaskListEntity): Long
@Update
fun update(list: TaskListEntity)
@Query("UPDATE task_lists SET is_visible = :visible WHERE id = :listId")
fun setVisible(listId: Long, visible: Boolean)
/** Attach a list to an account, or detach it with `null`. */
@Query("UPDATE task_lists SET account_id = :accountId WHERE id = :listId")
fun setAccount(listId: Long, accountId: Long?)
@Query("DELETE FROM task_lists WHERE id = :listId")
fun delete(listId: Long)
/**
* Every list belonging to [accountId].
*
* Only for rolling back a half-created account. Removing a *working* account
* must leave its lists alone — `account_id` is `ON DELETE SET NULL` for that
* reason, and deleting an account is not an instruction to destroy the tasks
* it held.
*/
@Query("DELETE FROM task_lists WHERE account_id = :accountId")
fun deleteForAccount(accountId: Long)
/**
* Refreshes just the ACL flag.
*
* Not [update]: sync holds a snapshot of the row from before it started, and
* writing that whole entity back would revert a rename or recolour the user
* made while it ran.
*/
@Query("UPDATE task_lists SET is_read_only = :readOnly WHERE id = :listId")
fun setReadOnly(listId: Long, readOnly: Boolean)
/**
* Stores the RFC 6578 cursor.
*
* ⚠️ Written **after** a page's bodies are applied, never before — the RFC's
* own Appendix B has this backwards, and under WorkManager process death
* mid-sync is routine rather than exotic. A token stored ahead of its bodies
* is a permanent hole in the collection.
*
* One column and one statement, so there is nothing to be half-written.
*/
@Query("UPDATE task_lists SET sync_token = :token WHERE id = :listId")
fun setSyncToken(listId: Long, token: String?)
/**
* Lists a collection URL may be pointed at instead of inserting a new row:
* the ones [accountId] already holds, and the ones a previous removal left
* detached.
*
* ⚠️ Both halves matter, and each is a duplicate bug on its own.
* `remove()` deliberately leaves lists as device-only ones, so re-adding the
* account must re-attach them or the user gets their old "Personal" full of
* tasks beside a freshly synced "Personal" holding the same tasks from the
* server. And re-*authenticating* walks the same picker, so a lookup that
* only saw detached rows would insert a second copy of every list the
* account already syncs — there is no unique index on `href` to catch it.
*/
@Query(
"""
SELECT * FROM task_lists
WHERE href IS NOT NULL AND (account_id IS NULL OR account_id = :accountId)
"""
)
fun attachable(accountId: Long): List<TaskListEntity>
/** The synced collections of one account, in the order sync walks them. */
@Query("SELECT * FROM task_lists WHERE account_id = :accountId AND is_synced = 1 ORDER BY id")
fun syncedForAccount(accountId: Long): List<TaskListEntity>
}
@@ -0,0 +1,34 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
/**
* Agendula's own task store (`docs/OWN-STORE.md`). Four tables, designed from
* what the app actually reads and writes plus RFC 5545's `VTODO`.
*
* Schemas are exported to `app/schemas/` and committed, so a future version can
* be migration-tested against this one.
*/
@Database(
entities = [
AccountEntity::class,
TaskListEntity::class,
TaskEntity::class,
TaskAlarmEntity::class,
],
version = 1,
exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class TasksDatabase : RoomDatabase() {
abstract fun taskLists(): TaskListDao
abstract fun tasks(): TaskDao
abstract fun alarms(): TaskAlarmDao
abstract fun accounts(): AccountDao
companion object {
const val NAME = "agendula-tasks.db"
}
}
@@ -0,0 +1,277 @@
package de.jeanlucmakiola.agendula.data.tasks.transfer
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import de.jeanlucmakiola.agendula.data.di.ExternalStore
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.toICal
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/** How much one copy moved. */
data class TransferCounts(val lists: Int, val tasks: Int, val alarms: Int)
/** The outcome of [ExternalImport.run]. */
sealed interface TransferResult {
/** The external store holds no list to copy — nothing was written. */
data object NothingToCopy : TransferResult
data class Copied(val counts: TransferCounts) : TransferResult
/** Nothing landed: the transaction rolled back and the source is untouched. */
data class Failed(val cause: Throwable) : TransferResult
}
/**
* Copies an external provider's tasks (OpenTasks, tasks.org) into Agendula's own
* Room store, once, when the user asks for it in Settings → Storage.
*
* This is the upgrade path 1.0.0 needs and [de.jeanlucmakiola.agendula.data
* .tasks.legacy.OneShotImport] does not provide: that one moves a *bundled dmfs
* provider's* SQLite file, which no released version ever shipped, so every
* existing install's tasks are in a third-party provider instead. Without this
* the only way onto the new store is to retype everything by hand.
*
* **A copy, not a sync, and deliberately one-directional.** The source is left
* exactly as it is — whatever syncs it (DAVx5 and friends) keeps doing so, and
* the two sets of rows drift apart from the moment this finishes. The reverse
* direction is not offered: 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**, because the read seam is
* [TasksDataSource.exportTasks] and that is shaped for iCalendar output:
* per-occurrence `RECURRENCE-ID` overrides (a series arrives as its master plus
* its rule, so an edited single occurrence reverts to the series' own values),
* `EXDATE`, `CLASS`, `DURATION`, the per-task timezone, and a task's exact
* `PRIORITY` digit — [de.jeanlucmakiola.agendula.domain.Priority] buckets 14 as
* HIGH, so a `PRIORITY:3` lands as `1`. Nothing the app itself displays is lost.
*
* Task `uid`s *are* preserved, which is what lets these rows be re-attached to a
* CalDAV collection once sync lands rather than duplicating server-side.
*/
@Singleton
class ExternalImport @Inject constructor(
@ExternalStore private val external: Provider<TasksDataSource>,
private val resolver: ProviderResolver,
private val database: TasksDatabase,
private val dataStore: DataStore<Preferences>,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Whether a copy has already succeeded. The UI uses this to stop offering the
* action, because a second run would duplicate every row: the rows it writes
* are ordinary tasks afterwards, indistinguishable from ones typed by hand, so
* there is nothing to reconcile a re-run against.
*/
val hasRun: Flow<Boolean> = dataStore.data.map { it[TRANSFER_DONE] ?: false }
/**
* What a copy would move, for the confirmation to name real numbers — or
* `null` when there is no readable external provider to copy from.
*
* Counts masters, the same rows [run] writes, so the number the user agrees to
* is the number they get.
*/
suspend fun preview(): TransferCounts? = withContext(io) {
val provider = resolver.resolveExternal() ?: return@withContext null
if (!resolver.hasPermission(provider)) return@withContext null
runCatching {
val source = external.get()
val lists = source.taskLists()
val tasks = lists.sumOf { source.exportTasks(it.id).size }
TransferCounts(lists = lists.size, tasks = tasks, alarms = source.alarms().size)
}.getOrNull()
}
/**
* Reads the external store, then writes everything 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.
*
* The flag is written only after the transaction commits. A crash in between
* leaves the rows in place and the action still on offer, which duplicates on
* a second run — the lesser of the two evils, since the alternative is
* claiming a copy that never happened.
*/
suspend fun run(): TransferResult = withContext(io) {
val snapshot = runCatching { read() }
.getOrElse { return@withContext TransferResult.Failed(it) }
if (snapshot.lists.isEmpty()) return@withContext TransferResult.NothingToCopy
val counts = runCatching {
database.runInTransaction<TransferCounts> {
val baseline = tableCounts()
val written = write(snapshot)
verify(written, baseline)
written
}
}.getOrElse { return@withContext TransferResult.Failed(it) }
markDone()
TransferResult.Copied(counts)
}
// --- reading the external provider ----------------------------------------
private fun read(): Snapshot {
val source = external.get()
val lists = source.taskLists()
return Snapshot(
lists = lists,
tasksByList = lists.associate { it.id to source.exportTasks(it.id) },
alarms = source.alarms(),
)
}
// --- writing into Room ----------------------------------------------------
/**
* Provider row ids are the source's own, and Room mints its own on insert, so
* `parentId` is remapped through the ids the inserts hand back. Tasks go in
* with their parent cleared and a second pass sets it, because a parent may
* sort after its child.
*
* Every list arrives as **device-only** (`account_id IS NULL`), including one
* that sat under a CalDAV account in the provider: the account belongs to the
* sync app, not to us, and claiming it here would suggest Agendula syncs it.
*/
private fun write(snapshot: Snapshot): TransferCounts {
val listDao = database.taskLists()
val taskDao = database.tasks()
val alarmDao = database.alarms()
val listIds = snapshot.lists.associate { list ->
list.id to listDao.insert(
TaskListEntity(
name = list.name,
color = list.color,
accountId = null,
isVisible = list.isVisible,
isSynced = false,
owner = list.owner,
),
)
}
val taskIds = mutableMapOf<Long, Long>()
val inserted = mutableListOf<Pair<ExportTask, TaskEntity>>()
// A `RECURRENCE-ID` override reaches us as another master-shaped row
// sharing its series' UID, which the unique index would reject and take
// the whole copy down with it. A fresh uid costs that row nothing it
// still has.
val seen = mutableSetOf<Pair<Long, String>>()
for ((sourceListId, tasks) in snapshot.tasksByList) {
val listId = listIds[sourceListId] ?: continue
for (task in tasks) {
val uid = task.uid?.takeIf { seen.add(listId to it) } ?: UUID.randomUUID().toString()
val entity = task.toEntity(listId, uid)
val newId = taskDao.insert(entity)
taskIds[task.taskId] = newId
inserted += task to entity.copy(id = newId)
}
}
for ((task, entity) in inserted) {
val parentId = task.parentId?.let(taskIds::get) ?: continue
taskDao.update(entity.copy(parentId = parentId))
}
var alarmCount = 0
for ((sourceTaskId, reminder) in snapshot.alarms) {
val taskId = taskIds[sourceTaskId] ?: continue
alarmDao.insert(reminder.toEntity(taskId))
alarmCount++
}
return TransferCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount)
}
private fun ExportTask.toEntity(listId: Long, uid: String) = TaskEntity(
listId = listId,
uid = uid,
title = title,
description = description,
location = location,
url = url,
status = status,
percentComplete = percentComplete,
completedAt = completedAt,
priority = priority.toICal(),
dtstart = start,
due = due,
isAllDay = isAllDay,
rrule = rrule,
rdate = rdate,
createdAt = created,
lastModified = lastModified,
)
private fun TaskReminder.toEntity(taskId: Long) = TaskAlarmEntity(
taskId = taskId,
minutesBefore = minutesBefore,
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
)
private fun verify(written: TransferCounts, before: TransferCounts) {
val after = tableCounts()
check(after.lists - before.lists == written.lists) {
"list count mismatch: ${after.lists - before.lists} != ${written.lists}"
}
check(after.tasks - before.tasks == written.tasks) {
"task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}"
}
check(after.alarms - before.alarms == written.alarms) {
"alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}"
}
}
private fun tableCounts() = TransferCounts(
lists = count("task_lists"),
tasks = count("tasks"),
alarms = count("task_alarms"),
)
private fun count(table: String): Int =
database.query("SELECT COUNT(*) FROM $table", null).use {
if (it.moveToFirst()) it.getInt(0) else 0
}
private suspend fun markDone() {
dataStore.edit { it[TRANSFER_DONE] = true }
}
/** Clears the guard so the action is offered again. Test and support hook. */
suspend fun clearCompletion() {
dataStore.edit { it.remove(TRANSFER_DONE) }
}
private class Snapshot(
val lists: List<TaskList>,
val tasksByList: Map<Long, List<ExportTask>>,
val alarms: Map<Long, TaskReminder>,
)
private companion object {
val TRANSFER_DONE = booleanPreferencesKey("external_copy_done")
}
}
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.agendula.domain
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
/**
* All-day tasks are date-only in iCalendar. OpenTasks reads them back through
* `DateTime.toAllDay()`, which discards the time-of-day and resolves the
* remaining date against UTC — so the storage convention is **UTC midnight of
* the intended calendar date, with a null timezone**. Timed tasks, by contrast,
* are ordinary instants rendered in the device's zone.
*
* These two conventions disagree about which day a given instant is, which is
* why every all-day value needs an explicit conversion rather than a raw
* `Instant` passed straight through.
*/
/** UTC midnight of [date] — the storage form for an all-day value. */
fun allDayInstantOf(date: java.time.LocalDate): Instant =
Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli())
/**
* The calendar date this instant denotes: read in UTC for [allDay] values,
* in [zone] for timed ones.
*/
fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.atZone(if (allDay) ZoneOffset.UTC else zone)
.toLocalDate()
/**
* Move an instant across the two conventions when the all-day switch flips, so
* the day the user is looking at stays put. Without this, toggling all-day off
* turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00,
* in New York) — reading to the user as "the time reset itself".
*/
fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant =
if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone))
else Instant.fromEpochMilliseconds(
calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(),
)

Some files were not shown because too many files have changed in this diff Show More