diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt index 8f1cb58..ec63c3a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt @@ -17,10 +17,11 @@ import javax.inject.Inject import javax.inject.Singleton /** - * The self-scheduled due-reminder engine. Tasks providers don't deliver - * reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager] - * alarm each, within a rolling window. Re-run on app start, boot and provider - * change; it diffs against [ScheduledReminderStore] so only changed alarms move. + * The self-scheduled due-reminder engine. Nothing else delivers task reminders — + * not the platform, not a tasks provider — so Agendula reads upcoming due tasks + * and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run + * on app start, on boot, and on an external provider change; it diffs against + * [ScheduledReminderStore] so only changed alarms move. */ @Singleton class ReminderScheduler @Inject constructor( @@ -32,9 +33,11 @@ class ReminderScheduler @Inject constructor( @IoDispatcher private val io: CoroutineDispatcher, ) { suspend fun sync() = withContext(io) { - val provider = providerResolver.resolve() 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() return@withContext } @@ -43,12 +46,11 @@ class ReminderScheduler @Inject constructor( val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } .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 // taskId-keyed Map — keying by task would collapse a daily recurring task - // down to one arbitrary reminder (the query is unsorted, so which one - // survived was provider-defined). - // Per-task leads, stored as Alarm property rows. One query for all of them. + // down to one arbitrary reminder. + // Per-task leads. One query for all of them. val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() } val desired = tasks diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index 207755f..a8ebe11 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -89,6 +89,19 @@ class ProviderResolver @Inject constructor( fun hasPermission(provider: TaskProvider): Boolean = 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 { /** * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt index da2eab5..6f4e129 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -39,6 +39,14 @@ class ProviderResolverTest { @Nested 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 fun `resolves to no provider at all`() { // Room has no authority and no ContentResolver, so there is nothing @@ -108,6 +116,21 @@ class ProviderResolverTest { 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 fun `external still requires the runtime permission`() { val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36d125e..c216fb7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,11 +8,10 @@ This document describes how Agendula is built **as it stands today**. For the ## 1. The thesis in one sentence -Agendula is a Material 3 Expressive task app over the dmfs `TaskContract` — it -reads, writes, and reminds against a task store the user chooses: **its own -bundled provider** (the default) or an external provider app already on the -device (OpenTasks, tasks.org) synced by DAVx5, SmoothSync, DecSync CC and the -like. It is the task-list sibling to +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`. @@ -20,19 +19,25 @@ for `CalendarContract`. the original "owns no database" thesis, settled in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md): depending on a provider app being installed made someone else's roadmap a gate on the app working at all. The -database is the vendored dmfs provider under *our* authority — our namespace, not -a schema written from scratch — so every CalDAV engine still understands it. A -sync adapter of our own is the 1.x arc, designed in [`SYNC.md`](SYNC.md). +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 `ContentResolver`, a `TaskContract`, or an authority string behind it. -> **Provider column names and the authority string never leak above the data -> layer.** +> 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 Posture B land as an addition rather than a rewrite: the -UI, the ViewModels and the domain were untouched by it. See §7. +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. --- @@ -46,35 +51,36 @@ UI, the ViewModels and the domain were untouched by it. See §7. │ domain models + Flows only ┌───────────────▼──────────────────────────────┐ Domain │ Models, TaskForm, TaskFilter, TaskSorting, │ - │ DayWindow (pure Kotlin, no Android) │ + │ TaskSections, RecurrenceExpander │ + │ (pure Kotlin, no Android) │ └───────────────┬──────────────────────────────┘ │ TasksRepository (interface) ┌───────────────▼──────────────────────────────┐ Data │ TasksRepositoryImpl │ │ └ TasksDataSource (interface) │ - │ └ AndroidTasksDataSource │ - │ └ ContentResolver / TaskContract / │ - │ ProviderResolver / ContentObserver│ + │ └ ModeRoutingTasksDataSource │ + │ ├ RoomTasksDataSource (OWN) │ + │ └ AndroidTasksDataSource (EXTERNAL)│ │ reminders/ prefs/ di/ demo/ │ - └───────────────┬──────────────────────────────┘ - │ content:// - ┌───────────────▼──────────────────────────────┐ - Storage │ Local mode (default): │ - │ :provider — our own bundled task provider │ - │ same uid, no permission grant needed │ - │ External mode: │ - │ OpenTasks / tasks.org ←sync← DAVx5 / … │ - │ dangerous perms, requested at point of use │ - └──────────────────────────────────────────────┘ + └────────┬────────────────────────┬────────────┘ + │ 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 interface that does the actual - provider work; `AndroidTasksDataSource` is the only Android-coupled - implementation. +- **`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`. @@ -82,49 +88,57 @@ Both are bound in Hilt in `data/di/DataModule.kt`. ## 3. Module & package layout -Two modules: `:app` and `:provider`. Package root `de.jeanlucmakiola.agendula`. - -| Module | Contents | -|---|---| -| `:provider` | Agendula's own task store — the Apache-2.0 dmfs task provider 1.4.2, vendored in-tree under our authority and permission namespace. Not our code; see [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) for the upstream commit and every deviation. No app code imports from it except `ProviderResolver`, which reads the authority out of its resources. | +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), `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. | -| `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/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. | | `data/prefs/` | `SettingsPrefs` (DataStore). | | `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). | | `data/demo/` | `DemoSeeder` (debug-only sample data). | -| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. | +| `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 Provider targeting — `ProviderResolver` +### 4.1 Which store — `StorageMode` and `ProviderResolver` -`ProviderResolver.resolve()` returns the active `TaskProvider(authority, -readPermission, writePermission, packageName, isOwn)` for the selected -`StorageMode`. +`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 | 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 | tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | -All three are backed by the same dmfs `TaskProvider` — ours *is* that provider, -vendored — so the **same `TaskContract` columns apply** throughout. +`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. -`hasPermission()` short-circuits to `true` for our own provider: a same-uid -caller bypasses a provider's permission checks outright, so -`ProviderStatus.NEEDS_PERMISSION` can never fire in Local mode. In External mode -it checks both runtime perms, and `null` from `resolve()` drives the "install a -tasks provider" gate. +`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 @@ -132,71 +146,194 @@ synchronously on every query and cannot read DataStore itself. When there is no explicit choice (the normal case), `autoMode()` decides: > **External if we already hold an external provider's runtime permission, -> otherwise Local.** +> 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 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 tested on the JVM (`ProviderResolverTest`) rather than only on a device. -**Storage modes** are `LOCAL` and `EXTERNAL` only. `STORAGE-AND-SYNC.md` -describes three, but *Synced* is not a third store — it is the same store with an -account attached, so it stays derived state. +**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.) -⚠️ **What this used to say — "so turning sync on is not a migration" — is wrong.** -A task list's `ACCOUNT_NAME`/`ACCOUNT_TYPE` are write-once in the provider -(`processors/lists/Validating.java:68-76`), so local lists cannot be re-pointed at -an account; tasks have to be moved into new lists. Not modelling `SYNCED` as a -mode is still right, but the migration it implied away is real. See -[`SYNC.md`](SYNC.md). +### 4.2 The own store — four Room tables -### 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 + 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. +`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 -occurrence is a row with the joined list colour, account, etc.), maps each -cursor row through `ColumnReader` → `TaskMapper` → domain `Task`, and exposes -the result as a `Flow`. A `ContentObserver` on the active authority's -Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change -re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks) -update the UI live, and multiple sync sources coexist in one list. +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`. -### 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 interface TasksRepository { fun taskLists(): Flow> fun tasks(filter: TaskFilter): Flow> + fun subtasks(parentId: Long): Flow> fun taskDetail(taskId: Long): Flow 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 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 } ``` -`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion -sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back -out as a normal VTODO status change. Writes to local/unsynced lists use the -sync-adapter URI form where the provider requires it. +`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. -### 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 - `tasks._id` and the stable target for edits/completion. +### 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. @@ -210,7 +347,7 @@ sync-adapter URI form where the provider requires it. `TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists — `ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking -`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it +`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/`. @@ -231,10 +368,24 @@ providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`) `set` when `canScheduleExactAlarms()` is false), keyed by `taskId`. - **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel, `POST_NOTIFICATIONS` gate, dedupe-by-tag). -- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and - **`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external - sync changed the data). The store lets each run diff like Calendula diffs - reminder rows. +- **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. @@ -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). Still fully supported; it stopped being the only option and became a user choice, `StorageMode.EXTERNAL`. -- **Posture B (shipped)** — the `:provider` module: the Apache-2.0 dmfs provider - vendored under **our own** authority `de.jeanlucmakiola.agendula.tasks` and our - own permission namespace. It **coexists with everything and replaces nothing**. +- **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:** 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 > same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same > `` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with > OpenTasks installed simply could not have installed Agendula. Account -> visibility is also keyed by *package*, not authority, which would have left the -> bundled provider seeing zero accounts and pruning synced lists as orphaned. -> Full reasoning in `STORAGE-AND-SYNC.md`. +> 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: `ProviderResolver` is still the only thing that knows -an authority exists and `AndroidTasksDataSource` the only thing that touches a -resolver, so vendoring an entire content provider **changed no UI, no ViewModel, -no domain type, and not one line of `TasksRepository`.** +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. -Bundling the provider bundles **storage, not sync**. Our own sync adapter is a -separate, later piece of work — designed in [`SYNC.md`](SYNC.md), and it lands -*underneath* this same seam: it writes through `TaskContract` with -`CALLER_IS_SYNCADAPTER`, so the layers above it stay untouched a second time. +⚠️ **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. --- @@ -283,9 +446,9 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`, `RootScreen` is the entry composable: it gates on `ProviderStatus` (`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` → -`ListsScreen`). The remaining screens are being built one at a time — their -ViewModels exist and are tested against the real data layer; navigation -callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the +`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). @@ -293,11 +456,17 @@ checkbox/FAB/swipe motion). ## 9. Dependency injection -Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module -(`TasksDataSource` → `AndroidTasksDataSource`, `TasksRepository` → -`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore, -the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; -`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected. +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` and `Provider` — 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 | | SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 | | UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) | -| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines | -| `:provider` | Java 17, `org.dmfs` jems / rfc5545-datetime / lib-recur (all Maven Central — no new repository; `settings.gradle.kts` stays `google()` + `mavenCentral()` under `FAIL_ON_PROJECT_REPOS`) | -| Tests | `:app` is JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source and `ProviderEnvironment` as the JVM-testable seams. **`:provider` is JUnit4 + Robolectric** — upstream's own suite, kept as written rather than rewritten, since that coverage is what makes vendoring safe. Don't add `useJUnitPlatform()` there. | +| 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 | @@ -319,25 +488,28 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; ## 11. Manifest surface -- **Declared by `:app`:** both `org.dmfs.*` and `org.tasks.*` read/write tasks - perms (static manifest, so they are always declared; requested at runtime only - in External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm - (`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). -- **Declared by `:provider`:** the `` itself plus our own - `de.jeanlucmakiola.agendula.permission.READ_TASKS` / `WRITE_TASKS` and their - permission group. These exist for **other** apps — Agendula reaches its own - provider same-uid and neither declares a `uses-permission` for them nor asks. - The provider is `exported="true"` on purpose: that is what would let DAVx5 - write into it once it knows our authority. -- **Deliberately absent:** `GET_ACCOUNTS` (stripped from the vendored provider — - see change 1 in `PROVENANCE.md`) and `INTERNET`, which stays undeclared until - sync actually ships. Export needs no storage permission at all; SAF hands us a - `Uri` the user picked. -- **``** for package visibility: both *external* provider authorities + +- **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 `` 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. +- **``** for package visibility: both external provider authorities + a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open - the provider / a store listing). Our own provider needs no entry. -- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, - `ProviderChangeReceiver` (all three authorities, ours first — an intent-filter - host must be a literal), and the vendored provider's own - `TaskProviderBroadcastReceiver`. No `EVENT_REMINDER` receiver — that's a - Calendula thing that doesn't apply here. + 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 564f999..4e55669 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,18 +10,19 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started ## Current state (one line) -Agendula now **carries its own task store**: the `:provider` module ships the -vendored dmfs provider under our authority, so the app is complete and local-first -with nothing else installed, and an external provider (OpenTasks / tasks.org) is a -user choice rather than a requirement. The non-visual stack over `TaskContract` is -done and unit-tested, export to iCalendar has landed, and the Material 3 -Expressive UI is built through **M5**: lists → task list (swipe gestures, inline -add, smart-list section headers) → detail / edit with full CRUD, date-time -pickers, priority, percent-complete, conflict-safe saves, per-task reminders, and -subtask create + reparent — plus a one-time reminder onboarding step and a -Settings screen. Remaining work is the **frontend surfaces for what just landed** -(a storage-mode picker, an export screen), then M6 (Glance widget, translations, -F-Droid release) and the sync adapter. +Agendula now **carries its own task store, and it is one we wrote**: a Room +database designed against `VTODO`, with recurrence expanded at read time. The +vendored dmfs provider and the `:provider` module that held it are deleted, a +v0.3.x install's tasks are imported on first launch, and an external provider +(OpenTasks / tasks.org) is a user choice rather than a requirement. Export to +iCalendar has landed, and the Material 3 Expressive UI is built through **M5**: +lists → task list (swipe gestures, inline add, smart-list section headers) → +detail / edit with full CRUD, date-time pickers, priority, percent-complete, +conflict-safe saves, per-task reminders, and subtask create + reparent — plus a +one-time reminder onboarding step and a Settings screen. Remaining work is +hardening the new store (`OWN-STORE.md` phase 6), the **frontend surfaces for +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 Agendula stopped depending on a provider app being installed. Direction and reasoning in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md); note it **redefined** -what Posture B means (our own authority, coexisting with everything — *not* -squatting `org.dmfs.tasks`, which is a dead end). +what Posture B means (our own store, coexisting with everything — *not* squatting +`org.dmfs.tasks`, which is a dead end). - ✅ `fix/provider-interaction-review` merged (step 1). -- ✅ `:provider` — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored in-tree - under `de.jeanlucmakiola.agendula.tasks` and our own permission namespace. - `GET_ACCOUNTS` dropped, with the account-cleanup path reworked so it can only - prune account types we authenticate ourselves — the deletion is unsafe without - that rework. Modernized to minSdk 29 / targetSdk 36 / Java 17. - [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) records every deviation, - each also marked `AGENDULA CHANGE` at the site. Upstream's 56 JVM tests pass. +- ✅ Step 2, first pass — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored + in-tree as `:provider`, under `de.jeanlucmakiola.agendula.tasks` and our own + permission namespace. Shipped in v0.3.x and **since superseded**: see "our own + store" below. - ✅ 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`). -- ✅ 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, via SAF. Backend only. - ⬜ **Frontend surfaces for the above** — a storage-mode picker in Settings and an export screen. The backend is done and unused until these exist. - ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users. + 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)**, - not started: mapper → auth → engine → hardening, ~8–9 weeks. The account model - is settled (`AccountManager`) and `ical4android` is closed out (superseded by - `synctools`, GPLv3, so we write the mapper in-house); what's still open is - dav4jvm's JitPack-only distribution, conflict policy, and whether External mode - survives the milestone. -- ⬜ Verify on a device: the local path with no account, and the vendored - provider's timezone-change behaviour (change 3 in `PROVENANCE.md`). + not started: mapper → auth → engine → hardening, ~8–9 weeks, minus the 2.5–4 + weeks owning the store deletes from it (`OWN-STORE.md`, "Effects on the sync + plan"). The account model is settled (`AccountManager`) and `ical4android` is + closed out (superseded by `synctools`, GPLv3, so we write the mapper + in-house); what's still open is dav4jvm's JitPack-only distribution, conflict + policy, and whether External mode survives the milestone. + +### ✅ 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 ``, + 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 [`SYNC.md`](SYNC.md) open question 3: if External mode is retired once we sync ourselves, the question disappears with it. -4. ~~**Posture B authority choice**~~ resolved: **our own** - `de.jeanlucmakiola.agendula.tasks`. Squatting `org.dmfs.tasks` is a dead end, - not merely a trade-off — two apps cannot declare the same authority or - permission name, so anyone with OpenTasks installed could not have installed - Agendula at all. -5. ~~**Recurring tasks** — recurrence-aware editing out of scope for v1~~ stale: - `fix/provider-interaction-review` routes edits to a recurring task through the - instances URI, so the provider forks an override instead of re-anchoring the - series. +4. ~~**Posture B authority choice**~~ moot: Agendula publishes no provider and + holds no authority at all. The question was live while the store was a + vendored provider, and squatting `org.dmfs.tasks` was a dead end even then — + two apps cannot declare the same authority or permission name, so anyone with + OpenTasks installed could not have installed Agendula at all. +5. ~~**Recurring tasks** — recurrence-aware editing out of scope for v1~~ + resolved: our own store expands a series at read time and writes an edit to + one occurrence as a `RECURRENCE-ID` override sharing the master's UID; in + External mode the edit still goes through the instances URI. 6. **Resolver ordering / mode-selection UX** — `autoMode()` picks a sane default today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it assumes is not built yet. @@ -196,7 +250,12 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through - Build: `./gradlew :app:assembleDebug` - Unit tests: `./gradlew :app:testDebugUnitTest` -- Run on a device/emulator that has **OpenTasks** or **tasks.org** installed (and - ideally DAVx5 syncing a CalDAV task list) so the read/write paths have real - data. Debug builds use `DemoSeeder` for sample data when no provider data is - present. +- Instrumented tests: `./gradlew :app:connectedDebugAndroidTest` — the Room + schema, `RoomTasksDataSource` and `OneShotImport` (the last against + `app/src/androidTest/assets/tasks-v23.db`, regenerated by + `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.