Files
agendula/docs/ARCHITECTURE.md
Jean-Luc Makiola 98ed339346 docs: bring the docs in line with what shipped
STORAGE-AND-SYNC.md asked for a follow-up pass on ARCHITECTURE.md §7 and the
ProviderResolver KDoc, which still defined Posture B as "bundle OpenTasks and
find org.dmfs.tasks first" — the plan that was withdrawn as a dead end. That pass,
plus the status the doc left open.

ARCHITECTURE.md now describes the app as built: two modules, the storage-mode
table with the permission each needs, the autoMode rule and why it keys on
holding an external provider's permission, the two-not-three mode vocabulary, and
a manifest section that says what :provider contributes and what is deliberately
absent (GET_ACCOUNTS, INTERNET). §7 records squatting the dmfs authority as a
dead end rather than a road not yet taken, so it doesn't get re-proposed.

ROADMAP.md turns "Posture B, later" into what actually landed and lists what
didn't: the frontend surfaces, the DAVx5 issue, the sync adapter, and device
verification. Two open decisions resolved and struck through — the authority
choice, and recurrence-aware editing, which fix/provider-interaction-review made
stale.

STORAGE-AND-SYNC.md gets per-step status. Open question 3 ("does it work with no
account?") is answered, with the caveat that the test proving it is Robolectric
and skips on ARM64 — answered by construction, not yet on a device.

PLAN.md gets a banner. It's the original design document and still holds the
reasoning behind the layering, but two of its premises are overturned and it
should not be read as current.

README.md was telling users they need a tasks provider installed. They don't, and
that's the headline feature: a table of where tasks can live, that our provider
coexists with OpenTasks rather than replacing it, and that everything exports as
standard .ics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 21:32:17 +02:00

336 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
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 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`
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 — see `STORAGE-AND-SYNC.md`.
---
## 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.