Files
agendula/docs/ARCHITECTURE.md
Jean-Luc Makiola ec2e2eb59d feat(settings): storage picker and export screen
The store picker and the export screen were the two frontend surfaces
the own-store work left unbuilt, so both backends shipped unreachable.
Settings gains a Storage section holding them: a full-screen picker over
Own / an installed external provider (dimmed when none is present, named
after the provider's own app), and an export screen with a per-list tick
and the two SAF destinations, a folder or a single zip. The picker asks
for the provider's runtime permission before writing the mode, so a
denial leaves the readable store in place instead of dropping the user on
the gate; a refusal is reported with a route to app settings.

Making the mode switchable at runtime had two consequences:

- reminders are armed off whichever store was active when they were
  scheduled, so a switch rebuilds the set. ReminderScheduler.sync() is
  now serialised — it is a read-modify-write over ScheduledReminderStore,
  and overlapping runs each wrote their own set as the whole truth
- the permission gate is the only screen an External user can reach once
  their provider app stops answering, so it offers the way back to our
  own store

ExportWriter no longer deletes a previous export before recreating it (a
failure in between lost both), lists the target directory once instead of
per document, and carries a typed ExportFailure so the screen can report
in the user's language rather than an exception message.
2026-09-04 15:37:48 +02:00

30 KiB
Raw Blame History

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 that reads, writes and reminds against a task store the user chooses: its own database (the default) or an external provider app already on the device (OpenTasks, tasks.org) synced by DAVx5, SmoothSync, DecSync CC and the like. It is the task-list sibling to Calendula, 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 store is a Room database of our own, designed against RFC 5545's VTODO and against what a CalDAV sync adapter will need — the reasoning is in STORAGE-DECISION.md, the architecture and the plan it implements in 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.

The whole design hangs off one rule:

The entire app talks to a TasksRepository. Only the data layer knows there is a Room database, a ContentResolver, a TaskContract or an authority string behind it. Table and column names and the authority string never leak above the data layer.

That rule is what let the entire store be swapped without a rewrite: replacing the provider with Room changed no UI, no ViewModel and exactly one domain field (Task.idTask.occurrenceStart, §4.8). See §7.


2. Layers

            ┌──────────────────────────────────────────────┐
  UI        │  Compose screens  +  ViewModels  (ui/*)       │
            │  RootScreen → permission gate → ListsScreen   │
            └───────────────┬──────────────────────────────┘
                            │  domain models + Flows only
            ┌───────────────▼──────────────────────────────┐
  Domain    │  Models, TaskForm, TaskFilter, TaskSorting,   │
            │  TaskSections, RecurrenceExpander              │
            │  (pure Kotlin, no Android)                    │
            └───────────────┬──────────────────────────────┘
                            │  TasksRepository (interface)
            ┌───────────────▼──────────────────────────────┐
  Data      │  TasksRepositoryImpl                          │
            │    └ TasksDataSource (interface)              │
            │        └ ModeRoutingTasksDataSource           │
            │            ├ RoomTasksDataSource     (OWN)    │
            │            └ AndroidTasksDataSource (EXTERNAL)│
            │  reminders/  prefs/  di/  demo/               │
            └────────┬────────────────────────┬────────────┘
                     │ Room DAOs              │ content://
       ┌─────────────▼──────────┐  ┌──────────▼─────────────┐
  Sto- │  Own mode (default):   │  │  External mode:        │
  rage │  agendula-tasks.db —   │  │  OpenTasks / tasks.org  │
       │  four tables in our    │  │    ←sync←  DAVx5 / …   │
       │  own data directory,   │  │  dangerous perms,      │
       │  nothing to permit     │  │  asked at point of use │
       └────────────────────────┘  └────────────────────────┘

The seam that matters is the pair of interfaces in the data layer:

  • TasksRepository — the only type the UI sees. Flow-based reads, suspend writes. (data/tasks/TasksRepository.kt)
  • TasksDataSource — the JVM-testable, domain-shaped interface that does the actual store work. Two implementations: RoomTasksDataSource for our own store and AndroidTasksDataSource for an external provider, picked per call by ModeRoutingTasksDataSource (§4.1).

Both are bound in Hilt in data/di/DataModule.kt.


3. Module & package layout

One module, :app, plus the floret-kit included build (includeBuild in settings.gradle.kts, consumed as de.jeanlucmakiola.floret:*). The :provider module — the vendored dmfs task provider — was deleted with the own store; provider/PROVENANCE.md went with it, its content preserved as a postscript in STORAGE-DECISION.md. Package root de.jeanlucmakiola.agendula.

Package (:app) Contents
domain/ Models (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), TaskConstants (status/priority/local-account constants), TaskForm (validated create/edit), TaskFilter + TaskFiltering (smart lists), TaskSorting, TaskSections (due-date sectioning), AllDayTime (the two date conventions). No Android imports; local-midnight maths comes from floret-kit's DayWindow.
domain/recurrence/ RecurrenceExpander — a stored rule set → its occurrences, over lib-recur. Pure Kotlin.
domain/export/ ExportModels + ICalendarWriter — VTODO serialization. Pure Kotlin, so the format is JVM-testable.
data/tasks/ StorageMode + StorageModeHolder + ProviderResolver + ProviderEnvironment (which store, §4.1), ModeRoutingTasksDataSource, StartupGate, TasksDataSource, TasksRepository + Impl, Failures; and the External-mode half — TasksContract (vendored subset), TaskProjections, ColumnReader, TaskMapper (cursor→domain), TaskWriteMapper (form→ContentValues), AndroidTasksDataSource.
data/tasks/room/ Agendula's own store: Entities (the four tables), a DAO per table, TasksDatabase, Converters, RoomTasksDataSource, RoomTaskMapper (row→domain), TaskFormWriter (form→entity), DatabaseCheckpoint.
data/tasks/legacy/ OneShotImport — a v0.3.x install's tasks out of the dmfs provider's file and into Room, once (§4.4).
data/export/ TaskExporter (lists → .ics documents), ExportWriter (SAF plumbing; a floret-kit candidate).
data/reminders/ ReminderScheduler (the self-scheduled engine), DueReminderReceiver, BootReceiver, ProviderChangeReceiver, ScheduledReminderStore, TaskNotifier.
data/prefs/ SettingsPrefs (DataStore).
data/di/ DataModule (binds + provides), Qualifiers (@IoDispatcher, @ApplicationScope).
data/demo/ DemoSeeder (debug-only sample data).
ui/ theme/, common/ (ListChip, PriorityChip, reminder pickers), navigation/ (AgendulaNavHost + Dest), lists/, tasklist/, detail/, edit/, settings/ (hub + sub-screens, StorageScreen among them), export/, permission/ (each a screen + ViewModel + UiState), crash/, RootScreen.
root AgendulaApp (Hilt app), MainActivity.

4. The data layer (the heart)

4.1 Which store — StorageMode and ProviderResolver

StorageMode has two values, OWN and EXTERNAL, and ModeRoutingTasksDataSource picks the implementation per call — the mode is a setting the user can change while the process lives, so binding it once would mean rebuilding the object graph to honour a change.

Mode Store Authority Permissions
Own (default) our Room database, agendula-tasks.db — none, it is not a provider none
External OpenTasks org.dmfs.tasks org.dmfs.permission.READ_TASKS / WRITE_TASKS
External tasks.org org.tasks.opentasks org.tasks.permission.READ_TASKS / WRITE_TASKS

ProviderResolver is now only about the second and third rows: it discovers the external providers a device has. resolve() returns null in OWN mode — there is no authority to resolve — and callers that need to tell that apart from "External, and nothing installed" ask mode(). TaskProvider no longer carries an isOwn flag; there is no own provider to flag.

providerStatus() is unconditionally READY in OWN mode. The permission gate only ever applied to External, and that is now visibly true rather than a same-uid special case inside hasPermission(). In External mode null from resolve() is NO_PROVIDER and a missing runtime permission is NEEDS_PERMISSION, which is what drives onboarding.

Choosing the mode. An explicit choice is stored in SettingsPrefs and mirrored into the resolver by StorageModeHolder — the resolver is consulted 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 Own.

That permission is dangerous-level, so it can only be there because an earlier version asked and the user agreed — the signature of an existing Posture A user, who must not be dropped onto an empty store and left to conclude their tasks were deleted. A fresh install holds nothing and gets our own store.

A stored LOCAL — the old name for the bundled provider — is read as OWN (SettingsPrefs.kt:104) rather than as an unparseable value. Left to fall through to autoMode(), someone who had explicitly chosen local storage and holds an OpenTasks grant would be sent to OpenTasks, away from the data the import just moved.

The platform calls sit behind ProviderEnvironment so this decision is unit tested on the JVM (ProviderResolverTest) rather than only on a device.

Synced is still not a third mode. STORAGE-AND-SYNC.md describes three; a synced list is OWN with an account attached, which is derived state rather than something the user picks. Attaching one is a plain UPDATE task_lists SET account_id = ?account_id is a nullable FK from v1, so turning sync on for an existing list is not a migration. (Under the dmfs provider it was: ACCOUNT_NAME/ACCOUNT_TYPE were write-once, so tasks had to be moved into new lists. That constraint left with the provider.)

4.2 The own store — four Room tables

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; 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 NULLs 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_idSET 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, 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. 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.6 Reads and reactivity

In OWN mode RoomTasksDataSource reads through the DAOs and expands recurrences (§4.3). In EXTERNAL mode AndroidTasksDataSource queries the denormalized instances view (so each occurrence is a row with the joined list colour, account, etc.) and maps each cursor row through ColumnReaderTaskMapper → domain Task.

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

interface TasksRepository {
    fun taskLists(): Flow<List<TaskList>>
    fun tasks(filter: TaskFilter): Flow<List<Task>>
    fun subtasks(parentId: Long): Flow<List<Task>>
    fun taskDetail(taskId: Long): Flow<TaskDetail?>

    suspend fun createTask(form: TaskForm): Long
    suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
    suspend fun setCompleted(taskId: Long, completed: Boolean)   // the core gesture
    suspend fun deleteTask(taskId: Long)
    suspend fun reminderFor(taskId: Long): Int?
    suspend fun createLocalList(name: String, color: Int): Long

    fun providerStatus(): ProviderStatus   // READY | NEEDS_PERMISSION | NO_PROVIDER
}

updateTask on an occurrence of a recurring task routes to TasksDataSource.updateInstance(taskId, occurrenceStart, form) rather than moving the series anchor. expectedLastModified re-checks the stored timestamp first and throws TaskConflictException when something changed underneath the form.

Completion sets STATUS = COMPLETED (+ percent/completed timestamp); in External mode DAVx5 syncs that back out as a normal VTODO status change.

4.8 Domain model notes

  • Task.taskId is the task row and the stable target for edits, completion and navigation. There is no Task.id any more — it was the materialised instance row id, and materialised instances are gone.
  • Task.occurrenceStart is the occurrence's RECURRENCE-ID anchor, null for a non-recurring task; Task.occurrenceKey ("$taskId@$millis") is what lazy lists key by. Two occurrences of one series can appear in the same list, so taskId alone would collide there — as a Compose key that is a visible bug.
  • Subtasks are carried via parentId (RELATED-TO / RELATION_TYPE_PARENT); TaskDetail bundles a task with its direct children.
  • effectiveColor = the task's own colour, else the list colour.
  • iCal priority is bucketed to NONE/LOW/MEDIUM/HIGH; status maps to a 4-value enum. Both mappings are pure functions in Models.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 floret-kit's DayWindow), so it unit-tests with a fixed clock. TaskSorting orders within a list (due / priority / etc.). None of this touches Android, which is why it's all in domain/.


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 as due lead (lead from SettingsPrefs), and diffs against ScheduledReminderStore so only changed alarms move. It bails and clears everything if the provider is absent or unpermissioned.
  • Alarms are exact where allowed (setExactAndAllowWhileIdle, falling back to set when canScheduleExactAlarms() is false), keyed by taskId.
  • DueReminderReceiver fires → posts via TaskNotifier (channel, POST_NOTIFICATIONS gate, dedupe-by-tag).
  • Re-sync triggers: app start (after StartupGate), BootReceiver (re-arm after reboot), and ProviderChangeReceiver. The store lets each run diff like Calendula diffs reminder rows.

sync() gates on ProviderResolver.canReadStore(), not on a provider resolving. OWN is always readable; only EXTERNAL can fail, and only for the two reasons it ever could (nothing installed, or no grant). Gating on resolve() != null — as it did briefly — clears every alarm the moment OWN is active, because OWN resolves to no provider by design. ProviderResolverTest covers both directions.

ProviderChangeReceiver's manifest filter now lists only the two external authorities: Agendula publishes no provider and broadcasts no ACTION_PROVIDER_CHANGED, so there is nothing of ours to listen for. In OWN mode Room's InvalidationTracker covers foreground changes and nothing outside the app can change our data. When SYNC.md phase 3 lands, the sync worker calls ReminderScheduler.sync() itself — that is the replacement for the broadcast, and it belongs in the sync work.

This is the single largest piece of genuinely-new code in Agendula.


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, then rebuilt) — a store of our own, StorageMode.OWN. It first shipped as the :provider module, the Apache-2.0 dmfs provider vendored under our own authority; it is now a Room database and the module is deleted. What made the vendored provider worth keeping was the sync bookkeeping it appeared to hand us for free, and the phase-1 sync audit measured that bookkeeping and found most of it broken, absent or unusable — the argument and the costing are in STORAGE-DECISION.md.

Dead end, do not revisit: publishing a task provider under dmfs's own authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the same authority (INSTALL_FAILED_CONFLICTING_PROVIDER) or the same <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 such a provider seeing zero accounts and pruning synced lists as orphaned. Full reasoning in STORAGE-AND-SYNC.md.

The seam earned its keep twice over. ProviderResolver is still the only thing that knows an authority exists and AndroidTasksDataSource the only thing that touches a resolver, so vendoring an entire content provider changed nothing above the data layer — and replacing it with a database of our own changed no UI, no ViewModel and exactly one domain field (Task.idoccurrenceStart), which was forced by dropping materialised instances rather than by the store swap itself.

⚠️ The authority is gone, and that is a breaking change. de.jeanlucmakiola.agendula.tasks and both custom permissions no longer exist, so anyone who had pointed DAVx5 or another app at that authority loses it. External mode is the answer for them; it needs saying in the release notes.

Owning the store owns storage, not sync. Our own sync adapter is a separate, later piece of work — designed in SYNC.md, and it lands underneath this same seam: it writes through the DAOs, so the layers above stay untouched a third 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; READYAgendulaNavHost). In OWN mode the status is always READY, so that gate is only ever seen in External mode — and it offers a way back to our own store, because it is the only screen an External user can reach once their provider app stops answering. Routes are the Dest table in ui/navigation/ (lists → task list → detail / edit, plus settings). Follow the material-3 skill for component choices (M3 ListItem rows, expressive checkbox/FAB/swipe motion).

Settings is a hub of sliding sub-screens rather than routes; Storage is the one with teeth. It holds the §4.1 store picker — which asks for an external provider's runtime permission before writing the mode, so a denial leaves the readable store in place instead of stranding the user on the gate — and the export screen (ui/export/, one .ics per ticked list, written through SAF to a folder or a single zip). Because the mode is now switchable while the process lives, AgendulaApp re-arms reminders on ProviderResolver.onModeChanged: an alarm is scheduled off whichever store was active at the time, so the whole set has to be rebuilt against the new one.


9. Dependency injection

Hilt, SingletonComponent. DataModule has a @Binds module (TasksRepositoryTasksRepositoryImpl, ProviderEnvironmentAndroidProviderEnvironment) and a @Provides module (the agendula_prefs DataStore, TasksDatabase, the @IoDispatcher, the @ApplicationScope). TasksDataSource is @Provides rather than @Binds, because it is a ModeRoutingTasksDataSource over Provider<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.


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)
Store Room 2.8.4 (KSP, room.schemaLocation = app/schemas, WAL), org.dmfs:lib-recur 0.12.2 pinned (0.16.0 removed RecurrenceSet; rfc5545-datetime arrives with it as part of its API surface)
Other DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines, the floret-kit included build
Tests JVM: JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source, ProviderEnvironment, TaskFormWriter and RecurrenceExpander as the JVM-testable seams. Instrumented (app/src/androidTest, AndroidJUnitRunner + Truth): the Room schema, RoomTasksDataSource and OneShotImport — the last against assets/tasks-v23.db, a fixture written by scripts/make_import_fixture.py in the provider's DATABASE_VERSION 23 schema, since the provider it came from no longer exists to test against.
Versioning committed versionName is the source of truth; a bump reaching main triggers the release and the pipeline mints the vX.Y.Z tag. versionCode = MAJOR*10000 + MINOR*100 + PATCH. See RELEASING.md.
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

  • Permissions: both org.dmfs.* and org.tasks.* read/write tasks perms (static manifest, so they are always declared; requested at runtime only in External mode); POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED, exact-alarm (USE_EXACT_ALARM on 33+, SCHEDULE_EXACT_ALARM ≤32). OWN mode needs nothing here at all: it is a database in our own data directory.
  • No <provider> and no custom permissions. Agendula publishes no content provider; de.jeanlucmakiola.agendula.tasks, the de.jeanlucmakiola.agendula.permission.* pair and their permission group all went with the :provider module.
  • Deliberately absent: GET_ACCOUNTS, and INTERNET, which stays undeclared until sync actually ships. Export needs no storage permission at all; SAF hands us a Uri the user picked.
  • <queries> for package visibility: both external provider authorities + a LAUNCHER intent (so resolveContentProvider works and onboarding can open the provider / a store listing).
  • Receivers: DueReminderReceiver (not exported), BootReceiver, and ProviderChangeReceiver — the latter filtering on the two external authorities only (an intent-filter host must be a literal). No EVENT_REMINDER receiver — that's a Calendula thing that doesn't apply here.
  • Backup: agendula-tasks.db plus its -wal and -shm sidecars are included in both backup_rules.xml and data_extraction_rules.xml; tasks.db.imported is excluded, since it is a copy of data already imported. Room runs in WAL mode and Auto Backup copies files without checkpointing, so DatabaseCheckpoint runs PRAGMA wal_checkpoint(TRUNCATE) on ON_STOP to keep the .db alone current for a restore that drops the sidecars.