Files
agendula/docs/ARCHITECTURE.md
Jean-Luc Makiola 217d5d7afd
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Renovate / renovate (push) Failing after 43s
Move the canonical forge to Codeberg, and add Renovate + Weblate (#2)
Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline. Ports the setup Calendula already runs on, adapted where Agendula genuinely differs.

**This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home.

### 1 · Workflows split by directory

Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync:

| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them |

The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API.

CI also gains three fixes: an explicit `SKIP_RE` skip-list that names the build-relevant files in the log, base-ref normalisation, and a fully-qualified `android-actions/setup-android` — Codeberg resolves bare `uses:` refs against `data.forgejo.org`, which does not carry that action.

### 2 · Three release-pipeline safety changes

- `detect` and the Renovate job get an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running these on the contributor-facing runner, without secrets.
- `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not.
- **The Codeberg publish step pushes the tag itself** instead of waiting for it to arrive by mirror. That wait was correct while Gitea mirrored *to* Codeberg; under Codeberg-canonical the mirror runs the other way and it would never resolve. Attaching the release to an already-pushed ref (no `target_commitish`) is what avoids the empty-bodied 500s, and the create call retries with backoff because Codeberg 500s on a tag it has only just received. The step stays **fail-loud**, not `continue-on-error` — it reported green through 0.2.1–0.3.2 while never once publishing, and that must not be possible again.

### 3 · Renovate

`renovate.json5` plus a Gitea-side job targeting Codeberg's API. `managerFilePatterns` covers **both** workflow directories, so the pinned Renovate image tag and the action versions in either file keep getting bumped.

Needs two new Gitea secrets: `RENOVATE_TOKEN` (Codeberg bot, repo read/write + PR scope) and `GITHUB_COM_TOKEN` (read-only github.com PAT, for changelog lookups).

### 4 · Weblate

A parity check (`scripts/check_translations.py`) runs on every PR without a path filter, so the required `Translations` status is always reported. Partial translations are expected, so `MissingTranslation` and `MissingQuantity` become informational — `ExtraTranslation` stays fatal. Agendula had no `lint` block at all, so the first locale to land would otherwise have failed the build.

**Settings → App language** now opens a picker carrying a "Help translate" header. That is why it drops floret-kit's `LanguagePickerRow` for a local row: the shared recipe has no `header` slot, and the framing is app-specific rather than a family primitive.

### 5 · Links repointed

In-app Source / License / report-issue URLs, F-Droid metadata, README (now with a Codeberg CI badge), and the docs.

`floret-kit` follows suit — `.gitmodules` points at `codeberg.org/jlmakiola/floret-kit`, so a clone no longer needs to reach the personal Gitea instance to resolve it. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases still resolve.

### 6 · Housekeeping

Drops `release-notes.md` — a release-pipeline scratch file that got committed — and gitignores the five others the release job writes into the workspace.

### Not in this PR

The Codeberg → Gitea push mirror, the Weblate component, and the Codeberg bot account (all browser-side). Until the mirror is flipped, merging this does **not** reach the Gitea runner.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/2
2026-08-01 16:34:20 +02:00

13 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 front-end over the OpenTasks TaskContract provider — it reads, writes, and reminds on top of a tasks store that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over CalDAV. Agendula owns no database and no sync stack. It is the task-list sibling to Calendula, which does the same thing for CalendarContract.

The whole design hangs off one rule:

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

This is what lets "Posture A" (front-end over an installed provider) become "Posture B" (bundle the Apache-2.0 provider, be self-contained) without touching the UI, the ViewModels, or the domain. 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:// + dangerous perms
            ┌───────────────▼──────────────────────────────┐
  External  │  OpenTasks provider  ←sync←  DAVx5 / DecSync… │
            └──────────────────────────────────────────────┘

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; AndroidTasksDataSource is the only Android-coupled implementation.

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


3. Module & package layout

Single :app module (Posture A). Package root de.jeanlucmakiola.agendula.

Package Contents
domain/ Models (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), TaskForm (validated create/edit), TaskFilter + TaskFiltering (smart lists), TaskSorting, DayWindow (local-midnight maths). No Android imports.
data/tasks/ TasksContract (vendored subset), ProviderResolver (the A/B seam), TaskProjections, ColumnReader, TaskMapper (cursor→domain), TaskWriteMapper (form→ContentValues), TasksDataSource + AndroidTasksDataSource, TasksRepository + Impl, Failures.
data/reminders/ ReminderScheduler (the self-scheduled engine), DueReminderReceiver, BootReceiver, ProviderChangeReceiver, ScheduledReminderStore, TaskNotifier.
data/prefs/ SettingsPrefs (DataStore).
data/di/ DataModule (binds + provides), Qualifiers (@IoDispatcher).
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() walks a preference-ordered candidate list and returns the first provider actually installed (via PackageManager.resolveContentProvider), or null if none is. Each candidate is a TaskProvider(authority, readPermission, writePermission, packageName).

Provider Authority Permissions
OpenTasks org.dmfs.tasks org.dmfs.permission.READ_TASKS / WRITE_TASKS
tasks.org org.tasks.opentasks org.tasks.permission.READ_TASKS / WRITE_TASKS

Both are backed by the same dmfs TaskProvider, so the same TaskContract columns apply regardless of which is present. null from resolve() drives the "install a tasks provider" onboarding gate. hasPermission() checks both runtime perms for the active provider.

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 ColumnReaderTaskMapper → 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.id is the instance row id; Task.taskId is the underlying tasks._id and the stable target for edits/completion.
  • 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 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, BootReceiver (re-arm after reboot), and ProviderChangeReceiver (PROVIDER_CHANGED on 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)

  • Posture A (today): front-end over whatever provider is installed. Ships fast; requires a provider app present (the "needs DAVx5/OpenTasks" onboarding moment).
  • Posture B (later): add a :provider module bundling the Apache-2.0 opentasks-provider. ProviderResolver then finds our own org.dmfs.tasks first; external CalDAV engines sync directly into it. The UI, ViewModels, domain, and TasksRepository do not change — only the resolver's default and some manifest perms.

Bundling the provider bundles storage, not sync — Agendula stays a pure front-end over open backends either way.


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; READYListsScreen). 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 (TasksDataSourceAndroidTasksDataSource, TasksRepositoryTasksRepositoryImpl) 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, kotlinx-datetime, kotlinx-coroutines
Tests JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam
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 declared statically (the active set is requested at runtime); POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED, exact-alarm (USE_EXACT_ALARM on 33+, SCHEDULE_EXACT_ALARM ≤32).
  • <queries> for package visibility: both provider authorities + a LAUNCHER intent (so resolveContentProvider works and onboarding can open the provider / a store listing).
  • Receivers: DueReminderReceiver (not exported), BootReceiver, ProviderChangeReceiver (both authorities). No EVENT_REMINDER receiver — that's a Calendula thing that doesn't apply here.