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:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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**.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user