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>
344 lines
19 KiB
Markdown
344 lines
19 KiB
Markdown
# Agendula — architecture
|
||
|
||
This document describes how Agendula is built **as it stands today**. For the
|
||
*why* behind the big decisions and the long-term plan, see
|
||
[`PLAN.md`](PLAN.md); for status and what's next, see [`ROADMAP.md`](ROADMAP.md).
|
||
|
||
---
|
||
|
||
## 1. The thesis in one sentence
|
||
|
||
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, designed in [`SYNC.md`](SYNC.md).
|
||
|
||
The whole design hangs off one rule:
|
||
|
||
> The entire app talks to a `TasksRepository`. Only the data layer knows there
|
||
> is a `ContentResolver`, a `TaskContract`, or an authority string behind it.
|
||
> **Provider column names and the authority string never leak above the data
|
||
> layer.**
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 2. Layers
|
||
|
||
```
|
||
┌──────────────────────────────────────────────┐
|
||
UI │ Compose screens + ViewModels (ui/*) │
|
||
│ RootScreen → permission gate → ListsScreen │
|
||
└───────────────┬──────────────────────────────┘
|
||
│ domain models + Flows only
|
||
┌───────────────▼──────────────────────────────┐
|
||
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
|
||
│ DayWindow (pure Kotlin, no Android) │
|
||
└───────────────┬──────────────────────────────┘
|
||
│ TasksRepository (interface)
|
||
┌───────────────▼──────────────────────────────┐
|
||
Data │ TasksRepositoryImpl │
|
||
│ └ TasksDataSource (interface) │
|
||
│ └ AndroidTasksDataSource │
|
||
│ └ ContentResolver / TaskContract / │
|
||
│ ProviderResolver / ContentObserver│
|
||
│ reminders/ prefs/ di/ demo/ │
|
||
└───────────────┬──────────────────────────────┘
|
||
│ content://
|
||
┌───────────────▼──────────────────────────────┐
|
||
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 │
|
||
└──────────────────────────────────────────────┘
|
||
```
|
||
|
||
The seam that matters is the pair of interfaces in the data layer:
|
||
|
||
- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend
|
||
writes. (`data/tasks/TasksRepository.kt`)
|
||
- **`TasksDataSource`** — the JVM-testable interface that does the actual
|
||
provider work; `AndroidTasksDataSource` is the only Android-coupled
|
||
implementation.
|
||
|
||
Both are bound in Hilt in `data/di/DataModule.kt`.
|
||
|
||
---
|
||
|
||
## 3. Module & package layout
|
||
|
||
Two modules: `:app` and `:provider`. Package root `de.jeanlucmakiola.agendula`.
|
||
|
||
| Module | Contents |
|
||
|---|---|
|
||
| `: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`, `@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`. |
|
||
|
||
---
|
||
|
||
## 4. The data layer (the heart)
|
||
|
||
### 4.1 Provider targeting — `ProviderResolver`
|
||
|
||
`ProviderResolver.resolve()` returns the active `TaskProvider(authority,
|
||
readPermission, writePermission, packageName, isOwn)` for the selected
|
||
`StorageMode`.
|
||
|
||
| 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` |
|
||
|
||
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 the same store with an
|
||
account attached, so it stays derived state.
|
||
|
||
⚠️ **What this used to say — "so turning sync on is not a migration" — is wrong.**
|
||
A task list's `ACCOUNT_NAME`/`ACCOUNT_TYPE` are write-once in the provider
|
||
(`processors/lists/Validating.java:68-76`), so local lists cannot be re-pointed at
|
||
an account; tasks have to be moved into new lists. Not modelling `SYNCED` as a
|
||
mode is still right, but the migration it implied away is real. See
|
||
[`SYNC.md`](SYNC.md).
|
||
|
||
### 4.2 `TasksContract`
|
||
|
||
A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
|
||
table paths, status/priority constants, the local-account type. Agendula does
|
||
**not** take a runtime dependency on OpenTasks; the authority is injected from
|
||
`ProviderResolver`, never hardcoded in the contract.
|
||
|
||
### 4.3 Reads — Instances + `ContentObserver`
|
||
|
||
`AndroidTasksDataSource` queries the denormalized **instances** view (so each
|
||
occurrence is a row with the joined list colour, account, etc.), maps each
|
||
cursor row through `ColumnReader` → `TaskMapper` → domain `Task`, and exposes
|
||
the result as a `Flow`. A `ContentObserver` on the active authority's
|
||
Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change
|
||
re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks)
|
||
update the UI live, and multiple sync sources coexist in one list.
|
||
|
||
### 4.4 Writes — repository API
|
||
|
||
```kotlin
|
||
interface TasksRepository {
|
||
fun taskLists(): Flow<List<TaskList>>
|
||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
||
|
||
suspend fun createTask(form: TaskForm): Long
|
||
suspend fun updateTask(taskId: Long, form: TaskForm)
|
||
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
|
||
suspend fun deleteTask(taskId: Long)
|
||
suspend fun createLocalList(name: String, color: Int): Long
|
||
|
||
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
|
||
}
|
||
```
|
||
|
||
`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion
|
||
sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back
|
||
out as a normal VTODO status change. Writes to local/unsynced lists use the
|
||
sync-adapter URI form where the provider requires it.
|
||
|
||
### 4.5 Domain model notes
|
||
|
||
- `Task.id` is the **instance** row id; `Task.taskId` is the underlying
|
||
`tasks._id` and the stable target for edits/completion.
|
||
- Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`);
|
||
`TaskDetail` bundles a task with its direct children.
|
||
- `effectiveColor` = the task's own colour, else the list colour.
|
||
- iCal priority is bucketed to `NONE/LOW/MEDIUM/HIGH`; status maps to a
|
||
4-value enum. Both mappings are pure functions in `Models.kt`, unit-tested.
|
||
|
||
---
|
||
|
||
## 5. Smart lists, filtering, sorting
|
||
|
||
`TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists —
|
||
`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due
|
||
dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking
|
||
`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it
|
||
unit-tests with a fixed clock. `TaskSorting` orders within a list (due /
|
||
priority / etc.). None of this touches Android, which is why it's all in
|
||
`domain/`.
|
||
|
||
---
|
||
|
||
## 6. Reminders — the one subsystem that does NOT mirror Calendula
|
||
|
||
Calendula relies on the calendar provider broadcasting `EVENT_REMINDER`. **Tasks
|
||
providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`):
|
||
|
||
- **`ReminderScheduler.sync()`** reads upcoming, non-closed, due-dated tasks
|
||
within a rolling **30-day window**, computes each trigger as `due − lead`
|
||
(lead from `SettingsPrefs`), and **diffs against `ScheduledReminderStore`** so
|
||
only changed alarms move. It bails and clears everything if the provider is
|
||
absent or unpermissioned.
|
||
- Alarms are exact where allowed (`setExactAndAllowWhileIdle`, falling back to
|
||
`set` when `canScheduleExactAlarms()` is false), keyed by `taskId`.
|
||
- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel,
|
||
`POST_NOTIFICATIONS` gate, dedupe-by-tag).
|
||
- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and
|
||
**`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external
|
||
sync changed the data). The store lets each run diff like Calendula diffs
|
||
reminder rows.
|
||
|
||
This is the single largest piece of genuinely-new code in Agendula.
|
||
|
||
---
|
||
|
||
## 7. The A / B seam (why the layering is shaped this way)
|
||
|
||
Both terms were **redefined** by [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md).
|
||
They no longer mean what earlier drafts of this document said.
|
||
|
||
- **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
|
||
> `<permission>` 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 — designed in [`SYNC.md`](SYNC.md), and it lands
|
||
*underneath* this same seam: it writes through `TaskContract` with
|
||
`CALLER_IS_SYNCADAPTER`, so the layers above it stay untouched a second time.
|
||
|
||
---
|
||
|
||
## 8. UI
|
||
|
||
Compose + Material 3 **Expressive** (`MaterialExpressiveTheme`,
|
||
`MotionScheme.standard()`, dynamic colour with a hand-tuned warm-mauve
|
||
fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`,
|
||
`edit`, `settings`, `permission`) has a ViewModel + immutable `UiState`;
|
||
`ListsScreen` is the first rendered surface.
|
||
|
||
`RootScreen` is the entry composable: it gates on `ProviderStatus`
|
||
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` →
|
||
`ListsScreen`). The remaining screens are being built one at a time — their
|
||
ViewModels exist and are tested against the real data layer; navigation
|
||
callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the
|
||
`material-3` skill for component choices (M3 `ListItem` rows, expressive
|
||
checkbox/FAB/swipe motion).
|
||
|
||
---
|
||
|
||
## 9. Dependency injection
|
||
|
||
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module
|
||
(`TasksDataSource` → `AndroidTasksDataSource`, `TasksRepository` →
|
||
`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore,
|
||
the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
|
||
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected.
|
||
|
||
---
|
||
|
||
## 10. Build & tooling
|
||
|
||
| | |
|
||
|---|---|
|
||
| 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, 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 |
|
||
|
||
---
|
||
|
||
## 11. Manifest surface
|
||
|
||
- **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).
|
||
- **Declared by `:provider`:** the `<provider>` 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.
|
||
- **`<queries>`** 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` (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.
|