diff --git a/README.md b/README.md
index a67a934..bd88bbd 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,8 @@
Agendula
A modern Material 3 Expressive task app for Android.
-Reads, writes, and reminds — on top of an existing tasks provider, with no own
-sync stack.
+Keeps your tasks on your device, or on top of a tasks provider you already use.
+Open standards, no account required.
@@ -15,31 +15,47 @@ sync stack.
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 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.
+Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula
+speaks the **dmfs `TaskContract`** — the same shape DAVx5 (and SmoothSync,
+DecSync, …) syncs your CalDAV `VTODO` tasks into.
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 task store, bundled in the app | none yet — 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 carries its own copy of the Apache-2.0 dmfs task provider, under its own
+name — so it **coexists with OpenTasks rather than replacing it**, and installing
+one never breaks the other. It is a fork of a proven schema, not a database
+written from scratch, which is why every CalDAV engine already understands it.
+
+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, provider, 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)
-Agendula 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 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
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index d7c562f..4fadb62 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -8,12 +8,21 @@ This document describes how Agendula is built **as it stands today**. For the
## 1. The thesis in one sentence
-Agendula is a Material 3 Expressive **front-end** over the OpenTasks
-`TaskContract` provider — it reads, writes, and reminds on top of a tasks store
-that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
-CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
-sibling to [Calendula](https://codeberg.org/jlmakiola/calendula),
-which does the same thing for `CalendarContract`.
+Agendula is a Material 3 Expressive task app over the dmfs `TaskContract` — it
+reads, writes, and reminds against a task store the user chooses: **its own
+bundled provider** (the default) or an external provider app already on the
+device (OpenTasks, tasks.org) synced by DAVx5, SmoothSync, DecSync CC and the
+like. It is the task-list sibling to
+[Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing
+for `CalendarContract`.
+
+**Agendula owns storage but not, yet, sync.** That is a deliberate change from
+the original "owns no database" thesis, settled in
+[`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md): depending on a provider app being
+installed made someone else's roadmap a gate on the app working at all. The
+database is the vendored dmfs provider under *our* authority — our namespace, not
+a schema written from scratch — so every CalDAV engine still understands it. A
+sync adapter of our own is the 1.x arc.
The whole design hangs off one rule:
@@ -22,9 +31,8 @@ The whole design hangs off one rule:
> **Provider column names and the authority string never leak above the data
> layer.**
-This is what lets "Posture A" (front-end over an installed provider) become
-"Posture B" (bundle the Apache-2.0 provider, be self-contained) without touching
-the UI, the ViewModels, or the domain. See §7.
+That rule is what let Posture B land as an addition rather than a rewrite: the
+UI, the ViewModels and the domain were untouched by it. See §7.
---
@@ -49,9 +57,14 @@ the UI, the ViewModels, or the domain. See §7.
│ ProviderResolver / ContentObserver│
│ reminders/ prefs/ di/ demo/ │
└───────────────┬──────────────────────────────┘
- │ content:// + dangerous perms
+ │ content://
┌───────────────▼──────────────────────────────┐
- External │ OpenTasks provider ←sync← DAVx5 / DecSync… │
+ Storage │ Local mode (default): │
+ │ :provider — our own bundled task provider │
+ │ same uid, no permission grant needed │
+ │ External mode: │
+ │ OpenTasks / tasks.org ←sync← DAVx5 / … │
+ │ dangerous perms, requested at point of use │
└──────────────────────────────────────────────┘
```
@@ -69,15 +82,21 @@ Both are bound in Hilt in `data/di/DataModule.kt`.
## 3. Module & package layout
-Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`.
+Two modules: `:app` and `:provider`. Package root `de.jeanlucmakiola.agendula`.
-| Package | Contents |
+| Module | Contents |
|---|---|
-| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `DayWindow` (local-midnight maths). No Android imports. |
-| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. |
+| `:provider` | Agendula's own task store — the Apache-2.0 dmfs task provider 1.4.2, vendored in-tree under our authority and permission namespace. Not our code; see [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) for the upstream commit and every deviation. No app code imports from it except `ProviderResolver`, which reads the authority out of its resources. |
+
+| Package (`:app`) | Contents |
+|---|---|
+| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `AllDayTime` (the two date conventions), `DayWindow` (local-midnight maths). No Android imports. |
+| `domain/export/` | `ExportModels` + `ICalendarWriter` — VTODO serialization. Pure Kotlin, so the format is JVM-testable. |
+| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` + `ProviderEnvironment` + `StorageMode` + `StorageModeHolder` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. |
+| `data/export/` | `TaskExporter` (lists → `.ics` documents), `ExportWriter` (SAF plumbing; a floret-kit candidate). |
| `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. |
| `data/prefs/` | `SettingsPrefs` (DataStore). |
-| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). |
+| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). |
| `data/demo/` | `DemoSeeder` (debug-only sample data). |
| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. |
| root | `AgendulaApp` (Hilt app), `MainActivity`. |
@@ -88,20 +107,45 @@ Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`.
### 4.1 Provider targeting — `ProviderResolver`
-`ProviderResolver.resolve()` walks a preference-ordered candidate list and
-returns the first provider actually installed (via
-`PackageManager.resolveContentProvider`), or `null` if none is. Each candidate
-is a `TaskProvider(authority, readPermission, writePermission, packageName)`.
+`ProviderResolver.resolve()` returns the active `TaskProvider(authority,
+readPermission, writePermission, packageName, isOwn)` for the selected
+`StorageMode`.
-| Provider | Authority | Permissions |
-|---|---|---|
-| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
-| tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` |
+| Mode | Provider | Authority | Permissions |
+|---|---|---|---|
+| **Local** (default) | ours, bundled | `de.jeanlucmakiola.agendula.tasks` | **none** — same uid |
+| External | OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
+| External | tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` |
-Both are backed by the same dmfs `TaskProvider`, so the **same `TaskContract`
-columns apply** regardless of which is present. `null` from `resolve()` drives
-the "install a tasks provider" onboarding gate. `hasPermission()` checks both
-runtime perms for the active provider.
+All three are backed by the same dmfs `TaskProvider` — ours *is* that provider,
+vendored — so the **same `TaskContract` columns apply** throughout.
+
+`hasPermission()` short-circuits to `true` for our own provider: a same-uid
+caller bypasses a provider's permission checks outright, so
+`ProviderStatus.NEEDS_PERMISSION` can never fire in Local mode. In External mode
+it checks both runtime perms, and `null` from `resolve()` drives the "install a
+tasks provider" gate.
+
+**Choosing the mode.** An explicit choice is stored in `SettingsPrefs` and
+mirrored into the resolver by `StorageModeHolder` — the resolver is consulted
+synchronously on every query and cannot read DataStore itself. When there is no
+explicit choice (the normal case), `autoMode()` decides:
+
+> **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 — the signature of an existing Posture A user,
+who must not be dropped onto an empty store and left to conclude their tasks were
+deleted. A fresh install holds nothing and gets local-first storage.
+
+The platform calls sit behind `ProviderEnvironment` so this decision is unit
+tested on the JVM (`ProviderResolverTest`) rather than only on a device.
+
+**Storage modes** are `LOCAL` and `EXTERNAL` only. `STORAGE-AND-SYNC.md`
+describes three, but *Synced* is not a third store — it is Local with an account
+attached, so it is derived state, and modelling it as a separate mode would imply
+that turning sync on is a migration. It isn't.
### 4.2 `TasksContract`
@@ -192,17 +236,32 @@ This is the single largest piece of genuinely-new code in Agendula.
## 7. The A / B seam (why the layering is shaped this way)
-- **Posture A (today):** front-end over whatever provider is installed. Ships
- fast; requires a provider app present (the "needs DAVx5/OpenTasks" onboarding
- moment).
-- **Posture B (later):** add a `:provider` module bundling the Apache-2.0
- `opentasks-provider`. `ProviderResolver` then finds **our own** `org.dmfs.tasks`
- first; external CalDAV engines sync directly into it. **The UI, ViewModels,
- domain, and `TasksRepository` do not change** — only the resolver's default and
- some manifest perms.
+Both terms were **redefined** by [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md).
+They no longer mean what earlier drafts of this document said.
-Bundling the provider bundles **storage, not sync** — Agendula stays a pure
-front-end over open backends either way.
+- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org).
+ Still fully supported; it stopped being the only option and became a user
+ choice, `StorageMode.EXTERNAL`.
+- **Posture B (shipped)** — the `:provider` module: the Apache-2.0 dmfs provider
+ vendored under **our own** authority `de.jeanlucmakiola.agendula.tasks` and our
+ own permission namespace. It **coexists with everything and replaces nothing**.
+
+> **Dead end, do not revisit:** bundling the provider under dmfs's *own*
+> authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the
+> same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same
+> `` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with
+> OpenTasks installed simply could not have installed Agendula. Account
+> visibility is also keyed by *package*, not authority, which would have left the
+> bundled provider seeing zero accounts and pruning synced lists as orphaned.
+> Full reasoning in `STORAGE-AND-SYNC.md`.
+
+The seam earned its keep: `ProviderResolver` is still the only thing that knows
+an authority exists and `AndroidTasksDataSource` the only thing that touches a
+resolver, so vendoring an entire content provider **changed no UI, no ViewModel,
+no domain type, and not one line of `TasksRepository`.**
+
+Bundling the provider bundles **storage, not sync**. Our own sync adapter is a
+separate, later piece of work — see `STORAGE-AND-SYNC.md`.
---
@@ -241,8 +300,9 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
| Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 |
| SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 |
| UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) |
-| Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
-| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
+| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines |
+| `:provider` | Java 17, `org.dmfs` jems / rfc5545-datetime / lib-recur (all Maven Central — no new repository; `settings.gradle.kts` stays `google()` + `mavenCentral()` under `FAIL_ON_PROJECT_REPOS`) |
+| Tests | `:app` is JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source and `ProviderEnvironment` as the JVM-testable seams. **`:provider` is JUnit4 + Robolectric** — upstream's own suite, kept as written rather than rewritten, since that coverage is what makes vendoring safe. Don't add `useJUnitPlatform()` there. |
| Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). |
| CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). |
| Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs |
@@ -251,13 +311,25 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
## 11. Manifest surface
-- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms
- declared statically (the active set is requested at runtime);
- `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
+- **Declared by `:app`:** both `org.dmfs.*` and `org.tasks.*` read/write tasks
+ perms (static manifest, so they are always declared; requested at runtime only
+ in External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
(`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32).
-- **``** for package visibility: both provider authorities + a LAUNCHER
- intent (so `resolveContentProvider` works and onboarding can open the
- provider / a store listing).
+- **Declared by `:provider`:** the `` itself plus our own
+ `de.jeanlucmakiola.agendula.permission.READ_TASKS` / `WRITE_TASKS` and their
+ permission group. These exist for **other** apps — Agendula reaches its own
+ provider same-uid and neither declares a `uses-permission` for them nor asks.
+ The provider is `exported="true"` on purpose: that is what would let DAVx5
+ write into it once it knows our authority.
+- **Deliberately absent:** `GET_ACCOUNTS` (stripped from the vendored provider —
+ see change 1 in `PROVENANCE.md`) and `INTERNET`, which stays undeclared until
+ sync actually ships. Export needs no storage permission at all; SAF hands us a
+ `Uri` the user picked.
+- **``** for package visibility: both *external* provider authorities +
+ a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open
+ the provider / a store listing). Our own provider needs no entry.
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`,
- `ProviderChangeReceiver` (both authorities). No `EVENT_REMINDER` receiver —
- that's a Calendula thing that doesn't apply here.
+ `ProviderChangeReceiver` (all three authorities, ours first — an intent-filter
+ host must be a literal), and the vendored provider's own
+ `TaskProviderBroadcastReceiver`. No `EVENT_REMINDER` receiver — that's a
+ Calendula thing that doesn't apply here.
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 4852655..90c7a2c 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -1,5 +1,24 @@
# Agendula — implementation plan
+> ⚠️ **Historical document.** This is the original design plan, kept for the
+> reasoning behind decisions that are still in force — the layering, the data
+> model, the reminder engine, what transfers from Calendula. It is **not** a
+> description of the app as it stands.
+>
+> Two things here have since been overturned, both by
+> [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md), which supersedes this document
+> wherever they disagree:
+>
+> 1. **"No own storage."** Agendula now ships its own bundled task provider, and
+> depending on an external provider app is a user choice rather than a
+> requirement.
+> 2. **"Posture B = bundle OpenTasks under `org.dmfs.tasks`."** That is a dead
+> end, not a later step — two apps cannot declare the same authority or
+> permission name. Posture B shipped under *our own* authority instead.
+>
+> For the current picture see [`ARCHITECTURE.md`](ARCHITECTURE.md); for status,
+> [`ROADMAP.md`](ROADMAP.md).
+
> A modern Material 3 Expressive **task** app for Android. Reads, writes, and
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
> DecSync over CalDAV), with no own sync stack.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 0c7e47d..81839d0 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -10,16 +10,18 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
## Current state (one line)
-The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
-provider is **done and unit-tested**, and the Material 3 Expressive UI is built
-through **M5**: lists → task list (swipe gestures, inline add, smart-list section
-headers) → detail / edit with full CRUD, date-time pickers, priority,
-percent-complete, conflict-safe saves, per-task reminders, and subtask
-create + reparent — plus a one-time reminder onboarding step and a Settings
-screen (theme, dynamic colour, due-reminder master toggle + default offset +
-exact-alarm status, default list, and the add-a-subtask-row opt-out). Remaining
-work is M6 (Glance widget, translations, F-Droid release; the Settings screen
-landed early with M5 and still needs a language entry).
+Agendula now **carries its own task store**: the `:provider` module ships the
+vendored dmfs provider under our authority, so the app is complete and local-first
+with nothing else installed, and an external provider (OpenTasks / tasks.org) is a
+user choice rather than a requirement. The non-visual stack over `TaskContract` is
+done and unit-tested, export to iCalendar has landed, and the Material 3
+Expressive UI is built through **M5**: lists → task list (swipe gestures, inline
+add, smart-list section headers) → detail / edit with full CRUD, date-time
+pickers, priority, percent-complete, conflict-safe saves, per-task reminders, and
+subtask create + reparent — plus a one-time reminder onboarding step and a
+Settings screen. Remaining work is the **frontend surfaces for what just landed**
+(a storage-mode picker, an export screen), then M6 (Glance widget, translations,
+F-Droid release) and the sync adapter.
---
@@ -128,11 +130,33 @@ The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
-### ⬜ Posture B (separate track, later)
-Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`;
-`ProviderResolver` defaults to our own `org.dmfs.tasks`; add sync-adapter
-permissions; ship self-contained. UI / repository / domain untouched — see
-[`ARCHITECTURE.md`](ARCHITECTURE.md) §7.
+### ✅ Posture B — our own task store
+Agendula stopped depending on a provider app being installed. Direction and
+reasoning in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md); note it **redefined**
+what Posture B means (our own authority, coexisting with everything — *not*
+squatting `org.dmfs.tasks`, which is a dead end).
+- ✅ `fix/provider-interaction-review` merged (step 1).
+- ✅ `:provider` — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored in-tree
+ under `de.jeanlucmakiola.agendula.tasks` and our own permission namespace.
+ `GET_ACCOUNTS` dropped, with the account-cleanup path reworked so it can only
+ prune account types we authenticate ourselves — the deletion is unsafe without
+ that rework. Modernized to minSdk 29 / targetSdk 36 / Java 17.
+ [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) records every deviation,
+ each also marked `AGENDULA CHANGE` at the site. Upstream's 51 JVM tests pass.
+- ✅ Storage modes + the permission-gate bypass — `ProviderStatus.NEEDS_PERMISSION`
+ can no longer fire in Local mode, and an upgrading Posture A user stays on the
+ provider that holds their data (`ProviderResolver.autoMode`).
+- ✅ Export to iCalendar (step 3) — a v1 feature now that Local-mode data lives
+ only in our app's private storage. One `.ics` per list, to a folder or a zip,
+ via SAF. Backend only.
+- ⬜ **Frontend surfaces for the above** — a storage-mode picker in Settings and
+ an export screen. The backend is done and unused until these exist.
+- ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users.
+- ⬜ Sync adapter (step 5) — the 1.x arc. Design discussion still open: protocol
+ coverage, account model, conflict resolution, and `ical4android`'s licence
+ against our MIT.
+- ⬜ Verify on a device: the local path with no account, and the vendored
+ provider's timezone-change behaviour (change 3 in `PROVENANCE.md`).
---
@@ -145,11 +169,20 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through
`org.tasks.opentasks` + `org.tasks.permission.*`.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
(Not in the candidate list today.)
-4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Agendula a
- *replacement* for OpenTasks (one authority owner per device). Intended, but a
- conscious choice.
-5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists);
- recurrence-aware editing is out of scope for v1.
+4. ~~**Posture B authority choice**~~ resolved: **our own**
+ `de.jeanlucmakiola.agendula.tasks`. Squatting `org.dmfs.tasks` is a dead end,
+ not merely a trade-off — two apps cannot declare the same authority or
+ permission name, so anyone with OpenTasks installed could not have installed
+ Agendula at all.
+5. ~~**Recurring tasks** — recurrence-aware editing out of scope for v1~~ stale:
+ `fix/provider-interaction-review` routes edits to a recurring task through the
+ instances URI, so the provider forks an override instead of re-anchoring the
+ series.
+6. **Resolver ordering / mode-selection UX** — `autoMode()` picks a sane default
+ today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it
+ assumes is not built yet.
+7. **Sync protocol coverage**, account model, conflict resolution — the next
+ design discussion.
---
diff --git a/docs/STORAGE-AND-SYNC.md b/docs/STORAGE-AND-SYNC.md
index d6831a9..aba0a6f 100644
--- a/docs/STORAGE-AND-SYNC.md
+++ b/docs/STORAGE-AND-SYNC.md
@@ -4,9 +4,13 @@
> bundle OpenTasks" working notes, which are withdrawn (see
> [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to
> `ARCHITECTURE.md` §7 and the `ProviderResolver` comments, **and it redefines
-> what Posture B means** — those two need a follow-up edit.
-> `ROADMAP.md` / `PLAN.md` remain known-stale and are due a deliberate pass;
-> this document does not attempt it.
+> what Posture B means.**
+>
+> **Status, 2026-08-02: steps 1–3 are done** — see
+> [Sequencing](#sequencing). `ARCHITECTURE.md` and `ROADMAP.md` have had their
+> follow-up pass and now match what shipped; `PLAN.md` is 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
@@ -24,13 +28,13 @@
**In what order**
-| # | Step | Why now |
-|---|---|---|
-| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 |
-| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app |
-| 3 | Export / backup | our data now lives only in our app's private storage |
-| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users |
-| 5 | Sync adapter | the 1.x arc; design discussion pending |
+| # | 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; design discussion pending | ⬜ |
Everything below is the reasoning behind those choices, the alternatives that
were rejected, and the constraints they have to survive.
@@ -64,8 +68,8 @@ 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:
+`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
@@ -173,19 +177,36 @@ feature — see [Storage modes](#storage-modes--the-users-choice).
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).
+**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:
-**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.
+> **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.
---
@@ -241,10 +262,11 @@ mechanisms, and conflating them is how apps end up over-permissioned:
| `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.
+~~**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`.
---
@@ -259,7 +281,7 @@ task-domain, it goes to the kit.
| 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 |
+| 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
@@ -337,14 +359,33 @@ the roadmap should say so rather than inheriting the old estimate.
## Open questions
1. **Sync protocol coverage**, account model, conflict resolution — the next
- discussion.
-2. **Resolver ordering / mode selection UX** once our provider coexists with
- external ones (see [Storage modes](#storage-modes--the-users-choice)).
-3. **Does the vendored provider work with no account at all?** Local-only mode
- depends on it entirely. First thing the vendoring work should prove.
-4. **`ical4android` licensing** vs our MIT.
+ discussion. Still open.
+2. **Resolver ordering** — ✅ decided, see
+ [Storage modes](#storage-modes--the-users-choice). The **mode-selection UX**
+ is still open: `autoMode()` picks a default, but the Settings override it
+ assumes does not exist yet.
+3. **Does the vendored provider work with no account at all?** ✅ Answered, with
+ a caveat about *how* it was answered.
+
+ By construction: `cleanUpLists` exempts 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.
+ `ProviderAccountCleanupTest` creates 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.
+4. **`ical4android` licensing** vs our MIT. Still open — and note the export path
+ does *not* depend on it: `ICalendarWriter` is our own ~200 lines, no library.
+ The question is really about the sync adapter's iCalendar *parsing*.
5. **jtx Board** as an additional External-mode candidate — richer contract,
later. (`PLAN.md` decision #3, still open.)
+6. **The vendored provider's timezone-change behaviour** — upstream's receiver
+ has a comment describing `break`s 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 in
+ [`provider/PROVENANCE.md`](../provider/PROVENANCE.md).
---