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>
24 KiB
Agendula — storage and sync
⚠️ Partly superseded, 2026-08-13. The core decision below — vendor the dmfs provider in-tree as
:provider— has been reversed. Agendula builds its own Room store and deletes the vendored provider; External mode (OpenTasks, tasks.org) is unaffected and everything this document says about it still stands. SeeSTORAGE-DECISION.mdfor why andOWN-STORE.mdfor what replaces it. The permissions, distribution, storage-mode and dead-end sections below remain accurate; treat the "our own provider" sections as the historical record of a decision that was made, shipped, and then costed properly.
Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B = bundle OpenTasks" working notes, which are withdrawn (see Dead ends). This is the detailed companion to
ARCHITECTURE.md§7 and theProviderResolvercomments, and it redefines what Posture B means.Status, 2026-08-02: steps 1–3 are done — see Sequencing.
ARCHITECTURE.mdandROADMAP.mdhave had their follow-up pass and now match what shipped;PLAN.mdis the original design document and is left as the historical record. What is built, and what is still only described here, is marked step by step below.
The plan, in short
What we're building
- Our own provider. Vendor the Apache-2.0 dmfs task provider in-tree as
:provider, renamed to our own authority and permission namespace. Our database, our namespace — coexists with everything, replaces nothing. - Our own sync. An Agendula sync adapter, so remote storage never depends on another app's roadmap.
- The user picks the mode. Local-only · Synced · External provider.
- Least permission. Ask only for what the chosen mode needs, when it needs it.
- Kit-first. Anything that isn't task-domain goes to floret-kit.
In what order
| # | Step | Why now | Status |
|---|---|---|---|
| 1 | Merge fix/provider-interaction-review |
unmerged and rotting; touches the same permission flow as step 2 | ✅ done |
| 2 | Vendor :provider under our own authority |
the identity, done once — and it ships a complete local-first app | ✅ done |
| 3 | Export / backup | our data now lives only in our app's private storage | ✅ backend done; no UI yet |
| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ |
| 5 | Sync adapter | the 1.x arc; designed in SYNC.md, not yet built |
⬜ |
Everything below is the reasoning behind those choices, the alternatives that were rejected, and the constraints they have to survive.
The decision
Agendula gets its own identity all the way down — its own task database under its own authority, its own sync, and a storage mode the user picks. It does not adopt, replace, or impersonate another project's provider.
Four parts:
- Own DB, bundled in-process. Vendor the Apache-2.0 dmfs
opentasks-provideras an in-tree:providerGradle module, renamed to authorityde.jeanlucmakiola.agendula.taskswith permissionsde.jeanlucmakiola.agendula.permission.READ_TASKS/…WRITE_TASKS. Not a separate provider app; not a schema written from scratch. We keep the dmfsTaskContractshape — it's proven, it's what our whole data layer already speaks, and it's what every CalDAV engine already understands — we just own the namespace it lives in. - Own sync adapter, so remote storage never depends on another app's roadmap. Protocol coverage is deliberately open — separate discussion.
- The user chooses the backend: local-only, synced, or an external provider that's already on the device.
- Ask for only what the chosen mode actually needs, at the moment it needs it.
Plus a standing rule: anything that isn't task-domain goes to floret-kit.
The vocabulary, redefined
ARCHITECTURE.md §7 and ProviderResolver's KDoc used to describe Posture B as
"bundle OpenTasks and find org.dmfs.tasks first." Both now read as below:
- Posture A — front-end over an external provider (OpenTasks, tasks.org). Still fully supported; it stops being the default and becomes a user choice.
- Posture B — our own bundled provider under our own authority. Coexists with everything; replaces nothing.
The A/B seam itself is unchanged and still earns its keep: ProviderResolver is
the only thing that knows an authority, AndroidTasksDataSource the only thing
that touches a resolver. UI, ViewModels, domain and repository are untouched by
all of this.
Why not squat org.dmfs.tasks
The rejected plan was to bundle the provider under dmfs's own authority so DAVx5 would sync into it unwittingly. Reasons it's out, in order of how badly each one bites:
- It is a structural identity mismatch, and the resulting bug is invisible to
both sides. Content-provider authorities are how a sync engine finds a
provider, but Android account visibility is keyed by package name: since
API 26 an app only sees accounts whose authenticator has made them visible to
its package, and
GET_ACCOUNTSalone no longer suffices. A sync engine's allowlist would name the packageorg.dmfs.tasks, notde.jeanlucmakiola.agendula. So the bundled provider could find zero accounts — and the dmfs provider uses account enumeration to prune task lists whose account has gone away. The failure mode isn't "no sync", it's "our provider quietly purges synced lists." (Reasoned from the platform rules, not from having read DAVx5's source — but the class of bug is structural, and every future place anything keys on package rather than authority is a fresh instance of it.) An explicit integration under our own name makes this bug impossible by construction. - Play Store install-time landmine. Two apps cannot declare the same
authority (
INSTALL_FAILED_CONFLICTING_PROVIDER) or the same<permission>name (INSTALL_FAILED_DUPLICATE_PERMISSION, waived only for identical signing certs). Anyone with OpenTasks installed gets a failed install, surfacing as one-star reviews we can't usefully answer. - Migration data loss. "Uninstall OpenTasks first" takes its DB with it. CalDAV-synced tasks reconcile back; local-only tasks are simply gone, and OpenTasks has no export (dmfs/opentasks #170, #204, #71 — years-old, unimplemented; their wiki punts to a desktop client).
- Squatting another project's namespace doesn't scale. At low install counts nobody notices. At scale we'd be generating issues on dmfs's tracker that aren't dmfs's fault, and silently maintaining a schema fork under their name.
The :provider module
Source. dmfs opentasks-provider, Apache-2.0. Target the 1.4.2 source
(DB version 23 — the version that actually carries is_recurring; tasks.org's
fork is DB 22 and lacks it, which is why TaskMapper.task reads rrule/rdate
for recurrence detection rather than trusting the column).
Layout: in-tree module, not a git submodule. floret-kit is a submodule
because we co-develop it. This is a fork we will sync from upstream
approximately never, so in-tree is simpler for both stores and honest about what
it is. Ship a provider/PROVENANCE.md: upstream commit, and every change we
made.
License hygiene, on day one. The app is MIT, the provider is Apache-2.0 —
permissive into permissive, fine — but the module keeps its Apache-2.0 headers,
LICENSE, and NOTICE. Ten minutes now; embarrassing to retrofit once it's in
two store listings.
What we change:
- Authority →
de.jeanlucmakiola.agendula.tasks(it's already a string resource,opentasks_authority). - Permission names →
de.jeanlucmakiola.agendula.permission.*. These are hardcoded in the AAR manifest, which is the single clearest reason vendoring is mandatory rather than merely preferable — you cannot rename them in a prebuilt artifact withouttools:node surgery we'd rather not ship. - Drop
<uses-permission android:name="android.permission.GET_ACCOUNTS" />. We own our own accounts, so we don't need it — but note the provider's account-cleanup path is written assuming it, so this is a review-and-rework item, not a free deletion. Verify the provider's local-list/local-account path works with no account present at all; that's the entire local-only mode. - Drop the exported
BOOT_COMPLETED/TIME_SET/TIMEZONE_CHANGEDreceiver, or keep it deliberately and give it an explicitandroid:exported. The AAR is from thetargetSdk 29era; AGP hard-errors on a merged manifest with an intent-filtered component and no explicitexportedonce targetSdk ≥ 31, and we're on 36. Fixed at source instead of patched around. - Modernize the build: it ships
minSdk 21/targetSdk 29, Robolectric 3.5.1, JUnit 4.12. We'reminSdk 29/targetSdk 36and Play raises its target-API floor annually, so this isn't optional upkeep.
Our data now lives in our app's private storage. Uninstall means deletion. That single fact is what promotes export/backup from "nice to have" to a v1 feature — see Storage modes.
Storage modes — the user's choice
| Mode | Backing store | Sync | Needs |
|---|---|---|---|
| Local | our bundled provider | none | no permissions at all — same-uid provider access needs no grant |
| Synced | our bundled provider | our sync adapter | network + an account the user configures |
| External | OpenTasks / tasks.org | whatever that provider's engine does (DAVx5 …) | that provider's READ/WRITE_TASKS, granted at runtime |
Local and Synced are the same store — but ⚠️ switching on sync is a
migration, contrary to what this document said until 2026-08-13. The provider
enforces ACCOUNT_NAME and ACCOUNT_TYPE as write-once on a task list
(processors/lists/Validating.java:68-76, which throws), so a list created under
org.dmfs.account.LOCAL can never be re-pointed at a real account. Enabling sync
means creating new lists under the account and moving tasks into them. See
SYNC.md — it is a costed deliverable there, not a free consequence.
Resolver ordering — decided, and it went both ways as expected.
ProviderResolver now takes an explicit StorageMode from Settings when there
is one, and otherwise calls autoMode(). The auto rule turned out to be sharper
than "rank ours first whenever it's non-empty", and needs no database probe:
External if we already hold an external provider's runtime permission, otherwise Local.
That permission is dangerous-level, so it can only be there because an earlier version asked and the user agreed — which is exactly what "existing Posture A user" means. A fresh install holds nothing and gets local-first. ⬜ The Settings override the rule assumes is not built yet.
Note on the mode vocabulary. The code has two modes, not three:
StorageMode.LOCAL and StorageMode.EXTERNAL. As this document says two
paragraphs up, Synced is Local with an account attached — so it is derived
state, and giving it its own constant would imply switching sync on is a
migration when the whole point is that it isn't.
Export/backup is a v1 feature. ✅ Backend built (no UI yet). Not, as previously framed, a migration safety net for "uninstall OpenTasks" — that scenario no longer exists. It's data portability for Local-mode users, whose tasks otherwise exist in exactly one place with no second copy. On Play, where most users won't have a sync engine, that's the majority.
One .ics per list — a list is a CalDAV collection, and that's the unit other
clients understand — written through SAF to either a folder or a single zip.
Local tasks have no _uid (only a sync adapter may assign one), so the writer
synthesises a stable UID per task; without it a re-imported backup would
duplicate every task instead of matching it.
Sync — own adapter
Decided: Agendula ships its own sync. Not because DAVx5 is bad, but because depending on it makes one external maintainer's roadmap the gate on our core feature — the same shape of dependency the whole identity decision exists to escape.
The two precedents diverge and the choice between them is the whole point: tasks.org has its own authority and its own sync (sovereign); jtx Board has its own authority and depends on DAVx5 (and got added, though a working relationship with bitfire is part of that story). We're taking the tasks.org shape.
Play sharpens this. DAVx5 is a paid app on Google Play and free only on F-Droid — worth confirming, since it's load-bearing — which means most Play users will never have it. Lobbying bitfire is therefore an F-Droid-audience feature, not a sync strategy.
Still file the DAVx5 issue — cheap, non-blocking, real value for F-Droid users. And make it the strongest possible version of the ask: our provider is the dmfs provider with renamed strings, so it's byte-identical contract compliance and a small enum-shaped addition with near-zero ongoing maintenance for them. Say that explicitly. "Here's a change that can't break anything" lands very differently from "please support my app."
The design is now written out in SYNC.md — protocol coverage,
the account model, conflict resolution, the VTODO ↔ TaskContract mapper, and
where the DAV/iCalendar work lives. Two corrections to what this section
originally said, both verified 2026-08-13:
dav4jvmis MPL-2.0, not Apache-2.0. Still fine against our MIT (file-level copyleft), but it is ⚠️ JitPack-only, which collides with ourFAIL_ON_PROJECT_REPOS+google()/mavenCentral()policy and with the JitPack dead end below.SYNC.mdopen question 1.ical4androidis superseded bysynctools, which is GPLv3 — so it is unusable, and the open question below is closed. We write the mapper in-house.
Permissions — only what the mode needs
The manifest is static, so "request only what we need" is really two different mechanisms, and conflating them is how apps end up over-permissioned:
- Runtime (dangerous) permissions — genuinely stageable. Ask at the moment the feature is used, never up front.
- Install-time (normal) permissions — declared unconditionally; the only lever is not declaring them until the feature ships, and not letting a bundled dependency drag in ones we don't use.
| Permission | When |
|---|---|
| (none) for our own provider | same-uid access needs no grant — ProviderStatus.NEEDS_PERMISSION must never fire in Local/Synced mode |
org.dmfs.permission.*, org.tasks.permission.* |
requested only when the user selects External mode; declared always (static manifest) |
POST_NOTIFICATIONS |
when reminders are first enabled |
USE_EXACT_ALARM / SCHEDULE_EXACT_ALARM |
when exact due-time reminders are used. Note Play reviews USE_EXACT_ALARM and requires the app to be a calendar/alarm/task app — we qualify, but it needs a justification in the listing |
INTERNET |
don't declare it until sync ships |
GET_ACCOUNTS |
never — stripped from the vendored provider; our own account type doesn't need it to see its own accounts |
Work item: the permission gate … needs a bypass for our own provider.
✅ Done. ProviderResolver.hasPermission short-circuits to true when
TaskProvider.isOwn, so ProviderStatus.NEEDS_PERMISSION cannot fire in
Local/Synced mode, and PermissionViewModel never offers our own permissions to
the request launcher. Covered by ProviderResolverTest.
What lands in floret-kit
Standing rule, matching the kit's own thesis (share the mechanics, keep the look — the kit never knows about a specific app's domain): if it isn't task-domain, it goes to the kit.
| Candidate | Kit module | Note |
|---|---|---|
ContentProvider seam — ColumnReader, failures, observer→Flow |
core-provider |
already on the kit's deferred list, blocked on migrating Calendula to the name-based reader. Bundling our own provider is the forcing function that makes this worth doing. |
| Runtime-permission staging — request/state machine, rationale plumbing, "ask at point of use" | new, e.g. core-permissions |
pure mechanics, and Calendula has the identical problem |
| DAV client + iCalendar parse/serialize | new, e.g. core-dav |
the big one. Calendula is a calendar app; it needs the same primitives. Worth designing for two consumers from the start rather than extracting later |
| Export/backup plumbing — SAF, file writing, share-out | kit | the serialization of tasks is domain; the plumbing isn't. Built app-local for now (data/export/ExportWriter), on the kit's own principle of not extracting before a second consumer exists — the seam is in place, so moving it is a file move. ICalendarWriter stays app-local permanently: it's domain. |
| Sync-adapter/account scaffolding | kit, probably | the AbstractThreadedSyncAdapter + authenticator boilerplate is identical everywhere; the delta logic is domain |
Stays app-local: the vendored :provider module (task-specific, and
Apache-2.0 against the kit's MIT), TaskContract and the mappers, domain models
and smart lists, all screens, and the reminder scheduler (per the kit's
existing "not shared" call — Agendula pulls, Calendula pushes).
Distribution — F-Droid and Play from day one
Not on either yet; both are targets, so build for them rather than retrofitting.
- F-Droid requires from-source. The in-tree
:providermodule satisfies it; a JitPack artifact would not. floret-kit's composite build already keeps the kit from-source, and the reproducibility guard (vcsInfo { include = false }inapp/build.gradle.kts) is already in place. - Play requires a rising target-API floor, a data-safety declaration, and
justification for
USE_EXACT_ALARM. It also means dangerous permissions we don't use are a liability, not just clutter — which is most of whyGET_ACCOUNTSand the stray receiver come out of the vendored provider. - Parked, not solved: dual-distribution signing. F-Droid reproducible builds
verify against our signed APK; Play App Signing re-signs with Google's key.
Both can coexist, but it needs a deliberate pass before the first Play upload.
See
RELEASING.md.
Dead ends — do not revisit
-
One APK that detects at install time and adapts. Impossible.
<provider>authorities and<permission>declarations are frozen at build time and read by the OS at install; there is no install-time hook where our code runs. And unlike a component, a<permission>cannot be runtime-toggled — nosetComponentEnabledSettingequivalent. -
Dynamic feature modules to deliver the provider conditionally. Conditions are limited to hardware features / SDK / country — there is no "only if app X is absent" — and they require Play, so they're dead for F-Droid regardless.
-
frontend/standalonebuild flavors. Two flavors means twoapplicationIds (two listings, two signing lines, and switching costs a user their local data), or oneapplicationIdand they can't coexist in a repo anyway. Obsolete now that our provider coexists with everything instead of replacing anything. -
Maven Central for the provider.
org.dmfs:opentasks-provideris there — but only up to1.1.8.1(2016,jarpackaging, 3 versions). No DB 23. Verified. -
JitPack (
com.github.dmfs.opentasks:opentasks-provider:1.4.2). Has the right version, but it's a prebuilt artifact (fails F-Droid from-source), it can't have its hardcoded permission names renamed, and adding JitPack widens the dependency trust surface —settings.gradle.ktsis currentlygoogle()+mavenCentral()only, underFAIL_ON_PROJECT_REPOS. Still usable for a throwaway spike (a library string resource can be overridden from the app module, so the authority rename works), but not for anything we ship.⚠️ This one comes back. It is a dead end for the provider, where vendoring was mandatory anyway.
dav4jvmis JitPack-only, so the sync adapter has to answer the same question on its own terms — and F-Droid turns out not to be the obstacle (its inclusion policy trusts jitpack.io for freely-licensed artifacts); our own trust-surface policy is. SeeSYNC.mdopen question 1.
Sequencing
- Merge
fix/provider-interaction-review(47cf99a, currently unmerged intomain). It's blocking nothing and rotting, and it touches the exact permission flow step 2 changes. - Vendor
:providerunder our own authority and permission namespace, with the permission-gate bypass. This is the identity, done once, done right — and it ships a complete local-first app to both stores. - Export/backup. Now a v1 feature, not a migration hack.
- File the DAVx5 issue. Non-blocking, cheap, serves F-Droid users.
- Sync adapter. The 1.x arc; design discussion pending.
Scope honesty: the withdrawn notes costed this at "2–4 days shippable, +1 week for F-Droid." Steps 2–5 are a substantially larger program than that, and the roadmap should say so rather than inheriting the old estimate.
Open questions
-
Sync protocol coverage, account model, conflict resolution — ✅ taken up in
SYNC.md. The account model is answered there (AccountManager, whichPROVENANCE.mdchange 1 already assumes); what stays open moves to that document's own list — dav4jvm's distribution, conflict policy, External mode's future, and recurring-task completion. -
Resolver ordering — ✅ decided, see Storage modes. The mode-selection UX is still open:
autoMode()picks a default, but the Settings override it assumes does not exist yet. -
Does the vendored provider work with no account at all? ✅ Answered, with a caveat about how it was answered.
By construction:
cleanUpListsexempts local lists explicitly (upstream's own rule), and our rework restricts pruning to account types this package authenticates — currently none — so nothing can be pruned at all.ProviderAccountCleanupTestcreates a local list and a task in it with zero accounts present and reads both back.⚠️ But that test is Robolectric, and it skips on ARM64, where Robolectric has no SQLite backend in either mode. It runs on x86_64 CI. It is not a substitute for a device, and this remains on the device-verification list.
-
✅ Closed:ical4androidlicensing vs our MIT.ical4androidis superseded bysynctools, which is GPLv3, so it is out — as iscert4android. The export path never depended on it anyway (ICalendarWriteris our own ~200 lines). The sync adapter's iCalendar parsing goes through an in-house mapper overical4j/biweekly; seeSYNC.md. -
jtx Board as an additional External-mode candidate — richer contract, later. (
PLAN.mddecision #3, still open.) -
The vendored provider's timezone-change behaviour — upstream's receiver has a comment describing
breaks that were never written. We preserved the observed behaviour and wrote it out explicitly; which of code or comment is the bug wants a device to settle. Change 3 inprovider/PROVENANCE.md.
Related
The provider-interaction review on fix/provider-interaction-review fixed,
among others: recurrence-aware editing (routes through the instances URI),
all-day UTC handling, the DUE/DURATION collision, per-task reminders (Alarm
property rows), and flow-recovery robustness. That makes ROADMAP.md open
decision #5 ("recurrence-aware editing out of scope for v1") stale.