# 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`](PLAN.md); for status and what's next, see [`ROADMAP.md`](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](https://codeberg.org/jlmakiola/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`](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`](STORAGE-DECISION.md), the architecture and the plan it implements in [`OWN-STORE.md`](OWN-STORE.md). It replaces a vendored copy of the dmfs task provider, which was deleted along with the `:provider` module it lived in. External mode is untouched by that and still speaks the dmfs `TaskContract`. A sync adapter of our own is the 1.x arc, designed in [`SYNC.md`](SYNC.md). The whole design hangs off one rule: > The entire app talks to a `TasksRepository`. Only the data layer knows there > is a 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.id` → `Task.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`](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/`, `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`](OWN-STORE.md); what matters structurally: - **`accounts` is empty until sync lands**, but the nullable `task_lists .account_id` FK exists from v1 — that is what makes §4.1's "attaching an account is an `UPDATE`" true. Deleting an account `SET NULL`s its lists rather than deleting them. - **Masters and `RECURRENCE-ID` overrides share the `tasks` table.** An override is a row with `recurrence_id` set and `master_id` pointing at its master, sharing the master's `uid`. The unique index is therefore `(list_id, uid, recurrence_id)`; SQLite treats NULLs as distinct, so it enforces the override half and states the master half as intent. - **`uid` is `NOT NULL`**, minted at creation in either mode, synced or not — so a task has a stable identity before a server ever sees it. - `priority` is stored as the raw iCalendar integer (0 none, 1 highest, 9 lowest). Bucketing into `Priority` on the way *in* would rewrite a server's `PRIORITY:3` as `1`; the bucketing belongs to the mapper. - Cascades: deleting a list takes its tasks, deleting a series takes its overrides (`master_id`), deleting a parent promotes its subtasks (`parent_id` → `SET NULL`). `RoomTaskMapper` maps a row to a domain `Task`; `TaskFormWriter` applies a validated `TaskForm` to an entity. `TaskFormWriter` is the Room counterpart of External mode's `TaskWriteMapper`, and deliberately not shared with it: most of what that mapper does is work around the provider (clearing `DURATION` because it validates a merged row; writing `STATUS` explicitly in both directions because it auto-completes at 100% but will not reopen below it). Here the completion rules are stated directly — progress and status move together in both directions, so a task can no longer strand itself "done at 75%". Deletes are hard when the list has no account and tombstones (`is_deleted`) when it does — a row a server still knows about has to survive long enough to be withdrawn from it. ### 4.3 Recurrence — expanded at read, not materialised There is **no instances table**. The dmfs provider maintained one, recomputed on every write, and still only ever materialised one upcoming occurrence. Agendula expands a series in memory instead: ``` tasks (masters + overrides) ──► RecurrenceExpander ──► List rrule/rdate/exdate (lib-recur, in memory) occurrences ``` This costs nothing because `TasksRepositoryImpl` already filters and sorts in Kotlin, not SQL — nothing depended on the database ordering by instance time — and the whole class of staleness bugs a cached table brings never exists. Expansion is bounded twice over: a window of **1 year back, 2 years forward** (`RoomTasksDataSource.WINDOW_BACK`/`WINDOW_FORWARD`) and a hard per-series occurrence ceiling, so an unbounded `RRULE` terminates. The iterator is fast-forwarded to the window start first, so a `FREQ=MINUTELY` series anchored years back does not scan millions of instances to emit one. A malformed `RRULE`/`RDATE`/`EXDATE` is dropped rather than thrown: a task whose stored rule cannot be parsed still has to appear. `RecurrenceExpander` returns each occurrence as its **`RECURRENCE-ID` anchor**. `RoomTasksDataSource.occurrencesOf` then substitutes any override for the occurrence it replaces; a timed series carries each occurrence's length across, while a due-anchored one has no start to offset from, so the anchor *is* the due date — matching how the provider instantiated the same series. `lib-recur` is pinned at **0.12.2** (0.16.0 removed `RecurrenceSet`) and is a direct `:app` dependency now rather than something `:provider` dragged in. It is still Apache-2.0 dmfs code, so the attribution is still owed — as a normal third-party dependency. **Editing one occurrence writes a `RECURRENCE-ID` override** — RFC 5545's model (a): a second `tasks` row with the master's `uid`, a `recurrence_id` naming the occurrence, `master_id` pointing at the series, and the edit applied. The provider's `Detaching.java` forked a brand-new task with its own UID instead (model (d), the one least compatible with CalDAV); we inherited that without choosing it, and this is the choice. ### 4.4 Startup — the import and the gate `OneShotImport` moves a v0.3.x install's tasks out of the bundled provider's `databases/tasks.db` and into Room, once. The file is opened read-only and directly — no provider, no `ContentResolver` — so it keeps working now that `:provider` is gone, and everything lands in one verified Room transaction, so a failure leaves both Room and the source file exactly as they were. dmfs row ids are remapped in two passes, because a parent can carry a higher `_id` than its child, and `RECURRENCE-ID` overrides are carried across as `master_id` / `recurrence_id` rather than imported as second masters, which would collide on the unique index. The source is archived to `tasks.db.imported` **before** the import, and the import always replaces: that ordering is what makes every kill point re-enter correctly. `StartupGate` holds the first store read until the stored mode has reached `ProviderResolver` *and* the import has run — `TasksRepositoryImpl.observing()` awaits it before its first load, and `AgendulaApp` awaits it before the launch reminder re-sync. Both are the same race: reading early answers from `autoMode()` instead of the user's choice, or shows an upgrading user an empty app. ### 4.5 `TasksContract` — External mode only A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names, table paths, status/priority constants, the local-account type. Agendula does **not** take a runtime dependency on OpenTasks; the authority is injected from `ProviderResolver`, never hardcoded in the contract. 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 `ColumnReader` → `TaskMapper` → 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 ```kotlin interface TasksRepository { fun taskLists(): Flow> fun tasks(filter: TaskFilter): Flow> fun subtasks(parentId: Long): Flow> fun taskDetail(taskId: Long): Flow suspend fun createTask(form: TaskForm): Long suspend fun updateTask(taskId: Long, form: TaskForm, 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`](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`](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 > `` 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.id` → `occurrenceStart`), 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`](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`; `READY` → `AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is only ever seen in External mode. Routes are the `Dest` table in `ui/navigation/` (lists → task list → detail / edit, plus settings). Follow the `material-3` skill for component choices (M3 `ListItem` rows, expressive checkbox/FAB/swipe motion). --- ## 9. Dependency injection Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module (`TasksRepository` → `TasksRepositoryImpl`, `ProviderEnvironment` → `AndroidProviderEnvironment`) and a `@Provides` module (the `agendula_prefs` DataStore, `TasksDatabase`, the `@IoDispatcher`, the `@ApplicationScope`). `TasksDataSource` is `@Provides` rather than `@Binds`, because it is a `ModeRoutingTasksDataSource` over `Provider` and `Provider` — both singletons, so it picks between two long-lived objects rather than building either. `AgendulaApp` is the `@HiltAndroidApp` entry point and pulls `StartupGate`, `DatabaseCheckpoint` and `ReminderScheduler` through an `@EntryPoint`; `MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected. --- ## 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`](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 | --- ## 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 `` and no custom permissions.** Agendula publishes no content provider; `de.jeanlucmakiola.agendula.tasks`, the `de.jeanlucmakiola.agendula.permission.*` pair and their permission group all went with the `:provider` module. - **Deliberately absent:** `GET_ACCOUNTS`, and `INTERNET`, which stays undeclared until sync actually ships. Export needs no storage permission at all; SAF hands us a `Uri` the user picked. - **``** for package visibility: both external provider authorities + a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open the provider / a store listing). - **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.