The document was written against the vendored provider and still said the
storage question was settled that way. Owning the store answered several
of its open questions and deleted others outright, so the corrections are
marked inline the way the rest of the file marks them, rather than
quietly rewritten.
Closed: the Local->Synced migration (account_id is a nullable FK, so
attaching an account is an UPDATE), the recurring-completion model (the
store writes model (a), RECURRENCE-ID overrides sharing the master's
UID), the Auto Backup / cleanUpLists data-loss path, and the lib-recur
version trap. Phase 0's UIDs-at-creation and backup safety are shipped.
The provider-mechanism table is kept as External-mode history rather than
deleted — that code still runs in OpenTasks and tasks.org.
Effort restated: 11.5-15 weeks minus the 2.5-4 owning the store removes,
so roughly 8-11. Also states plainly at the top that no sync code exists
and that ICalendarWriter is the export half of the mapper only.
The store picker and the export screen were the two frontend surfaces
the own-store work left unbuilt, so both backends shipped unreachable.
Settings gains a Storage section holding them: a full-screen picker over
Own / an installed external provider (dimmed when none is present, named
after the provider's own app), and an export screen with a per-list tick
and the two SAF destinations, a folder or a single zip. The picker asks
for the provider's runtime permission before writing the mode, so a
denial leaves the readable store in place instead of dropping the user on
the gate; a refusal is reported with a route to app settings.
Making the mode switchable at runtime had two consequences:
- reminders are armed off whichever store was active when they were
scheduled, so a switch rebuilds the set. ReminderScheduler.sync() is
now serialised — it is a read-modify-write over ScheduledReminderStore,
and overlapping runs each wrote their own set as the whole truth
- the permission gate is the only screen an External user can reach once
their provider app stops answering, so it offers the way back to our
own store
ExportWriter no longer deletes a previous export before recreating it (a
failure in between lost both), lists the target directory once instead of
per document, and carries a typed ExportFailure so the screen can report
in the user's language rather than an exception message.
The file is machine-specific rather than anything the project depends on:
the ARM64 box64 `aapt`/`aapt2` wrappers it documents live outside the repo,
and the rest is on-device working rules. Nothing in the tree links to it.
It stays on disk and is now ignored, so it keeps working locally without
riding along in the branch.
Owning the store left a fresh install with no lists and no way to make
one, so no way to save a task. The seam gains updateList/deleteList
beside createLocalList on both paths — the External one addresses the row
as its own account's sync adapter, the only caller the provider lets
write tasklists. ListEditorSheet is the family's full-screen sheet: name
field, a 12-colour palette, and a destructive row behind a confirm when
editing. Entry points are a "New list" row under the home Lists section,
an empty state with a create button, and the home FAB switching to "New
list" while there are none. Deleting takes the list's tasks with it and
is offered only for device-only lists.
Four defects a review of the branch turned up:
- completing one occurrence closed the whole series — setCompleted wrote
the master, the row TaskDao.tasks filters on. setCompletedInstance
forks a RECURRENCE-ID override the way updateInstance does; phase 2
always specified this, only the edit half had it
- the expansion ceiling was spent on the past, so a sub-daily series
stopped expanding months before today and never reached Today or
Upcoming
- an imported START-referenced reminder fired off DUE, because the seam
collapsed alarms to a bare minute count. TaskReminder carries the
anchor now
- registerObserver bound a live flow to whichever store was active at
subscription, so a Settings store switch left every screen listening to
the store it had stopped reading
- forking an occurrence copied the master's alarm row id and hit the
primary key; replaceForTask now clears it
- a sub-second DTSTART made lib-recur emit the anchor and its truncated
self, doubling a series' first occurrence; floor to the second, which
is all RFC 5545 DATE-TIME carries
- room-testing needs kotlinx-serialization 1.8+, but consistent
resolution pinned androidTest to the app's 1.7.3
Tests: 52 pass on device.
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.
Phase 6 of docs/OWN-STORE.md. MigrationTestHelper is wired against the
committed v1 schema, so the first real migration only has to add its own
case; the class KDoc says where it goes. app/schemas/ is added to the
androidTest assets — the schema location comes from the KSP arg, not the
Room Gradle plugin, so nothing wired the test assets automatically.
The restore tests state the WAL premise directly rather than around it: a
backup of the .db alone must lose whatever is still in the -wal, carrying
the sidecars must keep it, and checkpointing first must make the .db
alone sufficient. If the premise is wrong the first test fails instead of
passing vacuously.
Performance: 5,000 tasks and 20 FREQ=DAILY series — daily on purpose, so
the per-series occurrence cap is the case being measured — through one
full smart-list read. The ceiling is loose and the numbers are printed,
because nobody has run this on hardware yet.
Also fixes a lint error I introduced in the backup rules two commits ago.
Naming any <include> makes everything else excluded by default, so the
<exclude> for tasks.db.imported sat under no included path and
FullBackupContent rejected it — lintDebug has been failing at HEAD since,
and CI runs it.
The same defect had a second, quieter half: those explicit includes had
silently stopped DataStore being backed up at all, since it was only ever
covered by the old file's "everything by default". Settings are listed
back in explicitly.
Phase 5 of docs/OWN-STORE.md. The :provider module goes — 84 Java files,
14,555 lines, its <provider>, its two custom permissions, its 13
translated strings and its three dmfs runtime dependencies. Room has been
the default since the previous commit and every v0.3.x install has been
imported, so nothing reads it any more.
StorageMode.LOCAL is gone with it; OWN and EXTERNAL are what remain.
ProviderResolver narrows to what it was always really for — discovering
external providers — and answers null in OWN mode, where there is no
authority to resolve. Callers that need to tell that apart from "External
with nothing installed" ask mode(). ProviderStatus is unconditionally
READY in OWN mode: the permission gate only ever applied to External, and
that is now visibly true rather than a special case inside it.
A stored LOCAL is read as OWN rather than as an unparseable value. Left
to fall through to autoMode, someone who had explicitly chosen local
storage while also having OpenTasks granted would have been sent to
OpenTasks instead.
ProviderChangeReceiver's manifest filter drops our own authority — safe
now, because nothing of ours broadcasts ACTION_PROVIDER_CHANGED. In OWN
mode Room's InvalidationTracker covers foreground changes and nothing
outside the app can change our data. When SYNC.md phase 3 lands, the sync
worker must call ReminderScheduler.sync() itself; that is the replacement
for the broadcast and it belongs in the sync work.
lib-recur stays as a direct dependency and is still Apache-2.0 dmfs, so
the attribution is still owed — now as a normal third-party dependency.
provider/PROVENANCE.md is replaced by a postscript in STORAGE-DECISION.md
recording that the fork existed, why, and the one detail that still binds
us: tasks.org is DB 22 and has no is_recurring, so TaskMapper must keep
deriving recurrence from rrule/rdate.
BREAKING: the de.jeanlucmakiola.agendula.tasks authority and both custom
permissions are gone. Anyone who pointed DAVx5 or another app at that
authority loses it; External mode is the answer. Needs calling out in the
release notes.
Verified: the APK declares no ContentProvider, no custom permission and
no agendula.tasks authority, and carries no dmfs provider classes.
Phase 4 of docs/OWN-STORE.md. OneShotImport reads databases/tasks.db
directly — read-only, no provider, no ContentResolver — and writes it
into Room in one verified transaction. dmfs row ids are remapped in two
passes, because a parent can carry a higher _id than its child.
The archive happens before the import, not after, and the import always
replaces. That is what actually closes the crash window the plan's "flag
*and* rename" is meant to cover: renaming last leaves the flag unset with
tasks.db still in place, so the next launch imports a second copy. In
this order every kill point re-enters correctly.
Recurrence overrides are carried across as master_id/recurrence_id rather
than ignored. dmfs stores them as ordinary rows sharing their master's
_uid, so importing one as a second master would collide on the unique
index and abort the whole import.
autoMode now answers OWN, and a stored LOCAL reads as OWN — after the
import the dmfs file has been renamed away, so someone who chose local
storage explicitly must land on the store their data is now in.
StartupGate holds the first store read until the mode has landed and the
import has run; showing an upgrading user an empty app is the worst thing
this migration could do. The backup rules take the database with its WAL
sidecars and exclude the archive, and the app checkpoints on ON_STOP.
Joins phases 1 and 2 and covers phase 3's semantics. RoomTasksDataSource
implements all 14 seam methods; a StorageMode-routing delegate picks it
or the provider per call, since the mode is a setting the user can change
while the process lives.
There is no instances table, so a series is expanded at read time by
RecurrenceExpander and any RECURRENCE-ID override is substituted for the
occurrence it replaces. A timed series carries each occurrence's length
across; a due-anchored one has no start to offset from, so the anchor is
the due date — matching how the provider instantiated the same series.
Editing one occurrence writes a RECURRENCE-ID override sharing the
master's UID (RFC 5545 model (a)). The provider's Detaching.java forked a
brand-new task with its own UID instead — model (d), the one least
compatible with CalDAV. We inherited that without ever choosing it; this
is the choice.
TaskFormWriter states the completion rules directly instead of working
around the provider: progress and status now move together in both
directions, so a task can no longer strand itself "done at 75%".
TaskWriteMapper keeps the workarounds for External mode.
Deletes are hard when the list has no account and tombstones when it
does; master_id cascades, so a deleted series takes its overrides.
Phase 2 of docs/OWN-STORE.md, the engine half. RecurrenceExpander turns a
stored rule set into its occurrences at read time — no materialised
instances table, so none of its staleness bugs exist. Each occurrence is
returned as its RECURRENCE-ID anchor, which is what the seam now
addresses occurrences by.
Expansion is bounded two ways: the window end, and a hard occurrence
ceiling. The iterator is fast-forwarded to the window start first, so a
FREQ=MINUTELY series anchored years back doesn't scan millions of
instances to emit one.
Three things lib-recur 0.12.2 forced. RecurrenceSet.iterator injects the
start itself, so DTSTART is in the set for free and EXDATE can remove it
(RFC 5545 §3.8.5.3). Its window end is exclusive. And a floating UNTIL
against a zoned start throws, so the UNTIL's local fields are re-read in
the series zone — the vendored provider worked around the same thing via
TimeZone.getDefault(), which isn't deterministic.
Malformed RRULE/RDATE/EXDATE values are dropped, not thrown: a task with
an unparseable stored rule still has to appear.
38 tests. Multi-occurrence expansion has no provider behaviour to compare
against, so the reference is RFC 5545 directly — daily/weekly/monthly/
yearly, COUNT, UNTIL, a Europe/Berlin DST boundary, all-day series pinned
to UTC midnight, RDATE, EXDATE, and an unbounded rule hitting both bounds.
Phase 1 of docs/OWN-STORE.md. Four tables — task_lists, tasks,
task_alarms, accounts — with the indices, cascades and converters the
plan specifies, plus a DAO per table and the v1 schema JSON committed for
migration testing.
Masters and RECURRENCE-ID overrides share the tasks table, so the unique
index is on (list_id, uid, recurrence_id): an override shares its
master's UID, and a key without recurrence_id would reject exactly the
rows recurrence depends on. SQLite treats NULLs as distinct, so that
index only enforces the override half; the master half is intent, noted
where the index is declared.
PRIORITY is stored as the raw iCalendar integer rather than through the
Priority enum. Priority buckets 1..4 into HIGH, so a converter would
rewrite a server's PRIORITY:3 as 1 before it ever reached disk — the
bucketing belongs in the mapper. Status keeps its converter: that mapping
is total.
Two cascades the plan left unstated: deleting a list takes its tasks,
deleting an account only detaches its lists.
Instrumented tests cover read-back, the cascades and the unique index —
app/src/androidTest is new.
The own store expands recurrences itself, so lib-recur is a direct
dependency now rather than something :provider drags in. Still pinned at
0.12.2 — 0.16.0 removed RecurrenceSet.
scripts/make_import_fixture.py writes the tasks.db the one-shot import
will be tested against: the provider's DATABASE_VERSION 23 schema, seeded
with the cases the import has to get right (a task with no UID, a deleted
row, a recurring series, an all-day task, a subtask, an alarm property,
and a list under a real CalDAV account). The provider is being deleted, so
a fixture is the only way to keep testing against the schema it wrote.
Phase 0 of docs/OWN-STORE.md. Prepares the seam for the Room store while
the provider is still the store.
Task.id (the materialised instance row id) is gone; Task carries
occurrenceStart, its RECURRENCE-ID anchor, instead. updateInstance takes
(taskId, occurrenceStart, form) and AndroidTasksDataSource maps that back
to an instance row itself, so the provider path exercises the new
signature before Room exists.
Lazy-list keys move to Task.occurrenceKey. Two occurrences of one series
can appear in the same list once expansion is ours, and taskId alone
would collide there.
domain/Models.kt stops importing TasksContract — status, priority and
local-account constants now live in domain. StorageMode gains OWN as a
third value; LOCAL keeps meaning the dmfs provider until it is deleted.
Room 2.8.4 and room.schemaLocation added to the build.
Reviewed the plan against the code it describes. Two design holes and
five errors.
Instance identity was the real one. The plan deleted the materialised
instances table without saying what replaces the instance row id, which
TasksRepositoryImpl.updateTask passes to updateInstance and which
ListsScreen keys a lazy list by. Two occurrences of one series can
appear in the same list, so taskId alone is not unique and a hash of
(taskId, start) can collide - as a Compose key that is a visible bug.
Task.id is dropped for occurrenceStart, updateInstance takes
(taskId, occurrenceStart, form), and External mode maps back to a real
instance row with one query. This is the single seam change, and the
plan's "TasksDataSource unchanged" claim was wrong.
Local lists had no account name. TaskList.accountName is non-null,
ListsViewModel groups by it and ListsScreen renders it as a section
header, so a null account_id must still report "Local".
The unique index was wrong: overrides share their master's UID, so
unique (list_id, uid) would reject the rows the recurrence design
depends on. It needs recurrence_id in the key.
Phase 0 broke background reminders. It dropped our authority from
ProviderChangeReceiver's manifest filter while the provider was still
the store, and renamed StorageMode.LOCAL to OWN four phases before OWN
meant Room. Both moved to phase 5.
Parity against the provider was overclaimed: the provider materialises
one occurrence, so multi-occurrence expansion has nothing to compare
against and is tested against RFC 5545 directly.
The phases sum to 6.5-7 weeks, not the 6-6.5 stated, and the difference
from STORAGE-DECISION.md's 4.5-6 is now explained rather than left as a
contradiction.
Gaps closed: WAL vs Auto Backup (checkpoint on ON_STOP, sidecars in the
backup rules, tested in phase 6), cascade rules for master_id and
parent_id, Instant type converters, a rollback path that re-runs the
import from tasks.db.imported, the release note for dropping the
authority and its permissions, and ICalendarWriter.uidFor's synthesis
branch becoming External-only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vendored dmfs provider was kept on the grounds that it hands us the
sync bookkeeping for free. The phase-1 sync audit measured that
bookkeeping and found most of it broken, absent, or unusable: _DIRTY not
set on delete, no home for a per-collection sync token, read-only
collections inexpressible, ACCOUNT_TYPE write-once so enabling sync is a
full migration, and cleanUpLists able to delete a user's lists after a
backup restore. Sixteen findings are provider-imposed rather than
platform- or protocol-imposed.
Costing the alternative showed the swap is far smaller than assumed.
TasksDataSource is already a 14-method, domain-shaped interface;
exactly one file above the data layer references TasksContract. The
work is a second implementation behind an interface built for it, not a
rewrite. Against ~5 weeks to build, owning the store removes 2.5-4
weeks from the sync plan, and 8,200 of the vendored 14,555 lines are
things we would never write - 23 migrations from a 2013 schema, 798
lines of full-text search the app has zero call sites for, and 1,581
lines of a type-safe layer over ContentValues that Room deletes.
External mode (OpenTasks, tasks.org) is unaffected and keeps every
file that describes somebody else's schema.
STORAGE-DECISION.md is the reasoning; OWN-STORE.md is the architecture
and the six-phase plan. :provider stays in-tree until phase 5 so
recurrence parity can be tested against it before it goes.
Also corrected here: the provider's JVM test count (51 -> 56, measured
from the test-results XML) and a fourth site of the debunked "switching
sync on is never a migration" claim, in StorageMode.kt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
STORAGE-AND-SYNC.md asked for a follow-up pass on ARCHITECTURE.md §7 and the
ProviderResolver KDoc, which still defined Posture B as "bundle OpenTasks and
find org.dmfs.tasks first" — the plan that was withdrawn as a dead end. That pass,
plus the status the doc left open.
ARCHITECTURE.md now describes the app as built: two modules, the storage-mode
table with the permission each needs, the autoMode rule and why it keys on
holding an external provider's permission, the two-not-three mode vocabulary, and
a manifest section that says what :provider contributes and what is deliberately
absent (GET_ACCOUNTS, INTERNET). §7 records squatting the dmfs authority as a
dead end rather than a road not yet taken, so it doesn't get re-proposed.
ROADMAP.md turns "Posture B, later" into what actually landed and lists what
didn't: the frontend surfaces, the DAVx5 issue, the sync adapter, and device
verification. Two open decisions resolved and struck through — the authority
choice, and recurrence-aware editing, which fix/provider-interaction-review made
stale.
STORAGE-AND-SYNC.md gets per-step status. Open question 3 ("does it work with no
account?") is answered, with the caveat that the test proving it is Robolectric
and skips on ARM64 — answered by construction, not yet on a device.
PLAN.md gets a banner. It's the original design document and still holds the
reasoning behind the layering, but two of its premises are overturned and it
should not be read as current.
README.md was telling users they need a tasks provider installed. They don't, and
that's the headline feature: a table of where tasks can live, that our provider
coexists with OpenTasks rather than replacing it, and that everything exports as
standard .ics.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 3 of docs/STORAGE-AND-SYNC.md. Now that our own provider holds the data in
the app's private storage, a Local-mode user's tasks exist in exactly one place
and uninstalling deletes them — on Play, where most people will never have a sync
engine, that is the majority case. So export is a v1 feature, not a nicety.
One .ics per list, because a list is a CalDAV collection and that is the unit
other clients understand; folding everything into one file would flatten the
lists away, and list membership is not recoverable from a VTODO afterwards.
ExportWriter can put them in a folder (ACTION_OPEN_DOCUMENT_TREE) or a single zip
(ACTION_CREATE_DOCUMENT). No storage permission either way — SAF hands us a Uri
the user picked.
Two things needed care:
Export reads the tasks table, not the instances view the rest of the app reads
from. In the instances view a recurring task appears once per occurrence with its
times resolved and no rule attached, so exporting from there would write the same
task fifty times and lose the RRULE that generated them.
And local tasks have no UID. The dmfs provider only lets a sync adapter assign
one, so in Local mode every task arrives with _uid null — and a VTODO without a
UID is both invalid and un-mergeable, meaning a re-imported backup would
duplicate every task rather than match it. ICalendarWriter synthesises one from
the row id, stable across exports and tagged so it is recognisable as synthetic.
Times go out in UTC rather than with a TZID. Emitting TZID obliges us to emit a
matching VTIMEZONE with its transition rules, and a TZID referencing an absent
definition is what actually breaks importers. All-day values keep VALUE=DATE, the
only form that survives a timezone change intact.
The writer is pure Kotlin with no Android in it and is covered by 40 tests —
line folding counted in octets and never splitting a UTF-8 sequence, TEXT
escaping, forward references from a subtask to a parent later in the file, and
CRLF endings. An export is only as good as its ability to be read back, and
nothing about a malformed .ics is obvious until someone needs the backup.
The SAF plumbing is marked in the storage doc as floret-kit material. Kept
app-local for now on the kit's own stated principle of not extracting before a
second consumer exists; the seam is in place, so moving it is a file move.
Backend only — no UI yet; that comes with the frontend pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Steps 2 and 3 of docs/STORAGE-AND-SYNC.md. Agendula stops depending on a tasks
provider app being installed: it now carries one.
The module
New :provider — the dmfs task provider 1.4.2 (Apache-2.0, DB 23), vendored
in-tree, renamed to authority de.jeanlucmakiola.agendula.tasks and permissions
de.jeanlucmakiola.agendula.permission.*. It coexists with OpenTasks and
tasks.org rather than replacing them; nothing collides with org.dmfs.*, so both
can be installed at once. The contract shape is untouched — same tables, same
columns — because that is what our data layer and every CalDAV engine already
speak. We own the namespace it lives in, not the schema.
Vendored rather than depended on because the permission names are hardcoded in
the upstream AAR's manifest and cannot be renamed in a prebuilt artifact; in-tree
also satisfies F-Droid's from-source rule. provider/PROVENANCE.md records the
upstream commit and every deviation, each marked with an AGENDULA CHANGE comment
at the site so the list and the code cannot drift apart.
The change that matters most is the account cleanup. Upstream holds GET_ACCOUNTS
and deletes any task list whose account it cannot see. We dropped that permission
— we only ever need our own accounts, which are visible without it — but an
account we cannot see is indistinguishable from one that was removed, so left
alone the provider would quietly delete synced lists. Cleanup is now restricted
to account types this package authenticates itself, which is currently none.
ProviderAccountCleanupTest pins that, and answers open question 3: the local path
works with no account present at all.
Also required by targetSdk 36, none of which upstream faced at 29:
FLAG_IMMUTABLE on the notification PendingIntent, an inexact-alarm fallback so a
revoked SCHEDULE_EXACT_ALARM cannot kill the app on a timezone change, and an
explicit android:exported on the receiver.
Storage modes
ProviderResolver gains a StorageMode: LOCAL (our provider) or EXTERNAL (an
installed one). Not a third SYNCED value — synced is LOCAL with an account
attached, which is derived state, and modelling it as a separate store would
imply switching sync on is a migration. It isn't.
When the user has not chosen, the tell is whether we already hold an external
provider's runtime permission. That permission is dangerous-level, so it can only
be there because an earlier version asked and they agreed — the signature of an
existing Posture A user, who must not be dropped onto an empty store. Fresh
installs get local-first.
hasPermission now short-circuits for our own provider: same-uid access bypasses
the check outright, so ProviderStatus.NEEDS_PERMISSION can no longer fire in
Local mode. That was the work item the storage-and-sync doc called for. The
resolver's platform calls moved behind ProviderEnvironment so the decision — the
part that loses people their data if wrong — is unit-tested on the JVM.
Verified: 51 vendored provider tests pass, app tests pass, lintDebug and
assembleDebug clean. ProviderAccountCleanupTest skips on ARM64, where Robolectric
has no SQLite backend, and runs on x86_64 CI. Not yet exercised on a device.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrects the tasks-provider interaction end to end (see 47cf99a). Merged as
step 1 of the storage-and-sync sequence in docs/STORAGE-AND-SYNC.md: it touches
the same permission flow that the :provider vendoring is about to change, so it
lands first.
The floret-kit submodule pointer keeps main's e047a2b, which already contains
the branch's 396e538.
# Conflicts:
# app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt
Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline. Ports the setup Calendula already runs on, adapted where Agendula genuinely differs.
**This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home.
### 1 · Workflows split by directory
Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync:
| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them |
The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API.
CI also gains three fixes: an explicit `SKIP_RE` skip-list that names the build-relevant files in the log, base-ref normalisation, and a fully-qualified `android-actions/setup-android` — Codeberg resolves bare `uses:` refs against `data.forgejo.org`, which does not carry that action.
### 2 · Three release-pipeline safety changes
- `detect` and the Renovate job get an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running these on the contributor-facing runner, without secrets.
- `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not.
- **The Codeberg publish step pushes the tag itself** instead of waiting for it to arrive by mirror. That wait was correct while Gitea mirrored *to* Codeberg; under Codeberg-canonical the mirror runs the other way and it would never resolve. Attaching the release to an already-pushed ref (no `target_commitish`) is what avoids the empty-bodied 500s, and the create call retries with backoff because Codeberg 500s on a tag it has only just received. The step stays **fail-loud**, not `continue-on-error` — it reported green through 0.2.1–0.3.2 while never once publishing, and that must not be possible again.
### 3 · Renovate
`renovate.json5` plus a Gitea-side job targeting Codeberg's API. `managerFilePatterns` covers **both** workflow directories, so the pinned Renovate image tag and the action versions in either file keep getting bumped.
Needs two new Gitea secrets: `RENOVATE_TOKEN` (Codeberg bot, repo read/write + PR scope) and `GITHUB_COM_TOKEN` (read-only github.com PAT, for changelog lookups).
### 4 · Weblate
A parity check (`scripts/check_translations.py`) runs on every PR without a path filter, so the required `Translations` status is always reported. Partial translations are expected, so `MissingTranslation` and `MissingQuantity` become informational — `ExtraTranslation` stays fatal. Agendula had no `lint` block at all, so the first locale to land would otherwise have failed the build.
**Settings → App language** now opens a picker carrying a "Help translate" header. That is why it drops floret-kit's `LanguagePickerRow` for a local row: the shared recipe has no `header` slot, and the framing is app-specific rather than a family primitive.
### 5 · Links repointed
In-app Source / License / report-issue URLs, F-Droid metadata, README (now with a Codeberg CI badge), and the docs.
`floret-kit` follows suit — `.gitmodules` points at `codeberg.org/jlmakiola/floret-kit`, so a clone no longer needs to reach the personal Gitea instance to resolve it. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases still resolve.
### 6 · Housekeeping
Drops `release-notes.md` — a release-pipeline scratch file that got committed — and gitignores the five others the release job writes into the workspace.
### Not in this PR
The Codeberg → Gitea push mirror, the Weblate component, and the Codeberg bot account (all browser-side). Until the mirror is flipped, merging this does **not** reach the Gitea runner.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/2
A review of every path through the OpenTasks/tasks.org ContentProvider,
prompted by edited due times reverting. Four independent defects produced that
one symptom, plus several unrelated ones alongside.
Edits reverting
- The edit form was bound from a LaunchedEffect in the nav host while its
ViewModel survives on the back stack, and bindEdit replaced state wholesale.
MainActivity declares no configChanges, so any Activity recreation (rotation,
theme/font/display-size change, split-screen, unfolding) re-fired the effect
and overwrote in-progress edits with the stored row. Guarded with a `bound`
flag; picker state moved to rememberSaveable so an open picker also survives.
All-day handling
- All-day items are date-only in iCalendar and belong at UTC midnight with a
null tz. The app wrote *local* midnight, so in Berlin an all-day task drifted
back a day on every save cycle, corrupting anything synced. Rendering had the
mirror bug, so the two cancelled out locally and hid each other.
- Toggling the all-day switch flipped the flag but left the timestamp, so an
all-day task toggled off read back as 02:00 — another apparent "time reset".
- New domain/AllDayTime.kt owns the two conventions and the conversion between
them; the picker, the write mapper and the toggle all go through it.
Provider write contract
- DUE and DURATION are mutually exclusive and the provider validates the merged
row, so saving a due date onto a task that carried a duration threw
IllegalArgumentException — the save simply failed. DURATION is now cleared
alongside every time write.
- A recurring task's start/due are read from the instances view, and writing
them back to tasks/<id> re-anchored the whole series. Updates now go through
instances/<id>, where the provider forks an override instead.
- Recurrence is derived from rrule/rdate rather than the is_recurring column:
that column only exists from OpenTasks 1.4.0 (DB 23) and is absent on
tasks.org's bundled provider (DB 22), where it would report every recurring
task as one-off and send its edits to the anchor.
Reminders
- The per-task Reminder field in the edit form was inert: never persisted,
never read back, and REMINDER_WITHOUT_DUE could block a save over a value
that was discarded regardless. Leads are now stored as Alarm property rows
and preferred over the per-list/global setting. Written before the task
update so a recurrence fork copies them onto the override. Note the provider
fires nothing itself — ReminderScheduler still arms the alarm.
- Reminders were keyed by task id over rows read from the instances view, so
.toMap() collapsed a recurring task to one arbitrary occurrence (the query is
unsorted). Now keyed per occurrence, with request codes and intent data to
match. Missed reminders within 6h fire once on boot instead of being dropped.
Robustness
- Four terminal `catch`es killed their upstream on the first provider failure.
SettingsViewModel is collected in setContent above the permission gate for the
Activity's lifetime, so a pre-grant SecurityException left the list picker
empty until the process restarted. Replaced with capped-backoff retry.
- lazyChildren had no catch at all; an exception escaped stateIn past
viewModelScope's SupervisorJob and crashed the process.
- Observer registration is all-or-nothing (the second register throwing leaked
the first), ProviderChangeReceiver validates action and authority and
debounces, and the permission gate re-checks on resume.
Also drops the unused DateTimeField composable and the stale INSTANCES
projection, which omitted the recurrence columns the mapper now depends on.
Bumps floret-kit to pick up the matching all-day formatting fix.
Verified by unit tests (43 app, 15 core-time) and a clean assembleDebug; the
provider interaction itself has not been exercised on a device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cb-payload.json and cb-response.json are generated at runtime by the
release workflow; they were committed by accident from a local run and
still held stale v0.3.1 data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Codeberg publish has never once succeeded — 0.2.1, 0.2.2, 0.3.0, 0.3.1
and 0.3.2 all failed, and 0.3.0 was published by hand. Neither previous fix
could have worked, because the cause isn't in this file: ref writes to
jlmakiola/agendula on Codeberg fail. A tag push returns "cannot lock
references" and POST /tags returns an empty-bodied 500, while the identical
calls succeed on jlmakiola/calendula with the same token. Creating a release
mints a tag, so it inherited the same failure.
Attaching a release to a tag that is ALREADY there needs no ref write and
returns 201. So split the responsibility the way it should have been: the
push mirror owns delivering the tag, this step only attaches to it. Poll for
the tag, then POST with no target_commitish so the API attaches rather than
resolves a commit and mints one. If the tag never lands, fail with a pointer
at the mirror instead of trying to create it.
Drop continue-on-error. It reported green across five releases that never
published, which is how 0.3.1's crash fix reached F-Droid while the Codeberg
and Obtainium users who hit the crash got nothing.
Tested against the live API before committing (unlike its predecessors):
tag-present attaches 201, tag-absent exits 1 with the diagnostic. Note this
does not by itself restore publishing — the mirror cannot write the v0.3.2
tag either, so Codeberg must repair the repo's ref store first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No app-code change from 0.3.1: this exists to re-run the release pipeline
now that the Codeberg publish step is fixed. 0.3.1's APK reached F-Droid but
its Codeberg release 500'd, so anyone installing from Codeberg or Obtainium
is still on the crashing 0.3.0 — cutting 0.3.2 gets them the fix and proves
the workflow fix in the only way that counts, a real release.
Chosen over hand-patching the 0.3.1 release onto Codeberg: same outcome for
users, minus a manual APK upload, and it actually exercises the pipeline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revert 4aa65ed's approach. It read the Forgejo 500 as being caused by
target_commitish, and pushed the tag to Codeberg first so the release could
attach to an existing tag without one. That inverted the actual failure:
Forgejo 500s on POST /releases precisely WHEN the tag already exists, so
pre-pushing it guarantees the error it was meant to avoid.
The second half then can't recover — a bare tag is not a release, so
GET /releases/tags 404s and the upsert has no id, which is the
"Could not resolve Codeberg release id" that ended the 0.3.1 run.
0.3.0 published fine because it POSTed while the tag was still unsynced and
let the API mint tag + release together. Do that deliberately instead of by
luck: if no release owns the tag, delete the mirrored tag, then POST with
target_commitish. Branches mirror reliably (main was already at the release
commit when 0.3.1 failed), so the commit is present and only the raced-in
tag was in the way. Deleting it is safe exactly because no release owns it.
An existing release still takes the PATCH path and skips the delete, so
re-runs never disturb something already published.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R8 kept androidx.work.impl.WorkDatabase_Impl but pruned its no-arg
constructor: nothing calls it directly, Room only reaches it reflectively.
Room then threw InstantiationException, surfacing as "Failed to create an
instance of androidx.work.impl.WorkDatabase". WorkManager builds that
database from a startup ContentProvider, so 0.3.0 died before any of our
code ran — every install, every launch.
We don't depend on WorkManager directly; it arrives via Glance. AGP 9's
stricter R8 is what tipped this over, which is why 0.3.0 was the first
release to hit it.
Keep the Room no-arg constructor, and the ListenableWorker constructor
alongside it — same pruning hazard on the path WorkManager uses to
instantiate workers by name, which would have bitten once a Glance widget
update actually ran.
Reproduced the reporter's stack trace frame-for-frame on a releaseTest
build, then confirmed it launches clean afterwards. Fixes #1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first fix didn't help: the pipeline creates the tag via the Gitea
API, and the push mirror (sync_on_commit only fires on real git pushes)
doesn't propagate an API-created tag promptly. So the Codeberg release
POST still raced the mirror and 500'd on a commit/tag Codeberg hadn't
received (0.2.1 and 0.3.0 both shipped everywhere but Codeberg).
Push the tag straight to Codeberg from the runner (guaranteed present),
then attach the release to that existing tag with no target_commitish —
which is what Forgejo 500s on. Race-free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ships the M5 reminders onboarding + Settings screen alongside the reworked
overview (Today progress ring, live Upcoming preview, unfurling search) and
the real launcher mark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The press animation turned the glyph; put it on the scalloped cookie
container instead and make it stronger (scale 1→0.82, rotate 0→40°). The
glyph holds upright via a counter-rotation, so the shape spins while a
magnifier or list icon stays readable. New spinIcon opts a glyph into its own
quarter turn — the settings gear uses it, so it reads as a gear cranking.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the top bar into a custom row with no app title (the launcher icon
already names the app). Settings stays pinned at the right and the search
action sits just left of it; neither moves. Tapping search unfurls a pill
leftward from the magnifier (expandHorizontally anchored at the end) holding
the auto-focused query field and a clear button, with the search icon
remaining as the bar's fixed trailing icon. Results render over the home
content as you type; a blank query leaves the home screen visible. Tapping
the icon again or system back closes search; the FAB hides while searching.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first pass shifted the mark off-centre. Scale 0.66 about the canvas
centre, and centre the task CARD (not the card+bloom bounding box, which the
overhanging bloom badge drags low): pivot the Y-scale at the card's centre
(y=242.76) and translate +13.24 so the card sits dead-centre, horizontally
and vertically, with the bloom badging out to the lower-right.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The inline search bar was permanently visible. Replace it with a search
action button (a 6-sided cookie shape, sibling to the settings cookie) to
the left of settings: the search bar is absent until tapped, then opens
expanded and auto-focused, covering the home content with live results.
Back arrow or system back closes it; the FAB hides only while searching.
Pass windowInsets = 0 so the bar, already below the app bar, does not
re-apply the status-bar inset and float with a large top gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the placeholder check-mark foreground with the finished agendula
mark (converted from design/icon/agendula_icon.svg): a rounded line-art task
card with a check, plus a small Calendula bloom badge in the open
bottom-right corner — the sibling of Calendula's calendar mark.
Strokes render in Calendula's off-white (#FAF6F0) over agendula's existing
plum background (#7A5C6B, the hue-rotated counterpart of Calendula's slate),
so the two apps read as a family while staying distinct. Scaled 0.66 to
match Calendula's footprint and ~2.8dp stroke weight; reused as the
<monochrome> slot for themed icons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Material 3 SearchBar overlaying the top of the overview. Collapsed it
is a "Search tasks" bar below the title row; tapping expands it in place to
cover the home content with live results, filtering every task (open and
completed) by title, case-insensitive. The leading icon flips to a back
arrow while expanded, a clear button empties the query, and the FAB hides so
it does not float over the results. Results reuse the upcoming preview row.
ListsViewModel.Content now carries allTasks (open + completed) as the search
corpus; filtering stays in memory so the provider query is untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the overview's 2x2 smart grid into a daily-momentum layout:
- Promote Today into a full-width hero with an M3 Expressive
CircularWavyProgressIndicator over "x of y done" for tasks due today.
Empty/all-done states read as a calm finished state, not a bare 0.
- Drop the white "Upcoming 0" tile (it shouted loudest while carrying the
least) in favour of a live preview of the next few upcoming tasks, each a
slim row with the quiet meta line and a tap-through to the task.
- Overdue + All fall back to a 2-up of the existing tonal tiles.
ListsViewModel now combines a Smart(COMPLETED) flow so the ring can count
the tasks already ticked off today (the open smart lists drop them).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-cut after 0.2.1's Codeberg publish failed (500 on pre-synced tag,
fixed in the previous commit). Validates the fix end to end and ships
the Codeberg direct-download channel (signed APK + SHA-256).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The push mirror (sync_on_commit) syncs the tag to Codeberg before the
publish step runs, and Forgejo returns HTTP 500 on POST /releases with a
target_commitish when the tag already exists — so the release was never
created (v0.2.1 shipped to Gitea/F-Droid but not Codeberg). Only pass
target_commitish when the tag isn't mirrored yet; otherwise attach the
release to the existing tag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Publish releases to the Codeberg mirror (signed APK + SHA-256 checksum)
as a direct-download channel. Bumps versionName to 0.2.1; the pipeline
mints the v0.2.1 tag on merge to main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port Calendula's current CI/release pipeline:
- ci.yaml: pull_request-triggered, change-scope classification
(docs/metadata-only PRs skip the Android build but still report a
green CI), and a reproducible-release invariant guard.
- release.yaml: the committed versionName is the source of truth — a
bump reaching main triggers the release, which builds, signs,
publishes to the F-Droid repo, then mints the vX.Y.Z tag + Gitea
release and mirrors it to Codeberg with the signed APK + SHA-256
checksum. workflow_dispatch runs the re-sign-only recovery path.
- Gitea releases are flagged as pre-releases while MAJOR is 0.
- build.gradle.kts: reproducible-release invariants (vcsInfo,
dependenciesInfo) + a releaseTest variant for the on-device gate.
- fastlane/ becomes the single source of truth for store metadata;
the localized F-Droid layout is generated from it at release time.
- Port scripts/, .gitea/ISSUE_TEMPLATE/, and rewrite docs/RELEASING.md
for the versionName-in-main model; fix stale references elsewhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>