Regression from deleting the provider. sync() gated on providerResolver.resolve() != null, and OWN resolves to no provider by design — so from that commit no due reminder was ever armed in what had just become the default mode, and clearAll() cancelled any that survived the upgrade. The gate is now ProviderResolver.canReadStore(): OWN is always readable, and only EXTERNAL can fail, for the two reasons it ever could. Putting the decision on the resolver rather than inside the scheduler is what makes it testable at all — ReminderScheduler needs Context and AlarmManager, which is why nothing caught this. Also brings ARCHITECTURE.md and ROADMAP.md in line with the branch: one module, OWN/EXTERNAL, the four Room tables, expansion at read time, the import and startup gate, and the manifest surface that no longer declares a provider or any permission of its own.
516 lines
30 KiB
Markdown
516 lines
30 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 that reads, writes and reminds
|
||
against a task store the user chooses: **its own database** (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
|
||
store is a **Room database of our own**, designed against RFC 5545's `VTODO` and
|
||
against what a CalDAV sync adapter will need — the reasoning is in
|
||
[`STORAGE-DECISION.md`](STORAGE-DECISION.md), the architecture and the plan it
|
||
implements in [`OWN-STORE.md`](OWN-STORE.md). It replaces a vendored copy of the
|
||
dmfs task provider, which was deleted along with the `:provider` module it lived
|
||
in. External mode is untouched by that and still speaks the dmfs
|
||
`TaskContract`. 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 Room database, a `ContentResolver`, a `TaskContract` or an authority
|
||
> string behind it. **Table and column names and the authority string never leak
|
||
> above the data layer.**
|
||
|
||
That rule is what let the entire store be swapped without a rewrite: replacing
|
||
the provider with Room changed no UI, no ViewModel and exactly one domain field
|
||
(`Task.id` → `Task.occurrenceStart`, §4.8). See §7.
|
||
|
||
---
|
||
|
||
## 2. Layers
|
||
|
||
```
|
||
┌──────────────────────────────────────────────┐
|
||
UI │ Compose screens + ViewModels (ui/*) │
|
||
│ RootScreen → permission gate → ListsScreen │
|
||
└───────────────┬──────────────────────────────┘
|
||
│ domain models + Flows only
|
||
┌───────────────▼──────────────────────────────┐
|
||
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
|
||
│ TaskSections, RecurrenceExpander │
|
||
│ (pure Kotlin, no Android) │
|
||
└───────────────┬──────────────────────────────┘
|
||
│ TasksRepository (interface)
|
||
┌───────────────▼──────────────────────────────┐
|
||
Data │ TasksRepositoryImpl │
|
||
│ └ TasksDataSource (interface) │
|
||
│ └ ModeRoutingTasksDataSource │
|
||
│ ├ RoomTasksDataSource (OWN) │
|
||
│ └ AndroidTasksDataSource (EXTERNAL)│
|
||
│ reminders/ prefs/ di/ demo/ │
|
||
└────────┬────────────────────────┬────────────┘
|
||
│ Room DAOs │ content://
|
||
┌─────────────▼──────────┐ ┌──────────▼─────────────┐
|
||
Sto- │ Own mode (default): │ │ External mode: │
|
||
rage │ agendula-tasks.db — │ │ OpenTasks / tasks.org │
|
||
│ four tables in our │ │ ←sync← DAVx5 / … │
|
||
│ own data directory, │ │ dangerous perms, │
|
||
│ nothing to permit │ │ asked 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, domain-shaped interface that does the
|
||
actual store work. Two implementations: `RoomTasksDataSource` for our own
|
||
store and `AndroidTasksDataSource` for an external provider, picked per call by
|
||
`ModeRoutingTasksDataSource` (§4.1).
|
||
|
||
Both are bound in Hilt in `data/di/DataModule.kt`.
|
||
|
||
---
|
||
|
||
## 3. Module & package layout
|
||
|
||
One module, `:app`, plus the `floret-kit` included build (`includeBuild` in
|
||
`settings.gradle.kts`, consumed as `de.jeanlucmakiola.floret:*`). The
|
||
`:provider` module — the vendored dmfs task provider — was deleted with the own
|
||
store; `provider/PROVENANCE.md` went with it, its content preserved as a
|
||
postscript in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Package root
|
||
`de.jeanlucmakiola.agendula`.
|
||
|
||
| Package (`:app`) | Contents |
|
||
|---|---|
|
||
| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskConstants` (status/priority/local-account constants), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `TaskSections` (due-date sectioning), `AllDayTime` (the two date conventions). No Android imports; local-midnight maths comes from floret-kit's `DayWindow`. |
|
||
| `domain/recurrence/` | `RecurrenceExpander` — a stored rule set → its occurrences, over `lib-recur`. Pure Kotlin. |
|
||
| `domain/export/` | `ExportModels` + `ICalendarWriter` — VTODO serialization. Pure Kotlin, so the format is JVM-testable. |
|
||
| `data/tasks/` | `StorageMode` + `StorageModeHolder` + `ProviderResolver` + `ProviderEnvironment` (which store, §4.1), `ModeRoutingTasksDataSource`, `StartupGate`, `TasksDataSource`, `TasksRepository` + `Impl`, `Failures`; and the External-mode half — `TasksContract` (vendored subset), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `AndroidTasksDataSource`. |
|
||
| `data/tasks/room/` | Agendula's own store: `Entities` (the four tables), a DAO per table, `TasksDatabase`, `Converters`, `RoomTasksDataSource`, `RoomTaskMapper` (row→domain), `TaskFormWriter` (form→entity), `DatabaseCheckpoint`. |
|
||
| `data/tasks/legacy/` | `OneShotImport` — a v0.3.x install's tasks out of the dmfs provider's file and into Room, once (§4.4). |
|
||
| `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/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. |
|
||
| root | `AgendulaApp` (Hilt app), `MainActivity`. |
|
||
|
||
---
|
||
|
||
## 4. The data layer (the heart)
|
||
|
||
### 4.1 Which store — `StorageMode` and `ProviderResolver`
|
||
|
||
`StorageMode` has two values, `OWN` and `EXTERNAL`, and
|
||
`ModeRoutingTasksDataSource` picks the implementation **per call** — the mode is
|
||
a setting the user can change while the process lives, so binding it once would
|
||
mean rebuilding the object graph to honour a change.
|
||
|
||
| Mode | Store | Authority | Permissions |
|
||
|---|---|---|---|
|
||
| **Own** (default) | our Room database, `agendula-tasks.db` | — none, it is not a provider | **none** |
|
||
| 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` |
|
||
|
||
`ProviderResolver` is now only about the second and third rows: it discovers the
|
||
*external* providers a device has. `resolve()` returns `null` in `OWN` mode —
|
||
there is no authority to resolve — and callers that need to tell that apart from
|
||
"External, and nothing installed" ask `mode()`. `TaskProvider` no longer carries
|
||
an `isOwn` flag; there is no own provider to flag.
|
||
|
||
`providerStatus()` is unconditionally `READY` in `OWN` mode. The permission gate
|
||
only ever applied to External, and that is now visibly true rather than a
|
||
same-uid special case inside `hasPermission()`. In External mode `null` from
|
||
`resolve()` is `NO_PROVIDER` and a missing runtime permission is
|
||
`NEEDS_PERMISSION`, which is what drives onboarding.
|
||
|
||
**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 Own.**
|
||
|
||
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 our own store.
|
||
|
||
A stored `LOCAL` — the old name for the bundled provider — is read as `OWN`
|
||
(`SettingsPrefs.kt:104`) rather than as an unparseable value. Left to fall
|
||
through to `autoMode()`, someone who had explicitly chosen local storage *and*
|
||
holds an OpenTasks grant would be sent to OpenTasks, away from the data the
|
||
import just moved.
|
||
|
||
The platform calls sit behind `ProviderEnvironment` so this decision is unit
|
||
tested on the JVM (`ProviderResolverTest`) rather than only on a device.
|
||
|
||
**Synced is still not a third mode.** `STORAGE-AND-SYNC.md` describes three; a
|
||
synced list is `OWN` with an account attached, which is derived state rather than
|
||
something the user picks. Attaching one is a plain `UPDATE task_lists SET
|
||
account_id = ?` — `account_id` is a nullable FK from v1, so turning sync on for
|
||
an existing list is not a migration. (Under the dmfs provider it was:
|
||
`ACCOUNT_NAME`/`ACCOUNT_TYPE` were write-once, so tasks had to be moved into new
|
||
lists. That constraint left with the provider.)
|
||
|
||
### 4.2 The own store — four Room tables
|
||
|
||
`TasksDatabase` (v1, schema exported to `app/schemas/` and committed) holds
|
||
`task_lists`, `tasks`, `task_alarms` and `accounts`. The columns and the
|
||
reasoning behind each are in [`OWN-STORE.md`](OWN-STORE.md); what matters
|
||
structurally:
|
||
|
||
- **`accounts` is empty until sync lands**, but the nullable `task_lists
|
||
.account_id` FK exists from v1 — that is what makes §4.1's "attaching an
|
||
account is an `UPDATE`" true. Deleting an account `SET NULL`s its lists rather
|
||
than deleting them.
|
||
- **Masters and `RECURRENCE-ID` overrides share the `tasks` table.** An override
|
||
is a row with `recurrence_id` set and `master_id` pointing at its master,
|
||
sharing the master's `uid`. The unique index is therefore
|
||
`(list_id, uid, recurrence_id)`; SQLite treats NULLs as distinct, so it
|
||
enforces the override half and states the master half as intent.
|
||
- **`uid` is `NOT NULL`**, minted at creation in either mode, synced or not — so
|
||
a task has a stable identity before a server ever sees it.
|
||
- `priority` is stored as the raw iCalendar integer (0 none, 1 highest, 9
|
||
lowest). Bucketing into `Priority` on the way *in* would rewrite a server's
|
||
`PRIORITY:3` as `1`; the bucketing belongs to the mapper.
|
||
- Cascades: deleting a list takes its tasks, deleting a series takes its
|
||
overrides (`master_id`), deleting a parent promotes its subtasks
|
||
(`parent_id` → `SET NULL`).
|
||
|
||
`RoomTaskMapper` maps a row to a domain `Task`; `TaskFormWriter` applies a
|
||
validated `TaskForm` to an entity. `TaskFormWriter` is the Room counterpart of
|
||
External mode's `TaskWriteMapper`, and deliberately not shared with it: most of
|
||
what that mapper does is work around the provider (clearing `DURATION` because
|
||
it validates a merged row; writing `STATUS` explicitly in both directions because
|
||
it auto-completes at 100% but will not reopen below it). Here the completion
|
||
rules are stated
|
||
directly — progress and status move together in both directions, so a task can
|
||
no longer strand itself "done at 75%".
|
||
|
||
Deletes are hard when the list has no account and tombstones (`is_deleted`) when
|
||
it does — a row a server still knows about has to survive long enough to be
|
||
withdrawn from it.
|
||
|
||
### 4.3 Recurrence — expanded at read, not materialised
|
||
|
||
There is **no instances table**. The dmfs provider maintained one, recomputed on
|
||
every write, and still only ever materialised one upcoming occurrence. Agendula
|
||
expands a series in memory instead:
|
||
|
||
```
|
||
tasks (masters + overrides) ──► RecurrenceExpander ──► List<Task>
|
||
rrule/rdate/exdate (lib-recur, in memory) occurrences
|
||
```
|
||
|
||
This costs nothing because `TasksRepositoryImpl` already filters and sorts in
|
||
Kotlin, not SQL — nothing depended on the database ordering by instance time —
|
||
and the whole class of staleness bugs a cached table brings never exists.
|
||
|
||
Expansion is bounded twice over: a window of **1 year back, 2 years forward**
|
||
(`RoomTasksDataSource.WINDOW_BACK`/`WINDOW_FORWARD`) and a hard per-series
|
||
occurrence ceiling, so an unbounded `RRULE` terminates. The iterator is
|
||
fast-forwarded to the window start first, so a `FREQ=MINUTELY` series anchored
|
||
years back does not scan millions of instances to emit one. A malformed
|
||
`RRULE`/`RDATE`/`EXDATE` is dropped rather than thrown: a task whose stored rule
|
||
cannot be parsed still has to appear.
|
||
|
||
`RecurrenceExpander` returns each occurrence as its **`RECURRENCE-ID` anchor**.
|
||
`RoomTasksDataSource.occurrencesOf` then substitutes any override for the
|
||
occurrence it replaces; a timed series carries each occurrence's length across,
|
||
while a due-anchored one has no start to offset from, so the anchor *is* the due
|
||
date — matching how the provider instantiated the same series.
|
||
|
||
`lib-recur` is pinned at **0.12.2** (0.16.0 removed `RecurrenceSet`) and is a
|
||
direct `:app` dependency now rather than something `:provider` dragged in. It is
|
||
still Apache-2.0 dmfs code, so the attribution is still owed — as a normal
|
||
third-party dependency.
|
||
|
||
**Editing one occurrence writes a `RECURRENCE-ID` override** — RFC 5545's model
|
||
(a): a second `tasks` row with the master's `uid`, a `recurrence_id` naming the
|
||
occurrence, `master_id` pointing at the series, and the edit applied. The
|
||
provider's `Detaching.java` forked a brand-new task with its own UID instead
|
||
(model (d), the one least compatible with CalDAV); we inherited that without
|
||
choosing it, and this is the choice.
|
||
|
||
### 4.4 Startup — the import and the gate
|
||
|
||
`OneShotImport` moves a v0.3.x install's tasks out of the bundled provider's
|
||
`databases/tasks.db` and into Room, once. The file is opened read-only and
|
||
directly — no provider, no `ContentResolver` — so it keeps working now that
|
||
`:provider` is gone, and everything lands in one verified Room transaction, so a
|
||
failure leaves both Room and the source file exactly as they were. dmfs row ids
|
||
are remapped in two passes, because a parent can carry a higher `_id` than its
|
||
child, and `RECURRENCE-ID` overrides are carried across as `master_id` /
|
||
`recurrence_id` rather than imported as second masters, which would collide on
|
||
the unique index. The source is archived to `tasks.db.imported` **before** the
|
||
import, and the import always replaces: that ordering is what makes every kill
|
||
point re-enter correctly.
|
||
|
||
`StartupGate` holds the first store read until the stored mode has reached
|
||
`ProviderResolver` *and* the import has run — `TasksRepositoryImpl.observing()`
|
||
awaits it before its first load, and `AgendulaApp` awaits it before the launch
|
||
reminder re-sync. Both are the same race: reading early answers from
|
||
`autoMode()` instead of the user's choice, or shows an upgrading user an empty
|
||
app.
|
||
|
||
### 4.5 `TasksContract` — External mode only
|
||
|
||
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. Nothing in `OWN` mode
|
||
touches it: the domain's own status/priority/local-account constants live in
|
||
`domain/TaskConstants.kt`, so the domain layer never reaches into the data layer
|
||
to map its own enums.
|
||
|
||
### 4.6 Reads and reactivity
|
||
|
||
In `OWN` mode `RoomTasksDataSource` reads through the DAOs and expands
|
||
recurrences (§4.3). In `EXTERNAL` mode `AndroidTasksDataSource` queries the
|
||
denormalized **instances** view (so each occurrence is a row with the joined list
|
||
colour, account, etc.) and maps each cursor row through `ColumnReader` →
|
||
`TaskMapper` → domain `Task`.
|
||
|
||
`TasksDataSource.registerObserver(onChange): AutoCloseable` is unchanged and
|
||
backed differently on each side: Room's `InvalidationTracker` over the four
|
||
tables in `OWN` mode, a `ContentObserver` on the active authority's
|
||
Tasks/TaskLists URIs in `EXTERNAL`. Either way `TasksRepositoryImpl.observing()`
|
||
bridges it into a `Flow` via `callbackFlow`, so **any change re-emits** —
|
||
Agendula's own writes *and*, in External mode, DAVx5 pulling new tasks.
|
||
|
||
### 4.7 Writes — repository API
|
||
|
||
```kotlin
|
||
interface TasksRepository {
|
||
fun taskLists(): Flow<List<TaskList>>
|
||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||
fun subtasks(parentId: Long): Flow<List<Task>>
|
||
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
||
|
||
suspend fun createTask(form: TaskForm): Long
|
||
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
||
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
|
||
suspend fun deleteTask(taskId: Long)
|
||
suspend fun reminderFor(taskId: Long): Int?
|
||
suspend fun createLocalList(name: String, color: Int): Long
|
||
|
||
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
|
||
}
|
||
```
|
||
|
||
`updateTask` on an occurrence of a recurring task routes to
|
||
`TasksDataSource.updateInstance(taskId, occurrenceStart, form)` rather than
|
||
moving the series anchor. `expectedLastModified` re-checks the stored timestamp
|
||
first and throws `TaskConflictException` when something changed underneath the
|
||
form.
|
||
|
||
Completion sets `STATUS = COMPLETED` (+ percent/completed timestamp); in
|
||
External mode DAVx5 syncs that back out as a normal VTODO status change.
|
||
|
||
### 4.8 Domain model notes
|
||
|
||
- `Task.taskId` is the task row and the stable target for edits, completion and
|
||
navigation. There is no `Task.id` any more — it was the materialised instance
|
||
row id, and materialised instances are gone.
|
||
- `Task.occurrenceStart` is the occurrence's `RECURRENCE-ID` anchor, `null` for a
|
||
non-recurring task; `Task.occurrenceKey` (`"$taskId@$millis"`) is what lazy
|
||
lists key by. Two occurrences of one series can appear in the same list, so
|
||
`taskId` alone would collide there — as a Compose key that is a visible bug.
|
||
- 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 floret-kit's `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 (after `StartupGate`), **`BootReceiver`**
|
||
(re-arm after reboot), and **`ProviderChangeReceiver`**. The store lets each
|
||
run diff like Calendula diffs reminder rows.
|
||
|
||
`sync()` gates on **`ProviderResolver.canReadStore()`**, not on a provider
|
||
resolving. `OWN` is always readable; only `EXTERNAL` can fail, and only for the
|
||
two reasons it ever could (nothing installed, or no grant). Gating on
|
||
`resolve() != null` — as it did briefly — clears every alarm the moment `OWN` is
|
||
active, because `OWN` resolves to no provider by design. `ProviderResolverTest`
|
||
covers both directions.
|
||
|
||
`ProviderChangeReceiver`'s manifest filter now lists **only the two external
|
||
authorities**: Agendula publishes no provider and broadcasts no
|
||
`ACTION_PROVIDER_CHANGED`, so there is nothing of ours to listen for. In `OWN`
|
||
mode Room's `InvalidationTracker` covers foreground changes and nothing outside
|
||
the app can change our data. When `SYNC.md` phase 3 lands, the sync worker calls
|
||
`ReminderScheduler.sync()` itself — that is the replacement for the broadcast,
|
||
and it belongs in the sync work.
|
||
|
||
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, then rebuilt)** — a store of our own, `StorageMode.OWN`.
|
||
It first shipped as the `:provider` module, the Apache-2.0 dmfs provider
|
||
vendored under our own authority; it is now a Room database and the module is
|
||
deleted. What made the vendored provider worth keeping was the sync bookkeeping
|
||
it appeared to hand us for free, and the phase-1 sync audit measured that
|
||
bookkeeping and found most of it broken, absent or unusable — the argument and
|
||
the costing are in [`STORAGE-DECISION.md`](STORAGE-DECISION.md).
|
||
|
||
> **Dead end, do not revisit:** publishing a task 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 such
|
||
> a provider seeing zero accounts and pruning synced lists as orphaned. Full
|
||
> reasoning in `STORAGE-AND-SYNC.md`.
|
||
|
||
The seam earned its keep twice over. `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 nothing above
|
||
the data layer — and **replacing** it with a database of our own changed no UI,
|
||
no ViewModel and exactly one domain field (`Task.id` → `occurrenceStart`), which
|
||
was forced by dropping materialised instances rather than by the store swap
|
||
itself.
|
||
|
||
⚠️ **The authority is gone, and that is a breaking change.**
|
||
`de.jeanlucmakiola.agendula.tasks` and both custom permissions no longer exist,
|
||
so anyone who had pointed DAVx5 or another app at that authority loses it.
|
||
External mode is the answer for them; it needs saying in the release notes.
|
||
|
||
Owning the store owns **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 the DAOs, so the layers above stay untouched a
|
||
third 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` →
|
||
`AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is
|
||
only ever seen in External mode. Routes are the `Dest` table in
|
||
`ui/navigation/` (lists → task list → detail / edit, plus settings). 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 (`TasksRepository`
|
||
→ `TasksRepositoryImpl`, `ProviderEnvironment` → `AndroidProviderEnvironment`)
|
||
and a `@Provides` module (the `agendula_prefs` DataStore, `TasksDatabase`, the
|
||
`@IoDispatcher`, the `@ApplicationScope`). `TasksDataSource` is `@Provides`
|
||
rather than `@Binds`, because it is a `ModeRoutingTasksDataSource` over
|
||
`Provider<RoomTasksDataSource>` and `Provider<AndroidTasksDataSource>` — both
|
||
singletons, so it picks between two long-lived objects rather than building
|
||
either. `AgendulaApp` is the `@HiltAndroidApp` entry point and pulls
|
||
`StartupGate`, `DatabaseCheckpoint` and `ReminderScheduler` through an
|
||
`@EntryPoint`; `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) |
|
||
| Store | Room 2.8.4 (KSP, `room.schemaLocation = app/schemas`, WAL), `org.dmfs:lib-recur` 0.12.2 pinned (0.16.0 removed `RecurrenceSet`; `rfc5545-datetime` arrives with it as part of its API surface) |
|
||
| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines, the `floret-kit` included build |
|
||
| Tests | JVM: JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source, `ProviderEnvironment`, `TaskFormWriter` and `RecurrenceExpander` as the JVM-testable seams. Instrumented (`app/src/androidTest`, AndroidJUnitRunner + Truth): the Room schema, `RoomTasksDataSource` and `OneShotImport` — the last against `assets/tasks-v23.db`, a fixture written by `scripts/make_import_fixture.py` in the provider's DATABASE_VERSION 23 schema, since the provider it came from no longer exists to test against. |
|
||
| 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
|
||
|
||
- **Permissions:** 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). `OWN` mode needs
|
||
nothing here at all: it is a database in our own data directory.
|
||
- **No `<provider>` and no custom permissions.** Agendula publishes no content
|
||
provider; `de.jeanlucmakiola.agendula.tasks`, the
|
||
`de.jeanlucmakiola.agendula.permission.*` pair and their permission group all
|
||
went with the `:provider` module.
|
||
- **Deliberately absent:** `GET_ACCOUNTS`, 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).
|
||
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, and
|
||
`ProviderChangeReceiver` — the latter filtering on the two *external*
|
||
authorities only (an intent-filter host must be a literal). No `EVENT_REMINDER`
|
||
receiver — that's a Calendula thing that doesn't apply here.
|
||
- **Backup:** `agendula-tasks.db` plus its `-wal` and `-shm` sidecars are
|
||
included in both `backup_rules.xml` and `data_extraction_rules.xml`;
|
||
`tasks.db.imported` is excluded, since it is a copy of data already imported.
|
||
Room runs in WAL mode and Auto Backup copies files without checkpointing, so
|
||
`DatabaseCheckpoint` runs `PRAGMA wal_checkpoint(TRUNCATE)` on `ON_STOP` to
|
||
keep the `.db` alone current for a restore that drops the sidecars.
|