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:
1.**Own DB, bundled in-process.** Vendor the Apache-2.0 dmfs
`opentasks-provider` as an in-tree `:provider` Gradle module, renamed to
authority `de.jeanlucmakiola.agendula.tasks` with permissions
`de.jeanlucmakiola.agendula.permission.READ_TASKS` / `…WRITE_TASKS`. Not a
separate provider *app*; not a schema written from scratch. We keep the dmfs
`TaskContract` shape — 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.
2.**Own sync adapter**, so remote storage never depends on another app's
roadmap. Protocol coverage is deliberately open — separate discussion.
3.**The user chooses the backend**: local-only, synced, or an external provider
that's already on the device.
4.**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 still describe Posture B as
"bundle OpenTasks and find `org.dmfs.tasks` first." Replace with:
- **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:
1. **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_ACCOUNTS` alone no longer suffices. A sync engine's
allowlist would name the package `org.dmfs.tasks`, not
`de.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.
2.**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.
3.**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).
4.**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 without `tools:` 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_CHANGED` receiver,
or keep it deliberately and give it an explicit `android:exported`. The AAR is
from the `targetSdk 29` era; AGP hard-errors on a merged manifest with an
intent-filtered component and no explicit `exported` once 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're `minSdk 29` / `targetSdk 36` and 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-users-choice).
---
## 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 — Synced is Local with an account attached,
so switching on sync is not a migration.
**Resolver ordering needs deciding.** Today `ProviderResolver.CANDIDATES` is a
fixed priority list and the first hit wins. Once we bundle our own provider,
"first hit" is the wrong rule: someone who used Agendula locally and *later*
installs DAVx5 + OpenTasks would see an external candidate outrank the provider
that actually holds their data. Options: rank ours first whenever it's
non-empty, or make the mode an explicit Settings choice (it's user-visible
either way, so probably both — auto-pick a sane default, let Settings override).
**Export/backup is a v1 feature.** 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.
---
## 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."
**Open — the next discussion.** Protocol coverage ("support as much as
possible"), the account model, conflict resolution, and where the DAV/iCalendar
work lives. One constraint to settle early: we're MIT; `dav4jvm` is Apache-2.0
and fine, but **verify `ical4android`'s license** before assuming it's usable.
---
## 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 in `RootScreen` / `PermissionViewModel` /
`ProviderResolver.hasPermission` currently assumes an external provider always
needs a grant. It needs a bypass for our own provider. Modest, but it's the
exact flow `fix/provider-interaction-review` just touched — merge that first.
---
## 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 |
| 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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.