Files
agendula/docs/ARCHITECTURE.md
T
Jean-Luc Makiolaandmakiolaj 217d5d7afd
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 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.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; 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, 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.