docs: decide to build our own store and delete the vendored provider

The vendored dmfs provider was kept on the grounds that it hands us the
sync bookkeeping for free. The phase-1 sync audit measured that
bookkeeping and found most of it broken, absent, or unusable: _DIRTY not
set on delete, no home for a per-collection sync token, read-only
collections inexpressible, ACCOUNT_TYPE write-once so enabling sync is a
full migration, and cleanUpLists able to delete a user's lists after a
backup restore. Sixteen findings are provider-imposed rather than
platform- or protocol-imposed.

Costing the alternative showed the swap is far smaller than assumed.
TasksDataSource is already a 14-method, domain-shaped interface;
exactly one file above the data layer references TasksContract. The
work is a second implementation behind an interface built for it, not a
rewrite. Against ~5 weeks to build, owning the store removes 2.5-4
weeks from the sync plan, and 8,200 of the vendored 14,555 lines are
things we would never write - 23 migrations from a 2013 schema, 798
lines of full-text search the app has zero call sites for, and 1,581
lines of a type-safe layer over ContentValues that Room deletes.

External mode (OpenTasks, tasks.org) is unaffected and keeps every
file that describes somebody else's schema.

STORAGE-DECISION.md is the reasoning; OWN-STORE.md is the architecture
and the six-phase plan. :provider stays in-tree until phase 5 so
recurrence parity can be tested against it before it goes.

Also corrected here: the provider's JVM test count (51 -> 56, measured
from the test-results XML) and a fourth site of the debunked "switching
sync on is never a migration" claim, in StorageMode.kt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 14:46:07 +02:00
parent 98ed339346
commit 13cb27b2ab
10 changed files with 1984 additions and 39 deletions

View File

@@ -5,9 +5,14 @@ package de.jeanlucmakiola.agendula.data.tasks
* *
* Only two values, though the document describes three modes. **Synced is not a * Only two values, though the document describes three modes. **Synced is not a
* third store**: it is [LOCAL] with an account attached, so it is derived state * third store**: it is [LOCAL] with an account attached, so it is derived state
* (does an account of ours exist?) rather than something the user picks. That * (does an account of ours exist?) rather than something the user picks. Adding
* also means switching sync on is never a migration. Adding a `SYNCED` constant * a `SYNCED` constant here would imply otherwise.
* here would imply otherwise. *
* ⚠️ This used to add "so switching sync on is never a migration". That is wrong.
* `TaskLists.ACCOUNT_TYPE` is write-once in the provider — `processors/lists/
* Validating.java:68-76` throws `IllegalArgumentException` on any attempt to
* change it — so attaching an account to an existing local list means recreating
* every list and every task under the new account. See `docs/SYNC.md`.
* *
* Nothing above the data layer reads this; it selects an authority for * Nothing above the data layer reads this; it selects an authority for
* [ProviderResolver] and stops there. * [ProviderResolver] and stops there.

View File

