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>
19 KiB
Agendula — architecture
This document describes how Agendula is built as it stands today. For the
why behind the big decisions and the long-term plan, see
PLAN.md; for status and what's next, see ROADMAP.md.
1. The thesis in one sentence
Agendula is a Material 3 Expressive task app over the dmfs TaskContract — it
reads, writes, and reminds against a task store the user chooses: its own
bundled provider (the default) or an external provider app already on the
device (OpenTasks, tasks.org) synced by DAVx5, SmoothSync, DecSync CC and the
like. It is the task-list sibling to
Calendula, which does the same thing
for CalendarContract.
Agendula owns storage but not, yet, sync. That is a deliberate change from
the original "owns no database" thesis, settled in
STORAGE-AND-SYNC.md: depending on a provider app being
installed made someone else's roadmap a gate on the app working at all. The
database is the vendored dmfs provider under our authority — our namespace, not
a schema written from scratch — so every CalDAV engine still understands it. A
sync adapter of our own is the 1.x arc, designed in SYNC.md.
The whole design hangs off one rule:
The entire app talks to a
TasksRepository. Only the data layer knows there is aContentResolver, aTaskContract, or an authority string behind it. Provider column names and the authority string never leak above the data layer.
That rule is what let Posture B land as an addition rather than a rewrite: the UI, the ViewModels and the domain were untouched by it. See §7.
2. Layers
┌──────────────────────────────────────────────┐
UI │ Compose screens + ViewModels (ui/*) │
│ RootScreen → permission gate → ListsScreen │
└───────────────┬──────────────────────────────┘
│ domain models + Flows only
┌───────────────▼──────────────────────────────┐
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
│ DayWindow (pure Kotlin, no Android) │
└───────────────┬──────────────────────────────┘
│ TasksRepository (interface)
┌───────────────▼──────────────────────────────┐
Data │ TasksRepositoryImpl │
│ └ TasksDataSource (interface) │
│ └ AndroidTasksDataSource │
│ └ ContentResolver / TaskContract / │
│ ProviderResolver / ContentObserver│
│ reminders/ prefs/ di/ demo/ │
└───────────────┬──────────────────────────────┘
│ content://
┌───────────────▼──────────────────────────────┐
Storage │ Local mode (default): │
│ :provider — our own bundled task provider │
│ same uid, no permission grant needed │
│ External mode: │
│ OpenTasks / tasks.org ←sync← DAVx5 / … │
│ dangerous perms, requested at point of use │
└──────────────────────────────────────────────┘
The seam that matters is the pair of interfaces in the data layer:
TasksRepository— the only type the UI sees. Flow-based reads, suspend writes. (data/tasks/TasksRepository.kt)TasksDataSource— the JVM-testable interface that does the actual provider work;AndroidTasksDataSourceis the only Android-coupled implementation.
Both are bound in Hilt in data/di/DataModule.kt.
3. Module & package layout
Two modules: :app and :provider. Package root de.jeanlucmakiola.agendula.
| Module | Contents |
|---|---|
:provider |
Agendula's own task store — the Apache-2.0 dmfs task provider 1.4.2, vendored in-tree under our authority and permission namespace. Not our code; see provider/PROVENANCE.md for the upstream commit and every deviation. No app code imports from it except ProviderResolver, which reads the authority out of its resources. |
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/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/export/ |
TaskExporter (lists → .ics documents), ExportWriter (SAF plumbing; a floret-kit candidate). |
data/reminders/ |
ReminderScheduler (the self-scheduled engine), DueReminderReceiver, BootReceiver, ProviderChangeReceiver, ScheduledReminderStore, TaskNotifier. |
data/prefs/ |
SettingsPrefs (DataStore). |
data/di/ |
DataModule (binds + provides), Qualifiers (@IoDispatcher, @ApplicationScope). |
data/demo/ |
DemoSeeder (debug-only sample data). |
ui/ |
theme/, common/ (GroupedList, ListChip), lists/, tasklist/, detail/, edit/, settings/, permission/ (each a ViewModel + UiState; lists also has its screen), RootScreen. |
| root | AgendulaApp (Hilt app), MainActivity. |
4. The data layer (the heart)
4.1 Provider targeting — ProviderResolver
ProviderResolver.resolve() returns the active TaskProvider(authority, readPermission, writePermission, packageName, isOwn) for the selected
StorageMode.
| Mode | Provider | Authority | Permissions |
|---|---|---|---|
| Local (default) | ours, bundled | de.jeanlucmakiola.agendula.tasks |
none — same uid |
| External | OpenTasks | org.dmfs.tasks |
org.dmfs.permission.READ_TASKS / WRITE_TASKS |
| External | tasks.org | org.tasks.opentasks |
org.tasks.permission.READ_TASKS / WRITE_TASKS |
All three are backed by the same dmfs TaskProvider — ours is that provider,
vendored — so the same TaskContract columns apply throughout.
hasPermission() short-circuits to true for our own provider: a same-uid
caller bypasses a provider's permission checks outright, so
ProviderStatus.NEEDS_PERMISSION can never fire in Local mode. In External mode
it checks both runtime perms, and null from resolve() drives the "install a
tasks provider" gate.
Choosing the mode. An explicit choice is stored in SettingsPrefs and
mirrored into the resolver by StorageModeHolder — the resolver is consulted
synchronously on every query and cannot read DataStore itself. When there is no
explicit choice (the normal case), autoMode() decides:
External if we already hold an external provider's runtime permission, otherwise Local.
That permission is dangerous-level, so it can only be there because an earlier version asked and the user agreed — the signature of an existing Posture A user, who must not be dropped onto an empty store and left to conclude their tasks were deleted. A fresh install holds nothing and gets local-first storage.
The platform calls sit behind ProviderEnvironment so this decision is unit
tested on the JVM (ProviderResolverTest) rather than only on a device.
Storage modes are LOCAL and EXTERNAL only. STORAGE-AND-SYNC.md
describes three, but Synced is not a third store — it is the same store with an
account attached, so it stays derived state.
⚠️ 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.
4.2 TasksContract
A vendored subset of the Apache-2.0 OpenTasks TaskContract — column names,
table paths, status/priority constants, the local-account type. Agendula does
not take a runtime dependency on OpenTasks; the authority is injected from
ProviderResolver, never hardcoded in the contract.
4.3 Reads — Instances + ContentObserver
AndroidTasksDataSource queries the denormalized instances view (so each
occurrence is a row with the joined list colour, account, etc.), maps each
cursor row through ColumnReader → TaskMapper → domain Task, and exposes
the result as a Flow. A ContentObserver on the active authority's
Tasks/TaskLists URIs bridges into the Flow via callbackFlow, so any change
re-emits — Agendula's own writes and external sync (DAVx5 pulling new tasks)
update the UI live, and multiple sync sources coexist in one list.
4.4 Writes — repository API
interface TasksRepository {
fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long
suspend fun updateTask(taskId: Long, form: TaskForm)
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
suspend fun deleteTask(taskId: Long)
suspend fun createLocalList(name: String, color: Int): Long
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
}
TaskWriteMapper turns a validated TaskForm into ContentValues. Completion
sets STATUS = COMPLETED (+ percent/completed timestamp); DAVx5 syncs that back
out as a normal VTODO status change. Writes to local/unsynced lists use the
sync-adapter URI form where the provider requires it.
4.5 Domain model notes
Task.idis the instance row id;Task.taskIdis the underlyingtasks._idand the stable target for edits/completion.- Subtasks are carried via
parentId(RELATED-TO/RELATION_TYPE_PARENT);TaskDetailbundles a task with its direct children. effectiveColor= the task's own colour, else the list colour.- iCal priority is bucketed to
NONE/LOW/MEDIUM/HIGH; status maps to a 4-value enum. Both mappings are pure functions inModels.kt, unit-tested.
5. Smart lists, filtering, sorting
TaskFilter is either OfList(listId) or Smart(SmartList). The smart lists —
ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED — are computed from due
dates, not membership. TaskFiltering.matches() is a pure predicate taking
todayStart/todayEnd (local-midnight bounds from DayWindow), so it
unit-tests with a fixed clock. TaskSorting orders within a list (due /
priority / etc.). None of this touches Android, which is why it's all in
domain/.
6. Reminders — the one subsystem that does NOT mirror Calendula
Calendula relies on the calendar provider broadcasting EVENT_REMINDER. Tasks
providers broadcast nothing, so Agendula schedules its own (data/reminders/):
ReminderScheduler.sync()reads upcoming, non-closed, due-dated tasks within a rolling 30-day window, computes each trigger asdue − lead(lead fromSettingsPrefs), and diffs againstScheduledReminderStoreso only changed alarms move. It bails and clears everything if the provider is absent or unpermissioned.- Alarms are exact where allowed (
setExactAndAllowWhileIdle, falling back tosetwhencanScheduleExactAlarms()is false), keyed bytaskId. DueReminderReceiverfires → posts viaTaskNotifier(channel,POST_NOTIFICATIONSgate, dedupe-by-tag).- Re-sync triggers: app start,
BootReceiver(re-arm after reboot), andProviderChangeReceiver(PROVIDER_CHANGEDon both authorities → external sync changed the data). The store lets each run diff like Calendula diffs reminder rows.
This is the single largest piece of genuinely-new code in Agendula.
7. The A / B seam (why the layering is shaped this way)
Both terms were redefined by STORAGE-AND-SYNC.md.
They no longer mean what earlier drafts of this document said.
- Posture A — front-end over an external provider (OpenTasks, tasks.org).
Still fully supported; it stopped being the only option and became a user
choice,
StorageMode.EXTERNAL. - Posture B (shipped) — the
:providermodule: the Apache-2.0 dmfs provider vendored under our own authorityde.jeanlucmakiola.agendula.tasksand our own permission namespace. It coexists with everything and replaces nothing.
Dead end, do not revisit: bundling the provider under dmfs's own authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the same authority (
INSTALL_FAILED_CONFLICTING_PROVIDER) or the same<permission>name (INSTALL_FAILED_DUPLICATE_PERMISSION), so anyone with OpenTasks installed simply could not have installed Agendula. Account visibility is also keyed by package, not authority, which would have left the bundled provider seeing zero accounts and pruning synced lists as orphaned. Full reasoning inSTORAGE-AND-SYNC.md.
The seam earned its keep: ProviderResolver is still the only thing that knows
an authority exists and AndroidTasksDataSource the only thing that touches a
resolver, so vendoring an entire content provider changed no UI, no ViewModel,
no domain type, and not one line of TasksRepository.
Bundling the provider bundles storage, not sync. Our own sync adapter is a
separate, later piece of work — designed in 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.
8. UI
Compose + Material 3 Expressive (MaterialExpressiveTheme,
MotionScheme.standard(), dynamic colour with a hand-tuned warm-mauve
fallback in ui/theme/). Each screen area (lists, tasklist, detail,
edit, settings, permission) has a ViewModel + immutable UiState;
ListsScreen is the first rendered surface.
RootScreen is the entry composable: it gates on ProviderStatus
(NO_PROVIDER / NEEDS_PERMISSION → onboarding Gate; READY →
ListsScreen). The remaining screens are being built one at a time — their
ViewModels exist and are tested against the real data layer; navigation
callbacks are currently stubs (see ROADMAP.md). Follow the
material-3 skill for component choices (M3 ListItem rows, expressive
checkbox/FAB/swipe motion).
9. Dependency injection
Hilt, SingletonComponent. DataModule has a @Binds module
(TasksDataSource → AndroidTasksDataSource, TasksRepository →
TasksRepositoryImpl) and a @Provides module (the agendula_prefs DataStore,
the @IoDispatcher). AgendulaApp is the @HiltAndroidApp entry point;
MainActivity is @AndroidEntryPoint. ViewModels get the repository injected.
10. Build & tooling
| Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 |
| SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 |
| UI | Compose BOM 2026.05.01, Material3 1.5.0-alpha21 (Expressive APIs), Glance 1.1.1 (widget, later) |
| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines |
:provider |
Java 17, org.dmfs jems / rfc5545-datetime / lib-recur (all Maven Central — no new repository; settings.gradle.kts stays google() + mavenCentral() under FAIL_ON_PROJECT_REPOS) |
| Tests | :app is JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source and ProviderEnvironment as the JVM-testable seams. :provider is JUnit4 + Robolectric — upstream's own suite, kept as written rather than rewritten, since that coverage is what makes vendoring safe. Don't add useJUnitPlatform() there. |
| 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. |
| CI | Split by forge: .forgejo/workflows/ci.yaml on Codeberg (canonical, no secrets), .gitea/workflows/release.yaml on Gitea (all secrets). See RELEASING.md. |
| Distribution | F-Droid (fdroid-metadata/) + Codeberg release APKs |
11. Manifest surface
- Declared by
:app: bothorg.dmfs.*andorg.tasks.*read/write tasks perms (static manifest, so they are always declared; requested at runtime only in External mode);POST_NOTIFICATIONS,RECEIVE_BOOT_COMPLETED, exact-alarm (USE_EXACT_ALARMon 33+,SCHEDULE_EXACT_ALARM≤32). - Declared by
:provider: the<provider>itself plus our ownde.jeanlucmakiola.agendula.permission.READ_TASKS/WRITE_TASKSand their permission group. These exist for other apps — Agendula reaches its own provider same-uid and neither declares auses-permissionfor them nor asks. The provider isexported="true"on purpose: that is what would let DAVx5 write into it once it knows our authority. - Deliberately absent:
GET_ACCOUNTS(stripped from the vendored provider — see change 1 inPROVENANCE.md) andINTERNET, which stays undeclared until sync actually ships. Export needs no storage permission at all; SAF hands us aUrithe user picked. <queries>for package visibility: both external provider authorities + a LAUNCHER intent (soresolveContentProviderworks and onboarding can open the provider / a store listing). Our own provider needs no entry.- Receivers:
DueReminderReceiver(not exported),BootReceiver,ProviderChangeReceiver(all three authorities, ours first — an intent-filter host must be a literal), and the vendored provider's ownTaskProviderBroadcastReceiver. NoEVENT_REMINDERreceiver — that's a Calendula thing that doesn't apply here.