Release 1.0.0 (#20)

First stable release. Merging this bumps versionName to 1.0.0 and triggers the release pipeline (F-Droid, Codeberg, Play).

**App**
- CalDAV sync built in, with Agendula's own task store; OpenTasks / tasks.org stay available and can be copied over in Settings → Storage
- repeating tasks, several reminders per task, lists managed in the app, iCalendar import/export, widget and Quick Settings tile
- a list can be kept out of the smart lists (#18) and gets its own notification channel (#17)
- duplicate a task with its subtasks (#16)
- HTML descriptions shown as plain text (#15)
- relative day words in reminder notifications (#14)
- asks for exact-alarm access instead of claiming USE_EXACT_ALARM, and re-arms reminders when that access changes

**Release plumbing**
- floret-kit bumped to v0.4.0; the old pin was never pushed, so a clean clone couldn't check out the submodule. 0.4.0 drops CrashConfig.issueTitle (crash issues are always filed in English)
- prebuilt .so files ship unstripped, so the build no longer depends on whether an NDK is installed; now checked by check_reproducible_release.sh
- official F-Droid recipe in docs/fdroid-official/, to submit to fdroiddata once v1.0.0 is tagged
- Google Play: fastlane uploads the AAB and every locale's What's New after the F-Droid release; a separate listing lane pushes text and graphics from the fastlane tree, which CI now checks against Play's limits
- store listing: title "Agendula: Tasks" in every locale, icon, feature graphic, screenshots and 1.0.0 changelogs in en-US, en-GB, de-DE and pt-BR

crash_report_issue_title is now unused but stays until Weblate removes the translated copies.

Closes #14, closes #15, closes #16, closes #17, closes #18

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/20
This commit is contained in:
Jean-Luc Makiola
2026-09-24 16:36:49 +02:00
co-authored by makiolaj
parent 7bbdbe60e3
commit ac01993d41
476 changed files with 53728 additions and 2919 deletions
-263
View File
@@ -1,263 +0,0 @@
# 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 **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](https://codeberg.org/jlmakiola/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
```kotlin
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`](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`](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
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.
-322
View File
@@ -1,322 +0,0 @@
# Agendula — implementation plan
> A modern Material 3 Expressive **task** app for Android. Reads, writes, and
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
> DecSync over CalDAV), with no own sync stack.
>
> Sibling to **Calendula**. Calendula is a calendar over `CalendarContract`;
> Agendula is a to-do list over the **OpenTasks `TaskContract` provider**. The
> name mirrors Calendula's: *agenda* (Latin, “things to be done”) + the `-ula`
> ending — and a Calendula flower head is itself a cluster of small *florets*,
> so the two apps are florets of one bloom.
Identifiers: `applicationId = de.jeanlucmakiola.agendula`, app name **Agendula**
(renamed from the working title *Floret*, which was promoted to the shared
family / design-language name).
---
## 0. The thesis this app embodies
A nice M3-Expressive front end over open backends, no reinvented storage or
sync. Calendula proved the pattern against the OS calendar provider. Agendula
applies it to tasks. The crucial difference: **there is no OS tasks provider**,
so we depend on a tasks *provider app* being present — exactly as Calendula
depends on a sync app like DAVx5 for CalDAV.
### Posture A now, Posture B long-term (locked decision)
- **A (this plan):** pure front-end over whatever tasks provider is installed
(OpenTasks / tasks.org / jtx). Fast to ship; requires a provider app present.
- **B (later):** bundle the Apache-2.0 `opentasks-provider` so the app is
self-contained (owns the `org.dmfs.tasks` authority + `org.dmfs.permission.*`,
DAVx5 syncs directly into it). Bundling the provider bundles **storage, not
sync** — external CalDAV engines still feed it, which keeps us true to the
thesis.
**The one rule that makes B additive instead of a rewrite:** the entire app
talks to a `TasksRepository`; only *one* class (`OpenTasksDataSource`) knows
about a `ContentResolver`, `TaskContract`, or an authority string, and it
resolves its authority at **runtime** via `ProviderResolver`. In A the resolver
finds the installed provider; in B it finds our own bundled one. Repo + UI +
domain never change. **Never let `TaskContract` column names or the authority
string leak above the data layer.**
---
## 1. What transfers from Calendula
Calendula's layering is the template. Lift these **verbatim or near-verbatim**:
| Area | From Calendula | Change for Agendula |
|---|---|---|
| Gradle setup | `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`, wrapper, `key.properties` flow, versionCode-from-tag CI | namespace/appId only |
| Build config | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, compileSdk 37 / minSdk 29 / targetSdk 36, Java 17 | identical |
| Theme | `ui/theme/Theme.kt` (`MaterialExpressiveTheme`, `MotionScheme.standard()`, dynamic color + hand-tuned fallback), `Type.kt`, `Color.kt` | reseed fallback palette |
| DI shape | `data/di/DataModule.kt` (`DataBindModule` + `DataProvideModule`), `@IoDispatcher` qualifier, DataStore wiring | rename store |
| Data seam pattern | `CalendarRepository` (Flow API) + `CalendarRepositoryImpl` wrapping a `CalendarDataSource` interface + `AndroidCalendarDataSource` (Cursor/ContentObserver), `Projections`, `ColumnReader`, mappers | retarget to `TaskContract` |
| Reactive flows | `ContentObserver` → `callbackFlow` bridge in the data source | observe tasks URI |
| Notifications | `ReminderNotifier` (channel, POST_NOTIFICATIONS gate, dedupe by tag) | reuse almost as-is |
| Prefs | `data/prefs/SettingsPrefs`, DataStore | reuse |
| Settings/onboarding/permission UI | `ui/settings`, `ui/permission` (`OnboardingScaffold`, `PermissionScreen`) | adapt copy + permissions |
| Widgets | Glance `widget/` scaffolding (receiver + `PROVIDER_CHANGED` refresh) | task-list widget |
| Common UI | `ui/common/*` (GroupedList, InlineTextField, OptionCard, ColorSwatchRow, FailureView, transitions) | reuse |
| Test stack | JUnit5 + Truth + Turbine + coroutines-test; data source as a JVM-testable seam | reuse |
**Net: ~70–80% of the scaffolding is a copy.** The genuinely new code is the
`TaskContract` data layer, the task screens, and a **self-scheduled reminder
engine** (see §6 — the one place Calendula's pattern does *not* carry over).
### What does NOT exist here (why Agendula is simpler than Calendula)
No month/week/day grid rendering. No recurrence-scoped writes ("this & following"
vs "whole series"). No timezone/all-day gymnastics. Tasks are a flat-or-lightly-
nested list with a due date and a checkbox.
---
## 2. Module & package layout
Single `:app` module for A (mirrors Calendula). Package root
`de.jeanlucmakiola.agendula`.
```
domain/
Models.kt TaskList, Task, TaskStatus, Priority, SubtaskRelation
TaskForm.kt validated create/edit form (mirrors EventForm)
Filters.kt smart lists: Today, Upcoming, Overdue, Completed, All
data/
di/ Qualifiers.kt, DataModule.kt (copy + retarget)
tasks/
TasksContract.kt vendored subset of OpenTasks TaskContract (Apache-2.0)
ProviderResolver.kt detect installed provider authority + permissions ← the A/B seam
TaskProjections.kt column lists
ColumnReader.kt (copy from Calendula)
TaskMapper.kt cursor row -> domain
TaskWriteMapper.kt form -> ContentValues
TasksDataSource.kt interface (JVM-testable seam)
OpenTasksDataSource.kt ContentResolver/ContentObserver impl
TasksRepository.kt Flow API (interface)
TasksRepositoryImpl.kt wraps the data source
reminders/
DueReminderScheduler.kt AlarmManager scheduling (NEW — see §6)
DueReminderReceiver.kt alarm fires -> post notification
BootRescheduleReceiver.kt + provider-change reschedule
TaskNotifier.kt (copy ReminderNotifier, retarget)
prefs/ SettingsPrefs (copy)
ui/
theme/ (copy)
common/ (copy relevant pieces)
lists/ ListsScreen + ViewModel + UiState (overview of lists/accounts)
tasklist/ TaskListScreen (one list or a smart list) + VM + UiState
detail/ TaskDetailScreen + VM + UiState
edit/ TaskEditScreen + VM + UiState
settings/ (copy + adapt)
permission/ provider-presence + permission + POST_NOTIFICATIONS onboarding
AgendulaHost.kt nav host (mirrors CalendarHost)
RootScreen.kt
widget/ Glance task widget (later milestone)
AgendulaApp.kt, MainActivity.kt
```
---
## 3. The data layer (the heart)
### 3.1 Provider targeting — `ProviderResolver`
Known providers, in preference order, each = (authority, read perm, write perm):
| Provider | Authority | Permissions | Contract |
|---|---|---|---|
| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | OpenTasks TaskContract |
| tasks.org | `org.tasks.opentasks` *(verify on device)* | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | OpenTasks-compatible |
| jtx Board | `at.techbee.jtx` | `at.techbee.jtx.permission.READ` / `WRITE` | jtx (richer, different) |
`ProviderResolver.detect()`:
1. `PackageManager.resolveContentProvider(authority, 0)` for each known authority.
2. Return the first present as the active `TaskProvider(authority, readPerm, writePerm)`.
3. None present → `null` → drives the "install a tasks provider" onboarding (§5).
**v1 scope:** fully support the **OpenTasks contract** (covers OpenTasks +
tasks.org's compatible provider). jtx is detected but treated as "supported
later" (its contract differs). For B, the resolver simply also finds our bundled
`org.dmfs.tasks`.
> Verify the exact tasks.org provider authority on a real device before relying
> on it — docs are inconsistent; OpenTasks (`org.dmfs.tasks`) is the certain one.
### 3.2 `TasksContract.kt`
Vendor the subset we use from the Apache-2.0 OpenTasks `TaskContract` (don't take
a runtime dep on OpenTasks). Authority is injected, not hardcoded. Tables/columns:
- **TaskLists**: `_ID`, `LIST_NAME`, `LIST_COLOR`, `ACCOUNT_NAME`, `ACCOUNT_TYPE`,
`SYNC_ENABLED`, `VISIBLE`, `OWNER`. (Account fields → group lists by account in
the UI, like Calendula groups calendars.)
- **Tasks** (the denormalized instances view): `_ID`, `LIST_ID`, `TITLE`,
`DESCRIPTION`, `DTSTART`, `DUE`, `IS_ALLDAY`, `TZ`, `STATUS`, `PRIORITY`,
`PERCENT_COMPLETE`, `COMPLETED`, `RRULE`, `LIST_COLOR`, `ACCOUNT_*`.
- **Properties / Relation** (`Tasks.Properties`, mimetype Relation): subtasks via
`RELATED-TO` (`RELATION_TYPE_PARENT`). This is how DAVx5 carries the hierarchy.
### 3.3 Repository API
```kotlin
interface TasksRepository {
fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>> // list-id or smart list
suspend fun taskDetail(taskId: Long): TaskDetail
suspend fun createTask(form: TaskForm): Long
suspend fun updateTask(taskId: Long, original: TaskForm, updated: TaskForm)
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
suspend fun deleteTask(taskId: Long)
suspend fun createLocalList(name: String, color: Int): Long // device-only list we own
// subtasks: createSubtask(parentId, form) / reparent via RELATED-TO
}
```
Writes go through the **sync-adapter URI form** where the provider requires it
(local/unsynced lists), mirroring Calendula's `createLocalCalendar`. Completion =
set `STATUS=COMPLETED`, `PERCENT_COMPLETE=100`, `COMPLETED=now`; DAVx5 syncs it
back out as a normal VTODO status change.
### 3.4 Reactive flows
`OpenTasksDataSource` registers a `ContentObserver` on the active authority's
Tasks/TaskLists URIs and emits via `callbackFlow` — identical mechanism to
Calendula, so external sync (DAVx5 pulling new tasks) updates the UI live, and
multiple sync sources coexist in one list.
---
## 4. Screens (Compose, M3 Expressive)
1. **Lists overview** (`ui/lists`) — smart lists at top (Today, Upcoming,
Overdue, All), then user lists grouped by account (reuse `GroupedList`).
Per-list color dot + open/undone count.
2. **Task list** (`ui/tasklist`) — the workhorse. Checkbox rows, swipe-to-
complete + swipe-to-delete, inline "add task" field (reuse `InlineTextField`),
subtask indentation, sort (due/priority/manual), section headers for smart
lists (Overdue / Today / Later). FAB to add.
3. **Task detail** (`ui/detail`) — title, notes, due/start, priority,
percent-complete, subtasks, source list/account, completed timestamp.
4. **Task edit** (`ui/edit`) — title, notes, list picker, due/start date-time
(reuse Calendula date/time pickers), priority, reminder offset, subtasks.
"Conflict-safe save" like Calendula (re-check before overwrite).
5. **Settings** (`ui/settings`) — theme/dynamic-color/language (copy), default
list, default reminder offset, "tasks app" info + manage button.
6. **Onboarding / permission** (`ui/permission`) — see §5.
Material 3 Expressive throughout: `MaterialExpressiveTheme`,
`MotionScheme.standard()`, expressive checkbox/FAB/swipe motion. Follow the
`material-3` skill for component choices (M3 `ListItem` for rows, etc.).
---
## 5. Onboarding & permissions
Three gates, in order:
1. **Provider present?** `ProviderResolver.detect()`. If none → a screen
explaining Agendula needs a tasks provider, with one-tap links to install
**OpenTasks** (FOSS) or **tasks.org**, plus "I use DAVx5 — set its Tasks app".
(This is Agendula's "needs DAVx5" moment. Disappears entirely under Posture B.)
2. **Tasks read/write permission** — request the active provider's runtime perms
(`org.dmfs.permission.*` etc.). Declared in the manifest *and* requested at
runtime; resolved dynamically from the detected provider.
3. **POST_NOTIFICATIONS + exact-alarm** — for due reminders (reuse Calendula's
reminder onboarding).
`<queries>` in the manifest for package visibility (launch DAVx5 / OpenTasks),
exactly like Calendula's launcher query.
---
## 6. Reminders — the one pattern that does NOT carry over
Calendula relies on the **calendar provider broadcasting `EVENT_REMINDER`** and
just posts the notification (Etar model). **Tasks providers do not broadcast
reminders.** So Agendula must schedule its own:
- `DueReminderScheduler` (AlarmManager, exact alarms via `USE_EXACT_ALARM` /
`SCHEDULE_EXACT_ALARM`) sets an alarm per task at `DUE` minus the chosen
offset (and optionally at `DTSTART`).
- `DueReminderReceiver` fires → posts via `TaskNotifier` (the copied
`ReminderNotifier`), tapping opens the task.
- **Reschedule triggers:** boot (`RECEIVE_BOOT_COMPLETED`), and the data-source
`ContentObserver` firing on any task change (our edits *and* external sync) →
recompute alarms for the near-future window. Keep a small scheduled-alarm
registry in DataStore so we can diff like Calendula diffs reminder rows.
This is the single largest *new* subsystem. Everything downstream of it (channel,
notification building, POST_NOTIFICATIONS gating, dedupe-by-tag) is copied.
---
## 7. Manifest (vs Calendula)
```xml
<!-- declared statically; the active set is also requested at runtime -->
<uses-permission android:name="org.dmfs.permission.READ_TASKS"/>
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS"/>
<uses-permission android:name="org.tasks.permission.READ_TASKS"/>
<uses-permission android:name="org.tasks.permission.WRITE_TASKS"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
<queries> … LAUNCHER intent (package visibility) … </queries>
```
Receivers: `DueReminderReceiver`, `BootRescheduleReceiver`, Glance widget
receiver. **No `EVENT_REMINDER` receiver** (doesn't apply).
---
## 8. Milestones
- **M0 — Skeleton.** Copy Gradle/version-catalog/theme/DI/app+activity from
Calendula, rename to Agendula. Builds, shows themed empty scaffold. *(~½ day)*
- **M1 — Read path.** `TasksContract` + `ProviderResolver` + `OpenTasksDataSource`
+ repository. Lists overview + task list render real synced tasks (read-only),
live-updating via ContentObserver. *(the meaty milestone)*
- **M2 — Complete & CRUD.** Toggle complete (swipe + checkbox), create via inline
field + edit screen, delete. Local-list creation.
- **M3 — Detail/edit polish.** Due/start pickers, priority, percent, conflict-safe
saves, smart lists (Today/Upcoming/Overdue).
- **M4 — Subtasks.** `RELATED-TO` read + indentation; create/reparent. *(trickiest)*
- **M5 — Reminders.** `DueReminderScheduler` + receivers + onboarding.
- **M6 — Settings, widget, i18n, F-Droid metadata, CI** (clone Calendula's
`.gitea/workflows`, `fdroid-metadata/`, RELEASING docs).
- **B (separate, later):** add `:provider` module bundling Apache-2.0
`opentasks-provider`; `ProviderResolver` default authority becomes our own
`org.dmfs.tasks`; add sync-adapter permissions; ship self-contained. UI/repo
untouched.
**Effort:** M0–M3 is a small, fast core (days, given the Calendula head start).
M4 (subtasks) and M5 (reminders) are the two spots needing real thought.
---
## 9. Open decisions / to verify
1. ~~**Name**~~ — **Agendula** (final): *agenda* + Calendula's `-ula`. Renamed
from the working title *Floret*, which was promoted to the family /
design-language name.
2. **tasks.org provider authority** — verify on a real device; OpenTasks
(`org.dmfs.tasks`) is the certain target for v1.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
4. **B authority choice** — bundling `org.dmfs.tasks` makes Agendula a *replacement*
for OpenTasks (one authority owner per device). Intended (one app instead of
two), but a conscious choice.
5. **Repo home** — sibling Gitea repo next to Calendula, MIT license, same CI.
---
## 10. Sync sources Agendula inherits for free (README copy)
Because Agendula builds on the provider, not on any one sync app, it works with
**anything that writes to the tasks provider**: DAVx5 (CalDAV), SmoothSync,
CalDAV-Sync, DecSync CC, and any Android sync adapter — no per-app integration.
Google Tasks / Microsoft To Do are out of scope by design (proprietary, would
mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are
the lane.
+15 -6
View File
@@ -1,7 +1,7 @@
---
title: Privacy Policy — Agendula
description: What Agendula does with your data. No servers, no account, no analytics — your tasks stay on your device unless you add a CalDAV server yourself.
updated: 2026-09-15
updated: 2026-09-21
---
<!--
@@ -21,7 +21,7 @@ updated: 2026-09-15
render on the published page.
-->
**Last updated:** 9 September 2026
**Last updated:** 21 September 2026
Applies to the Android app **Agendula** (package `de.jeanlucmakiola.agendula`),
all versions and all distribution channels.
@@ -144,9 +144,13 @@ stored locally on your device and are removed when you uninstall the app.
If Android Auto Backup is enabled on your device, your tasks and settings may
be backed up to your own Google account, under Google's terms — the developer
has no access to it. Two things are deliberately excluded from that backup:
your stored CalDAV password, and Agendula's per-device sync bookkeeping. After
restoring onto a new device you therefore sign in to your server again.
has no access to it. What travels is Agendula's own task database and your
settings, and nothing else: the backup rules name those explicitly, which makes
everything not named — including the archived copy the app keeps of an older
version's database — excluded by default. Two things are deliberately kept out
of that backup: your stored CalDAV password, and Agendula's per-device sync
bookkeeping. After restoring onto a new device you therefore sign in to your
server again.
## 7. Crash reports
@@ -183,6 +187,10 @@ process — it only opens the address.
## 9. Permissions and why they exist
This is the complete list the released app declares — you can check it against
the app's entry in F-Droid, or against `app/src/main/AndroidManifest.xml` in the
source:
- `INTERNET`, `ACCESS_NETWORK_STATE` — CalDAV sync with the server you
configure, and checking whether a connection exists before trying. Without a
CalDAV account, no connection is made.
@@ -195,7 +203,8 @@ process — it only opens the address.
- `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` and
`org.tasks.permission.READ_TASKS` / `WRITE_TASKS` — optional, requested only
if you choose the external-provider storage mode, and only for the provider
you selected (OpenTasks or tasks.org).
you selected (OpenTasks or tasks.org). All four are declared in the manifest
because a manifest is static, but none is requested until you pick that mode.
- `WAKE_LOCK`, `FOREGROUND_SERVICE` — required by the Android system component
used for scheduled background work (WorkManager); on older Android versions
it needs them to run an expedited sync.
-27
View File
@@ -1,27 +0,0 @@
# Agendula — documentation
Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync
over CalDAV), with no own database or sync stack. Sibling to
[Calendula](https://codeberg.org/jlmakiola/calendula). See the
top-level [`../README.md`](../README.md) for the project pitch.
## Index
| Doc | What it covers |
|---|---|
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Agendula is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. |
| [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0–M6 + Posture B), what's done, open decisions, how to build/verify. |
| [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". |
| [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. |
Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections
feed the release notes).
## How the docs relate
- **PLAN** is the design decisions (mostly stable; the "why").
- **ARCHITECTURE** is the current shape of the code (kept in sync with the
source as it grows).
- **ROADMAP** is the moving status layer (update as milestones land).
- **RELEASING** is the operational runbook.
+125 -29
View File
@@ -1,8 +1,8 @@
# Agendula — releasing
Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) and a
**Codeberg release** per version carrying the signed APK as a direct download.
Both are produced automatically by `.gitea/workflows/release.yaml` when a
Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner), a
**Codeberg release** per version carrying the signed APK as a direct download,
and **Google Play**. All three are produced automatically by `.gitea/workflows/release.yaml` when a
**bumped `versionName` reaches `main`** — the pipeline builds and publishes that
version, then creates the matching `vX.Y.Z` tag and the releases itself. The
parallel **Gitea release** is the changelog of record on the build instance and
@@ -42,19 +42,19 @@ re-running the workflow safely retries).
that branch, before it reaches `main`.
2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new
`## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading
and the next `## [` becomes both the Gitea release notes and the F-Droid
per-version "What's New". The heading **must** match the version exactly.
and the next `## [` becomes the Gitea and Codeberg release notes. The
heading **must** match the version exactly.
3. **Bump the committed `versionName`** (and `versionCode`) in
`app/build.gradle.kts`. **This bump is what triggers the release** when the
branch merges to `main`. Then run
```sh
scripts/sync_changelog_to_fastlane.sh
```
and commit the generated
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` — this is what
makes the **official** F-Droid listing (which harvests the changelog from the
tagged source tree) show this version. The self-hosted pipeline regenerates it
regardless, so forgetting only affects the official listing.
branch merges to `main`.
Then write this version's **"What's New"** by hand:
`fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt`, one per
shipped locale, each a short summary **under 500 characters** — F-Droid and
Play both publish these files, and Play rejects a longer one. It is not a
copy of the CHANGELOG.md section. `scripts/sync_changelog_to_fastlane.sh`
keeps a committed `en-US` file as it is and only generates one from
CHANGELOG.md when it is missing; CI fails a PR whose version has no
committed `en-US` changelog.
4. **Verify the release build on a real device** — the mandatory gate:
```sh
scripts/verify-release.sh
@@ -71,7 +71,8 @@ re-running the workflow safely retries).
Only proceed once all of that passes on-device.
5. **Merge `release/vX.Y.Z` into `main`.** That's it — no manual tagging. The
merge triggers `release.yaml`, which detects the new version, builds, signs,
publishes to F-Droid, and creates the `vX.Y.Z` tag + Gitea release.
publishes to F-Droid, creates the `vX.Y.Z` tag + releases, and uploads the
App Bundle to Play.
> The `releaseTest` build type exists only for step 4 — it is never published.
> The pipeline always builds and signs the real `release` variant.
@@ -106,8 +107,13 @@ release work when a merge actually cuts a release:
with the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z`
tag + Gitea release (CHANGELOG section as notes, flagged pre-release while
`MAJOR` is 0), attach the R8 `mapping.txt`, and publish the release on
**Codeberg** with the signed APK + a SHA-256 checksum. Ordinary merges with no
version bump fall through `detect` and do nothing.
**Codeberg** with the signed APK + a SHA-256 checksum, and finally builds the
App Bundle. Ordinary merges with no version bump fall through `detect` and do
nothing.
- **`play` job** (same workflow, after `release`) — uploads the App Bundle and
every locale's "What's New" to Google Play. Runs last and separately so a Play
rejection can't endanger a release that already shipped; skips cleanly until
Play is configured.
### Codeberg direct-download channel
@@ -123,6 +129,44 @@ green while never once publishing, which is how a crash-fix release reached
F-Droid but not the Codeberg/Obtainium users who needed it. A broken mirror
fails the release loudly.
### Google Play channel
**Artifact.** Play gets an **App Bundle** (`bundleRelease`), never the APK. It is
built from the same source and signing config at the very end of the `release`
job, `continue-on-error`, and handed to the `play` job as a workflow artifact
(via a commit-pinned fork of `upload-artifact`/`download-artifact`; the official
v4 actions refuse any non-github.com forge). The APK and the F-Droid
reproducibility guarantee are untouched.
**Signature.** Play App Signing treats the app key as the **upload key** only
and re-signs with Google's key. A Play install and an F-Droid/Codeberg install
therefore have **different signatures** and cannot update each other; switching
channels means uninstalling. Losing the app key is recoverable for Play (upload
key reset) but not for F-Droid.
**fastlane** is only the Play Developer API client (`supply`), never the build.
The `deploy` lane uploads the bundle plus `changelogs/<versionCode>.txt` for
every locale that has one. It never touches the listing; that is the `listing`
lane's job (see [Store listings](#store-listings-single-source-of-truth)).
**Track.** Uploads go straight to `production` at full rollout — the release
branch review is already the gate. Set the `PLAY_TRACK` variable (e.g.
`internal`) to stage, or `PLAY_RELEASE_STATUS=draft` to hold it unpublished.
**First-time setup.**
1. Create the app in the Play Console with **English (United States) – en-US**
as the default language, so Play and F-Droid fall back to the same locale.
2. Upload the first AAB **by hand** — the API can't create an app's first
release. Enrol in Play App Signing on the way.
3. Create a Google Cloud service account, grant it release permissions for the
app in the Play Console, and store its JSON key as
`PLAY_SERVICE_ACCOUNT_JSON`.
4. Push the listing once with the `listing` lane (see
[Store listings](#store-listings-single-source-of-truth)), so every locale
that has a changelog also exists on Play before `deploy` sends its
"What's New".
5. Rehearse with `PLAY_DRY_RUN=true`, then clear it.
One-time setup: the Codeberg repo's **Releases** unit must be enabled and a
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope
— it pushes the tag as well as creating the release) added to Gitea Actions.
@@ -196,6 +240,7 @@ user's pinned repo).
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — pushes the tag to Codeberg, creates the release there and uploads the APK/checksum. If unset the step skips; if set and failing, the release fails. |
| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console release access. Uploads the AAB. If unset, the `play` job skips cleanly. |
| `RENOVATE_TOKEN` | Codeberg bot-account token — repo read/write + PR scope on `jlmakiola/agendula`. Used only by `renovate.yml`. |
| `GITHUB_COM_TOKEN` | Read-only github.com PAT (no scopes). Without it Renovate's changelog lookups hit the 60/h anonymous rate limit and PRs arrive with empty release notes. |
@@ -204,22 +249,73 @@ users pin). Neither key nor `config.yml` is ever uploaded to the server — they
live only in CI secrets and are reconstructed in-runner (nginx serves only
`repo/`).
---
### Variables (Gitea → repo Settings → Actions → Variables)
## F-Droid metadata (single source of truth)
Store-listing text lives in **`fastlane/metadata/android/<locale>/`** — the same
tree the official F-Droid repo harvests from source. At release time
`scripts/fastlane_to_fdroid_localized.sh` transforms it into the F-Droid repo's
"localized" layout, so there is no second copy to maintain. The app-level control
file (`Categories`/`License`/links) stays in
`fdroid-metadata/de.jeanlucmakiola.agendula.yml`. Per-version changelogs are
seeded into `fastlane/.../en-US/changelogs/<versionCode>.txt` by
`scripts/sync_changelog_to_fastlane.sh` (step 3 above) and carried across by the
transform.
| Variable | Default | Purpose |
| --- | --- | --- |
| `PLAY_TRACK` | `production` | Play track for the bundle; `internal`/`alpha`/`beta` to stage. |
| `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. |
| `PLAY_DRY_RUN` | `false` | `true` validates the Play edit and discards it. |
---
## Store listings (single source of truth)
Everything both stores show — title, short and full description, "What's New",
icon, feature graphic, screenshots — lives in
**`fastlane/metadata/android/<locale>/`**. Nothing is edited in the Play Console
or kept in a second copy for F-Droid.
```
fastlane/metadata/android/
en-US/ fallback of both stores; holds every graphic
title.txt ≤ 30 chars
short_description.txt ≤ 80 chars
full_description.txt ≤ 4000 chars
changelogs/<code>.txt ≤ 500 chars
images/
icon.png 512×512, 32-bit PNG
featureGraphic.png 1024×500, no alpha
phoneScreenshots/ 2–8, no alpha, 320–3840 px, long side ≤ 2× short
sevenInchScreenshots/ optional, same rules
tenInchScreenshots/ optional, same rules
en-GB/, de-DE/, pt-BR/, … text only; one per shipped values-* language
```
`en-US` is the fallback for both stores: F-Droid always uses it, and it is the
Play app's default language. So text and graphics committed there cover every
locale without its own. Graphics are committed **once, under `en-US`**; add them
to another locale only when they actually differ (e.g. localized screenshots).
A raw phone capture (1080×2400, 20:9) breaks Play's 2:1 aspect cap. Frame or
crop screenshots to at most 2:1, e.g. 1080×2160.
**Validation.** `scripts/check_store_listing.py` checks the whole tree against
Play's limits (the stricter of the two stores) and runs on every PR. Add
`--complete` to also require what a live Play listing needs: icon, feature
graphic, ≥ 2 phone screenshots and this version's `en-US` changelog.
**F-Droid.** The official repo harvests the tree from the tagged source. The
self-hosted repo gets it through `scripts/fastlane_to_fdroid_localized.sh` at
release time. The app-level control file (`Categories`/`License`/links) stays in
`fdroid-metadata/de.jeanlucmakiola.agendula.yml`.
**Play.** `fastlane/Fastfile`'s `listing` lane pushes text and graphics
(`supply`, only changed images are re-uploaded). It is run by hand, never by the
release pipeline, because overwriting a live listing triggers a Play review:
```sh
bundle install
SUPPLY_JSON_KEY=/path/to/play-service-account.json \
bundle exec fastlane listing dry_run:true # validate, discard
SUPPLY_JSON_KEY=/path/to/play-service-account.json \
bundle exec fastlane listing
```
The lane refuses to run unless `check_store_listing.py --complete` passes.
If the Play app's default language is ever not `en-US`, set
`PLAY_DEFAULT_LOCALE` and the lane copies the graphics into that locale.
## Crash deobfuscation
Each release attaches `mapping-<version>.txt.gz` (the R8 mapping) to its Gitea
-163
View File
@@ -1,163 +0,0 @@
# Agendula — roadmap
Where the project is and where it's going. This is the **status** view; the
design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the
pieces fit together is in [`ARCHITECTURE.md`](ARCHITECTURE.md).
Status legend: ✅ done · 🚧 in progress · ⬜ not started
---
## Current state (one line)
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**, and the Material 3 Expressive UI is built
through **M5**: lists → task list (swipe gestures, inline add, smart-list section
headers) → detail / edit with full CRUD, date-time pickers, priority,
percent-complete, conflict-safe saves, per-task reminders, and subtask
create + reparent — plus a one-time reminder onboarding step and a Settings
screen (theme, dynamic colour, due-reminder master toggle + default offset +
exact-alarm status, default list, and the add-a-subtask-row opt-out). Remaining
work is M6 (Glance widget, translations, F-Droid release; the Settings screen
landed early with M5 and still needs a language entry).
---
## Milestones
### ✅ M0 — Skeleton
Project scaffolding copied from Calendula (Gradle, version catalog, Hilt,
Material 3 Expressive theme, Gitea CI/release workflows, F-Droid metadata),
reseeded to a warm-mauve fallback palette. Builds, shows a themed placeholder.
### ✅ M1 — Read path + logic (the "backoffice")
The complete non-visual stack:
- Vendored `TaskContract` subset, `ProviderResolver` (runtime authority
detection), `ColumnReader`, mappers, `AndroidTasksDataSource` (Instances
query + `ContentObserver`), and `TasksRepository` exposing live Flows of
lists / tasks / detail.
- Domain models, smart-list filtering (Today / Upcoming / Overdue / No-date /
All / Completed), sorting, form validation, subtasks via `parentId`.
- Self-scheduled due-reminder engine (AlarmManager + boot / provider-change
re-sync), notifications, DataStore prefs.
- Render-only ViewModels + UiState for **every** screen, so the UI is build-only
from here.
- JVM unit tests across mappers, filtering, sorting, form, value mapping, and
day windows.
### ✅ M2 — Screens: lists, task list, complete & CRUD
The real Material 3 Expressive UI, built screen by screen against the M1
ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
- ✅ Navigation host wiring (`AgendulaNavHost` + `Dest` route table: lists → task
list → detail / edit, each binding its M1 ViewModel from route args).
- ✅ Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
-delete (`SwipeToDismissBox`), inline add field (`InlineAdd` → `quickAdd`),
smart-list section headers (`TaskSections`).
- ✅ Detail + edit screens — detail shows title / description / subtasks with
edit+delete; edit has the full CRUD form (title, description, save) wired to a
list picker.
### ✅ M3 — Detail / edit polish
- ✅ Due / start date-time pickers (`DateTimePickerFlow` in `TaskEditScreen`).
- ✅ Priority — coloured by level: green / amber / red pastels (`priorityFill`;
M3 has no priority role, only `error`). A shared `ui/common/PriorityChip` on the
list and detail screens, and the edit form's M3 segmented selector tints its
active segment in the chosen level's hue.
- ✅ Smart-list section presentation (`TaskSections` headers on the task list).
- ✅ Percent-complete field — optional "Progress" slider (5% detents) on the edit
form; writes `Tasks.PERCENT_COMPLETE` (clamped 0–100, status left to the
complete toggle).
- ✅ Conflict-safe saves — `updateTask` re-checks `last_modified` against the
value captured when the form loaded and throws `TaskConflictException`; the
editor offers overwrite-or-cancel instead of clobbering an external change.
### ✅ M4 — Subtasks (UI)
`RELATED-TO` hierarchy in the UI. The data layer already reads `parentId`.
- ✅ Create subtask (`AddSubtaskField` → `TaskDetailViewModel.addSubtask` sets
`parentId`); subtasks render as a grouped section in the detail screen. Tapping
a subtask opens its own detail (a new `TaskDetail` entry, so it can have
children too), and the subtask's detail shows a tappable "Part of …" parent
card so it never reads as a stray standalone task.
- ✅ Inline expansion on the task list — a parent row has a dedicated expand
button (trailing chevron, separate from the count chip). Each section flattens
into one grouped run (parents + their expanded children) and corners are chosen
per-edge (`cornerPosition`): top-level tasks keep their own run, an expanded
parent opens its bottom to its children, and the child group rounds off on its
last segment while the next top-level task stays mid-run. Children are
full-width, set a step down in tone (`surfaceContainer` vs the parents'
`surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask"
row (on by default; opt out in Settings → Tasks since M5).
Offered only where the list holds all the children (a real list; smart lists
that omit off-day children stay collapsed). `TaskDetail.parent` carries the
parent for the detail card.
- ✅ Reparent — a full-width, searchable "Parent task" sheet on the edit form
groups active candidates by due-date section (Overdue / Today / Upcoming / No
date) and files a task under any top-level task in its list (or "None" to
promote it); switching list clears the now-invalid parent. Candidates stay
active + top-level to keep nesting one level deep, matching the detail screen.
### ✅ M5 — Reminders onboarding & polish
The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
`DueReminderReceiver`, `TaskNotifier`).
- ✅ Per-task reminder-offset UI (`ReminderPickerDialog` → `TaskForm`
`reminderMinutesBeforeDue`).
- ✅ `POST_NOTIFICATIONS` onboarding flow — a one-time `ReminderOnboardingScreen`
(Calendula's shell + copy adapted for tasks) gated in `RootScreen` after the
provider/permission grant; it requests the runtime permission (API 33+) and
records the choice (`reminderOnboardingDone`). Re-requestable from Settings.
- ✅ Exact-alarm surface — a status row in Settings → Reminders, shown only on
Android 12 (where `SCHEDULE_EXACT_ALARM` is revocable), deep-linking to the
system grant screen; on 13+ the app holds `USE_EXACT_ALARM` (always granted).
- ✅ Default reminder-offset settings UI — exposed as "When to remind" in
Settings → Reminders (`reminderLeadMinutes`), behind a master `remindersEnabled`
switch that gates the entire engine (`ReminderScheduler` clears all alarms when
off; `DueReminderReceiver` suppresses any in-flight fire).
- ⬜ End-to-end verification on device (build + unit tests green; not yet run on
a device with a live provider).
### 🚧 M6 — Settings, widget, i18n, release
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
- ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
reached by the gear on the lists overview). Covers theme, dynamic colour, due
reminders (toggle + default offset + exact-alarm status), default list, and the
add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there
are translations to switch to).
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
### ⬜ Posture B (separate track, later)
Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`;
`ProviderResolver` defaults to our own `org.dmfs.tasks`; add sync-adapter
permissions; ship self-contained. UI / repository / domain untouched — see
[`ARCHITECTURE.md`](ARCHITECTURE.md) §7.
---
## Open decisions / to verify
These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through.
1. ~~**Name** — `Agendula`~~ confirmed (appId `de.jeanlucmakiola.agendula`).
2. ~~**tasks.org provider authority**~~ verified on device:
`org.tasks.opentasks` + `org.tasks.permission.*`.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
(Not in the candidate list today.)
4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Agendula a
*replacement* for OpenTasks (one authority owner per device). Intended, but a
conscious choice.
5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists);
recurrence-aware editing is out of scope for v1.
---
## How to contribute / verify
- Build: `./gradlew :app:assembleDebug`
- Unit tests: `./gradlew :app:testDebugUnitTest`
- Run on a device/emulator that has **OpenTasks** or **tasks.org** installed (and
ideally DAVx5 syncing a CalDAV task list) so the read/write paths have real
data. Debug builds use `DemoSeeder` for sample data when no provider data is
present.
-357
View File
@@ -1,357 +0,0 @@
# Agendula — storage and sync
> Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B =
> bundle OpenTasks" working notes, which are withdrawn (see
> [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to
> `ARCHITECTURE.md` §7 and the `ProviderResolver` comments, **and it redefines
> what Posture B means** — those two need a follow-up edit.
> `ROADMAP.md` / `PLAN.md` remain known-stale and are due a deliberate pass;
> this document does not attempt it.
## The plan, in short
**What we're building**
1. **Our own provider.** Vendor the Apache-2.0 dmfs task provider in-tree as
`:provider`, renamed to our own authority and permission namespace. Our
database, our namespace — coexists with everything, replaces nothing.
2. **Our own sync.** An Agendula sync adapter, so remote storage never depends
on another app's roadmap.
3. **The user picks the mode.** Local-only · Synced · External provider.
4. **Least permission.** Ask only for what the chosen mode needs, when it needs
it.
5. **Kit-first.** Anything that isn't task-domain goes to floret-kit.
**In what order**
| # | Step | Why now |
|---|---|---|
| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 |
| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app |
| 3 | Export / backup | our data now lives only in our app's private storage |
| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users |
| 5 | Sync adapter | the 1.x arc; design discussion pending |
Everything below is the reasoning behind those choices, the alternatives that
were rejected, and the constraints they have to survive.
---
## The decision
Agendula gets its **own identity all the way down** — its own task database
under its own authority, its own sync, and a storage mode the user picks. It
does not adopt, replace, or impersonate another project's provider.
Four parts:
1. **Own DB, bundled in-process.** Vendor the Apache-2.0 dmfs
`opentasks-provider` as an in-tree `:provider` Gradle module, renamed to
authority `de.jeanlucmakiola.agendula.tasks` with permissions
`de.jeanlucmakiola.agendula.permission.READ_TASKS` / `…WRITE_TASKS`. Not a
separate provider *app*; not a schema written from scratch. We keep the dmfs
`TaskContract` shape — it's proven, it's what our whole data layer already
speaks, and it's what every CalDAV engine already understands — we just own
the namespace it lives in.
2. **Own sync adapter**, so remote storage never depends on another app's
roadmap. Protocol coverage is deliberately open — separate discussion.
3. **The user chooses the backend**: local-only, synced, or an external provider
that's already on the device.
4. **Ask for only what the chosen mode actually needs**, at the moment it needs
it.
Plus a standing rule: **anything that isn't task-domain goes to floret-kit.**
### The vocabulary, redefined
`ARCHITECTURE.md` §7 and `ProviderResolver`'s KDoc still describe Posture B as
"bundle OpenTasks and find `org.dmfs.tasks` first." Replace with:
- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org).
Still fully supported; it stops being the default and becomes a **user
choice**.
- **Posture B** — our own bundled provider under **our own** authority.
Coexists with everything; replaces nothing.
The A/B seam itself is unchanged and still earns its keep: `ProviderResolver` is
the only thing that knows an authority, `AndroidTasksDataSource` the only thing
that touches a resolver. UI, ViewModels, domain and repository are untouched by
all of this.
---
## Why not squat `org.dmfs.tasks`
The rejected plan was to bundle the provider under dmfs's own authority so
DAVx5 would sync into it unwittingly. Reasons it's out, in order of how badly
each one bites:
1. **It is a structural identity mismatch, and the resulting bug is invisible to
both sides.** Content-provider *authorities* are how a sync engine finds a
provider, but Android **account visibility is keyed by package name**: since
API 26 an app only sees accounts whose authenticator has made them visible to
*its package*, and `GET_ACCOUNTS` alone no longer suffices. A sync engine's
allowlist would name the package `org.dmfs.tasks`, not
`de.jeanlucmakiola.agendula`. So the bundled provider could find **zero**
accounts — and the dmfs provider uses account enumeration to prune task lists
whose account has gone away. The failure mode isn't "no sync", it's "our
provider quietly purges synced lists." *(Reasoned from the platform rules,
not from having read DAVx5's source — but the class of bug is structural, and
every future place anything keys on package rather than authority is a fresh
instance of it.)* An explicit integration under our own name makes this bug
impossible by construction.
2. **Play Store install-time landmine.** Two apps cannot declare the same
authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same `<permission>`
name (`INSTALL_FAILED_DUPLICATE_PERMISSION`, waived only for identical
signing certs). Anyone with OpenTasks installed gets a failed install,
surfacing as one-star reviews we can't usefully answer.
3. **Migration data loss.** "Uninstall OpenTasks first" takes its DB with it.
CalDAV-synced tasks reconcile back; local-only tasks are simply gone, and
OpenTasks has no export (dmfs/opentasks #170, #204, #71 — years-old,
unimplemented; their wiki punts to a desktop client).
4. **Squatting another project's namespace doesn't scale.** At low install
counts nobody notices. At scale we'd be generating issues on dmfs's tracker
that aren't dmfs's fault, and silently maintaining a schema fork under their
name.
---
## The `:provider` module
**Source.** dmfs `opentasks-provider`, Apache-2.0. Target the **1.4.2** source
(DB version 23 — the version that actually carries `is_recurring`; tasks.org's
fork is DB 22 and lacks it, which is why `TaskMapper.task` reads `rrule`/`rdate`
for recurrence detection rather than trusting the column).
**Layout: in-tree module, not a git submodule.** floret-kit is a submodule
because we co-develop it. This is a fork we will sync from upstream
approximately never, so in-tree is simpler for both stores and honest about what
it is. Ship a `provider/PROVENANCE.md`: upstream commit, and every change we
made.
**License hygiene, on day one.** The app is MIT, the provider is Apache-2.0 —
permissive into permissive, fine — but the module keeps its Apache-2.0 headers,
`LICENSE`, and `NOTICE`. Ten minutes now; embarrassing to retrofit once it's in
two store listings.
**What we change:**
- Authority → `de.jeanlucmakiola.agendula.tasks` (it's already a string
resource, `opentasks_authority`).
- Permission names → `de.jeanlucmakiola.agendula.permission.*`. These are
**hardcoded in the AAR manifest**, which is the single clearest reason
vendoring is mandatory rather than merely preferable — you cannot rename them
in a prebuilt artifact without `tools:` node surgery we'd rather not ship.
- Drop `<uses-permission android:name="android.permission.GET_ACCOUNTS" />`.
We own our own accounts, so we don't need it — but note the provider's
account-cleanup path is written *assuming* it, so this is a review-and-rework
item, not a free deletion. **Verify** the provider's local-list/local-account
path works with no account present at all; that's the entire local-only mode.
- Drop the exported `BOOT_COMPLETED` / `TIME_SET` / `TIMEZONE_CHANGED` receiver,
or keep it deliberately and give it an explicit `android:exported`. The AAR is
from the `targetSdk 29` era; AGP hard-errors on a merged manifest with an
intent-filtered component and no explicit `exported` once targetSdk ≥ 31, and
we're on 36. Fixed at source instead of patched around.
- Modernize the build: it ships `minSdk 21` / `targetSdk 29`, Robolectric 3.5.1,
JUnit 4.12. We're `minSdk 29` / `targetSdk 36` and Play raises its target-API
floor annually, so this isn't optional upkeep.
**Our data now lives in our app's private storage.** Uninstall means deletion.
That single fact is what promotes export/backup from "nice to have" to a v1
feature — see [Storage modes](#storage-modes--the-users-choice).
---
## Storage modes — the user's choice
| Mode | Backing store | Sync | Needs |
|---|---|---|---|
| **Local** | our bundled provider | none | no permissions at all — same-uid provider access needs no grant |
| **Synced** | our bundled provider | our sync adapter | network + an account the user configures |
| **External** | OpenTasks / tasks.org | whatever that provider's engine does (DAVx5 …) | that provider's `READ`/`WRITE_TASKS`, granted at runtime |
Local and Synced are the same store — Synced is Local with an account attached,
so switching on sync is not a migration.
**Resolver ordering needs deciding.** Today `ProviderResolver.CANDIDATES` is a
fixed priority list and the first hit wins. Once we bundle our own provider,
"first hit" is the wrong rule: someone who used Agendula locally and *later*
installs DAVx5 + OpenTasks would see an external candidate outrank the provider
that actually holds their data. Options: rank ours first whenever it's
non-empty, or make the mode an explicit Settings choice (it's user-visible
either way, so probably both — auto-pick a sane default, let Settings override).
**Export/backup is a v1 feature.** Not, as previously framed, a migration safety
net for "uninstall OpenTasks" — that scenario no longer exists. It's data
portability for Local-mode users, whose tasks otherwise exist in exactly one
place with no second copy. On Play, where most users won't have a sync engine,
that's the majority.
---
## Sync — own adapter
**Decided:** Agendula ships its own sync. Not because DAVx5 is bad, but because
depending on it makes one external maintainer's roadmap the gate on our core
feature — the same shape of dependency the whole identity decision exists to
escape.
The two precedents diverge and the choice between them is the whole point:
tasks.org has its own authority **and its own sync** (sovereign); jtx Board has
its own authority and **depends on DAVx5** (and got added, though a working
relationship with bitfire is part of that story). We're taking the tasks.org
shape.
**Play sharpens this.** DAVx5 is a paid app on Google Play and free only on
F-Droid — *worth confirming, since it's load-bearing* — which means most Play
users will never have it. Lobbying bitfire is therefore an **F-Droid-audience
feature, not a sync strategy**.
**Still file the DAVx5 issue** — cheap, non-blocking, real value for F-Droid
users. And make it the strongest possible version of the ask: our provider *is*
the dmfs provider with renamed strings, so it's byte-identical contract
compliance and a small enum-shaped addition with near-zero ongoing maintenance
for them. Say that explicitly. "Here's a change that can't break anything" lands
very differently from "please support my app."
**Open — the next discussion.** Protocol coverage ("support as much as
possible"), the account model, conflict resolution, and where the DAV/iCalendar
work lives. One constraint to settle early: we're MIT; `dav4jvm` is Apache-2.0
and fine, but **verify `ical4android`'s license** before assuming it's usable.
---
## Permissions — only what the mode needs
The manifest is static, so "request only what we need" is really two different
mechanisms, and conflating them is how apps end up over-permissioned:
- **Runtime (dangerous) permissions** — genuinely stageable. Ask at the moment
the feature is used, never up front.
- **Install-time (normal) permissions** — declared unconditionally; the only
lever is **not declaring them until the feature ships**, and not letting a
bundled dependency drag in ones we don't use.
| Permission | When |
|---|---|
| *(none)* for our own provider | same-uid access needs no grant — `ProviderStatus.NEEDS_PERMISSION` must never fire in Local/Synced mode |
| `org.dmfs.permission.*`, `org.tasks.permission.*` | requested **only** when the user selects External mode; declared always (static manifest) |
| `POST_NOTIFICATIONS` | when reminders are first enabled |
| `USE_EXACT_ALARM` / `SCHEDULE_EXACT_ALARM` | when exact due-time reminders are used. Note Play reviews `USE_EXACT_ALARM` and requires the app to be a calendar/alarm/task app — we qualify, but it needs a justification in the listing |
| `INTERNET` | **don't declare it until sync ships** |
| `GET_ACCOUNTS` | never — stripped from the vendored provider; our own account type doesn't need it to see its own accounts |
**Work item:** the permission gate in `RootScreen` / `PermissionViewModel` /
`ProviderResolver.hasPermission` currently assumes an external provider always
needs a grant. It needs a bypass for our own provider. Modest, but it's the
exact flow `fix/provider-interaction-review` just touched — merge that first.
---
## What lands in floret-kit
Standing rule, matching the kit's own thesis (*share the mechanics, keep the
look — the kit never knows about a specific app's domain*): if it isn't
task-domain, it goes to the kit.
| Candidate | Kit module | Note |
|---|---|---|
| ContentProvider seam — `ColumnReader`, failures, observer→Flow | `core-provider` | **already on the kit's deferred list**, blocked on migrating Calendula to the name-based reader. Bundling our own provider is the forcing function that makes this worth doing. |
| Runtime-permission staging — request/state machine, rationale plumbing, "ask at point of use" | new, e.g. `core-permissions` | pure mechanics, and Calendula has the identical problem |
| DAV client + iCalendar parse/serialize | new, e.g. `core-dav` | **the big one.** Calendula is a calendar app; it needs the same primitives. Worth designing for two consumers from the start rather than extracting later |
| Export/backup plumbing — SAF, file writing, share-out | kit | the *serialization* of tasks is domain; the plumbing isn't |
| Sync-adapter/account scaffolding | kit, probably | the `AbstractThreadedSyncAdapter` + authenticator boilerplate is identical everywhere; the delta logic is domain |
**Stays app-local:** the vendored `:provider` module (task-specific, and
Apache-2.0 against the kit's MIT), `TaskContract` and the mappers, domain models
and smart lists, all screens, and the reminder *scheduler* (per the kit's
existing "not shared" call — Agendula pulls, Calendula pushes).
---
## Distribution — F-Droid and Play from day one
Not on either yet; both are targets, so build *for* them rather than retrofitting.
- **F-Droid** requires from-source. The in-tree `:provider` module satisfies it;
a JitPack artifact would not. floret-kit's composite build already keeps the
kit from-source, and the reproducibility guard (`vcsInfo { include = false }`
in `app/build.gradle.kts`) is already in place.
- **Play** requires a rising target-API floor, a data-safety declaration, and
justification for `USE_EXACT_ALARM`. It also means dangerous permissions we
don't use are a liability, not just clutter — which is most of why
`GET_ACCOUNTS` and the stray receiver come out of the vendored provider.
- **Parked, not solved:** dual-distribution signing. F-Droid reproducible builds
verify against *our* signed APK; Play App Signing re-signs with Google's key.
Both can coexist, but it needs a deliberate pass before the first Play upload.
See `RELEASING.md`.
---
## Dead ends — do not revisit
- **One APK that detects at install time and adapts.** Impossible.
`<provider>` authorities and `<permission>` declarations are frozen at build
time and read by the OS at install; there is no install-time hook where our
code runs. And unlike a component, a `<permission>` cannot be runtime-toggled
— no `setComponentEnabledSetting` equivalent.
- **Dynamic feature modules** to deliver the provider conditionally. Conditions
are limited to hardware features / SDK / country — there is no "only if app X
is absent" — and they require Play, so they're dead for F-Droid regardless.
- **`frontend` / `standalone` build flavors.** Two flavors means two
`applicationId`s (two listings, two signing lines, and switching costs a user
their local data), or one `applicationId` and they can't coexist in a repo
anyway. Obsolete now that our provider coexists with everything instead of
replacing anything.
- **Maven Central for the provider.** `org.dmfs:opentasks-provider` *is* there —
but only up to `1.1.8.1` (2016, `jar` packaging, 3 versions). No DB 23.
Verified.
- **JitPack (`com.github.dmfs.opentasks:opentasks-provider:1.4.2`).** Has the
right version, but it's a prebuilt artifact (fails F-Droid from-source), it
can't have its hardcoded permission names renamed, and adding JitPack widens
the dependency trust surface — `settings.gradle.kts` is currently `google()` +
`mavenCentral()` only, under `FAIL_ON_PROJECT_REPOS`. *Still usable for a
throwaway spike* (a library string resource can be overridden from the app
module, so the authority rename works), but not for anything we ship.
---
## Sequencing
1. **Merge `fix/provider-interaction-review`** (`47cf99a`, currently unmerged
into `main`). It's blocking nothing and rotting, and it touches the exact
permission flow step 2 changes.
2. **Vendor `:provider`** under our own authority and permission namespace, with
the permission-gate bypass. This is the identity, done once, done right —
and it ships a complete local-first app to both stores.
3. **Export/backup.** Now a v1 feature, not a migration hack.
4. **File the DAVx5 issue.** Non-blocking, cheap, serves F-Droid users.
5. **Sync adapter.** The 1.x arc; design discussion pending.
**Scope honesty:** the withdrawn notes costed this at "2–4 days shippable, +1
week for F-Droid." Steps 2–5 are a substantially larger program than that, and
the roadmap should say so rather than inheriting the old estimate.
---
## Open questions
1. **Sync protocol coverage**, account model, conflict resolution — the next
discussion.
2. **Resolver ordering / mode selection UX** once our provider coexists with
external ones (see [Storage modes](#storage-modes--the-users-choice)).
3. **Does the vendored provider work with no account at all?** Local-only mode
depends on it entirely. First thing the vendoring work should prove.
4. **`ical4android` licensing** vs our MIT.
5. **jtx Board** as an additional External-mode candidate — richer contract,
later. (`PLAN.md` decision #3, still open.)
---
## Related
The provider-interaction review on `fix/provider-interaction-review` fixed,
among others: recurrence-aware editing (routes through the instances URI),
all-day UTC handling, the `DUE`/`DURATION` collision, per-task reminders (Alarm
property rows), and flow-recovery robustness. That makes `ROADMAP.md` open
decision #5 ("recurrence-aware editing out of scope for v1") **stale**.
+50
View File
@@ -0,0 +1,50 @@
# Official F-Droid submission
`de.jeanlucmakiola.agendula.yml` is the fdroiddata recipe for the official
F-Droid repo. Same model as Calendula: F-Droid rebuilds each tag from source,
checks it is byte-identical to our signed APK (`Binaries`), and publishes our
binary, so official and self-hosted installs share a signature and update each
other. A version that doesn't reproduce is skipped, never published wrong.
## Verified (2026-09-24)
- **From-source rebuild matches the distributed APK.** v0.4.0 rebuilt from its
tag against the published `agendula_v0.4.0.apk`: 136 of 140 entries
identical. The other four were datastore's `libdatastore_shared_counter.so`,
stripped only because the local host had an NDK and CI doesn't. Fixed with
`jniLibs { keepDebugSymbols += "**/*.so" }`; the rebuild now ships them
byte-identical to the published APK. 1.0.0 is the first release with it.
- **Signing block is clean**: v2 signature + verity padding, no dependency
metadata. `fdroid scanner` finds no non-free classes and no extra blocks.
- **All dependencies are FOSS** (no Play Services, Firebase or analytics).
Crash reports are shown to the user and only sent by hand.
- **Committed version equals the tag-derived one**, so the pipeline's
versionCode pin is a no-op and F-Droid building the tag as-is matches.
- **floret-kit** is pinned to a tagged commit on its Codeberg `main`, so the
`submodules: true` checkout resolves from a clean clone.
- App signing cert SHA-256 (`AllowedAPKSigningKeys`) read from the published
APK: `097946b3…af120`. Agendula's own key, not Calendula's.
`scripts/check_reproducible_release.sh` guards all of the above on every PR.
## Listing
Nothing listing-related goes in the recipe. F-Droid harvests
`fastlane/metadata/android/<locale>/` from the tagged source, the same tree Play
is fed from: title, descriptions, icon, feature graphic, screenshots and
`changelogs/<versionCode>.txt`. `title.txt` becomes the app name per locale.
## Submitting
After `v1.0.0` is tagged and `agendula_v1.0.0.apk` is live on the self-hosted
repo:
1. Fork `https://gitlab.com/fdroid/fdroiddata`, copy the recipe to
`metadata/de.jeanlucmakiola.agendula.yml`.
2. `fdroid readmeta && fdroid lint de.jeanlucmakiola.agendula`, then
`fdroid build -v -l de.jeanlucmakiola.agendula` to rebuild and verify.
3. Open the merge request. Later versions are picked up from new `vX.Y.Z` tags
(`AutoUpdateMode`), so there is no per-release work in fdroiddata.
The recipe says `License: MIT`; `:dav` is vendored MPL-2.0 (`dav/PROVENANCE.md`).
If the reviewer asks, change it to `MIT AND MPL-2.0`.
@@ -0,0 +1,44 @@
# Draft fdroiddata recipe for the OFFICIAL F-Droid repository. Submitted as
# metadata/de.jeanlucmakiola.agendula.yml in fdroiddata; see README.md here.
# Not the self-hosted control file (that is ../../fdroid-metadata/).
#
# Reproducible build + developer-signed binary: F-Droid rebuilds from source,
# compares against our APK from `Binaries`, and on a match publishes OUR binary,
# so official and self-hosted installs share one signature.
Categories:
- Task
License: MIT
AuthorName: Jean-Luc Makiola
SourceCode: https://codeberg.org/jlmakiola/agendula
IssueTracker: https://codeberg.org/jlmakiola/agendula/issues
Translation: https://weblate.dev.jeanlucmakiola.de/engage/agendula/
Changelog: https://codeberg.org/jlmakiola/agendula/src/branch/main/CHANGELOG.md
Donate: https://ko-fi.com/jeanlucmakiola
AutoName: Agendula
RepoType: git
Repo: https://codeberg.org/jlmakiola/agendula.git
Binaries: https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo/agendula_v%v.apk
# 1.0.0 is the first release that carries every reproducibility fix
# (scripts/check_reproducible_release.sh); 0.x releases strip datastore's .so
# files host-dependently and won't verify. submodules: floret-kit is an
# included build. No key.properties on the buildserver, so the release build
# comes out unsigned, which is what F-Droid compares.
Builds:
- versionName: 1.0.0
versionCode: 10000
commit: v1.0.0
subdir: app
submodules: true
gradle:
- yes
AllowedAPKSigningKeys: 097946b32c375a8cc81fcdf1985d601c06134b29613d9d8632f59178085af120
AutoUpdateMode: Version
UpdateCheckMode: Tags ^v[0-9.]+$
CurrentVersion: 1.0.0
CurrentVersionCode: 10000