|
|
|
@@ -8,11 +8,10 @@ This document describes how Agendula is built **as it stands today**. For the
|
|
|
|
|
|
|
|
|
|
|
|
## 1. The thesis in one sentence
|
|
|
|
## 1. The thesis in one sentence
|
|
|
|
|
|
|
|
|
|
|
|
Agendula is a Material 3 Expressive task app over the dmfs `TaskContract` — it
|
|
|
|
Agendula is a Material 3 Expressive task app that reads, writes and reminds
|
|
|
|
reads, writes, and reminds against a task store the user chooses: **its own
|
|
|
|
against a task store the user chooses: **its own database** (the default) or an
|
|
|
|
bundled provider** (the default) or an external provider app already on the
|
|
|
|
external provider app already on the device (OpenTasks, tasks.org) synced by
|
|
|
|
device (OpenTasks, tasks.org) synced by DAVx5, SmoothSync, DecSync CC and the
|
|
|
|
DAVx5, SmoothSync, DecSync CC and the like. It is the task-list sibling to
|
|
|
|
like. It is the task-list sibling to
|
|
|
|
|
|
|
|
[Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing
|
|
|
|
[Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing
|
|
|
|
for `CalendarContract`.
|
|
|
|
for `CalendarContract`.
|
|
|
|
|
|
|
|
|
|
|
|
@@ -20,19 +19,25 @@ for `CalendarContract`.
|
|
|
|
the original "owns no database" thesis, settled in
|
|
|
|
the original "owns no database" thesis, settled in
|
|
|
|
[`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md): depending on a provider app being
|
|
|
|
[`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md): depending on a provider app being
|
|
|
|
installed made someone else's roadmap a gate on the app working at all. The
|
|
|
|
installed made someone else's roadmap a gate on the app working at all. The
|
|
|
|
database is the vendored dmfs provider under *our* authority — our namespace, not
|
|
|
|
store is a **Room database of our own**, designed against RFC 5545's `VTODO` and
|
|
|
|
a schema written from scratch — so every CalDAV engine still understands it. A
|
|
|
|
against what a CalDAV sync adapter will need — the reasoning is in
|
|
|
|
sync adapter of our own is the 1.x arc, designed in [`SYNC.md`](SYNC.md).
|
|
|
|
[`STORAGE-DECISION.md`](STORAGE-DECISION.md), the architecture and the plan it
|
|
|
|
|
|
|
|
implements in [`OWN-STORE.md`](OWN-STORE.md). It replaces a vendored copy of the
|
|
|
|
|
|
|
|
dmfs task provider, which was deleted along with the `:provider` module it lived
|
|
|
|
|
|
|
|
in. External mode is untouched by that and still speaks the dmfs
|
|
|
|
|
|
|
|
`TaskContract`. A sync adapter of our own is the 1.x arc, designed in
|
|
|
|
|
|
|
|
[`SYNC.md`](SYNC.md).
|
|
|
|
|
|
|
|
|
|
|
|
The whole design hangs off one rule:
|
|
|
|
The whole design hangs off one rule:
|
|
|
|
|
|
|
|
|
|
|
|
> The entire app talks to a `TasksRepository`. Only the data layer knows there
|
|
|
|
> The entire app talks to a `TasksRepository`. Only the data layer knows there
|
|
|
|
> is a `ContentResolver`, a `TaskContract`, or an authority string behind it.
|
|
|
|
> is a Room database, a `ContentResolver`, a `TaskContract` or an authority
|
|
|
|
> **Provider column names and the authority string never leak above the data
|
|
|
|
> string behind it. **Table and column names and the authority string never leak
|
|
|
|
> layer.**
|
|
|
|
> above the data layer.**
|
|
|
|
|
|
|
|
|
|
|
|
That rule is what let Posture B land as an addition rather than a rewrite: the
|
|
|
|
That rule is what let the entire store be swapped without a rewrite: replacing
|
|
|
|
UI, the ViewModels and the domain were untouched by it. See §7.
|
|
|
|
the provider with Room changed no UI, no ViewModel and exactly one domain field
|
|
|
|
|
|
|
|
(`Task.id` → `Task.occurrenceStart`, §4.8). See §7.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
@@ -46,35 +51,36 @@ UI, the ViewModels and the domain were untouched by it. See §7.
|
|
|
|
│ domain models + Flows only
|
|
|
|
│ domain models + Flows only
|
|
|
|
┌───────────────▼──────────────────────────────┐
|
|
|
|
┌───────────────▼──────────────────────────────┐
|
|
|
|
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
|
|
|
|
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
|
|
|
|
│ DayWindow (pure Kotlin, no Android) │
|
|
|
|
│ TaskSections, RecurrenceExpander │
|
|
|
|
|
|
|
|
│ (pure Kotlin, no Android) │
|
|
|
|
└───────────────┬──────────────────────────────┘
|
|
|
|
└───────────────┬──────────────────────────────┘
|
|
|
|
│ TasksRepository (interface)
|
|
|
|
│ TasksRepository (interface)
|
|
|
|
┌───────────────▼──────────────────────────────┐
|
|
|
|
┌───────────────▼──────────────────────────────┐
|
|
|
|
Data │ TasksRepositoryImpl │
|
|
|
|
Data │ TasksRepositoryImpl │
|
|
|
|
│ └ TasksDataSource (interface) │
|
|
|
|
│ └ TasksDataSource (interface) │
|
|
|
|
│ └ AndroidTasksDataSource │
|
|
|
|
│ └ ModeRoutingTasksDataSource │
|
|
|
|
│ └ ContentResolver / TaskContract / │
|
|
|
|
│ ├ RoomTasksDataSource (OWN) │
|
|
|
|
│ ProviderResolver / ContentObserver│
|
|
|
|
│ └ AndroidTasksDataSource (EXTERNAL)│
|
|
|
|
│ reminders/ prefs/ di/ demo/ │
|
|
|
|
│ reminders/ prefs/ di/ demo/ │
|
|
|
|
└───────────────┬──────────────────────────────┘
|
|
|
|
└────────┬────────────────────────┬────────────┘
|
|
|
|
│ content://
|
|
|
|
│ Room DAOs │ content://
|
|
|
|
┌───────────────▼──────────────────────────────┐
|
|
|
|
┌─────────────▼──────────┐ ┌──────────▼─────────────┐
|
|
|
|
Storage │ Local mode (default): │
|
|
|
|
Sto- │ Own mode (default): │ │ External mode: │
|
|
|
|
│ :provider — our own bundled task provider │
|
|
|
|
rage │ agendula-tasks.db — │ │ OpenTasks / tasks.org │
|
|
|
|
│ same uid, no permission grant needed │
|
|
|
|
│ four tables in our │ │ ←sync← DAVx5 / … │
|
|
|
|
│ External mode: │
|
|
|
|
│ own data directory, │ │ dangerous perms, │
|
|
|
|
│ OpenTasks / tasks.org ←sync← DAVx5 / … │
|
|
|
|
│ nothing to permit │ │ asked at point of use │
|
|
|
|
│ dangerous perms, requested at point of use │
|
|
|
|
└────────────────────────┘ └────────────────────────┘
|
|
|
|
└──────────────────────────────────────────────┘
|
|
|
|
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The seam that matters is the pair of interfaces in the data layer:
|
|
|
|
The seam that matters is the pair of interfaces in the data layer:
|
|
|
|
|
|
|
|
|
|
|
|
- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend
|
|
|
|
- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend
|
|
|
|
writes. (`data/tasks/TasksRepository.kt`)
|
|
|
|
writes. (`data/tasks/TasksRepository.kt`)
|
|
|
|
- **`TasksDataSource`** — the JVM-testable interface that does the actual
|
|
|
|
- **`TasksDataSource`** — the JVM-testable, domain-shaped interface that does the
|
|
|
|
provider work; `AndroidTasksDataSource` is the only Android-coupled
|
|
|
|
actual store work. Two implementations: `RoomTasksDataSource` for our own
|
|
|
|
implementation.
|
|
|
|
store and `AndroidTasksDataSource` for an external provider, picked per call by
|
|
|
|
|
|
|
|
`ModeRoutingTasksDataSource` (§4.1).
|
|
|
|
|
|
|
|
|
|
|
|
Both are bound in Hilt in `data/di/DataModule.kt`.
|
|
|
|
Both are bound in Hilt in `data/di/DataModule.kt`.
|
|
|
|
|
|
|
|
|
|
|
|
@@ -82,49 +88,57 @@ Both are bound in Hilt in `data/di/DataModule.kt`.
|
|
|
|
|
|
|
|
|
|
|
|
## 3. Module & package layout
|
|
|
|
## 3. Module & package layout
|
|
|
|
|
|
|
|
|
|
|
|
Two modules: `:app` and `:provider`. Package root `de.jeanlucmakiola.agendula`.
|
|
|
|
One module, `:app`, plus the `floret-kit` included build (`includeBuild` in
|
|
|
|
|
|
|
|
`settings.gradle.kts`, consumed as `de.jeanlucmakiola.floret:*`). The
|
|
|
|
| Module | Contents |
|
|
|
|
`:provider` module — the vendored dmfs task provider — was deleted with the own
|
|
|
|
|---|---|
|
|
|
|
store; `provider/PROVENANCE.md` went with it, its content preserved as a
|
|
|
|
| `:provider` | Agendula's own task store — the Apache-2.0 dmfs task provider 1.4.2, vendored in-tree under our authority and permission namespace. Not our code; see [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) for the upstream commit and every deviation. No app code imports from it except `ProviderResolver`, which reads the authority out of its resources. |
|
|
|
|
postscript in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Package root
|
|
|
|
|
|
|
|
`de.jeanlucmakiola.agendula`.
|
|
|
|
|
|
|
|
|
|
|
|
| Package (`:app`) | Contents |
|
|
|
|
| Package (`:app`) | Contents |
|
|
|
|
|---|---|
|
|
|
|
|---|---|
|
|
|
|
| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `AllDayTime` (the two date conventions), `DayWindow` (local-midnight maths). No Android imports. |
|
|
|
|
| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskConstants` (status/priority/local-account constants), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `TaskSections` (due-date sectioning), `AllDayTime` (the two date conventions). No Android imports; local-midnight maths comes from floret-kit's `DayWindow`. |
|
|
|
|
|
|
|
|
| `domain/recurrence/` | `RecurrenceExpander` — a stored rule set → its occurrences, over `lib-recur`. Pure Kotlin. |
|
|
|
|
| `domain/export/` | `ExportModels` + `ICalendarWriter` — VTODO serialization. Pure Kotlin, so the format is JVM-testable. |
|
|
|
|
| `domain/export/` | `ExportModels` + `ICalendarWriter` — VTODO serialization. Pure Kotlin, so the format is JVM-testable. |
|
|
|
|
| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` + `ProviderEnvironment` + `StorageMode` + `StorageModeHolder` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. |
|
|
|
|
| `data/tasks/` | `StorageMode` + `StorageModeHolder` + `ProviderResolver` + `ProviderEnvironment` (which store, §4.1), `ModeRoutingTasksDataSource`, `StartupGate`, `TasksDataSource`, `TasksRepository` + `Impl`, `Failures`; and the External-mode half — `TasksContract` (vendored subset), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `AndroidTasksDataSource`. |
|
|
|
|
|
|
|
|
| `data/tasks/room/` | Agendula's own store: `Entities` (the four tables), a DAO per table, `TasksDatabase`, `Converters`, `RoomTasksDataSource`, `RoomTaskMapper` (row→domain), `TaskFormWriter` (form→entity), `DatabaseCheckpoint`. |
|
|
|
|
|
|
|
|
| `data/tasks/legacy/` | `OneShotImport` — a v0.3.x install's tasks out of the dmfs provider's file and into Room, once (§4.4). |
|
|
|
|
| `data/export/` | `TaskExporter` (lists → `.ics` documents), `ExportWriter` (SAF plumbing; a floret-kit candidate). |
|
|
|
|
| `data/export/` | `TaskExporter` (lists → `.ics` documents), `ExportWriter` (SAF plumbing; a floret-kit candidate). |
|
|
|
|
| `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. |
|
|
|
|
| `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. |
|
|
|
|
| `data/prefs/` | `SettingsPrefs` (DataStore). |
|
|
|
|
| `data/prefs/` | `SettingsPrefs` (DataStore). |
|
|
|
|
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). |
|
|
|
|
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). |
|
|
|
|
| `data/demo/` | `DemoSeeder` (debug-only sample data). |
|
|
|
|
| `data/demo/` | `DemoSeeder` (debug-only sample data). |
|
|
|
|
| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. |
|
|
|
|
| `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. |
|
|
|
|
| root | `AgendulaApp` (Hilt app), `MainActivity`. |
|
|
|
|
| root | `AgendulaApp` (Hilt app), `MainActivity`. |
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 4. The data layer (the heart)
|
|
|
|
## 4. The data layer (the heart)
|
|
|
|
|
|
|
|
|
|
|
|
### 4.1 Provider targeting — `ProviderResolver`
|
|
|
|
### 4.1 Which store — `StorageMode` and `ProviderResolver`
|
|
|
|
|
|
|
|
|
|
|
|
`ProviderResolver.resolve()` returns the active `TaskProvider(authority,
|
|
|
|
`StorageMode` has two values, `OWN` and `EXTERNAL`, and
|
|
|
|
readPermission, writePermission, packageName, isOwn)` for the selected
|
|
|
|
`ModeRoutingTasksDataSource` picks the implementation **per call** — the mode is
|
|
|
|
`StorageMode`.
|
|
|
|
a setting the user can change while the process lives, so binding it once would
|
|
|
|
|
|
|
|
mean rebuilding the object graph to honour a change.
|
|
|
|
|
|
|
|
|
|
|
|
| Mode | Provider | Authority | Permissions |
|
|
|
|
| Mode | Store | Authority | Permissions |
|
|
|
|
|---|---|---|---|
|
|
|
|
|---|---|---|---|
|
|
|
|
| **Local** (default) | ours, bundled | `de.jeanlucmakiola.agendula.tasks` | **none** — same uid |
|
|
|
|
| **Own** (default) | our Room database, `agendula-tasks.db` | — none, it is not a provider | **none** |
|
|
|
|
| External | OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
|
|
|
|
| External | OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
|
|
|
|
| External | tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` |
|
|
|
|
| External | tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` |
|
|
|
|
|
|
|
|
|
|
|
|
All three are backed by the same dmfs `TaskProvider` — ours *is* that provider,
|
|
|
|
`ProviderResolver` is now only about the second and third rows: it discovers the
|
|
|
|
vendored — so the **same `TaskContract` columns apply** throughout.
|
|
|
|
*external* providers a device has. `resolve()` returns `null` in `OWN` mode —
|
|
|
|
|
|
|
|
there is no authority to resolve — and callers that need to tell that apart from
|
|
|
|
|
|
|
|
"External, and nothing installed" ask `mode()`. `TaskProvider` no longer carries
|
|
|
|
|
|
|
|
an `isOwn` flag; there is no own provider to flag.
|
|
|
|
|
|
|
|
|
|
|
|
`hasPermission()` short-circuits to `true` for our own provider: a same-uid
|
|
|
|
`providerStatus()` is unconditionally `READY` in `OWN` mode. The permission gate
|
|
|
|
caller bypasses a provider's permission checks outright, so
|
|
|
|
only ever applied to External, and that is now visibly true rather than a
|
|
|
|
`ProviderStatus.NEEDS_PERMISSION` can never fire in Local mode. In External mode
|
|
|
|
same-uid special case inside `hasPermission()`. In External mode `null` from
|
|
|
|
it checks both runtime perms, and `null` from `resolve()` drives the "install a
|
|
|
|
`resolve()` is `NO_PROVIDER` and a missing runtime permission is
|
|
|
|
tasks provider" gate.
|
|
|
|
`NEEDS_PERMISSION`, which is what drives onboarding.
|
|
|
|
|
|
|
|
|
|
|
|
**Choosing the mode.** An explicit choice is stored in `SettingsPrefs` and
|
|
|
|
**Choosing the mode.** An explicit choice is stored in `SettingsPrefs` and
|
|
|
|
mirrored into the resolver by `StorageModeHolder` — the resolver is consulted
|
|
|
|
mirrored into the resolver by `StorageModeHolder` — the resolver is consulted
|
|
|
|
@@ -132,71 +146,194 @@ synchronously on every query and cannot read DataStore itself. When there is no
|
|
|
|
explicit choice (the normal case), `autoMode()` decides:
|
|
|
|
explicit choice (the normal case), `autoMode()` decides:
|
|
|
|
|
|
|
|
|
|
|
|
> **External if we already hold an external provider's runtime permission,
|
|
|
|
> **External if we already hold an external provider's runtime permission,
|
|
|
|
> otherwise Local.**
|
|
|
|
> otherwise Own.**
|
|
|
|
|
|
|
|
|
|
|
|
That permission is dangerous-level, so it can only be there because an earlier
|
|
|
|
That permission is dangerous-level, so it can only be there because an earlier
|
|
|
|
version asked and the user agreed — the signature of an existing Posture A user,
|
|
|
|
version asked and the user agreed — the signature of an existing Posture A user,
|
|
|
|
who must not be dropped onto an empty store and left to conclude their tasks were
|
|
|
|
who must not be dropped onto an empty store and left to conclude their tasks were
|
|
|
|
deleted. A fresh install holds nothing and gets local-first storage.
|
|
|
|
deleted. A fresh install holds nothing and gets our own store.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A stored `LOCAL` — the old name for the bundled provider — is read as `OWN`
|
|
|
|
|
|
|
|
(`SettingsPrefs.kt:104`) rather than as an unparseable value. Left to fall
|
|
|
|
|
|
|
|
through to `autoMode()`, someone who had explicitly chosen local storage *and*
|
|
|
|
|
|
|
|
holds an OpenTasks grant would be sent to OpenTasks, away from the data the
|
|
|
|
|
|
|
|
import just moved.
|
|
|
|
|
|
|
|
|
|
|
|
The platform calls sit behind `ProviderEnvironment` so this decision is unit
|
|
|
|
The platform calls sit behind `ProviderEnvironment` so this decision is unit
|
|
|
|
tested on the JVM (`ProviderResolverTest`) rather than only on a device.
|
|
|
|
tested on the JVM (`ProviderResolverTest`) rather than only on a device.
|
|
|
|
|
|
|
|
|
|
|
|
**Storage modes** are `LOCAL` and `EXTERNAL` only. `STORAGE-AND-SYNC.md`
|
|
|
|
**Synced is still not a third mode.** `STORAGE-AND-SYNC.md` describes three; a
|
|
|
|
describes three, but *Synced* is not a third store — it is the same store with an
|
|
|
|
synced list is `OWN` with an account attached, which is derived state rather than
|
|
|
|
account attached, so it stays derived state.
|
|
|
|
something the user picks. Attaching one is a plain `UPDATE task_lists SET
|
|
|
|
|
|
|
|
account_id = ?` — `account_id` is a nullable FK from v1, so turning sync on for
|
|
|
|
|
|
|
|
an existing list is not a migration. (Under the dmfs provider it was:
|
|
|
|
|
|
|
|
`ACCOUNT_NAME`/`ACCOUNT_TYPE` were write-once, so tasks had to be moved into new
|
|
|
|
|
|
|
|
lists. That constraint left with the provider.)
|
|
|
|
|
|
|
|
|
|
|
|
⚠️ **What this used to say — "so turning sync on is not a migration" — is wrong.**
|
|
|
|
### 4.2 The own store — four Room tables
|
|
|
|
A task list's `ACCOUNT_NAME`/`ACCOUNT_TYPE` are write-once in the provider
|
|
|
|
|
|
|
|
(`processors/lists/Validating.java:68-76`), so local lists cannot be re-pointed at
|
|
|
|
|
|
|
|
an account; tasks have to be moved into new lists. Not modelling `SYNCED` as a
|
|
|
|
|
|
|
|
mode is still right, but the migration it implied away is real. See
|
|
|
|
|
|
|
|
[`SYNC.md`](SYNC.md).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 4.2 `TasksContract`
|
|
|
|
`TasksDatabase` (v1, schema exported to `app/schemas/` and committed) holds
|
|
|
|
|
|
|
|
`task_lists`, `tasks`, `task_alarms` and `accounts`. The columns and the
|
|
|
|
|
|
|
|
reasoning behind each are in [`OWN-STORE.md`](OWN-STORE.md); what matters
|
|
|
|
|
|
|
|
structurally:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- **`accounts` is empty until sync lands**, but the nullable `task_lists
|
|
|
|
|
|
|
|
.account_id` FK exists from v1 — that is what makes §4.1's "attaching an
|
|
|
|
|
|
|
|
account is an `UPDATE`" true. Deleting an account `SET NULL`s its lists rather
|
|
|
|
|
|
|
|
than deleting them.
|
|
|
|
|
|
|
|
- **Masters and `RECURRENCE-ID` overrides share the `tasks` table.** An override
|
|
|
|
|
|
|
|
is a row with `recurrence_id` set and `master_id` pointing at its master,
|
|
|
|
|
|
|
|
sharing the master's `uid`. The unique index is therefore
|
|
|
|
|
|
|
|
`(list_id, uid, recurrence_id)`; SQLite treats NULLs as distinct, so it
|
|
|
|
|
|
|
|
enforces the override half and states the master half as intent.
|
|
|
|
|
|
|
|
- **`uid` is `NOT NULL`**, minted at creation in either mode, synced or not — so
|
|
|
|
|
|
|
|
a task has a stable identity before a server ever sees it.
|
|
|
|
|
|
|
|
- `priority` is stored as the raw iCalendar integer (0 none, 1 highest, 9
|
|
|
|
|
|
|
|
lowest). Bucketing into `Priority` on the way *in* would rewrite a server's
|
|
|
|
|
|
|
|
`PRIORITY:3` as `1`; the bucketing belongs to the mapper.
|
|
|
|
|
|
|
|
- Cascades: deleting a list takes its tasks, deleting a series takes its
|
|
|
|
|
|
|
|
overrides (`master_id`), deleting a parent promotes its subtasks
|
|
|
|
|
|
|
|
(`parent_id` → `SET NULL`).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`RoomTaskMapper` maps a row to a domain `Task`; `TaskFormWriter` applies a
|
|
|
|
|
|
|
|
validated `TaskForm` to an entity. `TaskFormWriter` is the Room counterpart of
|
|
|
|
|
|
|
|
External mode's `TaskWriteMapper`, and deliberately not shared with it: most of
|
|
|
|
|
|
|
|
what that mapper does is work around the provider (clearing `DURATION` because
|
|
|
|
|
|
|
|
it validates a merged row; writing `STATUS` explicitly in both directions because
|
|
|
|
|
|
|
|
it auto-completes at 100% but will not reopen below it). Here the completion
|
|
|
|
|
|
|
|
rules are stated
|
|
|
|
|
|
|
|
directly — progress and status move together in both directions, so a task can
|
|
|
|
|
|
|
|
no longer strand itself "done at 75%".
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Deletes are hard when the list has no account and tombstones (`is_deleted`) when
|
|
|
|
|
|
|
|
it does — a row a server still knows about has to survive long enough to be
|
|
|
|
|
|
|
|
withdrawn from it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 4.3 Recurrence — expanded at read, not materialised
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
There is **no instances table**. The dmfs provider maintained one, recomputed on
|
|
|
|
|
|
|
|
every write, and still only ever materialised one upcoming occurrence. Agendula
|
|
|
|
|
|
|
|
expands a series in memory instead:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
tasks (masters + overrides) ──► RecurrenceExpander ──► List<Task>
|
|
|
|
|
|
|
|
rrule/rdate/exdate (lib-recur, in memory) occurrences
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
This costs nothing because `TasksRepositoryImpl` already filters and sorts in
|
|
|
|
|
|
|
|
Kotlin, not SQL — nothing depended on the database ordering by instance time —
|
|
|
|
|
|
|
|
and the whole class of staleness bugs a cached table brings never exists.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Expansion is bounded twice over: a window of **1 year back, 2 years forward**
|
|
|
|
|
|
|
|
(`RoomTasksDataSource.WINDOW_BACK`/`WINDOW_FORWARD`) and a hard per-series
|
|
|
|
|
|
|
|
occurrence ceiling, so an unbounded `RRULE` terminates. The iterator is
|
|
|
|
|
|
|
|
fast-forwarded to the window start first, so a `FREQ=MINUTELY` series anchored
|
|
|
|
|
|
|
|
years back does not scan millions of instances to emit one. A malformed
|
|
|
|
|
|
|
|
`RRULE`/`RDATE`/`EXDATE` is dropped rather than thrown: a task whose stored rule
|
|
|
|
|
|
|
|
cannot be parsed still has to appear.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`RecurrenceExpander` returns each occurrence as its **`RECURRENCE-ID` anchor**.
|
|
|
|
|
|
|
|
`RoomTasksDataSource.occurrencesOf` then substitutes any override for the
|
|
|
|
|
|
|
|
occurrence it replaces; a timed series carries each occurrence's length across,
|
|
|
|
|
|
|
|
while a due-anchored one has no start to offset from, so the anchor *is* the due
|
|
|
|
|
|
|
|
date — matching how the provider instantiated the same series.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`lib-recur` is pinned at **0.12.2** (0.16.0 removed `RecurrenceSet`) and is a
|
|
|
|
|
|
|
|
direct `:app` dependency now rather than something `:provider` dragged in. It is
|
|
|
|
|
|
|
|
still Apache-2.0 dmfs code, so the attribution is still owed — as a normal
|
|
|
|
|
|
|
|
third-party dependency.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**Editing one occurrence writes a `RECURRENCE-ID` override** — RFC 5545's model
|
|
|
|
|
|
|
|
(a): a second `tasks` row with the master's `uid`, a `recurrence_id` naming the
|
|
|
|
|
|
|
|
occurrence, `master_id` pointing at the series, and the edit applied. The
|
|
|
|
|
|
|
|
provider's `Detaching.java` forked a brand-new task with its own UID instead
|
|
|
|
|
|
|
|
(model (d), the one least compatible with CalDAV); we inherited that without
|
|
|
|
|
|
|
|
choosing it, and this is the choice.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 4.4 Startup — the import and the gate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`OneShotImport` moves a v0.3.x install's tasks out of the bundled provider's
|
|
|
|
|
|
|
|
`databases/tasks.db` and into Room, once. The file is opened read-only and
|
|
|
|
|
|
|
|
directly — no provider, no `ContentResolver` — so it keeps working now that
|
|
|
|
|
|
|
|
`:provider` is gone, and everything lands in one verified Room transaction, so a
|
|
|
|
|
|
|
|
failure leaves both Room and the source file exactly as they were. dmfs row ids
|
|
|
|
|
|
|
|
are remapped in two passes, because a parent can carry a higher `_id` than its
|
|
|
|
|
|
|
|
child, and `RECURRENCE-ID` overrides are carried across as `master_id` /
|
|
|
|
|
|
|
|
`recurrence_id` rather than imported as second masters, which would collide on
|
|
|
|
|
|
|
|
the unique index. The source is archived to `tasks.db.imported` **before** the
|
|
|
|
|
|
|
|
import, and the import always replaces: that ordering is what makes every kill
|
|
|
|
|
|
|
|
point re-enter correctly.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`StartupGate` holds the first store read until the stored mode has reached
|
|
|
|
|
|
|
|
`ProviderResolver` *and* the import has run — `TasksRepositoryImpl.observing()`
|
|
|
|
|
|
|
|
awaits it before its first load, and `AgendulaApp` awaits it before the launch
|
|
|
|
|
|
|
|
reminder re-sync. Both are the same race: reading early answers from
|
|
|
|
|
|
|
|
`autoMode()` instead of the user's choice, or shows an upgrading user an empty
|
|
|
|
|
|
|
|
app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 4.5 `TasksContract` — External mode only
|
|
|
|
|
|
|
|
|
|
|
|
A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
|
|
|
|
A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
|
|
|
|
table paths, status/priority constants, the local-account type. Agendula does
|
|
|
|
table paths, status/priority constants, the local-account type. Agendula does
|
|
|
|
**not** take a runtime dependency on OpenTasks; the authority is injected from
|
|
|
|
**not** take a runtime dependency on OpenTasks; the authority is injected from
|
|
|
|
`ProviderResolver`, never hardcoded in the contract.
|
|
|
|
`ProviderResolver`, never hardcoded in the contract. Nothing in `OWN` mode
|
|
|
|
|
|
|
|
touches it: the domain's own status/priority/local-account constants live in
|
|
|
|
|
|
|
|
`domain/TaskConstants.kt`, so the domain layer never reaches into the data layer
|
|
|
|
|
|
|
|
to map its own enums.
|
|
|
|
|
|
|
|
|
|
|
|
### 4.3 Reads — Instances + `ContentObserver`
|
|
|
|
### 4.6 Reads and reactivity
|
|
|
|
|
|
|
|
|
|
|
|
`AndroidTasksDataSource` queries the denormalized **instances** view (so each
|
|
|
|
In `OWN` mode `RoomTasksDataSource` reads through the DAOs and expands
|
|
|
|
occurrence is a row with the joined list colour, account, etc.), maps each
|
|
|
|
recurrences (§4.3). In `EXTERNAL` mode `AndroidTasksDataSource` queries the
|
|
|
|
cursor row through `ColumnReader` → `TaskMapper` → domain `Task`, and exposes
|
|
|
|
denormalized **instances** view (so each occurrence is a row with the joined list
|
|
|
|
the result as a `Flow`. A `ContentObserver` on the active authority's
|
|
|
|
colour, account, etc.) and maps each cursor row through `ColumnReader` →
|
|
|
|
Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change
|
|
|
|
`TaskMapper` → domain `Task`.
|
|
|
|
re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks)
|
|
|
|
|
|
|
|
update the UI live, and multiple sync sources coexist in one list.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 4.4 Writes — repository API
|
|
|
|
`TasksDataSource.registerObserver(onChange): AutoCloseable` is unchanged and
|
|
|
|
|
|
|
|
backed differently on each side: Room's `InvalidationTracker` over the four
|
|
|
|
|
|
|
|
tables in `OWN` mode, a `ContentObserver` on the active authority's
|
|
|
|
|
|
|
|
Tasks/TaskLists URIs in `EXTERNAL`. Either way `TasksRepositoryImpl.observing()`
|
|
|
|
|
|
|
|
bridges it into a `Flow` via `callbackFlow`, so **any change re-emits** —
|
|
|
|
|
|
|
|
Agendula's own writes *and*, in External mode, DAVx5 pulling new tasks.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 4.7 Writes — repository API
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
```kotlin
|
|
|
|
interface TasksRepository {
|
|
|
|
interface TasksRepository {
|
|
|
|
fun taskLists(): Flow<List<TaskList>>
|
|
|
|
fun taskLists(): Flow<List<TaskList>>
|
|
|
|
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
|
|
|
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
|
|
|
|
|
|
|
fun subtasks(parentId: Long): Flow<List<Task>>
|
|
|
|
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
|
|
|
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
|
|
|
|
|
|
|
|
|
|
|
suspend fun createTask(form: TaskForm): Long
|
|
|
|
suspend fun createTask(form: TaskForm): Long
|
|
|
|
suspend fun updateTask(taskId: Long, form: TaskForm)
|
|
|
|
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
|
|
|
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
|
|
|
|
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
|
|
|
|
suspend fun deleteTask(taskId: Long)
|
|
|
|
suspend fun deleteTask(taskId: Long)
|
|
|
|
|
|
|
|
suspend fun reminderFor(taskId: Long): Int?
|
|
|
|
suspend fun createLocalList(name: String, color: Int): Long
|
|
|
|
suspend fun createLocalList(name: String, color: Int): Long
|
|
|
|
|
|
|
|
|
|
|
|
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
|
|
|
|
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
|
|
|
|
}
|
|
|
|
}
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion
|
|
|
|
`updateTask` on an occurrence of a recurring task routes to
|
|
|
|
sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back
|
|
|
|
`TasksDataSource.updateInstance(taskId, occurrenceStart, form)` rather than
|
|
|
|
out as a normal VTODO status change. Writes to local/unsynced lists use the
|
|
|
|
moving the series anchor. `expectedLastModified` re-checks the stored timestamp
|
|
|
|
sync-adapter URI form where the provider requires it.
|
|
|
|
first and throws `TaskConflictException` when something changed underneath the
|
|
|
|
|
|
|
|
form.
|
|
|
|
|
|
|
|
|
|
|
|
### 4.5 Domain model notes
|
|
|
|
Completion sets `STATUS = COMPLETED` (+ percent/completed timestamp); in
|
|
|
|
|
|
|
|
External mode DAVx5 syncs that back out as a normal VTODO status change.
|
|
|
|
|
|
|
|
|
|
|
|
- `Task.id` is the **instance** row id; `Task.taskId` is the underlying
|
|
|
|
### 4.8 Domain model notes
|
|
|
|
`tasks._id` and the stable target for edits/completion.
|
|
|
|
|
|
|
|
|
|
|
|
- `Task.taskId` is the task row and the stable target for edits, completion and
|
|
|
|
|
|
|
|
navigation. There is no `Task.id` any more — it was the materialised instance
|
|
|
|
|
|
|
|
row id, and materialised instances are gone.
|
|
|
|
|
|
|
|
- `Task.occurrenceStart` is the occurrence's `RECURRENCE-ID` anchor, `null` for a
|
|
|
|
|
|
|
|
non-recurring task; `Task.occurrenceKey` (`"$taskId@$millis"`) is what lazy
|
|
|
|
|
|
|
|
lists key by. Two occurrences of one series can appear in the same list, so
|
|
|
|
|
|
|
|
`taskId` alone would collide there — as a Compose key that is a visible bug.
|
|
|
|
- Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`);
|
|
|
|
- Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`);
|
|
|
|
`TaskDetail` bundles a task with its direct children.
|
|
|
|
`TaskDetail` bundles a task with its direct children.
|
|
|
|
- `effectiveColor` = the task's own colour, else the list colour.
|
|
|
|
- `effectiveColor` = the task's own colour, else the list colour.
|
|
|
|
@@ -210,7 +347,7 @@ sync-adapter URI form where the provider requires it.
|
|
|
|
`TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists —
|
|
|
|
`TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists —
|
|
|
|
`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due
|
|
|
|
`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due
|
|
|
|
dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking
|
|
|
|
dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking
|
|
|
|
`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it
|
|
|
|
`todayStart`/`todayEnd` (local-midnight bounds from floret-kit's `DayWindow`), so it
|
|
|
|
unit-tests with a fixed clock. `TaskSorting` orders within a list (due /
|
|
|
|
unit-tests with a fixed clock. `TaskSorting` orders within a list (due /
|
|
|
|
priority / etc.). None of this touches Android, which is why it's all in
|
|
|
|
priority / etc.). None of this touches Android, which is why it's all in
|
|
|
|
`domain/`.
|
|
|
|
`domain/`.
|
|
|
|
@@ -231,10 +368,24 @@ providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`)
|
|
|
|
`set` when `canScheduleExactAlarms()` is false), keyed by `taskId`.
|
|
|
|
`set` when `canScheduleExactAlarms()` is false), keyed by `taskId`.
|
|
|
|
- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel,
|
|
|
|
- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel,
|
|
|
|
`POST_NOTIFICATIONS` gate, dedupe-by-tag).
|
|
|
|
`POST_NOTIFICATIONS` gate, dedupe-by-tag).
|
|
|
|
- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and
|
|
|
|
- **Re-sync triggers:** app start (after `StartupGate`), **`BootReceiver`**
|
|
|
|
**`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external
|
|
|
|
(re-arm after reboot), and **`ProviderChangeReceiver`**. The store lets each
|
|
|
|
sync changed the data). The store lets each run diff like Calendula diffs
|
|
|
|
run diff like Calendula diffs reminder rows.
|
|
|
|
reminder rows.
|
|
|
|
|
|
|
|
|
|
|
|
`sync()` gates on **`ProviderResolver.canReadStore()`**, not on a provider
|
|
|
|
|
|
|
|
resolving. `OWN` is always readable; only `EXTERNAL` can fail, and only for the
|
|
|
|
|
|
|
|
two reasons it ever could (nothing installed, or no grant). Gating on
|
|
|
|
|
|
|
|
`resolve() != null` — as it did briefly — clears every alarm the moment `OWN` is
|
|
|
|
|
|
|
|
active, because `OWN` resolves to no provider by design. `ProviderResolverTest`
|
|
|
|
|
|
|
|
covers both directions.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`ProviderChangeReceiver`'s manifest filter now lists **only the two external
|
|
|
|
|
|
|
|
authorities**: Agendula publishes no provider and broadcasts no
|
|
|
|
|
|
|
|
`ACTION_PROVIDER_CHANGED`, so there is nothing of ours to listen for. In `OWN`
|
|
|
|
|
|
|
|
mode Room's `InvalidationTracker` covers foreground changes and nothing outside
|
|
|
|
|
|
|
|
the app can change our data. When `SYNC.md` phase 3 lands, the sync worker calls
|
|
|
|
|
|
|
|
`ReminderScheduler.sync()` itself — that is the replacement for the broadcast,
|
|
|
|
|
|
|
|
and it belongs in the sync work.
|
|
|
|
|
|
|
|
|
|
|
|
This is the single largest piece of genuinely-new code in Agendula.
|
|
|
|
This is the single largest piece of genuinely-new code in Agendula.
|
|
|
|
|
|
|
|
|
|
|
|
@@ -248,28 +399,40 @@ They no longer mean what earlier drafts of this document said.
|
|
|
|
- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org).
|
|
|
|
- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org).
|
|
|
|
Still fully supported; it stopped being the only option and became a user
|
|
|
|
Still fully supported; it stopped being the only option and became a user
|
|
|
|
choice, `StorageMode.EXTERNAL`.
|
|
|
|
choice, `StorageMode.EXTERNAL`.
|
|
|
|
- **Posture B (shipped)** — the `:provider` module: the Apache-2.0 dmfs provider
|
|
|
|
- **Posture B (shipped, then rebuilt)** — a store of our own, `StorageMode.OWN`.
|
|
|
|
vendored under **our own** authority `de.jeanlucmakiola.agendula.tasks` and our
|
|
|
|
It first shipped as the `:provider` module, the Apache-2.0 dmfs provider
|
|
|
|
own permission namespace. It **coexists with everything and replaces nothing**.
|
|
|
|
vendored under our own authority; it is now a Room database and the module is
|
|
|
|
|
|
|
|
deleted. What made the vendored provider worth keeping was the sync bookkeeping
|
|
|
|
|
|
|
|
it appeared to hand us for free, and the phase-1 sync audit measured that
|
|
|
|
|
|
|
|
bookkeeping and found most of it broken, absent or unusable — the argument and
|
|
|
|
|
|
|
|
the costing are in [`STORAGE-DECISION.md`](STORAGE-DECISION.md).
|
|
|
|
|
|
|
|
|
|
|
|
> **Dead end, do not revisit:** bundling the provider under dmfs's *own*
|
|
|
|
> **Dead end, do not revisit:** publishing a task provider under dmfs's *own*
|
|
|
|
> authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the
|
|
|
|
> authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the
|
|
|
|
> same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same
|
|
|
|
> same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same
|
|
|
|
> `<permission>` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with
|
|
|
|
> `<permission>` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with
|
|
|
|
> OpenTasks installed simply could not have installed Agendula. Account
|
|
|
|
> OpenTasks installed simply could not have installed Agendula. Account
|
|
|
|
> visibility is also keyed by *package*, not authority, which would have left the
|
|
|
|
> visibility is also keyed by *package*, not authority, which would have left such
|
|
|
|
> bundled provider seeing zero accounts and pruning synced lists as orphaned.
|
|
|
|
> a provider seeing zero accounts and pruning synced lists as orphaned. Full
|
|
|
|
> Full reasoning in `STORAGE-AND-SYNC.md`.
|
|
|
|
> reasoning in `STORAGE-AND-SYNC.md`.
|
|
|
|
|
|
|
|
|
|
|
|
The seam earned its keep: `ProviderResolver` is still the only thing that knows
|
|
|
|
The seam earned its keep twice over. `ProviderResolver` is still the only thing
|
|
|
|
an authority exists and `AndroidTasksDataSource` the only thing that touches a
|
|
|
|
that knows an authority exists and `AndroidTasksDataSource` the only thing that
|
|
|
|
resolver, so vendoring an entire content provider **changed no UI, no ViewModel,
|
|
|
|
touches a resolver, so vendoring an entire content provider changed nothing above
|
|
|
|
no domain type, and not one line of `TasksRepository`.**
|
|
|
|
the data layer — and **replacing** it with a database of our own changed no UI,
|
|
|
|
|
|
|
|
no ViewModel and exactly one domain field (`Task.id` → `occurrenceStart`), which
|
|
|
|
|
|
|
|
was forced by dropping materialised instances rather than by the store swap
|
|
|
|
|
|
|
|
itself.
|
|
|
|
|
|
|
|
|
|
|
|
Bundling the provider bundles **storage, not sync**. Our own sync adapter is a
|
|
|
|
⚠️ **The authority is gone, and that is a breaking change.**
|
|
|
|
separate, later piece of work — designed in [`SYNC.md`](SYNC.md), and it lands
|
|
|
|
`de.jeanlucmakiola.agendula.tasks` and both custom permissions no longer exist,
|
|
|
|
*underneath* this same seam: it writes through `TaskContract` with
|
|
|
|
so anyone who had pointed DAVx5 or another app at that authority loses it.
|
|
|
|
`CALLER_IS_SYNCADAPTER`, so the layers above it stay untouched a second time.
|
|
|
|
External mode is the answer for them; it needs saying in the release notes.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Owning the store owns **storage, not sync**. Our own sync adapter is a separate,
|
|
|
|
|
|
|
|
later piece of work — designed in [`SYNC.md`](SYNC.md), and it lands *underneath*
|
|
|
|
|
|
|
|
this same seam: it writes through the DAOs, so the layers above stay untouched a
|
|
|
|
|
|
|
|
third time.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
@@ -283,9 +446,9 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`,
|
|
|
|
|
|
|
|
|
|
|
|
`RootScreen` is the entry composable: it gates on `ProviderStatus`
|
|
|
|
`RootScreen` is the entry composable: it gates on `ProviderStatus`
|
|
|
|
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` →
|
|
|
|
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` →
|
|
|
|
`ListsScreen`). The remaining screens are being built one at a time — their
|
|
|
|
`AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is
|
|
|
|
ViewModels exist and are tested against the real data layer; navigation
|
|
|
|
only ever seen in External mode. Routes are the `Dest` table in
|
|
|
|
callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the
|
|
|
|
`ui/navigation/` (lists → task list → detail / edit, plus settings). Follow the
|
|
|
|
`material-3` skill for component choices (M3 `ListItem` rows, expressive
|
|
|
|
`material-3` skill for component choices (M3 `ListItem` rows, expressive
|
|
|
|
checkbox/FAB/swipe motion).
|
|
|
|
checkbox/FAB/swipe motion).
|
|
|
|
|
|
|
|
|
|
|
|
@@ -293,11 +456,17 @@ checkbox/FAB/swipe motion).
|
|
|
|
|
|
|
|
|
|
|
|
## 9. Dependency injection
|
|
|
|
## 9. Dependency injection
|
|
|
|
|
|
|
|
|
|
|
|
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module
|
|
|
|
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module (`TasksRepository`
|
|
|
|
(`TasksDataSource` → `AndroidTasksDataSource`, `TasksRepository` →
|
|
|
|
→ `TasksRepositoryImpl`, `ProviderEnvironment` → `AndroidProviderEnvironment`)
|
|
|
|
`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore,
|
|
|
|
and a `@Provides` module (the `agendula_prefs` DataStore, `TasksDatabase`, the
|
|
|
|
the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
|
|
|
|
`@IoDispatcher`, the `@ApplicationScope`). `TasksDataSource` is `@Provides`
|
|
|
|
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected.
|
|
|
|
rather than `@Binds`, because it is a `ModeRoutingTasksDataSource` over
|
|
|
|
|
|
|
|
`Provider<RoomTasksDataSource>` and `Provider<AndroidTasksDataSource>` — both
|
|
|
|
|
|
|
|
singletons, so it picks between two long-lived objects rather than building
|
|
|
|
|
|
|
|
either. `AgendulaApp` is the `@HiltAndroidApp` entry point and pulls
|
|
|
|
|
|
|
|
`StartupGate`, `DatabaseCheckpoint` and `ReminderScheduler` through an
|
|
|
|
|
|
|
|
`@EntryPoint`; `MainActivity` is `@AndroidEntryPoint`. ViewModels get the
|
|
|
|
|
|
|
|
repository injected.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
@@ -308,9 +477,9 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
|
|
|
|
| Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 |
|
|
|
|
| Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 |
|
|
|
|
| SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 |
|
|
|
|
| SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 |
|
|
|
|
| UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) |
|
|
|
|
| UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) |
|
|
|
|
| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines |
|
|
|
|
| Store | Room 2.8.4 (KSP, `room.schemaLocation = app/schemas`, WAL), `org.dmfs:lib-recur` 0.12.2 pinned (0.16.0 removed `RecurrenceSet`; `rfc5545-datetime` arrives with it as part of its API surface) |
|
|
|
|
| `:provider` | Java 17, `org.dmfs` jems / rfc5545-datetime / lib-recur (all Maven Central — no new repository; `settings.gradle.kts` stays `google()` + `mavenCentral()` under `FAIL_ON_PROJECT_REPOS`) |
|
|
|
|
| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines, the `floret-kit` included build |
|
|
|
|
| Tests | `:app` is JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source and `ProviderEnvironment` as the JVM-testable seams. **`:provider` is JUnit4 + Robolectric** — upstream's own suite, kept as written rather than rewritten, since that coverage is what makes vendoring safe. Don't add `useJUnitPlatform()` there. |
|
|
|
|
| Tests | JVM: JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source, `ProviderEnvironment`, `TaskFormWriter` and `RecurrenceExpander` as the JVM-testable seams. Instrumented (`app/src/androidTest`, AndroidJUnitRunner + Truth): the Room schema, `RoomTasksDataSource` and `OneShotImport` — the last against `assets/tasks-v23.db`, a fixture written by `scripts/make_import_fixture.py` in the provider's DATABASE_VERSION 23 schema, since the provider it came from no longer exists to test against. |
|
|
|
|
| Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). |
|
|
|
|
| Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). |
|
|
|
|
| CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). |
|
|
|
|
| CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). |
|
|
|
|
| Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs |
|
|
|
|
| Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs |
|
|
|
|
@@ -319,25 +488,28 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
|
|
|
|
|
|
|
|
|
|
|
|
## 11. Manifest surface
|
|
|
|
## 11. Manifest surface
|
|
|
|
|
|
|
|
|
|
|
|
- **Declared by `:app`:** both `org.dmfs.*` and `org.tasks.*` read/write tasks
|
|
|
|
- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms
|
|
|
|
perms (static manifest, so they are always declared; requested at runtime only
|
|
|
|
(static manifest, so they are always declared; requested at runtime only in
|
|
|
|
in External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
|
|
|
|
External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
|
|
|
|
(`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32).
|
|
|
|
(`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). `OWN` mode needs
|
|
|
|
- **Declared by `:provider`:** the `<provider>` itself plus our own
|
|
|
|
nothing here at all: it is a database in our own data directory.
|
|
|
|
`de.jeanlucmakiola.agendula.permission.READ_TASKS` / `WRITE_TASKS` and their
|
|
|
|
- **No `<provider>` and no custom permissions.** Agendula publishes no content
|
|
|
|
permission group. These exist for **other** apps — Agendula reaches its own
|
|
|
|
provider; `de.jeanlucmakiola.agendula.tasks`, the
|
|
|
|
provider same-uid and neither declares a `uses-permission` for them nor asks.
|
|
|
|
`de.jeanlucmakiola.agendula.permission.*` pair and their permission group all
|
|
|
|
The provider is `exported="true"` on purpose: that is what would let DAVx5
|
|
|
|
went with the `:provider` module.
|
|
|
|
write into it once it knows our authority.
|
|
|
|
- **Deliberately absent:** `GET_ACCOUNTS`, and `INTERNET`, which stays undeclared
|
|
|
|
- **Deliberately absent:** `GET_ACCOUNTS` (stripped from the vendored provider —
|
|
|
|
until sync actually ships. Export needs no storage permission at all; SAF hands
|
|
|
|
see change 1 in `PROVENANCE.md`) and `INTERNET`, which stays undeclared until
|
|
|
|
us a `Uri` the user picked.
|
|
|
|
sync actually ships. Export needs no storage permission at all; SAF hands us a
|
|
|
|
- **`<queries>`** for package visibility: both external provider authorities +
|
|
|
|
`Uri` the user picked.
|
|
|
|
|
|
|
|
- **`<queries>`** for package visibility: both *external* provider authorities +
|
|
|
|
|
|
|
|
a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open
|
|
|
|
a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open
|
|
|
|
the provider / a store listing). Our own provider needs no entry.
|
|
|
|
the provider / a store listing).
|
|
|
|
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`,
|
|
|
|
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, and
|
|
|
|
`ProviderChangeReceiver` (all three authorities, ours first — an intent-filter
|
|
|
|
`ProviderChangeReceiver` — the latter filtering on the two *external*
|
|
|
|
host must be a literal), and the vendored provider's own
|
|
|
|
authorities only (an intent-filter host must be a literal). No `EVENT_REMINDER`
|
|
|
|
`TaskProviderBroadcastReceiver`. No `EVENT_REMINDER` receiver — that's a
|
|
|
|
receiver — that's a Calendula thing that doesn't apply here.
|
|
|
|
Calendula thing that doesn't apply here.
|
|
|
|
- **Backup:** `agendula-tasks.db` plus its `-wal` and `-shm` sidecars are
|
|
|
|
|
|
|
|
included in both `backup_rules.xml` and `data_extraction_rules.xml`;
|
|
|
|
|
|
|
|
`tasks.db.imported` is excluded, since it is a copy of data already imported.
|
|
|
|
|
|
|
|
Room runs in WAL mode and Auto Backup copies files without checkpointing, so
|
|
|
|
|
|
|
|
`DatabaseCheckpoint` runs `PRAGMA wal_checkpoint(TRUNCATE)` on `ON_STOP` to
|
|
|
|
|
|
|
|
keep the `.db` alone current for a restore that drops the sidecars.
|
|
|
|
|