Files
agendula/docs/ROADMAP.md
Jean-Luc Makiola 1d4fe5b301 fix(reminders): arm reminders again in our own store
Regression from deleting the provider. sync() gated on
providerResolver.resolve() != null, and OWN resolves to no provider by
design — so from that commit no due reminder was ever armed in what had
just become the default mode, and clearAll() cancelled any that survived
the upgrade.

The gate is now ProviderResolver.canReadStore(): OWN is always readable,
and only EXTERNAL can fail, for the two reasons it ever could. Putting
the decision on the resolver rather than inside the scheduler is what
makes it testable at all — ReminderScheduler needs Context and
AlarmManager, which is why nothing caught this.

Also brings ARCHITECTURE.md and ROADMAP.md in line with the branch: one
module, OWN/EXTERNAL, the four Room tables, expansion at read time, the
import and startup gate, and the manifest surface that no longer declares
a provider or any permission of its own.
2026-08-13 16:46:31 +02:00

16 KiB
Raw Blame History

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, and how the pieces fit together is in ARCHITECTURE.md.

Status legend: done · 🚧 in progress · not started


Current state (one line)

Agendula now carries its own task store, and it is one we wrote: a Room database designed against VTODO, with recurrence expanded at read time. The vendored dmfs provider and the :provider module that held it are deleted, a v0.3.x install's tasks are imported on first launch, and an external provider (OpenTasks / tasks.org) is a user choice rather than a requirement. Export to iCalendar has landed, 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. Remaining work is hardening the new store (OWN-STORE.md phase 6), the frontend surfaces for what has landed (a storage-mode picker, an export screen), then M6 (Glance widget, translations, F-Droid release) and the sync adapter.


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 (InlineAddquickAdd), 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 0100, 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 (AddSubtaskFieldTaskDetailViewModel.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 (ReminderPickerDialogTaskForm 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 — our own task store

Agendula stopped depending on a provider app being installed. Direction and reasoning in STORAGE-AND-SYNC.md; note it redefined what Posture B means (our own store, coexisting with everything — not squatting org.dmfs.tasks, which is a dead end).

  • fix/provider-interaction-review merged (step 1).
  • Step 2, first pass — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored in-tree as :provider, under de.jeanlucmakiola.agendula.tasks and our own permission namespace. Shipped in v0.3.x and since superseded: see "our own store" below.
  • Storage modes + the permission-gate bypass — ProviderStatus.NEEDS_PERMISSION can no longer fire in our own mode, and an upgrading Posture A user stays on the provider that holds their data (ProviderResolver.autoMode).
  • Export to iCalendar (step 3) — a v1 feature now that own-mode data lives only in our app's private storage. One .ics per list, to a folder or a zip, via SAF. Backend only.
  • Frontend surfaces for the above — a storage-mode picker in Settings and an export screen. The backend is done and unused until these exist.
  • File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users. Note it now means "sync into an app that has no provider", so the ask has changed shape.
  • Sync adapter (step 5) — the 1.x arc. Designed in SYNC.md, not started: mapper → auth → engine → hardening, ~89 weeks, minus the 2.54 weeks owning the store deletes from it (OWN-STORE.md, "Effects on the sync plan"). The account model is settled (AccountManager) and ical4android is closed out (superseded by synctools, GPLv3, so we write the mapper in-house); what's still open is dav4jvm's JitPack-only distribution, conflict policy, and whether External mode survives the milestone.

Our own store — Room, and the provider deleted

The vendored provider was kept because it appeared to hand us the sync bookkeeping for free; the phase-1 sync audit measured that bookkeeping and found most of it broken, absent or unusable. Reasoning in STORAGE-DECISION.md, architecture and six-phase plan in OWN-STORE.md.

  • Phase 0 — occurrences addressed by (taskId, occurrenceStart). Task.id (the materialised instance row id) dropped for occurrenceStart, lazy-list keys moved to Task.occurrenceKey, updateInstance re-signed, domain/ stopped importing TasksContract. Done while the provider was still the store, so the External path exercised the new seam first.
  • Phase 1 — the schema: task_lists, tasks, task_alarms, accounts, a DAO per table, the v1 schema JSON committed for migration testing. Masters and RECURRENCE-ID overrides share the tasks table, so the unique index is (list_id, uid, recurrence_id).
  • Phase 2 — RecurrenceExpander over lib-recur 0.12.2: a series expanded in memory at read time, bounded by a window (1 year back, 2 forward) and a hard per-series ceiling. No materialised instances table, so none of its staleness bugs. 38 tests against RFC 5545 directly, since the provider only ever materialised one occurrence to compare against.
  • Phase 3 — RoomTasksDataSource implements all 14 seam methods, picked per call by ModeRoutingTasksDataSource. Editing one occurrence writes a RECURRENCE-ID override sharing the master's UID (RFC 5545 model (a)), where the provider forked a new task with a new UID (model (d)). Completion rules are stated directly rather than worked around, so a task can no longer strand itself "done at 75%".
  • Phase 4 — OneShotImport moves a v0.3.x install's databases/tasks.db into Room on first launch, archiving the source as tasks.db.imported; OWN is the default; StartupGate holds the first store read until the mode has landed and the import has run; backup rules take the database with its WAL sidecars and the app checkpoints on ON_STOP.
  • Phase 5 — :provider deleted: 84 Java files, 14,555 lines, its <provider>, its two custom permissions and its three dmfs runtime dependencies. StorageMode.LOCAL is gone (a stored LOCAL reads as OWN); ProviderResolver narrows to discovering external providers; ProviderChangeReceiver filters on the two external authorities only. provider/PROVENANCE.md is replaced by a postscript in STORAGE-DECISION.md. Breaking: the de.jeanlucmakiola.agendula.tasks authority and both custom permissions no longer exist — anyone who pointed DAVx5 at that authority loses it, and the release notes have to say so.
  • Phase 6 — harden: MigrationTestHelper wired against the committed v1 schema so v1 → v2 is cheap when sync adds columns, an Auto Backup restore test covering the WAL case in both directions, and a performance check at 5,000 tasks with 20 recurring series.
  • Fallout: ReminderScheduler.sync() gated on resolve() != null, which is what OWN returns, so no due reminder armed in the default mode. It now gates on ProviderResolver.canReadStore(), with tests.
  • Run the instrumented suite on a device. Six classes — the Room seam, the DAOs, the import, the migration harness, the restore path and the performance check — all compile and none has ever executed. Everything load-bearing about this migration is verified only by tests that have not run.
  • Verify on a device: a fresh install on the Room store, and an upgrade from a v0.3.2 APK with seeded data landing every task, list and reminder.
  • Per-locale release notes for the dropped authority and permissions.

Open decisions / to verify

These carry over from PLAN.md §9; resolved ones are struck through.

  1. NameAgendula 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.) Note this is now downstream of SYNC.md open question 3: if External mode is retired once we sync ourselves, the question disappears with it.
  4. Posture B authority choice moot: Agendula publishes no provider and holds no authority at all. The question was live while the store was a vendored provider, and squatting org.dmfs.tasks was a dead end even then — two apps cannot declare the same authority or permission name, so anyone with OpenTasks installed could not have installed Agendula at all.
  5. Recurring tasks — recurrence-aware editing out of scope for v1 resolved: our own store expands a series at read time and writes an edit to one occurrence as a RECURRENCE-ID override sharing the master's UID; in External mode the edit still goes through the instances URI.
  6. Resolver ordering / mode-selection UXautoMode() picks a sane default today (see ARCHITECTURE.md §4.1); the Settings override it assumes is not built yet.
  7. Sync protocol coverage, account model, conflict resolution — the next design discussion. Taken up in SYNC.md; the remaining opens live on that document's list.

How to contribute / verify

  • Build: ./gradlew :app:assembleDebug
  • Unit tests: ./gradlew :app:testDebugUnitTest
  • Instrumented tests: ./gradlew :app:connectedDebugAndroidTest — the Room schema, RoomTasksDataSource and OneShotImport (the last against app/src/androidTest/assets/tasks-v23.db, regenerated by scripts/make_import_fixture.py).
  • Any device or emulator will do for the default path: the store is ours and needs nothing installed. Debug builds seed an "Agendula Demo" list via DemoSeeder unless it already exists. To exercise External mode, use a device with OpenTasks or tasks.org installed, and ideally DAVx5 syncing a CalDAV task list.