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.

CI Android 10+ @@ -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). ---