fix(reminders): arm reminders again in our own store

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.
This commit is contained in:
2026-08-13 16:46:31 +02:00
parent 5abcbfc956
commit 1d4fe5b301
5 changed files with 455 additions and 186 deletions

View File

@@ -17,10 +17,11 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* The self-scheduled due-reminder engine. Tasks providers don't deliver * The self-scheduled due-reminder engine. Nothing else delivers task reminders —
* reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager] * not the platform, not a tasks provider so Agendula reads upcoming due tasks
* alarm each, within a rolling window. Re-run on app start, boot and provider * and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run
* change; it diffs against [ScheduledReminderStore] so only changed alarms move. * on app start, on boot, and on an external provider change; it diffs against
* [ScheduledReminderStore] so only changed alarms move.
*/ */
@Singleton @Singleton
class ReminderScheduler @Inject constructor( class ReminderScheduler @Inject constructor(
@@ -32,9 +33,11 @@ class ReminderScheduler @Inject constructor(
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) { ) {
suspend fun sync() = withContext(io) { suspend fun sync() = withContext(io) {
val provider = providerResolver.resolve()
val settings = settingsPrefs.settings.first() val settings = settingsPrefs.settings.first()
if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) { // Gate on whether the store is readable, not on whether a provider
// resolves: our own store deliberately resolves to no provider, so the
// latter clears every reminder in the default mode.
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
clearAll() clearAll()
return@withContext return@withContext
} }
@@ -43,12 +46,11 @@ class ReminderScheduler @Inject constructor(
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return@withContext } .getOrElse { return@withContext }
// One reminder per *occurrence*: the instances view yields a row per // One reminder per *occurrence*: a recurring series yields a row per
// occurrence, all sharing a taskId, so this is a Set rather than a // occurrence, all sharing a taskId, so this is a Set rather than a
// taskId-keyed Map — keying by task would collapse a daily recurring task // taskId-keyed Map — keying by task would collapse a daily recurring task
// down to one arbitrary reminder (the query is unsorted, so which one // down to one arbitrary reminder.
// survived was provider-defined). // Per-task leads. One query for all of them.
// Per-task leads, stored as Alarm property rows. One query for all of them.
val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() } val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() }
val desired = tasks val desired = tasks

View File

@@ -89,6 +89,19 @@ class ProviderResolver @Inject constructor(
fun hasPermission(provider: TaskProvider): Boolean = fun hasPermission(provider: TaskProvider): Boolean =
environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission) environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission)
/**
* Whether the active store can be read at all.
*
* [StorageMode.OWN] always can — it is our own database, with nothing to
* install and nothing to grant. Only [StorageMode.EXTERNAL] can be
* unreadable. Callers that gate on `resolve() != null` instead get this wrong
* the moment OWN is active, because OWN resolves to no provider by design.
*/
fun canReadStore(): Boolean = when (mode()) {
StorageMode.OWN -> true
StorageMode.EXTERNAL -> resolveExternal()?.let(::hasPermission) == true
}
companion object { companion object {
/** /**
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by

View File

@@ -39,6 +39,14 @@ class ProviderResolverTest {
@Nested @Nested
inner class OwnStore { inner class OwnStore {
@Test
fun `is readable without anything installed or granted`() {
// Guards a real regression: the reminder engine used to gate on
// resolve() != null, which is exactly what OWN returns, so every
// reminder was cleared the moment our own store became the default.
assertThat(resolver(mode = StorageMode.OWN).canReadStore()).isTrue()
}
@Test @Test
fun `resolves to no provider at all`() { fun `resolves to no provider at all`() {
// Room has no authority and no ContentResolver, so there is nothing // Room has no authority and no ContentResolver, so there is nothing
@@ -108,6 +116,21 @@ class ProviderResolverTest {
assertThat(resolver(mode = StorageMode.EXTERNAL).resolve()).isNull() assertThat(resolver(mode = StorageMode.EXTERNAL).resolve()).isNull()
} }
@Test
fun `external is unreadable until a provider is installed and granted`() {
assertThat(resolver(mode = StorageMode.EXTERNAL).canReadStore()).isFalse()
assertThat(
resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL).canReadStore(),
).isFalse()
assertThat(
resolver(
installed = openTasksInstalled,
granted = openTasksGranted,
mode = StorageMode.EXTERNAL,
).canReadStore(),
).isTrue()
}
@Test @Test
fun `external still requires the runtime permission`() { fun `external still requires the runtime permission`() {
val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL) val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL)

View File

@@ -8,11 +8,10 @@ This document describes how Agendula is built **as it stands today**. For the
## 1. The thesis in one sentence ## 1. The thesis in one sentence
Agendula is a Material 3 Expressive task app over the dmfs `TaskContract` — it Agendula is a Material 3 Expressive task app that reads, writes and reminds
reads, writes, and reminds against a task store the user chooses: **its own against a task store the user chooses: **its own database** (the default) or an
bundled provider** (the default) or an external provider app already on the external provider app already on the device (OpenTasks, tasks.org) synced by
device (OpenTasks, tasks.org) synced by DAVx5, SmoothSync, DecSync CC and the DAVx5, SmoothSync, DecSync CC and the like. It is the task-list sibling to
like. It is the task-list sibling to
[Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing [Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing
for `CalendarContract`. for `CalendarContract`.
@@ -20,19 +19,25 @@ for `CalendarContract`.
the original "owns no database" thesis, settled in the original "owns no database" thesis, settled in
[`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md): depending on a provider app being [`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 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 store is a **Room database of our own**, designed against RFC 5545's `VTODO` and
a schema written from scratch — so every CalDAV engine still understands it. A against what a CalDAV sync adapter will need — the reasoning is in
sync adapter of our own is the 1.x arc, designed in [`SYNC.md`](SYNC.md). [`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 whole design hangs off one rule:
> The entire app talks to a `TasksRepository`. Only the data layer knows there > The entire app talks to a `TasksRepository`. Only the data layer knows there
> is a `ContentResolver`, a `TaskContract`, or an authority string behind it. > is a Room database, a `ContentResolver`, a `TaskContract` or an authority
> **Provider column names and the authority string never leak above the data > string behind it. **Table and column names and the authority string never leak
> layer.** > above the data layer.**
That rule is what let Posture B land as an addition rather than a rewrite: the That rule is what let the entire store be swapped without a rewrite: replacing
UI, the ViewModels and the domain were untouched by it. See §7. the provider with Room changed no UI, no ViewModel and exactly one domain field
(`Task.id``Task.occurrenceStart`, §4.8). See §7.
--- ---
@@ -46,35 +51,36 @@ UI, the ViewModels and the domain were untouched by it. See §7.
│ domain models + Flows only │ domain models + Flows only
┌───────────────▼──────────────────────────────┐ ┌───────────────▼──────────────────────────────┐
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │ Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
DayWindow (pure Kotlin, no Android) TaskSections, RecurrenceExpander
│ (pure Kotlin, no Android) │
└───────────────┬──────────────────────────────┘ └───────────────┬──────────────────────────────┘
│ TasksRepository (interface) │ TasksRepository (interface)
┌───────────────▼──────────────────────────────┐ ┌───────────────▼──────────────────────────────┐
Data │ TasksRepositoryImpl │ Data │ TasksRepositoryImpl │
│ └ TasksDataSource (interface) │ │ └ TasksDataSource (interface) │
│ └ AndroidTasksDataSource │ └ ModeRoutingTasksDataSource │
└ ContentResolver / TaskContract / ├ RoomTasksDataSource (OWN)
ProviderResolver / ContentObserver └ AndroidTasksDataSource (EXTERNAL)
│ reminders/ prefs/ di/ demo/ │ │ reminders/ prefs/ di/ demo/ │
└───────────────┬──────────────────────────────┘ └────────┬────────────────────────┬────────────┘
content:// │ Room DAOs │ content://
┌───────────────▼──────────────────────────────┐ ─────────────▼──────────┐ ┌──────────▼─────────────┐
Storage │ Local mode (default): Sto- │ Own mode (default): External mode:
:provider — our own bundled task provider rage │ agendula-tasks.db — OpenTasks / tasks.org
│ same uid, no permission grant needed four tables in our │ │ ←sync← DAVx5 / …
│ External mode: │ own data directory, dangerous perms,
OpenTasks / tasks.org ←sync← DAVx5 / … nothing to permit │ │ asked at point of use
│ dangerous perms, requested at point of use │ └────────────────────────┘ └────────────────────────┘
└──────────────────────────────────────────────┘
``` ```
The seam that matters is the pair of interfaces in the data layer: The seam that matters is the pair of interfaces in the data layer:
- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend - **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend
writes. (`data/tasks/TasksRepository.kt`) writes. (`data/tasks/TasksRepository.kt`)
- **`TasksDataSource`** — the JVM-testable interface that does the actual - **`TasksDataSource`** — the JVM-testable, domain-shaped interface that does the
provider work; `AndroidTasksDataSource` is the only Android-coupled actual store work. Two implementations: `RoomTasksDataSource` for our own
implementation. store and `AndroidTasksDataSource` for an external provider, picked per call by
`ModeRoutingTasksDataSource` (§4.1).
Both are bound in Hilt in `data/di/DataModule.kt`. Both are bound in Hilt in `data/di/DataModule.kt`.
@@ -82,49 +88,57 @@ Both are bound in Hilt in `data/di/DataModule.kt`.
## 3. Module & package layout ## 3. Module & package layout
Two modules: `:app` and `:provider`. Package root `de.jeanlucmakiola.agendula`. One module, `:app`, plus the `floret-kit` included build (`includeBuild` in
`settings.gradle.kts`, consumed as `de.jeanlucmakiola.floret:*`). The
| Module | Contents | `:provider` module — the vendored dmfs task provider — was deleted with the own
|---|---| store; `provider/PROVENANCE.md` went with it, its content preserved as a
| `: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. | postscript in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Package root
`de.jeanlucmakiola.agendula`.
| Package (`:app`) | Contents | | 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/` | `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. | | `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/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/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/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. |
| `data/prefs/` | `SettingsPrefs` (DataStore). | | `data/prefs/` | `SettingsPrefs` (DataStore). |
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). | | `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). |
| `data/demo/` | `DemoSeeder` (debug-only sample data). | | `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`. | | `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`. | | root | `AgendulaApp` (Hilt app), `MainActivity`. |
--- ---
## 4. The data layer (the heart) ## 4. The data layer (the heart)
### 4.1 Provider targeting — `ProviderResolver` ### 4.1 Which store — `StorageMode` and `ProviderResolver`
`ProviderResolver.resolve()` returns the active `TaskProvider(authority, `StorageMode` has two values, `OWN` and `EXTERNAL`, and
readPermission, writePermission, packageName, isOwn)` for the selected `ModeRoutingTasksDataSource` picks the implementation **per call** — the mode is
`StorageMode`. 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 | Provider | Authority | Permissions | | Mode | Store | Authority | Permissions |
|---|---|---|---| |---|---|---|---|
| **Local** (default) | ours, bundled | `de.jeanlucmakiola.agendula.tasks` | **none** — same uid | | **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 | OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
| External | tasks.org | `org.tasks.opentasks` | `org.tasks.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, `ProviderResolver` is now only about the second and third rows: it discovers the
vendored — so the **same `TaskContract` columns apply** throughout. *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.
`hasPermission()` short-circuits to `true` for our own provider: a same-uid `providerStatus()` is unconditionally `READY` in `OWN` mode. The permission gate
caller bypasses a provider's permission checks outright, so only ever applied to External, and that is now visibly true rather than a
`ProviderStatus.NEEDS_PERMISSION` can never fire in Local mode. In External mode same-uid special case inside `hasPermission()`. In External mode `null` from
it checks both runtime perms, and `null` from `resolve()` drives the "install a `resolve()` is `NO_PROVIDER` and a missing runtime permission is
tasks provider" gate. `NEEDS_PERMISSION`, which is what drives onboarding.
**Choosing the mode.** An explicit choice is stored in `SettingsPrefs` and **Choosing the mode.** An explicit choice is stored in `SettingsPrefs` and
mirrored into the resolver by `StorageModeHolder` — the resolver is consulted mirrored into the resolver by `StorageModeHolder` — the resolver is consulted
@@ -132,71 +146,194 @@ synchronously on every query and cannot read DataStore itself. When there is no
explicit choice (the normal case), `autoMode()` decides: explicit choice (the normal case), `autoMode()` decides:
> **External if we already hold an external provider's runtime permission, > **External if we already hold an external provider's runtime permission,
> otherwise Local.** > otherwise Own.**
That permission is dangerous-level, so it can only be there because an earlier 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, 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 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. 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 The platform calls sit behind `ProviderEnvironment` so this decision is unit
tested on the JVM (`ProviderResolverTest`) rather than only on a device. tested on the JVM (`ProviderResolverTest`) rather than only on a device.
**Storage modes** are `LOCAL` and `EXTERNAL` only. `STORAGE-AND-SYNC.md` **Synced is still not a third mode.** `STORAGE-AND-SYNC.md` describes three; a
describes three, but *Synced* is not a third store — it is the same store with an synced list is `OWN` with an account attached, which is derived state rather than
account attached, so it stays derived state. 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.)
⚠️ **What this used to say — "so turning sync on is not a migration" — is wrong.** ### 4.2 The own store — four Room tables
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` `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, A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
table paths, status/priority constants, the local-account type. Agendula does table paths, status/priority constants, the local-account type. Agendula does
**not** take a runtime dependency on OpenTasks; the authority is injected from **not** take a runtime dependency on OpenTasks; the authority is injected from
`ProviderResolver`, never hardcoded in the contract. `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.3 Reads — Instances + `ContentObserver` ### 4.6 Reads and reactivity
`AndroidTasksDataSource` queries the denormalized **instances** view (so each In `OWN` mode `RoomTasksDataSource` reads through the DAOs and expands
occurrence is a row with the joined list colour, account, etc.), maps each recurrences (§4.3). In `EXTERNAL` mode `AndroidTasksDataSource` queries the
cursor row through `ColumnReader``TaskMapper` → domain `Task`, and exposes denormalized **instances** view (so each occurrence is a row with the joined list
the result as a `Flow`. A `ContentObserver` on the active authority's colour, account, etc.) and maps each cursor row through `ColumnReader` →
Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change `TaskMapper` → domain `Task`.
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 `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 ```kotlin
interface TasksRepository { interface TasksRepository {
fun taskLists(): Flow<List<TaskList>> fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>> fun tasks(filter: TaskFilter): Flow<List<Task>>
fun subtasks(parentId: Long): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?> fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long suspend fun createTask(form: TaskForm): Long
suspend fun updateTask(taskId: Long, form: TaskForm) suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
suspend fun deleteTask(taskId: Long) suspend fun deleteTask(taskId: Long)
suspend fun reminderFor(taskId: Long): Int?
suspend fun createLocalList(name: String, color: Int): Long suspend fun createLocalList(name: String, color: Int): Long
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
} }
``` ```
`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion `updateTask` on an occurrence of a recurring task routes to
sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back `TasksDataSource.updateInstance(taskId, occurrenceStart, form)` rather than
out as a normal VTODO status change. Writes to local/unsynced lists use the moving the series anchor. `expectedLastModified` re-checks the stored timestamp
sync-adapter URI form where the provider requires it. first and throws `TaskConflictException` when something changed underneath the
form.
### 4.5 Domain model notes Completion sets `STATUS = COMPLETED` (+ percent/completed timestamp); in
External mode DAVx5 syncs that back out as a normal VTODO status change.
- `Task.id` is the **instance** row id; `Task.taskId` is the underlying ### 4.8 Domain model notes
`tasks._id` and the stable target for edits/completion.
- `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`); - Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`);
`TaskDetail` bundles a task with its direct children. `TaskDetail` bundles a task with its direct children.
- `effectiveColor` = the task's own colour, else the list colour. - `effectiveColor` = the task's own colour, else the list colour.
@@ -210,7 +347,7 @@ sync-adapter URI form where the provider requires it.
`TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists — `TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists —
`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due `ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due
dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking
`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it `todayStart`/`todayEnd` (local-midnight bounds from floret-kit's `DayWindow`), so it
unit-tests with a fixed clock. `TaskSorting` orders within a list (due / 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 priority / etc.). None of this touches Android, which is why it's all in
`domain/`. `domain/`.
@@ -231,10 +368,24 @@ providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`)
`set` when `canScheduleExactAlarms()` is false), keyed by `taskId`. `set` when `canScheduleExactAlarms()` is false), keyed by `taskId`.
- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel, - **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel,
`POST_NOTIFICATIONS` gate, dedupe-by-tag). `POST_NOTIFICATIONS` gate, dedupe-by-tag).
- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and - **Re-sync triggers:** app start (after `StartupGate`), **`BootReceiver`**
**`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external (re-arm after reboot), and **`ProviderChangeReceiver`**. The store lets each
sync changed the data). The store lets each run diff like Calendula diffs run diff like Calendula diffs reminder rows.
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. This is the single largest piece of genuinely-new code in Agendula.
@@ -248,28 +399,40 @@ They no longer mean what earlier drafts of this document said.
- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org). - **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org).
Still fully supported; it stopped being the only option and became a user Still fully supported; it stopped being the only option and became a user
choice, `StorageMode.EXTERNAL`. choice, `StorageMode.EXTERNAL`.
- **Posture B (shipped)** — the `:provider` module: the Apache-2.0 dmfs provider - **Posture B (shipped, then rebuilt)** — a store of our own, `StorageMode.OWN`.
vendored under **our own** authority `de.jeanlucmakiola.agendula.tasks` and our It first shipped as the `:provider` module, the Apache-2.0 dmfs provider
own permission namespace. It **coexists with everything and replaces nothing**. 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:** bundling the provider under dmfs's *own* > **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 > authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the
> same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same > same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same
> `<permission>` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with > `<permission>` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with
> OpenTasks installed simply could not have installed Agendula. Account > OpenTasks installed simply could not have installed Agendula. Account
> visibility is also keyed by *package*, not authority, which would have left the > visibility is also keyed by *package*, not authority, which would have left such
> bundled provider seeing zero accounts and pruning synced lists as orphaned. > a provider seeing zero accounts and pruning synced lists as orphaned. Full
> Full reasoning in `STORAGE-AND-SYNC.md`. > reasoning in `STORAGE-AND-SYNC.md`.
The seam earned its keep: `ProviderResolver` is still the only thing that knows The seam earned its keep twice over. `ProviderResolver` is still the only thing
an authority exists and `AndroidTasksDataSource` the only thing that touches a that knows an authority exists and `AndroidTasksDataSource` the only thing that
resolver, so vendoring an entire content provider **changed no UI, no ViewModel, touches a resolver, so vendoring an entire content provider changed nothing above
no domain type, and not one line of `TasksRepository`.** 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.
Bundling the provider bundles **storage, not sync**. Our own sync adapter is a ⚠️ **The authority is gone, and that is a breaking change.**
separate, later piece of work — designed in [`SYNC.md`](SYNC.md), and it lands `de.jeanlucmakiola.agendula.tasks` and both custom permissions no longer exist,
*underneath* this same seam: it writes through `TaskContract` with so anyone who had pointed DAVx5 or another app at that authority loses it.
`CALLER_IS_SYNCADAPTER`, so the layers above it stay untouched a second time. 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.
--- ---
@@ -283,9 +446,9 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`,
`RootScreen` is the entry composable: it gates on `ProviderStatus` `RootScreen` is the entry composable: it gates on `ProviderStatus`
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` → (`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` →
`ListsScreen`). The remaining screens are being built one at a time — their `AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is
ViewModels exist and are tested against the real data layer; navigation only ever seen in External mode. Routes are the `Dest` table in
callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the `ui/navigation/` (lists → task list → detail / edit, plus settings). Follow the
`material-3` skill for component choices (M3 `ListItem` rows, expressive `material-3` skill for component choices (M3 `ListItem` rows, expressive
checkbox/FAB/swipe motion). checkbox/FAB/swipe motion).
@@ -293,11 +456,17 @@ checkbox/FAB/swipe motion).
## 9. Dependency injection ## 9. Dependency injection
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module (`TasksRepository`
(`TasksDataSource``AndroidTasksDataSource`, `TasksRepository` → `TasksRepositoryImpl`, `ProviderEnvironment` → `AndroidProviderEnvironment`)
`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore, and a `@Provides` module (the `agendula_prefs` DataStore, `TasksDatabase`, the
the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; `@IoDispatcher`, the `@ApplicationScope`). `TasksDataSource` is `@Provides`
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected. 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.
--- ---
@@ -308,9 +477,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 | | 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 | | 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) | | 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 | | 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) |
| `: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`) | | Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines, the `floret-kit` included build |
| 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. | | 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). | | 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). | | 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 | | Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs |
@@ -319,25 +488,28 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
## 11. Manifest surface ## 11. Manifest surface
- **Declared by `:app`:** both `org.dmfs.*` and `org.tasks.*` read/write tasks - **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms
perms (static manifest, so they are always declared; requested at runtime only (static manifest, so they are always declared; requested at runtime only in
in External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
(`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). (`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). `OWN` mode needs
- **Declared by `:provider`:** the `<provider>` itself plus our own nothing here at all: it is a database in our own data directory.
`de.jeanlucmakiola.agendula.permission.READ_TASKS` / `WRITE_TASKS` and their - **No `<provider>` and no custom permissions.** Agendula publishes no content
permission group. These exist for **other** apps — Agendula reaches its own provider; `de.jeanlucmakiola.agendula.tasks`, the
provider same-uid and neither declares a `uses-permission` for them nor asks. `de.jeanlucmakiola.agendula.permission.*` pair and their permission group all
The provider is `exported="true"` on purpose: that is what would let DAVx5 went with the `:provider` module.
write into it once it knows our authority. - **Deliberately absent:** `GET_ACCOUNTS`, and `INTERNET`, which stays undeclared
- **Deliberately absent:** `GET_ACCOUNTS` (stripped from the vendored provider — until sync actually ships. Export needs no storage permission at all; SAF hands
see change 1 in `PROVENANCE.md`) and `INTERNET`, which stays undeclared until us a `Uri` the user picked.
sync actually ships. Export needs no storage permission at all; SAF hands us a - **`<queries>`** for package visibility: both external provider authorities +
`Uri` the user picked.
- **`<queries>`** for package visibility: both *external* provider authorities +
a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open
the provider / a store listing). Our own provider needs no entry. the provider / a store listing).
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, - **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, and
`ProviderChangeReceiver` (all three authorities, ours first — an intent-filter `ProviderChangeReceiver` — the latter filtering on the two *external*
host must be a literal), and the vendored provider's own authorities only (an intent-filter host must be a literal). No `EVENT_REMINDER`
`TaskProviderBroadcastReceiver`. No `EVENT_REMINDER` receiver — that's a receiver — that's a Calendula thing that doesn't apply here.
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.

View File

@@ -10,18 +10,19 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
## Current state (one line) ## Current state (one line)
Agendula now **carries its own task store**: the `:provider` module ships the Agendula now **carries its own task store, and it is one we wrote**: a Room
vendored dmfs provider under our authority, so the app is complete and local-first database designed against `VTODO`, with recurrence expanded at read time. The
with nothing else installed, and an external provider (OpenTasks / tasks.org) is a vendored dmfs provider and the `:provider` module that held it are deleted, a
user choice rather than a requirement. The non-visual stack over `TaskContract` is v0.3.x install's tasks are imported on first launch, and an external provider
done and unit-tested, export to iCalendar has landed, and the Material 3 (OpenTasks / tasks.org) is a user choice rather than a requirement. Export to
Expressive UI is built through **M5**: lists → task list (swipe gestures, inline iCalendar has landed, and the Material 3 Expressive UI is built through **M5**:
add, smart-list section headers) → detail / edit with full CRUD, date-time lists → task list (swipe gestures, inline add, smart-list section headers) →
pickers, priority, percent-complete, conflict-safe saves, per-task reminders, and detail / edit with full CRUD, date-time pickers, priority, percent-complete,
subtask create + reparent — plus a one-time reminder onboarding step and a conflict-safe saves, per-task reminders, and subtask create + reparent — plus a
Settings screen. Remaining work is the **frontend surfaces for what just landed** one-time reminder onboarding step and a Settings screen. Remaining work is
(a storage-mode picker, an export screen), then M6 (Glance widget, translations, hardening the new store (`OWN-STORE.md` phase 6), the **frontend surfaces for
F-Droid release) and the sync adapter. what has landed** (a storage-mode picker, an export screen), then M6 (Glance
widget, translations, F-Droid release) and the sync adapter.
--- ---
@@ -133,33 +134,86 @@ The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
### ✅ Posture B — our own task store ### ✅ Posture B — our own task store
Agendula stopped depending on a provider app being installed. Direction and Agendula stopped depending on a provider app being installed. Direction and
reasoning in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md); note it **redefined** reasoning in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md); note it **redefined**
what Posture B means (our own authority, coexisting with everything — *not* what Posture B means (our own store, coexisting with everything — *not* squatting
squatting `org.dmfs.tasks`, which is a dead end). `org.dmfs.tasks`, which is a dead end).
-`fix/provider-interaction-review` merged (step 1). -`fix/provider-interaction-review` merged (step 1).
-`:provider` — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored in-tree -Step 2, first pass — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored
under `de.jeanlucmakiola.agendula.tasks` and our own permission namespace. in-tree as `:provider`, under `de.jeanlucmakiola.agendula.tasks` and our own
`GET_ACCOUNTS` dropped, with the account-cleanup path reworked so it can only permission namespace. Shipped in v0.3.x and **since superseded**: see "our own
prune account types we authenticate ourselves — the deletion is unsafe without store" below.
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 56 JVM tests pass.
- ✅ Storage modes + the permission-gate bypass — `ProviderStatus.NEEDS_PERMISSION` - ✅ 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 can no longer fire in our own mode, and an upgrading Posture A user stays on the
provider that holds their data (`ProviderResolver.autoMode`). provider that holds their data (`ProviderResolver.autoMode`).
- ✅ Export to iCalendar (step 3) — a v1 feature now that Local-mode data lives - ✅ Export to iCalendar (step 3) — a v1 feature now that own-mode data lives
only in our app's private storage. One `.ics` per list, to a folder or a zip, only in our app's private storage. One `.ics` per list, to a folder or a zip,
via SAF. Backend only. via SAF. Backend only.
-**Frontend surfaces for the above** — a storage-mode picker in Settings and -**Frontend surfaces for the above** — a storage-mode picker in Settings and
an export screen. The backend is done and unused until these exist. 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. - ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users.
Note it now means "sync into an app that has no provider", so the ask has
changed shape.
- ⬜ Sync adapter (step 5) — the 1.x arc. **Designed in [`SYNC.md`](SYNC.md)**, - ⬜ Sync adapter (step 5) — the 1.x arc. **Designed in [`SYNC.md`](SYNC.md)**,
not started: mapper → auth → engine → hardening, ~89 weeks. The account model not started: mapper → auth → engine → hardening, ~89 weeks, minus the 2.54
is settled (`AccountManager`) and `ical4android` is closed out (superseded by weeks owning the store deletes from it (`OWN-STORE.md`, "Effects on the sync
`synctools`, GPLv3, so we write the mapper in-house); what's still open is plan"). The account model is settled (`AccountManager`) and `ical4android` is
dav4jvm's JitPack-only distribution, conflict policy, and whether External mode closed out (superseded by `synctools`, GPLv3, so we write the mapper
survives the milestone. in-house); what's still open is dav4jvm's JitPack-only distribution, conflict
- ⬜ Verify on a device: the local path with no account, and the vendored policy, and whether External mode survives the milestone.
provider's timezone-change behaviour (change 3 in `PROVENANCE.md`).
### ✅ Our own store — Room, and the provider deleted
The vendored provider was kept because it appeared to hand us the sync
bookkeeping for free; the phase-1 sync audit measured that bookkeeping and found
most of it broken, absent or unusable. Reasoning in
[`STORAGE-DECISION.md`](STORAGE-DECISION.md), architecture and six-phase plan in
[`OWN-STORE.md`](OWN-STORE.md).
- ✅ Phase 0 — occurrences addressed by `(taskId, occurrenceStart)`. `Task.id`
(the materialised instance row id) dropped for `occurrenceStart`, lazy-list
keys moved to `Task.occurrenceKey`, `updateInstance` re-signed, `domain/`
stopped importing `TasksContract`. Done while the provider was still the store,
so the External path exercised the new seam first.
- ✅ Phase 1 — the schema: `task_lists`, `tasks`, `task_alarms`, `accounts`, a
DAO per table, the v1 schema JSON committed for migration testing. Masters and
`RECURRENCE-ID` overrides share the `tasks` table, so the unique index is
`(list_id, uid, recurrence_id)`.
- ✅ Phase 2 — `RecurrenceExpander` over `lib-recur` 0.12.2: a series expanded in
memory at read time, bounded by a window (1 year back, 2 forward) and a hard
per-series ceiling. No materialised instances table, so none of its staleness
bugs. 38 tests against RFC 5545 directly, since the provider only ever
materialised one occurrence to compare against.
- ✅ Phase 3 — `RoomTasksDataSource` implements all 14 seam methods, picked per
call by `ModeRoutingTasksDataSource`. Editing one occurrence writes a
`RECURRENCE-ID` override sharing the master's UID (RFC 5545 model (a)), where
the provider forked a new task with a new UID (model (d)). Completion rules are
stated directly rather than worked around, so a task can no longer strand
itself "done at 75%".
- ✅ Phase 4 — `OneShotImport` moves a v0.3.x install's `databases/tasks.db` into
Room on first launch, archiving the source as `tasks.db.imported`; `OWN` is the
default; `StartupGate` holds the first store read until the mode has landed and
the import has run; backup rules take the database with its WAL sidecars and
the app checkpoints on `ON_STOP`.
- ✅ Phase 5 — `:provider` deleted: 84 Java files, 14,555 lines, its `<provider>`,
its two custom permissions and its three dmfs runtime dependencies.
`StorageMode.LOCAL` is gone (a stored `LOCAL` reads as `OWN`); `ProviderResolver`
narrows to discovering external providers; `ProviderChangeReceiver` filters on
the two external authorities only. `provider/PROVENANCE.md` is replaced by a
postscript in `STORAGE-DECISION.md`. **Breaking:** the
`de.jeanlucmakiola.agendula.tasks` authority and both custom permissions no
longer exist — anyone who pointed DAVx5 at that authority loses it, and the
release notes have to say so.
- ✅ Phase 6 — harden: `MigrationTestHelper` wired against the committed v1
schema so v1 → v2 is cheap when sync adds columns, an Auto Backup restore test
covering the WAL case in both directions, and a performance check at 5,000
tasks with 20 recurring series.
- ✅ Fallout: `ReminderScheduler.sync()` gated on `resolve() != null`, which is
what `OWN` returns, so no due reminder armed in the default mode. It now gates
on `ProviderResolver.canReadStore()`, with tests.
-**Run the instrumented suite on a device.** Six classes — the Room seam, the
DAOs, the import, the migration harness, the restore path and the performance
check — all compile and none has ever executed. Everything load-bearing about
this migration is verified only by tests that have not run.
- ⬜ Verify on a device: a fresh install on the Room store, and an upgrade from a
v0.3.2 APK with seeded data landing every task, list and reminder.
- ⬜ Per-locale release notes for the dropped authority and permissions.
--- ---
@@ -174,15 +228,15 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through
(Not in the candidate list today.) Note this is now downstream of (Not in the candidate list today.) Note this is now downstream of
[`SYNC.md`](SYNC.md) open question 3: if External mode is retired once we sync [`SYNC.md`](SYNC.md) open question 3: if External mode is retired once we sync
ourselves, the question disappears with it. ourselves, the question disappears with it.
4. ~~**Posture B authority choice**~~ resolved: **our own** 4. ~~**Posture B authority choice**~~ moot: Agendula publishes no provider and
`de.jeanlucmakiola.agendula.tasks`. Squatting `org.dmfs.tasks` is a dead end, holds no authority at all. The question was live while the store was a
not merely a trade-off — two apps cannot declare the same authority or vendored provider, and squatting `org.dmfs.tasks` was a dead end even then —
permission name, so anyone with OpenTasks installed could not have installed two apps cannot declare the same authority or permission name, so anyone with
Agendula at all. OpenTasks installed could not have installed Agendula at all.
5. ~~**Recurring tasks** — recurrence-aware editing out of scope for v1~~ stale: 5. ~~**Recurring tasks** — recurrence-aware editing out of scope for v1~~
`fix/provider-interaction-review` routes edits to a recurring task through the resolved: our own store expands a series at read time and writes an edit to
instances URI, so the provider forks an override instead of re-anchoring the one occurrence as a `RECURRENCE-ID` override sharing the master's UID; in
series. External mode the edit still goes through the instances URI.
6. **Resolver ordering / mode-selection UX**`autoMode()` picks a sane default 6. **Resolver ordering / mode-selection UX**`autoMode()` picks a sane default
today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it
assumes is not built yet. assumes is not built yet.
@@ -196,7 +250,12 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through
- Build: `./gradlew :app:assembleDebug` - Build: `./gradlew :app:assembleDebug`
- Unit tests: `./gradlew :app:testDebugUnitTest` - Unit tests: `./gradlew :app:testDebugUnitTest`
- Run on a device/emulator that has **OpenTasks** or **tasks.org** installed (and - Instrumented tests: `./gradlew :app:connectedDebugAndroidTest` — the Room
ideally DAVx5 syncing a CalDAV task list) so the read/write paths have real schema, `RoomTasksDataSource` and `OneShotImport` (the last against
data. Debug builds use `DemoSeeder` for sample data when no provider data is `app/src/androidTest/assets/tasks-v23.db`, regenerated by
present. `scripts/make_import_fixture.py`).
- Any device or emulator will do for the default path: the store is ours and
needs nothing installed. Debug builds seed an "Agendula Demo" list via
`DemoSeeder` unless it already exists. To exercise **External** mode, use a
device with **OpenTasks**
or **tasks.org** installed, and ideally DAVx5 syncing a CalDAV task list.