@@ -57,12 +57,23 @@ object ICalendarWriter {
/** /**
* The UID to write for [task]. * The UID to write for [task].
* *
* Local tasks have none: the dmfs provider only permits a sync adapter to * Local tasks have none, because nothing assigns one: the provider never
* assign `_uid`, so in Local mode every task arrives here with `uid == null`. * generates a `_uid` itself, and our write path does not set it either, so in
* A VTODO without a UID is invalid and, worse, un-mergeable — re-importing a * Local mode every task arrives here with `uid == null`.
* backup would duplicate every task instead of matching it. So we synthesise *
* one from the row id, which is stable for as long as the row is, and tag it * Note this is a gap we leave open, not one the provider imposes.
* with our own domain so a synthesised UID is recognisable as such. * `processors/tasks/Validating.java:92-96` restricts `_uid` to sync adapters
* on *update* only; `insert` does not check it, so any caller may assign a UID
* at creation. Doing that would be strictly better than synthesising here —
* see `docs/SYNC.md`, where it is a phase-1 item, because a real UID minted at
* creation is what lets a local task later be pushed to CalDAV without
* duplicating.
*
* Until then: a VTODO without a UID is invalid and, worse, un-mergeable —
* re-importing a backup would duplicate every task instead of matching it. So
* we synthesise one from the row id, which is stable for as long as the row
* is, and tag it with our own domain so a synthesised UID is recognisable as
* such.
*/ */
fun uidFor(task: ExportTask): String = fun uidFor(task: ExportTask): String =
task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de" task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de"

View File

@@ -22,7 +22,7 @@ the original "owns no database" thesis, settled in
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 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 a schema written from scratch — so every CalDAV engine still understands it. A
sync adapter of our own is the 1.x arc. 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:
@@ -143,9 +143,15 @@ 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` **Storage modes** are `LOCAL` and `EXTERNAL` only. `STORAGE-AND-SYNC.md`
describes three, but *Synced* is not a third store — it is Local with an account describes three, but *Synced* is not a third store — it is the same store with an
attached, so it is derived state, and modelling it as a separate mode would imply account attached, so it stays derived state.
that turning sync on is a migration. It isn't.
⚠️ **What this used to say — "so turning sync on is not a migration" — is wrong.**
A task list's `ACCOUNT_NAME`/`ACCOUNT_TYPE` are write-once in the provider
(`processors/lists/Validating.java:68-76`), so local lists cannot be re-pointed at
an account; tasks have to be moved into new lists. Not modelling `SYNCED` as a
mode is still right, but the migration it implied away is real. See
[`SYNC.md`](SYNC.md).
### 4.2 `TasksContract` ### 4.2 `TasksContract`
@@ -261,7 +267,9 @@ resolver, so vendoring an entire content provider **changed no UI, no ViewModel,
no domain type, and not one line of `TasksRepository`.** no domain type, and not one line of `TasksRepository`.**
Bundling the provider bundles **storage, not sync**. Our own sync adapter is a Bundling the provider bundles **storage, not sync**. Our own sync adapter is a
separate, later piece of work — see `STORAGE-AND-SYNC.md`. 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.
--- ---

439
docs/OWN-STORE.md Normal file
View File

@@ -0,0 +1,439 @@
# Agendula's own task store — architecture and plan
**Branch:** `feat/own-store`
**Decision:** taken. `docs/STORAGE-DECISION.md` costed it; this is the build.
**Supersedes:** the "keep `:provider`" position in `STORAGE-AND-SYNC.md` and the
"Settled" section of `SYNC.md`.
---
## The decision, in one paragraph
Agendula stops vendoring the dmfs OpenTasks provider. The `:provider` module —
14,555 lines of Java, 1.66× the size of the app itself — is **deleted**. In its
place the app gets its own Room database, designed for the two things Agendula
actually does: show tasks, and sync them over CalDAV. Support for *external*
providers (OpenTasks, tasks.org) **stays**, unchanged, as a user choice — so
anyone already syncing through DAVx5 keeps working exactly as they do today.
Agendula becomes a normal Android app with a normal database, plus an optional
compatibility path into somebody else's ContentProvider.
---
## What changes and what does not
```
BEFORE AFTER
UI / ViewModels UI / ViewModels
│ │
TasksRepository TasksRepository ← unchanged
│ │
TasksDataSource (interface) TasksDataSource ← unchanged
AndroidTasksDataSource RoomTasksDataSource AndroidTasksDataSource
│ │ │
ContentResolver Room / SQLite ContentResolver
│ │ │
┌────┴─────┐ our tables ┌──────┴──────┐
│ │ │ │
:provider OpenTasks OpenTasks tasks.org
(deleted) tasks.org (external, unchanged)
```
**Unchanged above the data layer.** `TasksRepository`, every ViewModel, every
screen. Verified: exactly one file outside `data/tasks` references
`TasksContract` (`domain/Models.kt`, for five constants), and it stops doing so
in phase 0.
**Deleted.** The `:provider` Gradle module, its manifest `<provider>`, its two
custom permissions, its 84 Java files, its 13 translated string resources, and
its three dmfs runtime dependencies from `:provider`'s own build file.
**Kept for External mode.** `TasksContract.kt`, `ColumnReader.kt`,
`TaskMapper.kt`, `TaskWriteMapper.kt`, `AndroidTasksDataSource.kt`,
`ProviderResolver.kt`, `ProviderEnvironment.kt`, `TaskProjections.kt`. These
describe *somebody else's* schema and are exactly right for that job.
---
## Storage modes after the change
```kotlin
enum class StorageMode {
/** Agendula's own Room database. The default; always available. */
OWN,
/** A tasks provider app already installed — OpenTasks, tasks.org. */
EXTERNAL,
}
```
`LOCAL` (meaning "our bundled dmfs provider") is gone. `ProviderResolver.own`
and the `TaskProvider(isOwn = true)` case go with it: in `OWN` mode there is no
authority, no ContentResolver and no permission to grant.
`ProviderResolver` narrows to what it was always really for — **discovering
external providers** — and `ProviderStatus.READY` becomes unconditional in `OWN`
mode.
### Consequences worth stating plainly
- **No runtime permission is needed for the default path.** Today's permission
prompt only ever applied to External mode; now that is visibly true.
- **Third-party apps can no longer read Agendula's tasks.** We publish no
ContentProvider. Users who need interop pick External mode, or wait for a
possible read-only facade (explicitly out of scope — see *Deliberately not
doing*).
- **Auto Backup gets simpler and safer.** One Room file we control, with a
documented restore path, instead of a provider database whose `cleanUpLists`
routine could delete restored lists whose accounts no longer exist.
---
## The schema
Four tables. Designed from Agendula's actual reads and writes plus RFC 5545's
`VTODO`, not inherited from a 2013 schema.
### `task_lists`
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER PK | |
| `name` | TEXT NOT NULL | |
| `color` | INTEGER NOT NULL | ARGB |
| `account_id` | INTEGER NULL | FK → `accounts`, NULL = device-only |
| `is_visible` | INTEGER NOT NULL | default 1 |
| `is_synced` | INTEGER NOT NULL | default 1 |
| `owner` | TEXT NULL | CalDAV owner display name |
| `is_read_only` | INTEGER NOT NULL | **new** — the provider could not express this at all |
| `sort_order` | INTEGER NOT NULL | user ordering, which the provider also lacked |
| `href` | TEXT NULL | collection URL, relative to the account root |
| `ctag` | TEXT NULL | |
| `sync_token` | TEXT NULL | RFC 6578, **per collection** — not squatted into a shared slot |
| `is_dirty` | INTEGER NOT NULL | a real boolean, not dmfs's monotonic counter |
> `account_id` being nullable is the single most important schema change. In the
> dmfs provider `ACCOUNT_TYPE` is write-once and throws on change, which made
> "turn sync on" a full data migration. Here, attaching a local list to an
> account is `UPDATE task_lists SET account_id = ?`.
### `tasks`
Master rows *and* recurrence overrides live here; an override is a row with
`recurrence_id` set and `parent_task_id` pointing at its master.
| Group | Columns |
|---|---|
| identity | `id`, `list_id`, `uid` (NOT NULL, minted at creation), `href`, `etag` |
| content | `title`, `description`, `location`, `url`, `color` |
| state | `status`, `percent_complete`, `completed_at`, `priority`, `classification` |
| time | `dtstart`, `due`, `duration`, `is_all_day`, `timezone` |
| recurrence | `rrule`, `rdate`, `exdate`, `recurrence_id`, `master_id` |
| hierarchy | `parent_id`, `sort_order` |
| audit | `created_at`, `last_modified`, `sequence` |
| sync | `is_dirty`, `is_deleted`, `unknown_properties` |
Two entries deserve explanation.
**`uid` is NOT NULL and assigned at creation.** Every task gets a real
RFC 4122 UUID the moment it is inserted, in every mode, synced or not. This
closes the gap `ICalendarWriter.uidFor` currently papers over by synthesising
`agendula-<rowid>@…`, and it means any local task can later be pushed to a
server without duplicating. The provider never assigned one.
**`unknown_properties`** holds the raw unfolded iCalendar lines of every
property we do not model — `ATTENDEE`, `CATEGORIES`, `X-*`, `GEO`, and anything
a future RFC adds. On write we re-emit them verbatim after the properties we do
own. This is what makes an honest round-trip possible, and it replaces the
provider's `data0``data15` bag with something that cannot silently lose a field
it has no column for.
Indices: `(list_id, is_deleted)`, `(parent_id)`, `(uid)` unique per list,
`(master_id, recurrence_id)`, `(is_dirty)`.
### `task_alarms`
| Column | Notes |
|---|---|
| `id`, `task_id` | FK, `ON DELETE CASCADE` |
| `minutes_before` | positive = before the reference |
| `reference` | `DUE` or `START` |
| `message` | optional |
Replaces `AlarmHandler` (133 lines of Java) and the `dataN` slot convention.
Delete-and-reinsert stops being necessary — the provider's re-validate-everything
behaviour was the only reason `setAlarm` worked that way.
### `accounts`
| Column | Notes |
|---|---|
| `id`, `display_name`, `principal_url`, `home_set_url` |
| `username` | the app password is **not** here — Keystore only, per `SYNC.md` |
| `last_sync_at`, `last_sync_error` |
Not populated until `SYNC.md` phase 2, but the FK exists from v1 so enabling
sync never requires a schema migration.
---
## Recurrence: expand at read, not on write
The provider maintained a materialised `instances` table, recomputed by
`Instantiating.java` on every write — and still only ever materialised **one**
upcoming occurrence.
Agendula expands lazily instead:
```
tasks (masters + overrides) ──► RecurrenceExpander ──► List<Task>
rrule/rdate/exdate (lib-recur, in memory) occurrences
```
This is the right call here because **the repository already filters and sorts
in Kotlin, not SQL**. `TasksRepositoryImpl.loadTasks` reads the whole set,
applies `TaskFiltering.matches`, then `TaskSorting.DEFAULT`. Nothing depends on
the database being able to order by instance time, so nothing is lost — and a
materialised table's entire class of staleness bugs never exists.
- Bounded window: expansion is capped (default: 1 year back, 2 years forward,
hard ceiling of N occurrences per series) so an unbounded `RRULE` cannot hang
the UI.
- `lib-recur` **pinned at 0.12.2** — 0.16.0 removed `RecurrenceSet`. We pin
because we chose to, and the pin is now ours to lift on our own schedule.
- Client-side expansion is required for CalDAV regardless: server-side
`CALDAV:expand` on `VTODO` is broken on every server `SYNC.md` targets.
### Completing one occurrence of a recurring task
The provider's `Detaching.java` implemented **model (d): detach the occurrence
as a brand-new task with its own UID**. We inherited that without ever choosing
it, and it is the model least compatible with CalDAV.
**We implement model (a): a `RECURRENCE-ID` override.** Completing one
occurrence writes a second `tasks` row with the same `uid`, a `recurrence_id`
naming the occurrence, `master_id` pointing at the series, and the completed
state. This is what RFC 5545 specifies and what every other CalDAV client
expects to receive.
This decision is now made explicitly, recorded here, and testable.
---
## Reactivity
`TasksDataSource.registerObserver(onChange: () -> Unit): AutoCloseable` **stays
as-is**. The Room implementation backs it with `InvalidationTracker.Observer`
over the four tables; the External implementation keeps its `ContentObserver`.
One interface, two mechanisms, `TasksRepositoryImpl.observing()` untouched.
Going Flow-native in the DAOs is a later, optional refinement. Doing it now
would change the interface and therefore the External path, for no user-visible
gain.
---
## Migrating existing users
Anyone on v0.3.x has their tasks inside the bundled provider's SQLite file at
`/data/data/de.jeanlucmakiola.agendula/databases/tasks.db` (dmfs schema
version 23). Removing the Gradle module does **not** remove that file — an app
update leaves the data directory intact.
So the migration reads the file directly, with no provider and no
ContentResolver involved:
```
OneShotImport
1. does databases/tasks.db exist? no → nothing to do, mark done
2. open SQLiteDatabase.OPEN_READONLY
3. read tasklists → task_lists (account_type LOCAL → account_id NULL)
4. read tasks → tasks (skip _deleted = 1; mint uid where NULL)
5. read properties → task_alarms (mimetype = …/alarm only)
6. verify counts, inside one Room transaction
7. record completion in DataStore
8. rename tasks.db → tasks.db.imported (kept one release, then deleted)
```
Rules that make this safe:
- **Read-only, single transaction, verified counts.** Either the whole import
lands or none of it does.
- **The source file is renamed, never deleted**, for one release. If the import
is wrong we can still recover from a user's device.
- **Idempotent.** Guarded by a DataStore flag *and* by the rename, so a crash
mid-import cannot double-import.
- **Runs before first UI read**, gated the same way `StorageModeHolder.awaitReady()`
already gates the launch reminder re-sync.
- Tasks that were in an *external* account inside our bundled provider (only
possible if the user had pointed DAVx5 at our authority) are imported as
local lists, with their `uid` preserved. Rare, but preserving the UID is what
lets them be re-attached to an account later.
`tasks.db.imported` is excluded from Auto Backup; the new Room database is
included, which is the whole point of owning it.
---
## Effects on the sync plan
`SYNC.md`'s phase list was written against the provider. Owning the store
deletes work from it outright:
| `SYNC.md` item | Fate |
|---|---|
| "Assign UIDs at creation" (phase 0) | **gone**`uid` is NOT NULL from v1 |
| Auto Backup / `cleanUpLists` data-loss guard (phase 0) | **gone** — no `cleanUpLists` |
| `lib-recur` pin rationale (phase 0) | reduced to a normal version choice |
| Local → Synced migration (phase 3) | **gone**`account_id` is a nullable FK |
| ETag / href / CTag squats into `SYNC1``SYNC8` | **gone** — real columns |
| Per-collection sync token (phase 3) | **gone** — real column |
| `_DIRTY` set-on-delete workaround (phase 3) | **gone** — tombstones are ours |
| `CALLER_IS_SYNCADAPTER` ignored by instances URI | **gone** — no URIs |
| `Moving` dual-UID collision (phase 4) | **gone** |
| Read-only collections (phase 4) | now *possible*`is_read_only` exists |
| Recurring-completion model | **decided here** — RECURRENCE-ID override |
| Byte-stable round-trip | improved — `unknown_properties` preserves the rest |
Everything platform-level and protocol-level in `SYNC.md` is untouched: the
`targetSdk 34` sync-framework gate, the stub sync-adapter pattern, credential
storage, Play compliance, discovery, conditional `PUT`, conflict policy, and
every per-server quirk in the server-reality table.
---
## Plan
### Phase 0 — Untangle (0.5 wk)
- `domain/Models.kt` stops importing `TasksContract`; the status and priority
constants move into `domain`. This is the last contract reference above the
data layer.
- `StorageMode`: `LOCAL``OWN`; `ProviderResolver` loses `own` / `isOwn`.
- `ProviderChangeReceiver`'s manifest filter drops our own authority.
- Add Room + KSP to the version catalog (KSP is already applied to `:app`).
**Done when:** the app still builds and behaves identically, with the provider
still present and still default.
### Phase 1 — Schema and DAOs (1 wk)
- The four entities above, plus DAOs, plus `schemas/` exported for migration
testing (`room.schemaLocation`, committed).
- `RoomTasksDataSource` implementing all 14 `TasksDataSource` methods except the
recurrence-dependent ones, which throw until phase 2.
- `DataModule` binds by `StorageMode`.
**Done when:** a JVM test creates lists and non-recurring tasks through
`TasksDataSource` against an in-memory Room database and reads them back.
### Phase 2 — Recurrence (1.52 wk)
The hard phase. Budget accordingly.
- `RecurrenceExpander` over `lib-recur`: `RRULE`, `RDATE`, `EXDATE`, overrides,
all-day handling, bounded window, `distanceFromCurrent`.
- `RECURRENCE-ID` override creation on single-occurrence edit and completion.
- A test suite that is the deliverable, not an afterthought: daily/weekly/
monthly-by-day/yearly, `COUNT` and `UNTIL`, DST boundaries, all-day series,
a series with an override, a series with an exception, and an unbounded rule
hitting the window ceiling.
**Done when:** `updateInstance` and recurring reads pass parity tests written
against the current provider's observed behaviour, *except* where model (a)
deliberately differs from model (d) — those differences enumerated as tests.
### Phase 3 — Semantics parity (1 wk)
- Completion coherence: `status``percent_complete``completed_at` ↔ closed,
replacing `AutoCompleting.java` and — importantly — the reopen asymmetry that
`TaskWriteMapper` currently works around in the app.
- Parent/child integrity, orphan handling on delete.
- Validation: `DUE` xor `DURATION`, `due >= dtstart`, all-day pinned to UTC
midnight, list must exist.
- Delete semantics: hard delete when `account_id IS NULL`, tombstone when set.
**Done when:** `TaskWriteMapper`'s provider-quirk workarounds are demonstrably
unnecessary on the Room path (they stay for External).
### Phase 4 — Import and cutover (1 wk)
- `OneShotImport` per the rules above, with tests over a fixture `tasks.db`
captured from a real v0.3.x install.
- `OWN` becomes the default for new installs and for upgraders after import.
- Backup rules updated: include the Room database, exclude `tasks.db.imported`
and the Keystore blob.
**Done when:** an upgrade from a v0.3.2 APK with seeded data lands every task,
list and reminder in Room, verified by count and by content.
### Phase 5 — Delete `:provider` (0.5 wk)
- Remove the module, its `settings.gradle.kts` include, its `:app` dependency,
the three dmfs deps it pulled in, `provider/PROVENANCE.md`.
- Add `lib-recur` (and `rfc5545-datetime`) directly to `:app`.
- Attribution screen: dmfs code is gone, but `lib-recur` stays and is
Apache-2.0. `PROVENANCE.md` is replaced by a short note in
`STORAGE-DECISION.md` recording that the fork existed and why it ended.
**Done when:** `./gradlew build` is green with `:provider` absent, and the APK
declares no ContentProvider and no custom permissions.
### Phase 6 — Harden (1 wk)
- Room migration test infrastructure (`MigrationTestHelper`) wired up, so v1 →
v2 is cheap when sync adds columns.
- Restore-path test: Auto Backup restore into a fresh install.
- Performance check at 5,000 tasks with 20 recurring series.
**Total: 66.5 weeks** to a shipping app with its own store, before any CalDAV
work begins. `SYNC.md`'s own estimate drops by 2.54 weeks in exchange.
---
## Testing posture
| Layer | How |
|---|---|
| Entities, DAOs, migrations | Room in-memory + `MigrationTestHelper`, JVM |
| `RecurrenceExpander` | pure JVM, no Android — the largest suite |
| Semantics (completion, hierarchy, validation) | JVM through `TasksDataSource` |
| `OneShotImport` | fixture `tasks.db` committed as a test resource |
| External mode | unchanged; existing `TaskMapper` / `TaskWriteMapper` tests stay |
The 93 existing app tests must stay green throughout. The 56 provider tests
leave with the module in phase 5 — replaced, not abandoned: phases 2 and 3 owe
equivalent coverage of the behaviour those tests protected, and phase 2's
parity suite is written against them.
---
## Risks
| Risk | Mitigation |
|---|---|
| **Recurrence is subtler than estimated** | Phase 2 is isolated and pure-JVM; it can overrun without blocking phases 34. The provider stays in-tree until phase 5, so we can always compare against it. |
| **Import loses a user's data** | Read-only source, single transaction, count verification, source file renamed not deleted, fixture-based tests. |
| **Regression in a behaviour nobody documented** | Phase 2's parity tests are written *against the provider while it is still present*. That is why deletion is phase 5, not phase 0. |
| **Losing third-party interop** | External mode covers users who need it. A read-only facade stays possible later; nothing in this design forecloses it. |
| **Room + KSP build cost** | KSP is already in the build for Hilt; Room adds one processor. |
---
## Deliberately not doing
- **An exported ContentProvider facade over Room.** Possible later (~11.5 wk),
not now. Shipping one would recreate the public-API surface whose validation
and URI plumbing is most of what we are deleting.
- **A domain-native schema.** The table shapes above stay recognisably close to
`TaskContract` where `TaskContract` was right, because it is a proven design
for `VTODO` and because it keeps a future facade cheap.
- **Flow-native DAOs.** Later refinement; changes the interface for no
user-visible gain today.
- **FTS / search.** The provider carried 798 lines of it. The app has never
called it. If search is wanted it is a feature request, designed on its own
terms.
- **Categories and attendees as first-class tables.** They round-trip through
`unknown_properties` until a feature actually needs them.

View File

@@ -1,8 +1,10 @@
# Agendula — documentation # Agendula — documentation
Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over Agendula is a Material 3 Expressive **task** app for Android. It **carries its
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync own task store** — the dmfs task provider vendored under our own authority — so
over CalDAV), with no own database or sync stack. Sibling to it is complete and local-first with nothing else installed; an external provider
(OpenTasks / tasks.org, synced by DAVx5 / SmoothSync / DecSync) is a user choice
rather than a requirement, and our own CalDAV sync is the 1.x arc. Sibling to
[Calendula](https://codeberg.org/jlmakiola/calendula). See the [Calendula](https://codeberg.org/jlmakiola/calendula). See the
top-level [`../README.md`](../README.md) for the project pitch. top-level [`../README.md`](../README.md) for the project pitch.
@@ -12,15 +14,24 @@ top-level [`../README.md`](../README.md) for the project pitch.
|---|---| |---|---|
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Agendula is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. | | [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Agendula is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. |
| [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0M6 + Posture B), what's done, open decisions, how to build/verify. | | [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0M6 + Posture B), what's done, open decisions, how to build/verify. |
| [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md) | **Where task data lives** — the decision to ship our own provider, the storage modes, permissions, distribution, and the dead ends. Supersedes `PLAN.md` on storage. |
| [`SYNC.md`](SYNC.md) | **How data reaches a server** — the CalDAV sync adapter: the VTODO ↔ `TaskContract` mapper, Nextcloud sign-in, the engine, libraries and their licenses. Step 5 of `STORAGE-AND-SYNC.md`. |
| [`STORAGE-DECISION.md`](STORAGE-DECISION.md) | **Keep the vendored provider, or build our own?** The measured cost of both. **Decided: build our own.** |
| [`OWN-STORE.md`](OWN-STORE.md) | **Agendula's own Room store** — the schema, recurrence design, migration off the vendored provider, and the six-phase plan that deletes `:provider`. Supersedes the "keep the provider" position in `STORAGE-AND-SYNC.md`. |
| [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". | | [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". |
| [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. | | [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. |
| [`../provider/PROVENANCE.md`](../provider/PROVENANCE.md) | What the vendored `:provider` module is, where it came from, and **every** deviation from upstream dmfs. |
Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections
feed the release notes). feed the release notes).
## How the docs relate ## How the docs relate
- **PLAN** is the design decisions (mostly stable; the "why"). - **PLAN** is the original design decisions (the "why"), left as the historical
record. On storage it is **superseded by STORAGE-AND-SYNC**.
- **STORAGE-AND-SYNC** and **SYNC** are the standing decision documents: the
first settles where data lives, the second how it syncs. Both record rejected
alternatives on purpose, so decisions don't get relitigated.
- **ARCHITECTURE** is the current shape of the code (kept in sync with the - **ARCHITECTURE** is the current shape of the code (kept in sync with the
source as it grows). source as it grows).
- **ROADMAP** is the moving status layer (update as milestones land). - **ROADMAP** is the moving status layer (update as milestones land).

View File

@@ -142,7 +142,7 @@ squatting `org.dmfs.tasks`, which is a dead end).
prune account types we authenticate ourselves — the deletion is unsafe without prune account types we authenticate ourselves — the deletion is unsafe without
that rework. Modernized to minSdk 29 / targetSdk 36 / Java 17. that rework. Modernized to minSdk 29 / targetSdk 36 / Java 17.
[`provider/PROVENANCE.md`](../provider/PROVENANCE.md) records every deviation, [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) records every deviation,
each also marked `AGENDULA CHANGE` at the site. Upstream's 51 JVM tests pass. 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 Local mode, and an upgrading Posture A user stays on the
provider that holds their data (`ProviderResolver.autoMode`). provider that holds their data (`ProviderResolver.autoMode`).
@@ -152,9 +152,12 @@ squatting `org.dmfs.tasks`, which is a dead end).
-**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.
- ⬜ Sync adapter (step 5) — the 1.x arc. Design discussion still open: protocol - ⬜ Sync adapter (step 5) — the 1.x arc. **Designed in [`SYNC.md`](SYNC.md)**,
coverage, account model, conflict resolution, and `ical4android`'s licence not started: mapper → auth → engine → hardening, ~89 weeks. The account model
against our MIT. 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 - ⬜ Verify on a device: the local path with no account, and the vendored
provider's timezone-change behaviour (change 3 in `PROVENANCE.md`). provider's timezone-change behaviour (change 3 in `PROVENANCE.md`).
@@ -168,7 +171,9 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through
2. ~~**tasks.org provider authority**~~ verified on device: 2. ~~**tasks.org provider authority**~~ verified on device:
`org.tasks.opentasks` + `org.tasks.permission.*`. `org.tasks.opentasks` + `org.tasks.permission.*`.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? 3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
(Not in the candidate list today.) (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** 4. ~~**Posture B authority choice**~~ resolved: **our own**
`de.jeanlucmakiola.agendula.tasks`. Squatting `org.dmfs.tasks` is a dead end, `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 not merely a trade-off — two apps cannot declare the same authority or
@@ -181,8 +186,9 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through
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.
7. **Sync protocol coverage**, account model, conflict resolution — the next 7. ~~**Sync protocol coverage**, account model, conflict resolution — the next
design discussion. design discussion.~~ Taken up in [`SYNC.md`](SYNC.md); the remaining opens
live on that document's list.
--- ---

View File

@@ -1,5 +1,15 @@
# Agendula — storage and sync # Agendula — storage and sync
> ⚠️ **Partly superseded, 2026-08-13.** The core decision below — *vendor the
> dmfs provider in-tree as `:provider`* — has been **reversed**. Agendula builds
> its own Room store and deletes the vendored provider; External mode (OpenTasks,
> tasks.org) is unaffected and everything this document says about it still
> stands. See [`STORAGE-DECISION.md`](STORAGE-DECISION.md) for why and
> [`OWN-STORE.md`](OWN-STORE.md) for what replaces it. The permissions,
> distribution, storage-mode and dead-end sections below remain accurate; treat
> the "our own provider" sections as the historical record of a decision that was
> made, shipped, and then costed properly.
> Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B = > Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B =
> bundle OpenTasks" working notes, which are withdrawn (see > bundle OpenTasks" working notes, which are withdrawn (see
> [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to > [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to
@@ -34,7 +44,7 @@
| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done | | 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done |
| 3 | Export / backup | our data now lives only in our app's private storage | ✅ backend done; no UI yet | | 3 | Export / backup | our data now lives only in our app's private storage | ✅ backend done; no UI yet |
| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ | | 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ |
| 5 | Sync adapter | the 1.x arc; design discussion pending | ⬜ | | 5 | Sync adapter | the 1.x arc; designed in [`SYNC.md`](SYNC.md), not yet built | ⬜ |
Everything below is the reasoning behind those choices, the alternatives that Everything below is the reasoning behind those choices, the alternatives that
were rejected, and the constraints they have to survive. were rejected, and the constraints they have to survive.
@@ -174,8 +184,13 @@ feature — see [Storage modes](#storage-modes--the-users-choice).
| **Synced** | our bundled provider | our sync adapter | network + an account the user configures | | **Synced** | our bundled provider | our sync adapter | network + an account the user configures |
| **External** | OpenTasks / tasks.org | whatever that provider's engine does (DAVx5 …) | that provider's `READ`/`WRITE_TASKS`, granted at runtime | | **External** | OpenTasks / tasks.org | whatever that provider's engine does (DAVx5 …) | that provider's `READ`/`WRITE_TASKS`, granted at runtime |
Local and Synced are the same store — Synced is Local with an account attached, Local and Synced are the same store — but ⚠️ **switching on sync *is* a
so switching on sync is not a migration. migration, contrary to what this document said until 2026-08-13.** The provider
enforces `ACCOUNT_NAME` and `ACCOUNT_TYPE` as **write-once** on a task list
(`processors/lists/Validating.java:68-76`, which throws), so a list created under
`org.dmfs.account.LOCAL` can never be re-pointed at a real account. Enabling sync
means creating new lists under the account and moving tasks into them. See
[`SYNC.md`](SYNC.md) — it is a costed deliverable there, not a free consequence.
**Resolver ordering — decided, and it went both ways as expected.** **Resolver ordering — decided, and it went both ways as expected.**
`ProviderResolver` now takes an explicit `StorageMode` from Settings when there `ProviderResolver` now takes an explicit `StorageMode` from Settings when there
@@ -235,10 +250,17 @@ compliance and a small enum-shaped addition with near-zero ongoing maintenance
for them. Say that explicitly. "Here's a change that can't break anything" lands for them. Say that explicitly. "Here's a change that can't break anything" lands
very differently from "please support my app." very differently from "please support my app."
**Open — the next discussion.** Protocol coverage ("support as much as **The design is now written out in [`SYNC.md`](SYNC.md)** — protocol coverage,
possible"), the account model, conflict resolution, and where the DAV/iCalendar the account model, conflict resolution, the VTODO ↔ `TaskContract` mapper, and
work lives. One constraint to settle early: we're MIT; `dav4jvm` is Apache-2.0 where the DAV/iCalendar work lives. Two corrections to what this section
and fine, but **verify `ical4android`'s license** before assuming it's usable. originally said, both verified 2026-08-13:
- `dav4jvm` is **MPL-2.0**, not Apache-2.0. Still fine against our MIT (file-level
copyleft), but it is ⚠️ **JitPack-only**, which collides with our
`FAIL_ON_PROJECT_REPOS` + `google()`/`mavenCentral()` policy and with the
JitPack dead end below. `SYNC.md` open question 1.
- `ical4android` is **superseded by `synctools`, which is GPLv3** — so it is
unusable, and the open question below is closed. We write the mapper in-house.
--- ---
@@ -336,6 +358,13 @@ Not on either yet; both are targets, so build *for* them rather than retrofittin
throwaway spike* (a library string resource can be overridden from the app throwaway spike* (a library string resource can be overridden from the app
module, so the authority rename works), but not for anything we ship. module, so the authority rename works), but not for anything we ship.
⚠️ **This one comes back.** It is a dead end *for the provider*, where
vendoring was mandatory anyway. `dav4jvm` is JitPack-only, so the sync adapter
has to answer the same question on its own terms — and F-Droid turns out not to
be the obstacle (its inclusion policy trusts jitpack.io for freely-licensed
artifacts); our own trust-surface policy is. See [`SYNC.md`](SYNC.md) open
question 1.
--- ---
## Sequencing ## Sequencing
@@ -358,8 +387,11 @@ the roadmap should say so rather than inheriting the old estimate.
## Open questions ## Open questions
1. **Sync protocol coverage**, account model, conflict resolution — the next 1. **Sync protocol coverage**, account model, conflict resolution — ✅ taken up in
discussion. Still open. [`SYNC.md`](SYNC.md). The account model is answered there (`AccountManager`,
which `PROVENANCE.md` change 1 already assumes); what stays open moves to that
document's own list — dav4jvm's distribution, conflict policy, External mode's
future, and recurring-task completion.
2. **Resolver ordering** — ✅ decided, see 2. **Resolver ordering** — ✅ decided, see
[Storage modes](#storage-modes--the-users-choice). The **mode-selection UX** [Storage modes](#storage-modes--the-users-choice). The **mode-selection UX**
is still open: `autoMode()` picks a default, but the Settings override it is still open: `autoMode()` picks a default, but the Settings override it
@@ -376,9 +408,11 @@ the roadmap should say so rather than inheriting the old estimate.
⚠️ **But that test is Robolectric, and it skips on ARM64**, where Robolectric ⚠️ **But that test is Robolectric, and it skips on ARM64**, where Robolectric
has no SQLite backend in either mode. It runs on x86_64 CI. It is not a has no SQLite backend in either mode. It runs on x86_64 CI. It is not a
substitute for a device, and this remains on the device-verification list. substitute for a device, and this remains on the device-verification list.
4. **`ical4android` licensing** vs our MIT. Still open — and note the export path 4. ~~**`ical4android` licensing** vs our MIT.~~ ✅ Closed: `ical4android` is
does *not* depend on it: `ICalendarWriter` is our own ~200 lines, no library. superseded by `synctools`, which is **GPLv3**, so it is out — as is
The question is really about the sync adapter's iCalendar *parsing*. `cert4android`. The export path never depended on it anyway (`ICalendarWriter`
is our own ~200 lines). The sync adapter's iCalendar parsing goes through an
in-house mapper over `ical4j`/`biweekly`; see [`SYNC.md`](SYNC.md).
5. **jtx Board** as an additional External-mode candidate — richer contract, 5. **jtx Board** as an additional External-mode candidate — richer contract,
later. (`PLAN.md` decision #3, still open.) later. (`PLAN.md` decision #3, still open.)
6. **The vendored provider's timezone-change behaviour** — upstream's receiver 6. **The vendored provider's timezone-change behaviour** — upstream's receiver

246
docs/STORAGE-DECISION.md Normal file
View File

@@ -0,0 +1,246 @@
# Storage: keep the vendored provider, or build our own?
**Status:** **decided — build our own.** See [`OWN-STORE.md`](OWN-STORE.md) for
the architecture and plan; this document is the reasoning that got there.
The decision went further than the recommendation below: `:provider` is not kept
alongside a Room store, it is **deleted**. External mode (OpenTasks, tasks.org)
stays. The staged sequencing survives in a different form — the provider remains
in-tree until `OWN-STORE.md` phase 5 so recurrence parity can be tested against
it, then goes.
This reopens a question `SYNC.md` marked settled. It is reopened on purpose: the
argument that settled it was *"the provider hands us the sync bookkeeping for
free"*, and the phase-1 audit found most of that bookkeeping broken, absent, or
unusable for our purposes. A conclusion is only as good as its premise.
---
## The three options
| | What it means | Store | Exported provider |
|---|---|---|---|
| **A** | Keep `:provider` as-is | dmfs `TaskProvider` | yes, ours today |
| **B** | Room, same `TaskContract` shape | our Room DB | dropped, or a later facade |
| **C** | Room, clean domain schema, `TaskContract` only as an export format | our Room DB | no |
External mode (talking to OpenTasks / tasks.org) is orthogonal and survives all
three. It is the reason nothing below gets deleted.
---
## What the swap actually touches — measured, not estimated
### The seam is already there, and it is clean
`TasksDataSource` (`data/tasks/TasksDataSource.kt`, 58 lines) is a **14-method,
domain-shaped interface**. It takes and returns `Task`, `TaskList`, `TaskForm`
no `Cursor`, no `Uri`, no `ContentValues`.
Above it, **17 files** import from `data.tasks`. What they import:
```
8 × TasksRepository 4 × TasksDataSource 3 × ProviderResolver
4 × recoveringFromProviderFailure 2 × ProviderStatus
1 each: StorageMode, StorageModeHolder, ProviderEnvironment, TaskQuery, …
```
**Exactly one file outside the data package touches `TasksContract` at all**
`domain/Models.kt`, and only for four status integers, one priority constant and
one account-type string. Ten lines. Nothing else above the data layer knows a
ContentProvider exists.
> The whole UI, all five milestones of it, is untouched by a storage swap.
> That is not luck — `AndroidTasksDataSource`'s own KDoc says the seam exists so
> that *"swapping the provider never reaches above this file."* It holds.
### Nothing gets deleted
| File | Lines | Under a Room store |
|---|---|---|
| `AndroidTasksDataSource.kt` | 220 | **kept** — External mode still needs it |
| `TasksContract.kt` | 178 | **kept** — External mode speaks it |
| `TasksRepositoryImpl.kt` | 151 | unchanged |
| `ProviderResolver.kt` | 143 | unchanged |
| `TaskWriteMapper.kt` | 118 | **kept** for External |
| `TaskMapper.kt` | 102 | **kept** for External |
| `TasksDataSource.kt` | 58 | unchanged — it is the interface |
| `TasksRepository.kt` | 53 | unchanged |
| `ProviderEnvironment.kt` | 51 | unchanged |
| `StorageModeHolder.kt` | 47 | unchanged |
| `ColumnReader.kt` | 40 | **kept** for External |
| `ProviderFlow.kt` | 31 | unchanged |
| `StorageMode.kt` | 30 | one new constant |
| `TaskProjections.kt` | 24 | **kept** for External |
| `Failures.kt` | 16 | unchanged |
| | **1,262** | **0 removed** |
The work is **additive**: a second `TasksDataSource` implementation, a third
`StorageMode`, and one `@Binds` becoming a dispatcher. `DataModule.kt` has a
single binding to change.
This reframes the question. It is not *rewrite vs. keep*. It is **write a second
backend behind an interface that exists for exactly this purpose, and run both
until one wins.**
---
## What the new backend has to do
Room entities and DAOs for the ~50 columns the app actually uses across four
tables are mechanical. The real work is the behaviour the provider's processors
perform. Measured against `:provider`'s Java:
| Behaviour | Provider | Notes |
|---|---:|---|
| Instance expansion | ~1,070 | `Instantiating` + `instancedata` + iterables. **The hard one.** |
| Recurring-instance edit | 337 | `Detaching` — this *is* the recurrence-model decision |
| Completion coherence | 210 | `AutoCompleting`: status ↔ percent ↔ completed ↔ is_closed |
| Validation | 601 | three processors, mostly defending a *public* API |
| Parent / child | 269 | we use `parent_id` only |
| Alarm property rows | 133 | one Room entity |
| | **~2,620** | |
And what we would **not** write, of the 14,555 vendored lines:
| Not needed | Lines | Why |
|---|---:|---|
| `TaskDatabaseHelper` | 895 | 23 migrations from a 2013 schema. We start at v1. |
| `FTSDatabaseHelper` + ngrams | 798 | **the app never searches the provider** — verified, zero call sites |
| `model/adapters` | 1,581 | a type-safe layer over `ContentValues`. Room entities delete the problem. |
| `model` | 1,811 | cursor ↔ entity adaptation. Room's job. |
| `TaskProvider` + `SQLiteContentProvider` | 1,772 | URI matching, permissions, batch ops — for a public API |
| `CategoryHandler` + `RelationHandler` | 553 | unused |
| `utils` (most) | ~800 | dmfs jems idiom → Kotlin stdlib |
| **≈ 8,200 lines we would simply not have** | | |
Two things make instance expansion less frightening than its line count:
1. **We need client-side recurrence expansion regardless.** Server-side
`CALDAV:expand` on `VTODO` is broken on every server we target (`SYNC.md`),
so `lib-recur` is in the build either way.
2. **We would use the same eight `lib-recur` classes the provider does**
`RecurrenceRule`, `RecurrenceSet`, `RecurrenceSetIterator`, `RecurrenceList`,
`RecurrenceRuleAdapter`, `DateTime`, `Duration`,
`InvalidRecurrenceRuleException`. The algorithm is in the library, not in the
provider.
3. And the provider's expansion **materialises only one upcoming occurrence
anyway** — it is not the complete implementation its size suggests.
---
## The cost, both directions
### Building it
| | |
|---|---:|
| Schema, entities, DAOs | 1 wk |
| Instance expansion on `lib-recur`, with a real test suite | 1.52 wk |
| Completion / parent / validation semantics | 1 wk |
| Recurring-edit model — *shared cost, phase 1 either way* | (0.51 wk) |
| Migrating existing users' local data out of the provider | 0.5 wk |
| Tests to parity with the current 93 + 56 | 1 wk |
| **Net additional** | **4.56 wk** |
### What it removes from the sync plan
Roughly sixteen of the phase-1 audit's storage findings are **provider-imposed**
— they exist only because we run dmfs's implementation, and vanish when we own
the store:
- `_DIRTY` not set on delete, and defaulting to `1`
- `TaskLists._DIRTY` as a monotonic counter, not a flag
- the instances URI ignoring `CALLER_IS_SYNCADAPTER`
- no home for a per-collection sync token, href, ETag or CTag — all four squat
into generic `SYNC1``SYNC8` slots
- **read-only collections cannot be represented at all** (`ACCESS_LEVEL` inert)
- sync-adapter delete ignoring the account parameters it forces you to supply
- `Moving` leaving a dual-UID collision
- `ACCOUNT_TYPE` write-once → enabling sync is a full data migration
- Auto Backup restore arming `cleanUpLists` → silent task loss
- `Detaching` deciding the recurring-completion model for us
- the `lib-recur` version trap (0.16.0 removed `RecurrenceSet`) — we pin because
the provider does, not because we want to
Conservatively that is **2.54 weeks** off phases 0, 3 and 4 of the 11.515 week
sync plan, plus a class of bug that is currently *unfixable without patching
vendored Java*.
### Net
**≈ +1 to +3.5 weeks**, for a store we control, in exchange for two real losses.
---
## The honest case for keeping it (Option A)
Not nothing, and it should not be waved away:
- **It works, and it has 56 passing JVM tests** over recurrence, reparenting,
instances and observers. A Room reimplementation is *new code with new bugs*,
in the layer that holds the user's only copy of their data. That risk is real
and it points at A.
- **Tombstones actually work.** Soft delete for account rows, hard delete for
sync adapters, hidden from normal queries, undelete refused. Of all the sync
bookkeeping, this is the piece that held up under audit.
- **The exported provider under our own authority** — third-party apps can read
Agendula's tasks, and asking DAVx5 to sync us stays possible.
- Eleven local modification sites, all marked `AGENDULA CHANGE`, all documented
in `provider/PROVENANCE.md`. The fork is under control today.
And the case against keeping it:
- **14,555 lines of Java — 1.66× the entire app** (8,783 lines of Kotlin). We
carry, build, lint, translate and ship all of it to use maybe a third.
- Upstream is effectively dormant; every future `targetSdk` bump and every
Android SQLite behaviour change lands on us, in someone else's code, in a
language the rest of the app does not use.
- It makes behavioural decisions on our behalf (`Detaching`, `AutoCompleting`)
that we then have to reverse-engineer before we can honour them over CalDAV.
---
## Recommendation
**Option B — build our own store on Room, keeping the `TaskContract` *shape* as
the internal model — and keep `:provider` in-tree while we do.**
Three reasons, in order of weight:
1. **The seam already exists and the work is additive.** Nothing is deleted,
nothing above `data/tasks` changes, and both backends can ship side by side
behind `StorageMode`. The "big rewrite" this decision was originally weighed
against does not exist.
2. **The premise that settled it is gone.** The provider was kept for sync
bookkeeping we have since measured as broken. Sixteen findings deep, keeping
it is now a *cost* to the sync plan, not a saving.
3. **The window is now.** After phase 1 the mapper and engine are written against
whichever store won, and this stops being a two-file change.
Keeping the `TaskContract` *shape* rather than going domain-native (Option C) is
deliberate: it is a proven schema for exactly this problem, other engines
understand it, and it keeps a future exported facade cheap — without obliging us
to run a 2015 Java implementation of it.
### Sequencing that keeps the risk low
1. Add `StorageMode.OWN` and a Room `TasksDataSource`. Both backends live.
2. Ship it behind a setting; the vendored provider stays the default.
3. Run the sync engine against Room only.
4. Once Room has real production mileage, decide whether the exported
ContentProvider is worth re-implementing as a thin facade (~11.5 wk) or
whether External mode already covers everyone who wanted it.
Step 4 is a genuinely open question and does not need answering now. That is the
point of sequencing it last.
---
## Open
- **Is an exported provider worth keeping at all?** It matters only if third
parties should read our tasks, or if we want DAVx5 to sync our store. External
mode arguably already serves the second. Undecided.
- **The Room estimate is mine, not measured.** Instance expansion is the item
that could overrun; everything else is well-bounded.

1185
docs/SYNC.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -159,7 +159,7 @@ drift apart. Keep that convention.
`contenttestpal`, which are JitPack-only; adding JitPack would widen the `contenttestpal`, which are JitPack-only; adding JitPack would widen the
dependency trust surface for test-only code. ⚠️ This is the one place dependency trust surface for test-only code. ⚠️ This is the one place
vendoring lost coverage — those were the provider's *integration* tests vendoring lost coverage — those were the provider's *integration* tests
(recurrence, reparenting, instances, observers). The 51 JVM tests in (recurrence, reparenting, instances, observers). The 56 JVM tests in
`src/test` all pass and are retained. `src/test` all pass and are retained.
15. **`agendula_provider_changed_receivers` emptied.** Upstream notifies 15. **`agendula_provider_changed_receivers` emptied.** Upstream notifies
`org.andstatus.todoagenda`, which listens for changes to the *dmfs* authority `org.andstatus.todoagenda`, which listens for changes to the *dmfs* authority
@@ -175,7 +175,7 @@ drift apart. Keep that convention.
Unlikely to ever be worth it — upstream 1.4.2 is from 2021 — but if it is: the Unlikely to ever be worth it — upstream 1.4.2 is from 2021 — but if it is: the
`AGENDULA CHANGE` markers are the complete list of what to reapply, `git log` on `AGENDULA CHANGE` markers are the complete list of what to reapply, `git log` on
this directory is the audit trail, and the 51 JVM tests are the safety net. this directory is the audit trail, and the 56 JVM tests are the safety net.
Re-read change 1 before touching anything account-related. Re-read change 1 before touching anything account-related.
## Known-unverified ## Known-unverified