24 Commits

Author SHA1 Message Date
Jean-Luc Makiola
8f735ffce7 feat: our own task store, and the frontend for it (#10)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/10
2026-09-04 15:54:34 +02:00
fdbc236ab4 docs(sync): bring SYNC.md in line with owning the store
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.
2026-09-04 15:53:01 +02:00
ec2e2eb59d feat(settings): storage picker and export screen
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.
2026-09-04 15:37:48 +02:00
9ff6027e50 chore: stop tracking CLAUDE.md
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.
2026-09-04 14:09:25 +02:00
fcee1d1736 feat(lists): manage lists in the app, and fix four store defects
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
2026-09-04 13:56:52 +02:00
Jean-Luc Makiola
d7131087cb Merge pull request 'release: cut 0.4.0 — ship German and Brazilian Portuguese' (#9) from release/v0.4.0 into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m30s
2026-08-31 19:02:24 +02:00
faee90f8b1 docs: note the ARM64 box64 aapt setup and the exit-code trap it causes 2026-08-13 17:33:36 +02:00
90140112bb fix(store): three defects the instrumented suite found on first run
- 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.
2026-08-13 17:23:31 +02:00
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
5abcbfc956 test(store): migration harness, restore path and a 5k-task check
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.
2026-08-13 16:43:13 +02:00
1ed192f150 feat(store)!: delete the vendored dmfs provider
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.
2026-08-13 16:33:19 +02:00
76f9ae6780 feat(store): import the dmfs database and make Room the default
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.
2026-08-13 16:24:19 +02:00
2e915da588 feat(store): implement TasksDataSource over Room
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.
2026-08-13 16:16:17 +02:00
829a27da82 feat(recurrence): expand a series in memory over lib-recur
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.
2026-08-13 16:09:30 +02:00
fd8363e356 feat(store): add the Room schema, DAOs and exported schema
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.
2026-08-13 16:09:11 +02:00
96a2995df4 build: add lib-recur to :app, and a dmfs v23 fixture for the import
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.
2026-08-13 15:58:52 +02:00
11b20faf82 refactor(data): address occurrences by (taskId, occurrenceStart)
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.
2026-08-13 15:55:24 +02:00
8c3cbcf928 docs: fix seven defects in the own-store plan
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>
2026-08-13 15:30:37 +02:00
13cb27b2ab docs: decide to build our own store and delete the vendored provider
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>
2026-08-13 14:46:07 +02:00
98ed339346 docs: bring the docs in line with what shipped
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>
2026-08-02 21:32:17 +02:00
c5041d3f29 feat(export): write task lists out as iCalendar
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>
2026-08-02 21:27:36 +02:00
f978c3727c feat(provider): ship our own task store, vendored under our own authority
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>
2026-08-02 21:19:52 +02:00
3d54a896ce Merge branch 'fix/provider-interaction-review'
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
2026-08-02 20:53:55 +02:00
47cf99af32 fix(data): correct the tasks-provider interaction end to end
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>
2026-07-20 22:13:26 +02:00
103 changed files with 11157 additions and 570 deletions

4
.gitignore vendored
View File

@@ -65,3 +65,7 @@ Thumbs.db
# KSP # KSP
.ksp/ .ksp/
# Local agent notes: machine-specific build setup and on-device rules, not
# anything the project itself depends on.
/CLAUDE.md

View File

@@ -3,8 +3,8 @@
<h1>Agendula</h1> <h1>Agendula</h1>
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br> <p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
Reads, writes, and reminds — on top of an existing tasks provider, with no own Keeps your tasks on your device, or on top of a tasks provider you already use.
sync stack.</p> Open standards, no account required.</p>
<a href="https://codeberg.org/jlmakiola/agendula/actions"><img src="https://codeberg.org/jlmakiola/agendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a> <a href="https://codeberg.org/jlmakiola/agendula/actions"><img src="https://codeberg.org/jlmakiola/agendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+"> <img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
@@ -15,31 +15,53 @@ sync stack.</p>
</div> </div>
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula). Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that keeps its own store, designed around RFC 5545's `VTODO` — the same tasks DAVx5
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own (and SmoothSync, DecSync, …) sync out of your CalDAV server. It can also read and
database, no reinvented sync. write a tasks provider you already have, for anyone already syncing that way.
The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
“things to be done” — given Calendula's `-ula` ending. Calendula keeps your days; “things to be done” — given Calendula's `-ula` ending. Calendula keeps your days;
Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of
many small *florets* — so the two apps are florets of one bloom.) many small *florets* — so the two apps are florets of one bloom.)
> **Status: data layer done, UI in progress.** The full non-visual stack over ## Where your tasks live — your choice
> the `TaskContract` provider — provider resolution, live-updating reads,
> writes, smart-list filtering, and a self-scheduled reminder engine — is built | | Where | Sync | Needs |
> and unit-tested. The Material 3 Expressive screens are now being built on top, |---|---|---|---|
> one at a time. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status, | **On your device** *(default)* | Agendula's own database | none yet — CalDAV sync of our own is planned | nothing. No account, no permissions, no other app |
| **In a provider you already use** | OpenTasks or tasks.org | whatever syncs it for you — DAVx5 and friends | that app installed, and its read/write permission |
Agendula's own store is an ordinary app database — nothing is published to other
apps, so there is no authority to clash over and no permission to grant. It
**coexists with OpenTasks rather than replacing it**: installing one never breaks
the other, and if you already sync through a provider, that keeps working exactly
as it did.
Recurring tasks are expanded per RFC 5545, and everything the schema does not
model is round-tripped verbatim rather than dropped — so passing your tasks
through Agendula does not quietly lose fields a server sent.
Your tasks are exportable as standard iCalendar `.ics` files at any time, because
data you can't take with you isn't really yours.
> **Status: backend complete, UI catching up.** Storage, reads and
> writes, smart-list filtering, a self-scheduled reminder engine, and export are
> built and unit-tested. The Material 3 Expressive screens are being built on
> top, one at a time — the storage-mode picker and export screen are not there
> yet. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
> [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and > [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and
> [`docs/PLAN.md`](docs/PLAN.md) for the A-now-B-later design rationale. > [`docs/STORAGE-AND-SYNC.md`](docs/STORAGE-AND-SYNC.md) for why storage works
> the way it does.
## Sync sources (by design) ## Sync sources (by design)
Agendula works with anything that writes to the tasks provider — **DAVx5** In external-provider mode Agendula works with anything that writes to that provider —
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync **DAVx5** (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any
adapter — because it builds on the provider, not on any one sync app. Google Android sync adapter — because it builds on the provider, not on any one sync
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean app. Google Tasks / Microsoft To Do are out of scope by design (proprietary; they
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane. would mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync —
are the lane.
## Translations ## Translations

View File

@@ -129,6 +129,10 @@ android {
isReturnDefaultValues = true isReturnDefaultValues = true
} }
} }
// MigrationTestHelper reads the exported schemas out of the test APK's
// assets, so app/schemas/ has to ship with the instrumented tests.
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
} }
kotlin { kotlin {
@@ -137,11 +141,27 @@ kotlin {
} }
} }
// Export each Room schema version to app/schemas/ and commit it. That JSON is
// what MigrationTestHelper reads to build an old database and migrate it, so
// without it a migration can only be tested by hand.
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies { dependencies {
// Not a dependency we use directly — lifecycle already drags it in at 1.7.3.
// AGP's consistent resolution then pins androidTest to the app classpath, and
// room-testing's MigrationTestHelper needs 1.8+ to deserialize the exported
// schema; on 1.7.3 it dies with an AbstractMethodError. Raise it in one place.
constraints {
implementation(libs.kotlinx.serialization.json)
}
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
@@ -157,7 +177,17 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
// RFC 5545 recurrence expansion, in-process. Pinned at 0.12.2 — 0.16.0
// removed RecurrenceSet. rfc5545-datetime comes with it and is part of its
// API surface, so it isn't declared separately.
implementation(libs.dmfs.lib.recur)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3) implementation(libs.androidx.glance.material3)
@@ -185,6 +215,7 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.truth) androidTestImplementation(libs.truth)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
} }

View File

@@ -0,0 +1,522 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "c94852274d874fe255ee76e1e46a3003",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principalUrl",
"columnName": "principal_url",
"affinity": "TEXT"
},
{
"fieldPath": "homeSetUrl",
"columnName": "home_set_url",
"affinity": "TEXT"
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "lastSyncAt",
"columnName": "last_sync_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSyncError",
"columnName": "last_sync_error",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "task_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER"
},
{
"fieldPath": "isVisible",
"columnName": "is_visible",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "isSynced",
"columnName": "is_synced",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "owner",
"columnName": "owner",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "is_read_only",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "ctag",
"columnName": "ctag",
"affinity": "TEXT"
},
{
"fieldPath": "syncToken",
"columnName": "sync_token",
"affinity": "TEXT"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_lists_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "listId",
"columnName": "list_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "etag",
"columnName": "etag",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "percentComplete",
"columnName": "percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "completedAt",
"columnName": "completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "classification",
"columnName": "classification",
"affinity": "INTEGER"
},
{
"fieldPath": "dtstart",
"columnName": "dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "due",
"columnName": "due",
"affinity": "INTEGER"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT"
},
{
"fieldPath": "isAllDay",
"columnName": "is_all_day",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT"
},
{
"fieldPath": "rrule",
"columnName": "rrule",
"affinity": "TEXT"
},
{
"fieldPath": "rdate",
"columnName": "rdate",
"affinity": "TEXT"
},
{
"fieldPath": "exdate",
"columnName": "exdate",
"affinity": "TEXT"
},
{
"fieldPath": "recurrenceId",
"columnName": "recurrence_id",
"affinity": "INTEGER"
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER"
},
{
"fieldPath": "parentId",
"columnName": "parent_id",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastModified",
"columnName": "last_modified",
"affinity": "INTEGER"
},
{
"fieldPath": "sequence",
"columnName": "sequence",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDeleted",
"columnName": "is_deleted",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "unknownProperties",
"columnName": "unknown_properties",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tasks_list_id_is_deleted",
"unique": false,
"columnNames": [
"list_id",
"is_deleted"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
},
{
"name": "index_tasks_parent_id",
"unique": false,
"columnNames": [
"parent_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
},
{
"name": "index_tasks_master_id_recurrence_id",
"unique": false,
"columnNames": [
"master_id",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
},
{
"name": "index_tasks_is_dirty",
"unique": false,
"columnNames": [
"is_dirty"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
},
{
"name": "index_tasks_list_id_uid_recurrence_id",
"unique": true,
"columnNames": [
"list_id",
"uid",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
}
],
"foreignKeys": [
{
"table": "task_lists",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"list_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"parent_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "task_alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minutesBefore",
"columnName": "minutes_before",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reference",
"columnName": "reference",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DUE'"
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_alarms_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"task_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c94852274d874fe255ee76e1e46a3003')"
]
}
}

Binary file not shown.

View File

@@ -0,0 +1,290 @@
package de.jeanlucmakiola.agendula.data.tasks.legacy
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
import kotlin.time.Instant
/**
* The one-shot import, against `assets/tasks-v23.db` — the dmfs v23 fixture
* `scripts/make_import_fixture.py` seeds. Instrumented because both halves need
* a real SQLite: the source file and Room.
*/
@RunWith(AndroidJUnit4::class)
class OneShotImportTest {
@get:Rule
val temp = TemporaryFolder()
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var scope: CoroutineScope
private lateinit var prefs: DataStore<Preferences>
private lateinit var db: TasksDatabase
private lateinit var importer: OneShotImport
@Before
fun setUp() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
prefs = PreferenceDataStoreFactory.create(scope = scope) {
temp.newFile("import-${counter++}.preferences_pb").also(File::delete)
}
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
.allowMainThreadQueries()
.build()
importer = OneShotImport(context, db, prefs)
legacyFile().delete()
archiveFile().delete()
}
@After
fun tearDown() {
db.close()
scope.cancel()
legacyFile().delete()
archiveFile().delete()
}
private fun legacyFile() = context.getDatabasePath(OneShotImport.LEGACY_NAME)
private fun archiveFile() = context.getDatabasePath(OneShotImport.ARCHIVE_NAME)
/** The fixture, copied out of the test APK's assets. */
private fun fixture(target: File = temp.newFile("tasks-v23-copy.db")): File {
InstrumentationRegistry.getInstrumentation().context.assets.open(FIXTURE).use { source ->
target.outputStream().use(source::copyTo)
}
return target
}
private fun taskRows(): Map<String, TaskEntity> =
db.tasks().tasks(null, includeCompleted = true).associate { it.task.title!! to it.task }
// --- what lands -----------------------------------------------------------
@Test
fun importsEveryLiveTaskAndLeavesTheDeletedOneBehind() {
val counts = importer.importFrom(fixture())
assertThat(counts).isEqualTo(ImportCounts(lists = 3, tasks = 8, alarms = 2))
assertThat(taskRows().keys).containsExactly(
"Buy milk",
"Call the dentist",
"Gather receipts",
"Renew domain",
"Water the plants",
"Team offsite",
"Task in a hidden list",
"Ship the release",
)
}
@Test
fun importsEveryListAsADeviceOnlyListWithItsFlags() {
importer.importFrom(fixture())
val lists = db.taskLists().lists().associateBy { it.list.name }
assertThat(lists.keys).containsExactly("Personal", "Hidden list", "Work")
assertThat(lists.values.map { it.list.accountId }).containsExactly(null, null, null)
assertThat(lists.getValue("Personal").list.isVisible).isTrue()
assertThat(lists.getValue("Hidden list").list.isVisible).isFalse()
// The list that sat under a real account: still imported, owner kept.
assertThat(lists.getValue("Work").list.owner).isEqualTo("Me")
assertThat(lists.getValue("Work").list.color).isEqualTo(0xFF2244AA.toInt())
}
@Test
fun carriesTheTaskFieldsAcross() {
importer.importFrom(fixture())
val tasks = taskRows()
val milk = tasks.getValue("Buy milk")
assertThat(milk.due).isEqualTo(Instant.fromEpochMilliseconds(T0 + DAY))
assertThat(milk.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(milk.createdAt).isEqualTo(Instant.fromEpochMilliseconds(T0))
val dentist = tasks.getValue("Call the dentist")
assertThat(dentist.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(dentist.percentComplete).isEqualTo(40)
val domain = tasks.getValue("Renew domain")
assertThat(domain.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(domain.completedAt).isEqualTo(Instant.fromEpochMilliseconds(T0 - DAY))
val plants = tasks.getValue("Water the plants")
assertThat(plants.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH")
assertThat(plants.timezone).isEqualTo("Europe/Berlin")
assertThat(plants.dtstart).isEqualTo(Instant.fromEpochMilliseconds(T0))
assertThat(tasks.getValue("Team offsite").isAllDay).isTrue()
}
// --- uids -----------------------------------------------------------------
@Test
fun keepsExistingUidsAndMintsOneWhereTheLegacyRowHadNone() {
importer.importFrom(fixture())
val tasks = taskRows()
assertThat(tasks.getValue("Buy milk").uid).isEqualTo("a1b2c3d4-0000-4000-8000-000000000001")
// The external-account row's uid is what lets it be re-attached later.
assertThat(tasks.getValue("Ship the release").uid)
.isEqualTo("a1b2c3d4-0000-4000-8000-000000000009")
val minted = tasks.getValue("Call the dentist").uid
assertThat(minted).isNotEmpty()
assertThat(UUID.fromString(minted).version()).isEqualTo(4)
assertThat(tasks.values.map { it.uid }.toSet()).hasSize(tasks.size)
}
// --- the id remap ---------------------------------------------------------
@Test
fun remapsListIdsOntoTheNewRowIds() {
importer.importFrom(fixture())
val lists = db.taskLists().lists().associateBy { it.list.name }
val byList = db.tasks().tasks(null, includeCompleted = true)
.groupBy { it.task.listId }
.mapValues { (_, rows) -> rows.size }
assertThat(byList[lists.getValue("Personal").list.id]).isEqualTo(6)
assertThat(byList[lists.getValue("Hidden list").list.id]).isEqualTo(1)
assertThat(byList[lists.getValue("Work").list.id]).isEqualTo(1)
// No task kept a dmfs row id that Room never handed out.
assertThat(byList.keys).containsExactlyElementsIn(lists.values.map { it.list.id })
}
@Test
fun remapsParentIdsOntoTheNewRowIds() {
importer.importFrom(fixture())
val tasks = taskRows()
val parent = tasks.getValue("Buy milk")
val child = tasks.getValue("Gather receipts")
assertThat(child.parentId).isEqualTo(parent.id)
assertThat(db.tasks().subtasks(parent.id).map { it.task.title }).containsExactly("Gather receipts")
assertThat(tasks.values.filter { it.parentId != null }).hasSize(1)
}
// --- alarms ---------------------------------------------------------------
@Test
fun importsAlarmsAndSkipsEveryOtherProperty() {
importer.importFrom(fixture())
val tasks = taskRows()
assertThat(db.alarms().all()).hasSize(2)
val milk = db.alarms().forTask(tasks.getValue("Buy milk").id).single()
assertThat(milk.minutesBefore).isEqualTo(30)
assertThat(milk.reference).isEqualTo(AlarmReference.DUE)
assertThat(milk.message).isNull()
val release = db.alarms().forTask(tasks.getValue("Ship the release").id).single()
assertThat(release.minutesBefore).isEqualTo(1440)
assertThat(release.reference).isEqualTo(AlarmReference.DUE)
assertThat(release.message).isEqualTo("Ship it")
// The category property on task 1 is not an alarm.
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
}
// --- running it -----------------------------------------------------------
@Test
fun runIfNeededImportsArchivesTheSourceAndThenDoesNothing() = runBlocking {
fixture(legacyFile())
val first = importer.runIfNeeded()
assertThat(first).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
assertThat(legacyFile().exists()).isFalse()
assertThat(archiveFile().exists()).isTrue()
assertThat(importer.isDone.first()).isTrue()
val second = importer.runIfNeeded()
assertThat(second).isEqualTo(ImportResult.AlreadyDone)
assertThat(taskRows()).hasSize(8)
}
@Test
fun anInterruptedImportResumesFromTheArchiveWithoutDoubling() = runBlocking {
// The process dying between the commit and the flag write is the one gap
// the DataStore flag cannot cover on its own. Because the rename happens
// first and the import always replaces, the next run finds the archive and
// redoes the same work rather than importing a second copy.
fixture(legacyFile())
importer.runIfNeeded()
importer.clearCompletion()
val resumed = importer.runIfNeeded()
assertThat(resumed).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
assertThat(taskRows()).hasSize(8)
assertThat(db.taskLists().lists()).hasSize(3)
assertThat(db.alarms().all()).hasSize(2)
}
@Test
fun runIfNeededMarksItselfDoneWhenThereIsNoLegacyDatabase() = runBlocking {
assertThat(importer.runIfNeeded()).isEqualTo(ImportResult.NothingToImport)
assertThat(importer.isDone.first()).isTrue()
assertThat(taskRows()).isEmpty()
}
@Test
fun reimportFromTheArchiveReplacesRatherThanMerges() = runBlocking {
fixture(legacyFile())
importer.runIfNeeded()
val again = importer.reimportFromArchive()
assertThat(again).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
assertThat(db.taskLists().lists()).hasSize(3)
assertThat(taskRows()).hasSize(8)
assertThat(db.alarms().all()).hasSize(2)
assertThat(archiveFile().exists()).isTrue()
}
@Test
fun replacingTwiceFromTheSameFileLeavesOneCopy() {
importer.importFrom(fixture())
val counts = importer.importFrom(fixture(temp.newFile("second.db")), replaceExisting = true)
assertThat(counts).isEqualTo(ImportCounts(3, 8, 2))
assertThat(taskRows()).hasSize(8)
assertThat(db.taskLists().lists()).hasSize(3)
assertThat(db.alarms().all()).hasSize(2)
}
private companion object {
const val FIXTURE = "tasks-v23.db"
const val T0 = 1_768_467_600_000L
const val DAY = 86_400_000L
var counter = 0
}
}

View File

@@ -0,0 +1,389 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
/**
* The seam over Room, exercised through [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] rather than the DAOs — recurrence expansion and override
* forking only exist at this level.
*/
@RunWith(AndroidJUnit4::class)
class RoomTasksDataSourceTest {
private lateinit var db: TasksDatabase
private lateinit var source: RoomTasksDataSource
private var listId = 0L
/** Truncated to the store's granularity: instants are columns of epoch millis. */
private val now get() = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds())
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
TasksDatabase::class.java,
).allowMainThreadQueries().build()
source = RoomTasksDataSource(db)
listId = source.createLocalList("Personal", 0xFF112233.toInt())
}
@After
fun tearDown() = db.close()
private fun form(
title: String = "task",
due: Instant? = null,
percentComplete: Int? = null,
) = TaskForm(title = title, listId = listId, due = due, percentComplete = percentComplete)
/** Turns [taskId] into a weekly series anchored at [anchor]. */
private fun makeRecurring(taskId: Long, anchor: Instant, rule: String = "FREQ=WEEKLY") {
val entity = db.tasks().entity(taskId)!!
db.tasks().update(entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = rule))
}
@Test
fun createsAndReadsBackALocalList() {
val lists = source.taskLists()
assertThat(lists).hasSize(1)
assertThat(lists.single().name).isEqualTo("Personal")
// No account, so the list still has to report something the lists screen
// can group under.
assertThat(lists.single().isLocal).isTrue()
assertThat(lists.single().accountName).isEqualTo("Local")
}
@Test
fun renamesAndRecoloursAList() {
source.updateList(listId, " Errands ", 0xFF445566.toInt())
val list = source.taskLists().single()
assertThat(list.name).isEqualTo("Errands")
assertThat(list.color).isEqualTo(0xFF445566.toInt())
// Nothing to sync a device-only list to, so the edit leaves it clean.
assertThat(db.taskLists().entity(listId)!!.isDirty).isFalse()
}
@Test
fun deletingAListTakesItsTasksWithIt() {
source.insertTask(form(title = "Buy milk"))
source.insertTask(form(title = "Call the bank"))
val other = source.createLocalList("Work", 0xFF778899.toInt())
val keeper = source.insertTask(TaskForm(title = "Ship it", listId = other))
source.deleteList(listId)
assertThat(source.taskLists().map { it.id }).containsExactly(other)
assertThat(source.tasks(TaskQuery(includeCompleted = true)).map { it.taskId })
.containsExactly(keeper)
}
@Test
fun createsAndReadsBackANonRecurringTask() {
val due = now + 1.days
val id = source.insertTask(form(title = "Buy milk", due = due))
val task = source.task(id)!!
assertThat(task.taskId).isEqualTo(id)
assertThat(task.title).isEqualTo("Buy milk")
assertThat(task.due).isEqualTo(due)
assertThat(task.isRecurring).isFalse()
// A task that does not recur has no occurrence anchor, so it keys and edits
// by task id exactly as it did against the provider.
assertThat(task.occurrenceStart).isNull()
assertThat(task.occurrenceKey).isEqualTo("$id")
}
@Test
fun mintsAUidForEveryTask() {
val id = source.insertTask(form())
assertThat(db.tasks().entity(id)!!.uid).isNotEmpty()
}
@Test
fun expandsARecurringSeriesIntoManyOccurrences() {
val anchor = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, anchor)
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
// The provider materialised exactly one upcoming occurrence; we expand the
// whole window, so a weekly series yields well over a hundred.
assertThat(occurrences.size).isGreaterThan(100)
assertThat(occurrences.map { it.occurrenceStart }).containsNoDuplicates()
assertThat(occurrences.map { it.occurrenceKey }).containsNoDuplicates()
assertThat(occurrences.all { it.isRecurring }).isTrue()
// Each occurrence keeps the series' length rather than the master's dates.
val first = occurrences.minBy { it.occurrenceStart!! }
assertThat(first.due!! - first.start!!).isEqualTo(1.days)
}
@Test
fun exactlyOneOccurrenceIsTheCurrentOne() {
val id = source.insertTask(form())
makeRecurring(id, now - 30.days)
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
assertThat(occurrences.count { it.distanceFromCurrent == 0 }).isEqualTo(1)
assertThat(source.task(id)!!.distanceFromCurrent).isEqualTo(0)
}
@Test
fun editingOneOccurrenceForksARecurrenceIdOverride() {
val anchor = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, anchor)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
val override = db.tasks().override(id, target.occurrenceStart)!!
// RFC 5545's model: the override shares its master's UID — that is what
// makes it an override rather than a separate task. The dmfs provider
// detached the occurrence into a new task with its own UID instead.
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
assertThat(override.masterId).isEqualTo(id)
assertThat(override.recurrenceId).isEqualTo(target.occurrenceStart)
assertThat(override.rrule).isNull()
assertThat(override.title).isEqualTo("Water them twice")
}
@Test
fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
val before = open()
val target = before.first { it.distanceFromCurrent == 0 }
source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
// Writing the status onto the master would close the series: the master is
// the row the task query filters on, so every occurrence would vanish.
val after = open()
assertThat(after).hasSize(before.size - 1)
assertThat(after.map { it.occurrenceStart }).doesNotContain(target.occurrenceStart)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(override.rrule).isNull()
// The override stands for *that* occurrence, so it carries the
// occurrence's resolved times, not the master's anchor.
assertThat(override.dtstart).isEqualTo(target.occurrenceStart)
}
@Test
fun reopeningACompletedOccurrenceReusesItsOverride() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = listId))
.first { it.taskId == id && it.distanceFromCurrent == 0 }
source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
source.setCompletedInstance(id, target.occurrenceStart, completed = false)
assertThat(db.tasks().overrides(id)).hasSize(1)
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status)
.isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart })
.contains(target.occurrenceStart)
}
@Test
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
source.setCompletedInstance(id, now, completed = true)
assertThat(db.tasks().overrides(id)).isEmpty()
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun anOverrideReplacesOnlyItsOwnOccurrence() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
// The list holds this series alone, so no filter is needed — and none can
// be written on taskId, since the override reports its own row id.
val before = source.tasks(TaskQuery(listId = listId))
val target = before.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
val after = source.tasks(TaskQuery(listId = listId))
assertThat(after).hasSize(before.size)
val edited = after.single { it.title == "Water them twice" }
assertThat(edited.occurrenceStart).isEqualTo(target.occurrenceStart)
assertThat(after.filter { it.occurrenceStart == target.occurrenceStart }).hasSize(1)
}
/**
* An edited occurrence addresses its own row, not the master's. That is what
* sends the *next* edit down `updateTask` rather than forking a second time:
* an override carries no rule, so it reads back as non-recurring.
*/
@Test
fun anEditedOccurrenceReportsTheOverridesOwnId() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = listId)).first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
val edited = source.tasks(TaskQuery(listId = listId)).single { it.title == "Water them twice" }
val overrideId = db.tasks().override(id, target.occurrenceStart)!!.id
assertThat(edited.taskId).isEqualTo(overrideId)
assertThat(edited.taskId).isNotEqualTo(id)
assertThat(source.task(overrideId)!!.isRecurring).isFalse()
}
@Test
fun editingASeriesDoesNotReAnchorItWhenOneOccurrenceIsEdited() {
val anchor = now
val id = source.insertTask(form())
makeRecurring(id, anchor)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 2 }
source.updateInstance(id, target.occurrenceStart!!, form(due = now + 99.days))
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(anchor)
}
@Test
fun updatingANonRecurringTaskWritesThroughToItsRow() {
val id = source.insertTask(form(title = "old"))
source.updateTask(id, form(title = "new"))
assertThat(source.task(id)!!.title).isEqualTo("new")
}
@Test
fun completionTogglesTheWholeTriple() {
val id = source.insertTask(form())
source.setCompleted(id, completed = true)
val done = db.tasks().entity(id)!!
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(done.percentComplete).isEqualTo(100)
assertThat(done.completedAt).isNotNull()
source.setCompleted(id, completed = false)
assertThat(db.tasks().entity(id)!!.completedAt).isNull()
}
@Test
fun completedTasksAreExcludedUnlessAskedFor() {
val id = source.insertTask(form())
source.setCompleted(id, completed = true)
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = false))).isEmpty()
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = true))).hasSize(1)
}
@Test
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
val id = source.insertTask(form(due = now + 1.days))
source.setAlarm(id, 30)
assertThat(source.alarms()[id]).isEqualTo(30)
source.setAlarm(id, 60)
assertThat(db.alarms().forTask(id)).hasSize(1)
assertThat(source.alarms()[id]).isEqualTo(60)
source.setAlarm(id, null)
assertThat(source.alarms()).doesNotContainKey(id)
}
@Test
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
val id = source.insertTask(form(due = now + 1.days))
makeRecurring(id, now)
source.setAlarm(id, 30)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form())
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
}
@Test
fun deletingATaskInALocalListRemovesItOutright() {
val id = source.insertTask(form())
source.deleteTask(id)
// No account knows about it, so there is nothing to tombstone for.
assertThat(db.tasks().entity(id)).isNull()
}
@Test
fun deletingASeriesTakesItsOverridesWithIt() {
val id = source.insertTask(form())
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
source.deleteTask(id)
assertThat(db.tasks().allOverrides(listId)).isEmpty()
}
@Test
fun subtasksReadBackUnderTheirParent() {
val parent = source.insertTask(form(title = "Prepare invoice"))
val child = source.insertTask(form(title = "Gather receipts").copy(parentId = parent))
assertThat(source.subtasks(parent).map { it.taskId }).containsExactly(child)
}
@Test
fun exportReadsMastersNotOccurrences() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
val exported = source.exportTasks(listId)
// One row carrying the rule, not one row per occurrence with the rule lost.
assertThat(exported).hasSize(1)
assertThat(exported.single().rrule).isEqualTo("FREQ=WEEKLY")
assertThat(exported.single().uid).isNotEmpty()
}
@Test
fun insertingIntoAMissingListFails() {
val thrown = runCatching { source.insertTask(form().copy(listId = 9_999)) }.exceptionOrNull()
assertThat(thrown).isNotNull()
}
}

View File

@@ -0,0 +1,68 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The migration harness, proven against the committed schema in `app/schemas/`.
*
* There is one schema version today, so all there is to assert is that the helper
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
* export, the assets wiring and the identity hash all line up. That is the point:
* the first real migration only has to add its own case.
*
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
* database, and add a test here shaped like this:
*
* ```
* helper.createDatabase(TEST_DB, 1).use { db ->
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
* }
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
* // read the seeded rows back — validation proves the shape, not the data
* }
* ```
*/
@RunWith(AndroidJUnit4::class)
class TasksDatabaseMigrationTest {
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
TasksDatabase::class.java,
)
@Test
fun buildsV1FromTheExportedSchema() {
helper.createDatabase(TEST_DB, 1).use { db ->
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Groceries', 0)")
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (1, 1, 'uid-1', 'Buy milk')")
db.query("SELECT title FROM tasks").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getString(0)).isEqualTo("Buy milk")
}
}
}
@Test
fun validatesV1AgainstTheExportedSchema() {
helper.createDatabase(TEST_DB, 1).close()
// No migrations to run: v1 is opened and checked against 1.json, which is
// what proves the harness rather than the schema.
helper.runMigrationsAndValidate(TEST_DB, 1, true).use { db ->
assertThat(db.version).isEqualTo(1)
}
}
private companion object {
const val TEST_DB = "migration-test.db"
}
}

View File

@@ -0,0 +1,107 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.domain.TaskForm
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.measureTime
import kotlin.time.measureTimedValue
/**
* The plan's shape at scale: 5,000 tasks with 20 recurring series, read the way a
* smart list reads them — one `tasks(TaskQuery(includeCompleted = true))`, which
* includes expanding every series in memory.
*
* The assertion is a deliberately loose ceiling, so it catches a real regression
* rather than CI jitter; the printed numbers are what the check is actually for.
*/
@RunWith(AndroidJUnit4::class)
class TasksDatabasePerformanceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var db: TasksDatabase
private lateinit var source: RoomTasksDataSource
private var listId = 0L
@Before
fun setUp() {
delete()
db = Room.databaseBuilder(context, TasksDatabase::class.java, DB)
.allowMainThreadQueries()
.build()
source = RoomTasksDataSource(db)
listId = source.createLocalList("Everything", 0xFF112233.toInt())
}
@After
fun tearDown() {
db.close()
delete()
}
@Test
fun readsFiveThousandTasksWithTwentySeriesInsideTheBudget() {
val seeded = measureTime { seed() }
// Discard the first read: it pays for statement compilation and page cache
// warming, which a running app has already paid.
source.tasks(TaskQuery(includeCompleted = true))
val (tasks, elapsed) = measureTimedValue {
source.tasks(TaskQuery(includeCompleted = true))
}
println(
"[perf] $TASK_COUNT tasks / $SERIES_COUNT series -> ${tasks.size} occurrences " +
"in $elapsed (seed $seeded)",
)
// Expansion is bounded twice over: the read window is 1 year back and 2
// forward, and each series stops at ExpansionWindow.maxOccurrences (500),
// so the occurrence count cannot grow with the age of the series.
assertThat(tasks.size).isAtLeast(TASK_COUNT)
assertThat(elapsed.inWholeMilliseconds).isLessThan(CEILING_MILLIS)
}
private fun seed() {
val anchor = Clock.System.now() - 30.days
val ids = ArrayList<Long>(TASK_COUNT)
db.runInTransaction {
repeat(TASK_COUNT) { index ->
ids += source.insertTask(
TaskForm(title = "Task $index", listId = listId, due = anchor + index.days),
)
}
}
db.runInTransaction {
ids.take(SERIES_COUNT).forEach { id ->
val entity = db.tasks().entity(id)!!
db.tasks().update(
entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = "FREQ=DAILY"),
)
}
}
}
private fun delete() {
val base = context.getDatabasePath(DB)
base.delete()
listOf("-wal", "-shm").forEach { File(base.path + it).delete() }
}
private companion object {
const val DB = "performance-test.db"
const val TASK_COUNT = 5_000
const val SERIES_COUNT = 20
const val CEILING_MILLIS = 8_000L
}
}

View File

@@ -0,0 +1,155 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.domain.TaskForm
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* The Auto Backup restore path, on disk.
*
* Auto Backup copies database files without checkpointing, and Room runs in WAL
* mode — so `.db` alone can be a *stale* copy of a database whose recent writes
* are still in the `-wal` sidecar. `res/xml/backup_rules.xml` carries all three
* files and [DatabaseCheckpoint] truncates the log on `ON_STOP`; this asserts
* that both of those actually do what they claim, and that neither alone is an
* assumption.
*
* A file copy of a live database stands in for the backup transport — the
* transport is what Auto Backup does to these files, and it is not what is under
* test here.
*/
@RunWith(AndroidJUnit4::class)
class TasksDatabaseRestoreTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var db: TasksDatabase
private lateinit var source: RoomTasksDataSource
private var listId = 0L
private var restored: TasksDatabase? = null
@Before
fun setUp() {
delete(LIVE)
delete(BACKUP)
db = open(LIVE)
source = RoomTasksDataSource(db)
listId = source.createLocalList("Personal", 0xFF112233.toInt())
}
@After
fun tearDown() {
restored?.close()
db.close()
delete(LIVE)
delete(BACKUP)
}
@Test
fun roomRunsInWalMode() {
// Everything below is only interesting because of this.
assertThat(journalMode()).isEqualTo("wal")
}
@Test
fun aBackupOfTheDbFileAloneLosesWhateverIsStillInTheWal() {
write("checkpointed")
checkpoint()
write("only in the wal")
backUp(withSidecars = false)
assertThat(restore()).containsExactly("checkpointed")
}
@Test
fun aBackupThatCarriesTheSidecarsKeepsTheLastWrite() {
write("checkpointed")
checkpoint()
write("only in the wal")
backUp(withSidecars = true)
assertThat(restore()).containsExactly("checkpointed", "only in the wal")
}
@Test
fun checkpointingFirstMakesTheDbFileAloneEnough() {
write("checkpointed")
checkpoint()
write("last write")
// What DatabaseCheckpoint runs on ON_STOP — the fallback for a restore
// that arrives without the sidecars.
checkpoint()
backUp(withSidecars = false)
assertThat(restore()).containsExactly("checkpointed", "last write")
}
// --- the moving parts -----------------------------------------------------
private fun open(name: String): TasksDatabase =
Room.databaseBuilder(context, TasksDatabase::class.java, name)
.allowMainThreadQueries()
.build()
private fun write(title: String) {
source.insertTask(TaskForm(title = title, listId = listId))
}
private fun journalMode(): String =
db.openHelper.writableDatabase.query("PRAGMA journal_mode").use { cursor ->
cursor.moveToFirst()
cursor.getString(0).lowercase()
}
/** [DatabaseCheckpoint]'s pragma, asserting it was not blocked by a reader. */
private fun checkpoint() {
db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor ->
cursor.moveToFirst()
assertThat(cursor.getInt(0)).isEqualTo(0)
}
}
/** Copies the live database the way Auto Backup would: no checkpoint, files as they lie. */
private fun backUp(withSidecars: Boolean) {
delete(BACKUP)
val live = context.getDatabasePath(LIVE)
val backup = context.getDatabasePath(BACKUP)
live.copyTo(backup, overwrite = true)
if (!withSidecars) return
SIDECARS.forEach { suffix ->
val from = File(live.path + suffix)
if (from.exists()) from.copyTo(File(backup.path + suffix), overwrite = true)
}
}
/** Opens the copy as a fresh install would and reports the task titles that survived. */
private fun restore(): List<String> {
restored?.close()
val database = open(BACKUP).also { restored = it }
return RoomTasksDataSource(database).tasks(TaskQuery(includeCompleted = true)).map { it.title }
}
private fun delete(name: String) {
val base = context.getDatabasePath(name)
base.delete()
SIDECARS.forEach { File(base.path + it).delete() }
}
private companion object {
const val LIVE = "restore-live.db"
const val BACKUP = "restore-backup.db"
val SIDECARS = listOf("-wal", "-shm")
}
}

View File

@@ -0,0 +1,274 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Instant
/**
* The schema, exercised through the DAOs. Instrumented rather than JVM because
* the app's unit tests are plain JUnit 5 with no Robolectric, and Room needs a
* real SQLite.
*/
@RunWith(AndroidJUnit4::class)
class TasksDatabaseTest {
private lateinit var db: TasksDatabase
private lateinit var lists: TaskListDao
private lateinit var tasks: TaskDao
private lateinit var alarms: TaskAlarmDao
private lateinit var accounts: AccountDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
TasksDatabase::class.java,
).allowMainThreadQueries().build()
lists = db.taskLists()
tasks = db.tasks()
alarms = db.alarms()
accounts = db.accounts()
}
@After
fun tearDown() = db.close()
private fun newList(name: String = "Groceries", accountId: Long? = null): Long =
lists.insert(TaskListEntity(name = name, color = 0xFF00FF00.toInt(), accountId = accountId))
private fun newTask(
listId: Long,
uid: String = "uid-${counter++}",
title: String? = "Buy milk",
status: TaskStatus = TaskStatus.NEEDS_ACTION,
parentId: Long? = null,
masterId: Long? = null,
recurrenceId: Instant? = null,
): Long = tasks.insert(
TaskEntity(
listId = listId,
uid = uid,
title = title,
status = status,
parentId = parentId,
masterId = masterId,
recurrenceId = recurrenceId,
),
)
@Test
fun writesAndReadsAListWithItsTasks() {
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
val listId = newList(accountId = accountId)
val due = Instant.fromEpochMilliseconds(1_700_000_000_000)
val taskId = tasks.insert(
TaskEntity(
listId = listId,
uid = "uid-1",
title = "Buy milk",
description = "2%",
due = due,
priority = 3,
status = TaskStatus.IN_PROCESS,
percentComplete = 40,
),
)
val list = lists.lists().single()
assertThat(list.list.id).isEqualTo(listId)
assertThat(list.list.name).isEqualTo("Groceries")
assertThat(list.accountDisplayName).isEqualTo("Fastmail")
val row = tasks.task(taskId)!!
assertThat(row.task.title).isEqualTo("Buy milk")
assertThat(row.task.due).isEqualTo(due)
// Stored raw: an off-bucket PRIORITY must come back as it went in.
assertThat(row.task.priority).isEqualTo(3)
assertThat(row.task.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(row.task.percentComplete).isEqualTo(40)
assertThat(row.listName).isEqualTo("Groceries")
assertThat(row.accountDisplayName).isEqualTo("Fastmail")
}
@Test
fun readsTasksOfOneListAndHidesClosedOnesUnlessAsked() {
val a = newList("A")
val b = newList("B")
newTask(a, title = "open")
newTask(a, title = "done", status = TaskStatus.COMPLETED)
newTask(a, title = "cancelled", status = TaskStatus.CANCELLED)
newTask(b, title = "elsewhere")
assertThat(tasks.tasks(a, includeCompleted = false).map { it.task.title })
.containsExactly("open")
assertThat(tasks.tasks(a, includeCompleted = true)).hasSize(3)
assertThat(tasks.tasks(null, includeCompleted = true)).hasSize(4)
}
@Test
fun readsSubtasksByParent() {
val listId = newList()
val parent = newTask(listId, title = "parent")
newTask(listId, title = "child", parentId = parent)
assertThat(tasks.subtasks(parent).map { it.task.title }).containsExactly("child")
}
@Test
fun hidesTombstonesFromReadsAndExports() {
val listId = newList()
val taskId = newTask(listId)
tasks.markDeleted(taskId, Instant.fromEpochMilliseconds(1))
assertThat(tasks.tasks(listId, includeCompleted = true)).isEmpty()
assertThat(tasks.task(taskId)).isNull()
assertThat(tasks.exportTasks(listId)).isEmpty()
assertThat(tasks.entity(taskId)).isNotNull()
}
@Test
fun keepsOverridesOutOfTheMasterReads() {
val listId = newList()
val master = newTask(listId, uid = "series")
val override = newTask(
listId,
uid = "series",
masterId = master,
recurrenceId = Instant.fromEpochMilliseconds(5_000),
)
assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id })
.containsExactly(master)
assertThat(tasks.overrides(master).map { it.id }).containsExactly(override)
assertThat(tasks.allOverrides(listId).map { it.id }).containsExactly(override)
assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id)
.isEqualTo(override)
assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master)
}
// --- cascades -------------------------------------------------------------
@Test
fun deletingAListDeletesItsTasks() {
val listId = newList()
val taskId = newTask(listId)
lists.delete(listId)
assertThat(tasks.entity(taskId)).isNull()
}
@Test
fun deletingASeriesDeletesItsOverrides() {
val listId = newList()
val master = newTask(listId, uid = "series")
val override = newTask(
listId,
uid = "series",
masterId = master,
recurrenceId = Instant.fromEpochMilliseconds(5_000),
)
tasks.delete(master)
assertThat(tasks.entity(override)).isNull()
}
@Test
fun deletingAParentPromotesItsSubtasks() {
val listId = newList()
val parent = newTask(listId, title = "parent")
val child = newTask(listId, title = "child", parentId = parent)
tasks.delete(parent)
val promoted = tasks.entity(child)
assertThat(promoted).isNotNull()
assertThat(promoted!!.parentId).isNull()
}
@Test
fun deletingATaskDeletesItsAlarms() {
val listId = newList()
val taskId = newTask(listId)
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
assertThat(alarms.all()).hasSize(1)
tasks.delete(taskId)
assertThat(alarms.all()).isEmpty()
}
@Test
fun deletingAnAccountDetachesItsListsInsteadOfDeletingThem() {
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
val listId = newList(accountId = accountId)
accounts.delete(accountId)
assertThat(lists.entity(listId)!!.accountId).isNull()
}
@Test
fun replacingAnAlarmLeavesOnlyTheNewOne() {
val listId = newList()
val taskId = newTask(listId)
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 30))
assertThat(alarms.forTask(taskId).map { it.minutesBefore }).containsExactly(30)
assertThat(alarms.forTask(taskId).single().reference).isEqualTo(AlarmReference.DUE)
alarms.replaceForTask(taskId, null)
assertThat(alarms.forTask(taskId)).isEmpty()
}
// --- the unique index -----------------------------------------------------
@Test
fun anOverrideMayShareItsMastersUid() {
val listId = newList()
val master = newTask(listId, uid = "series")
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(1))
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(2))
assertThat(tasks.overrides(master)).hasSize(2)
}
@Test
fun rejectsTwoOverridesOfTheSameOccurrence() {
val listId = newList()
val master = newTask(listId, uid = "series")
val at = Instant.fromEpochMilliseconds(1)
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
val failure = runCatching {
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
}.exceptionOrNull()
assertThat(failure).isNotNull()
assertThat(failure!!.message).contains("UNIQUE")
}
@Test
fun theSameUidMayExistInAnotherList() {
val a = newList("A")
val b = newList("B")
newTask(a, uid = "shared")
newTask(b, uid = "shared")
assertThat(tasks.byUid(a, "shared")).isNotNull()
assertThat(tasks.byUid(b, "shared")).isNotNull()
}
private companion object {
var counter = 0
}
}

View File

@@ -2,9 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- Tasks provider access. Both permission sets are declared; the active one <!-- External tasks-provider access, for StorageMode.EXTERNAL only. Both
permission sets are declared, since the manifest is static; the active one
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at (org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
runtime by the permission flow. Both are dangerous-level. --> runtime by the permission flow, and only once the user has actually
selected External mode. Both are dangerous-level.
StorageMode.OWN needs nothing here: it is a Room database in our own data
directory. Agendula publishes no ContentProvider and declares no
permissions of its own. -->
<uses-permission android:name="org.dmfs.permission.READ_TASKS" /> <uses-permission android:name="org.dmfs.permission.READ_TASKS" />
<uses-permission android:name="org.dmfs.permission.WRITE_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.READ_TASKS" />
@@ -74,8 +80,11 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Re-sync reminders when the provider changes (external DAVx5 sync). <!-- Re-sync reminders when an external provider changes — DAVx5 pulling
Targets both known authorities; the host must be static. --> tasks while Agendula is backgrounded. External mode only: in OWN mode
nothing outside the app can change our data, and Room's
InvalidationTracker covers our own writes. An intent-filter host must
be a literal, so both external authorities are listed. -->
<receiver <receiver
android:name=".data.reminders.ProviderChangeReceiver" android:name=".data.reminders.ProviderChangeReceiver"
android:exported="true"> android:exported="true">

View File

@@ -1,18 +1,22 @@
package de.jeanlucmakiola.agendula package de.jeanlucmakiola.agendula
import android.app.Application import android.app.Application
import androidx.lifecycle.ProcessLifecycleOwner
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/** /**
* Application entry point. Registered as android:name=".AgendulaApp". Besides * Application entry point. Registered as android:name=".AgendulaApp". Besides
@@ -35,17 +39,42 @@ class AgendulaApp : Application() {
issueTitle = getString(R.string.crash_report_issue_title), issueTitle = getString(R.string.crash_report_issue_title),
), ),
) )
val scheduler = EntryPointAccessors val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
.fromApplication(this, ReminderEntryPoint::class.java) val scheduler = entryPoint.reminderScheduler()
.reminderScheduler() // Mirror the stored storage mode into ProviderResolver and import a
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { // v0.3.x install's tasks, both before anything reads a store.
runCatching { scheduler.sync() } val startupGate = entryPoint.startupGate()
val scope = entryPoint.applicationScope()
// An alarm is armed off whichever store was active when it was scheduled,
// so a switch has to rebuild the set. Armed only once startup's own
// null -> stored transition is past, which the launch sync below covers.
val started = AtomicBoolean(false)
entryPoint.providerResolver().onModeChanged {
if (started.get()) scope.launch { runCatching { scheduler.sync() } }
}
startupGate.start()
ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint())
scope.launch {
// Wait for the stored mode and the import to land first. Rescheduling
// alarms against whichever store autoMode happens to pick would arm
// them off the wrong one — or off an empty one, mid-import.
runCatching {
startupGate.awaitReady()
started.set(true)
scheduler.sync()
}
} }
} }
@EntryPoint @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface ReminderEntryPoint { interface AppEntryPoint {
fun reminderScheduler(): ReminderScheduler fun reminderScheduler(): ReminderScheduler
fun startupGate(): StartupGate
fun providerResolver(): ProviderResolver
@ApplicationScope
fun applicationScope(): CoroutineScope
fun databaseCheckpoint(): DatabaseCheckpoint
} }
} }

View File

@@ -50,7 +50,7 @@ class DemoSeeder @Inject constructor(
repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId)) repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId))
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day))) val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
repository.setCompleted(done, completed = true) repository.setCompleted(done, occurrenceStart = null, completed = true)
} }
private companion object { private companion object {

View File

@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
@@ -10,12 +12,21 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Provider
import javax.inject.Singleton import javax.inject.Singleton
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore( private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
@@ -28,11 +39,11 @@ abstract class DataBindModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
@Binds @Binds
@Singleton @Singleton
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
} }
@Module @Module
@@ -44,7 +55,41 @@ object DataProvideModule {
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.agendulaDataStore context.agendulaDataStore
@Provides
@Singleton
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
Room.databaseBuilder(context, TasksDatabase::class.java, TasksDatabase.NAME)
// Room's default, stated rather than assumed: Auto Backup copies files
// without checkpointing, so a `-wal` sidecar can hold writes the
// backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.build()
/**
* The active store, chosen by [StorageMode].
*
* Resolved per injection point rather than bound once, because the mode is a
* user setting that [de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder]
* can change while the process lives. Both implementations are singletons, so
* this picks between two long-lived objects rather than building either.
*/
@Provides
@Singleton
fun provideTasksDataSource(
resolver: ProviderResolver,
room: Provider<RoomTasksDataSource>,
external: Provider<AndroidTasksDataSource>,
): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external)
@Provides @Provides
@IoDispatcher @IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Singleton
@ApplicationScope
fun provideApplicationScope(): CoroutineScope =
// SupervisorJob so one failing collector can't take the others down with it.
CoroutineScope(SupervisorJob() + Dispatchers.Default)
} }

View File

@@ -6,3 +6,13 @@ import javax.inject.Qualifier
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher annotation class IoDispatcher
/**
* Marks the process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that
* outlives any screen and has nothing to be cancelled by, such as keeping the
* selected storage mode mirrored out of DataStore. It is never cancelled, so
* don't launch anything unbounded in it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope

View File

@@ -0,0 +1,132 @@
package de.jeanlucmakiola.agendula.data.export
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.inject.Inject
import javax.inject.Singleton
/** Where an export ended up, for the UI to report. */
data class ExportResult(val fileCount: Int, val taskListNames: List<String>)
/**
* Why an export failed, as a value rather than a message: the UI ships in eleven
* locales, so the wording has to come from a string resource.
*/
enum class ExportFailure {
FOLDER_UNAVAILABLE,
FOLDER_NOT_WRITABLE,
CANNOT_CREATE_FILE,
LOST_ACCESS,
WRITE_FAILED,
}
/** The export could not be written. */
class ExportFailedException(
val failure: ExportFailure,
cause: Throwable? = null,
) : IOException(failure.name, cause)
/**
* Writes [ExportDocument]s to a user-chosen location through the Storage Access
* Framework.
*
* No storage permission anywhere: SAF hands us a `Uri` the user picked
* themselves, which is both the modern approach and the only one that still works
* on scoped storage. The caller owns launching `ACTION_CREATE_DOCUMENT` (for
* [writeZip]) or `ACTION_OPEN_DOCUMENT_TREE` (for [writeToTree]) and passes the
* result here.
*
* Marked in `docs/STORAGE-AND-SYNC.md` as floret-kit material — the plumbing is
* not task-domain and Calendula will want the same thing. Kept app-local for now
* on the kit's own stated principle of not extracting until a second consumer
* actually exists; the seam is here, so moving it later is a file move.
*/
@Singleton
class ExportWriter @Inject constructor(
@ApplicationContext private val context: Context,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Writes every document into [treeUri], a directory the user picked.
*
* A same-named file is truncated and rewritten in place rather than deleted
* and recreated: SAF would otherwise append " (1)" and turn the folder into
* an unusable pile of snapshots, and a delete that is not followed by a
* successful create loses the previous export outright.
*
* The directory is listed once. `DocumentFile.findFile` queries the whole
* tree per call, so looking each name up in the loop is one full
* cross-process directory scan per list.
*/
suspend fun writeToTree(treeUri: Uri, documents: List<ExportDocument>): ExportResult =
withContext(io) {
runCatching {
val tree = DocumentFile.fromTreeUri(context, treeUri)
?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE)
if (!tree.canWrite()) throw ExportFailedException(ExportFailure.FOLDER_NOT_WRITABLE)
val existing = tree.listFiles().associateBy { it.name }
documents.forEach { document ->
val file = existing[document.fileName]
?: tree.createFile(MIME_ICALENDAR, document.fileName)
?: throw ExportFailedException(ExportFailure.CANNOT_CREATE_FILE)
write(file.uri, document.content)
}
}.getOrElse { throw asExportFailure(it) }
ExportResult(documents.size, documents.map { it.fileName })
}
/**
* Writes every document into a single zip at [target].
*
* The one-file form, for sharing or for a backup the user filed somewhere
* themselves — one attachment rather than one per list.
*/
suspend fun writeZip(target: Uri, documents: List<ExportDocument>): ExportResult =
withContext(io) {
runCatching {
context.contentResolver.openOutputStream(target, "wt")?.use { raw ->
ZipOutputStream(raw.buffered()).use { zip ->
documents.forEach { document ->
zip.putNextEntry(ZipEntry(document.fileName))
zip.write(document.content)
zip.closeEntry()
}
}
} ?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
}.getOrElse { throw asExportFailure(it) }
ExportResult(documents.size, documents.map { it.fileName })
}
private fun write(target: Uri, bytes: ByteArray) {
runCatching {
// "wt" truncates. Without it a shorter export leaves the tail of the
// previous, longer one behind and produces a corrupt file.
context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) }
?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
}.getOrElse { throw asExportFailure(it) }
}
private fun asExportFailure(cause: Throwable): Throwable = when (cause) {
is ExportFailedException -> cause
// A SAF grant can be revoked between the picker and the write (the volume
// was unmounted, the provider's process died, the user cleared the grant).
is SecurityException -> ExportFailedException(ExportFailure.LOST_ACCESS, cause)
is IOException -> ExportFailedException(ExportFailure.WRITE_FAILED, cause)
else -> cause
}
private companion object {
const val MIME_ICALENDAR = "text/calendar"
}
}

View File

@@ -0,0 +1,76 @@
package de.jeanlucmakiola.agendula.data.export
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
import de.jeanlucmakiola.agendula.domain.export.ExportList
import de.jeanlucmakiola.agendula.domain.export.ICalendarWriter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Turns the user's task lists into `.ics` documents.
*
* Export is a v1 feature rather than a nicety because of where the data now
* lives: our own provider is inside the app's private storage, so in Local mode a
* 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.
*
* **One document per list**, because a list is a CalDAV collection and that is the
* unit every other client understands. Bundling everything into a single file
* would flatten the lists away, and list membership is not recoverable from a
* VTODO afterwards.
*/
@Singleton
class TaskExporter @Inject constructor(
private val dataSource: TasksDataSource,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Serialises [listIds] — every visible list when null.
*
* A list with no tasks still produces a document. An empty `.ics` is a real
* answer ("this list is empty"), whereas a missing file is indistinguishable
* from the export having gone wrong.
*/
suspend fun export(listIds: Set<Long>? = null): List<ExportDocument> = withContext(io) {
dataSource.taskLists()
.filter { listIds == null || it.id in listIds }
.map { list ->
val document = ExportList(
listId = list.id,
name = list.name,
accountName = list.accountName,
tasks = dataSource.exportTasks(list.id),
)
ExportDocument(
fileName = fileNameFor(list.name, list.id),
content = ICalendarWriter.write(document).toByteArray(Charsets.UTF_8),
)
}
}
companion object {
/**
* A file name derived from the list name, safe on every filesystem the
* user might pick through SAF (including FAT32 on an SD card).
*
* The list id is appended rather than trusted to be redundant: two lists on
* different accounts may share a name, and two exports landing on the same
* file would silently lose one of them.
*/
fun fileNameFor(listName: String, listId: Long): String {
val safe = listName
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '-' }
.joinToString("")
.trim('-')
.take(60)
.ifBlank { "list" }
return "$safe-$listId.ics"
}
}
}

View File

@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
@@ -82,6 +83,31 @@ class SettingsPrefs @Inject constructor(
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes } suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
/**
* Which task store backs the app, or `null` while the user has not chosen —
* which is the normal state, since most people never open Settings.
*
* Kept out of [Settings] on purpose. Everything in there is a rendering
* preference collected by the UI; this one selects an authority in the data
* layer, is read on paths that must not wait for a whole settings object, and
* `null` genuinely means "undecided" rather than "default" — the difference
* matters, because undecided is what lets `ProviderResolver.autoMode` keep an
* upgrading Posture A user pointed at the provider that holds their data.
*/
val storageMode: Flow<StorageMode?> = dataStore.data.map { p ->
when (val stored = p[STORAGE_MODE]) {
null -> null
// 0.3.x's value for the bundled dmfs provider. That store is gone and
// its data was imported into OWN, so read it as OWN rather than
// letting it fall through to autoMode — someone who chose local
// storage explicitly would otherwise be sent to an external provider.
"LOCAL" -> StorageMode.OWN
else -> runCatching { StorageMode.valueOf(stored) }.getOrNull()
}
}
suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name }
/** One-time reminder onboarding gate; false until the step has been shown. */ /** One-time reminder onboarding gate; false until the step has been shown. */
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false } val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
@@ -113,6 +139,7 @@ class SettingsPrefs @Inject constructor(
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row") val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar") val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done") val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
val STORAGE_MODE = stringPreferencesKey("storage_mode")
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override") val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
} }

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
@@ -45,7 +46,14 @@ class DueReminderReceiver : BroadcastReceiver() {
companion object { companion object {
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID" private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
fun intent(context: Context, taskId: Long): Intent = /**
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId) * [triggerAt] rides in the intent *data*, not just an extra: PendingIntent
* identity ignores extras, so two occurrences of the same recurring task
* would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT.
*/
fun intent(context: Context, taskId: Long, triggerAt: Long): Intent =
Intent(context, DueReminderReceiver::class.java)
.setData("agendula://reminder/$taskId/$triggerAt".toUri())
.putExtra(EXTRA_TASK_ID, taskId)
} }
} }

View File

@@ -3,7 +3,9 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.SystemClock
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -20,10 +22,25 @@ import javax.inject.Inject
class ProviderChangeReceiver : BroadcastReceiver() { class ProviderChangeReceiver : BroadcastReceiver() {
@Inject lateinit var scheduler: ReminderScheduler @Inject lateinit var scheduler: ReminderScheduler
@Inject lateinit var providerResolver: ProviderResolver
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
// The receiver has to stay exported to hear the provider's broadcast, and
// the sender holds no permission we could require — so validate the
// broadcast itself. Without this, any installed app can spam a full
// re-sync (an unbounded provider read) by firing a matching intent.
if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return
val authority = providerResolver.resolve()?.authority ?: return
if (intent.data?.host != authority) return
// External sync can fire these in bursts; one re-sync per burst is plenty.
val now = SystemClock.elapsedRealtime()
synchronized(Companion) {
if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return
lastSyncAt = now
}
val pending = goAsync() val pending = goAsync()
scope.launch { scope.launch {
try { try {
@@ -33,4 +50,11 @@ class ProviderChangeReceiver : BroadcastReceiver() {
} }
} }
} }
private companion object {
const val MIN_SYNC_INTERVAL_MS = 10_000L
@Volatile
var lastSyncAt = -MIN_SYNC_INTERVAL_MS
}
} }

View File

@@ -12,15 +12,18 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* The self-scheduled due-reminder engine. Tasks providers don't deliver * The self-scheduled due-reminder engine. Nothing else delivers task reminders —
* reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager] * not the platform, not a tasks provider so Agendula reads upcoming due tasks
* alarm each, within a rolling window. Re-run on app start, boot and provider * and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run
* change; it diffs against [ScheduledReminderStore] so only changed alarms move. * on app start, on boot, on a store switch, and on an external provider change;
* it diffs against [ScheduledReminderStore] so only changed alarms move.
*/ */
@Singleton @Singleton
class ReminderScheduler @Inject constructor( class ReminderScheduler @Inject constructor(
@@ -31,47 +34,89 @@ class ReminderScheduler @Inject constructor(
private val providerResolver: ProviderResolver, private val providerResolver: ProviderResolver,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) { ) {
suspend fun sync() = withContext(io) { private val syncLock = Mutex()
val provider = providerResolver.resolve()
/**
* Diff the armed alarms against the store and move only what changed.
*
* Serialised: the diff is a read-modify-write over [ScheduledReminderStore],
* and callers overlap (a store switch fires this while the launch sync may
* still be running). Two interleaved runs would each write their own set as
* the whole truth, leaving the other's alarms armed but unrecorded — never
* cancelled, and firing against the wrong store's task ids.
*/
suspend fun sync() = withContext(io) { syncLock.withLock { syncLocked() } }
private suspend fun syncLocked() {
val settings = settingsPrefs.settings.first() val settings = settingsPrefs.settings.first()
if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) { // Gate on whether the store is readable, not on whether a provider
// resolves: our own store deliberately resolves to no provider, so the
// latter clears every reminder in the default mode.
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
clearAll() clearAll()
return@withContext return
} }
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val horizon = now + WINDOW_MS val horizon = now + WINDOW_MS
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return@withContext } .getOrElse { return }
// One reminder per *occurrence*: a recurring series yields a row per
// occurrence, all sharing a taskId, so this is a Set rather than a
// taskId-keyed Map — keying by task would collapse a daily recurring task
// down to one arbitrary reminder.
// Per-task leads. One query for all of them.
val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() }
val desired = tasks val desired = tasks
.filter { !it.isClosed && it.due != null } .filter { !it.isClosed && it.due != null }
.mapNotNull { task -> .mapNotNull { task ->
// The task's list may override the global lead, or opt out entirely // A reminder set on the task itself wins; otherwise the task's list
// (override = null), in which case it gets no reminder at all. // may override the global lead, or opt out entirely (override =
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null // null), in which case it gets no reminder at all.
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L) val reminder = perTask[task.taskId]
val lead = reminder?.minutesBefore
?: settings.reminderLeadFor(task.listId)
?: return@mapNotNull null
// A stored reminder says what it counts back from. Ours are always
// before due, but an imported dmfs alarm or another client's can be
// before *start* — firing those off the due date is silently wrong
// for every task whose start and due differ.
val anchor = if (reminder?.fromStart == true) task.start ?: task.due!! else task.due!!
ScheduledReminder(
taskId = task.taskId,
triggerAt = anchor.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L,
)
} }
.toMap() // The lower bound trails `now` so a reminder missed while the device was
.filterValues { it in now..horizon } // off still fires once on boot instead of being silently dropped —
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
// already armed stays armed (the diff below), so it can't re-fire.
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon }
.toSet()
val previous = store.all() val previous = store.all()
(previous.keys - desired.keys).forEach { cancel(it) } (previous - desired).forEach { cancel(it) }
desired.forEach { (taskId, triggerAt) -> (desired - previous).forEach { schedule(it) }
if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
}
store.replace(desired) store.replace(desired)
} }
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java) private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? { private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags) return PendingIntent.getBroadcast(
context,
reminder.requestCode,
DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt),
flags,
)
} }
private fun schedule(taskId: Long, triggerAt: Long) { private fun schedule(reminder: ScheduledReminder) {
val pi = pendingIntent(taskId, create = true) ?: return val triggerAt = reminder.triggerAt
val pi = pendingIntent(reminder, create = true) ?: return
val am = alarmManager() val am = alarmManager()
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms() val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
if (canExact) { if (canExact) {
@@ -81,19 +126,21 @@ class ReminderScheduler @Inject constructor(
} }
} }
private fun cancel(taskId: Long) { private fun cancel(reminder: ScheduledReminder) {
pendingIntent(taskId, create = false)?.let { pendingIntent(reminder, create = false)?.let {
alarmManager().cancel(it) alarmManager().cancel(it)
it.cancel() it.cancel()
} }
} }
private suspend fun clearAll() { private suspend fun clearAll() {
store.all().keys.forEach { cancel(it) } store.all().forEach { cancel(it) }
store.replace(emptyMap()) store.replace(emptySet())
} }
private companion object { private companion object {
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
/** How long after its trigger a missed reminder is still worth firing. */
const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
} }
} }

View File

@@ -9,25 +9,38 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Remembers which task reminders are currently scheduled (taskId → trigger time), * One armed alarm. A recurring task has many occurrences sharing a [taskId], so
* so [ReminderScheduler] can diff against a fresh computation and cancel only the * the trigger time is part of the identity — keying by task alone would collapse
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings. * a daily task down to a single reminder.
*/
data class ScheduledReminder(val taskId: Long, val triggerAt: Long) {
/**
* Request code for this alarm's PendingIntent. Derived from both fields so
* sibling occurrences don't share (and overwrite) one alarm slot.
*/
val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode()
}
/**
* Remembers which task reminders are currently armed, so [ReminderScheduler] can
* diff against a fresh computation and touch only the alarms that changed.
* Persisted in DataStore as a set of `taskId|trigger` strings.
*/ */
@Singleton @Singleton
class ScheduledReminderStore @Inject constructor( class ScheduledReminderStore @Inject constructor(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
) { ) {
suspend fun all(): Map<Long, Long> = suspend fun all(): Set<ScheduledReminder> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry -> dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
val parts = entry.split('|') val parts = entry.split('|')
val id = parts.getOrNull(0)?.toLongOrNull() val id = parts.getOrNull(0)?.toLongOrNull()
val at = parts.getOrNull(1)?.toLongOrNull() val at = parts.getOrNull(1)?.toLongOrNull()
if (id != null && at != null) id to at else null if (id != null && at != null) ScheduledReminder(id, at) else null
}.toMap() }.toSet()
suspend fun replace(scheduled: Map<Long, Long>) { suspend fun replace(scheduled: Set<ScheduledReminder>) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet() prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet()
} }
} }

View File

@@ -11,13 +11,16 @@ import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import java.time.ZoneId import java.time.ZoneId
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Instant
/** /**
* The only class that knows about the ContentResolver, [TasksContract] and the * The only class that knows about the ContentResolver, [TasksContract] and the
@@ -83,6 +86,26 @@ class AndroidTasksDataSource @Inject constructor(
} ?: emptyList() } ?: emptyList()
} }
override fun exportTasks(listId: Long): List<ExportTask> {
// projection = null for the same reason queryInstances uses it: the tasks
// table's shape varies across provider versions, and the by-name mapper
// reads what's there.
val uri = TasksContract.tasksUri(authority())
return resolver.query(
uri,
null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
// the user is concerned, so exporting it would resurrect deleted tasks
// in the backup.
"${Tasks.LIST_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(listId.toString()),
null,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList()
}
// --- writes --------------------------------------------------------------- // --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long { override fun insertTask(form: TaskForm): Long {
@@ -98,12 +121,109 @@ class AndroidTasksDataSource @Inject constructor(
if (rows == 0) throw TaskWriteFailedException("update task $taskId") if (rows == 0) throw TaskWriteFailedException("update task $taskId")
} }
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
}
/**
* The provider's instance row id for one occurrence.
*
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
* through the instances URI still needs the row id, so it is looked up here
* rather than carried around above the data layer. Selection is on `task_id`
* only — the anchor is matched in Kotlin because the column that holds it
* (`instance_original_time`) is missing on older provider schemas, where a
* WHERE clause naming it would throw instead of falling back.
*/
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
val uri = TasksContract.instancesUri(authority())
val selection = "${Instances.TASK_ID} = ?"
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
val reader = CursorColumnReader(c)
while (c.moveToNext()) {
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
return@use reader.getLong(Tasks.ID)
}
}
null
}
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
val uri = TasksContract.propertiesUri(authority())
// Replace rather than update: the provider's AlarmHandler re-validates the
// whole row on every update, so a partial edit throws — and delete+insert
// means we never have to track property_id.
resolver.delete(
uri,
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE),
)
if (minutesBeforeDue != null) {
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
?: throw TaskWriteFailedException("set alarm for task $taskId")
}
}
override fun alarms(): Map<Long, TaskReminder> {
val uri = TasksContract.propertiesUri(authority())
val projection = arrayOf(
Properties.TASK_ID,
TasksContract.Alarm.MINUTES_BEFORE,
TasksContract.Alarm.REFERENCE,
)
return resolver.query(
uri,
projection,
"${Properties.MIMETYPE} = ?",
arrayOf(TasksContract.Alarm.MIMETYPE),
null,
)?.use { c ->
val reader = CursorColumnReader(c)
buildMap {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID)
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
val reference = reader.getInt(TasksContract.Alarm.REFERENCE)
if (id != null && minutes != null) {
put(
id,
TaskReminder(
minutesBefore = minutes,
fromStart = reference == TasksContract.Alarm.REFERENCE_START,
),
)
}
}
}
} ?: emptyMap()
}
override fun setCompleted(taskId: Long, completed: Boolean) { override fun setCompleted(taskId: Long, completed: Boolean) {
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis()) val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("complete task $taskId") if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
} }
/**
* Through the instances URI, which is what makes the provider fork an override
* rather than close the series. No instance row for the anchor means the task
* is not a series after all — the plain write is then the right one.
*/
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: return setCompleted(taskId, completed)
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
}
override fun deleteTask(taskId: Long) { override fun deleteTask(taskId: Long) {
resolver.delete(taskUri(authority(), taskId), null, null) resolver.delete(taskUri(authority(), taskId), null, null)
} }
@@ -120,6 +240,31 @@ class AndroidTasksDataSource @Inject constructor(
return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id") return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id")
} }
override fun updateList(listId: Long, name: String, color: Int) {
val values = TaskWriteMapper.listValues(name, color)
val rows = resolver.update(listSyncUri(listId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update list $listId")
}
override fun deleteList(listId: Long) {
val rows = resolver.delete(listSyncUri(listId), null, null)
if (rows == 0) throw TaskWriteFailedException("delete list $listId")
}
/**
* A list row addressed as its own account's sync adapter — the provider only
* lets that caller write the `tasklists` table, and the account has to be the
* row's own (the params are matched against it, not merely accepted).
*/
private fun listSyncUri(listId: Long): Uri {
val authority = authority()
val uri = TasksContract.listUri(authority, listId)
val account = resolver.query(uri, arrayOf(Lists.ACCOUNT_NAME, Lists.ACCOUNT_TYPE), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0).orEmpty() to c.getString(1).orEmpty() else null }
?: throw TaskWriteFailedException("list $listId not found")
return TasksContract.asSyncAdapter(uri, account.first, account.second)
}
// --- observation ---------------------------------------------------------- // --- observation ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable { override fun registerObserver(onChange: () -> Unit): AutoCloseable {
@@ -127,9 +272,16 @@ class AndroidTasksDataSource @Inject constructor(
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) = onChange() override fun onChange(selfChange: Boolean) = onChange()
} }
// Register both or neither: if the second call throws, the first
// registration would otherwise leak (no AutoCloseable was handed back yet).
try {
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer) resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer) resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
return AutoCloseable { resolver.unregisterContentObserver(observer) } } catch (e: RuntimeException) {
runCatching { resolver.unregisterContentObserver(observer) }
throw e
}
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
} }
private fun Map<String, Any?>.toContentValues(): ContentValues { private fun Map<String, Any?>.toContentValues(): ContentValues {

View File

@@ -0,0 +1,88 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import javax.inject.Provider
import kotlin.time.Instant
/**
* Routes every call to the store [StorageMode] selects.
*
* Per-call rather than bound once: the mode is a setting the user can change
* while the process lives, and [StorageModeHolder] pushes the new value into
* [ProviderResolver] without rebuilding the object graph. Both delegates are
* singletons, so this chooses between two existing objects.
*
* Room versus a third-party ContentProvider — nothing else.
*/
class ModeRoutingTasksDataSource(
private val resolver: ProviderResolver,
private val room: Provider<RoomTasksDataSource>,
private val external: Provider<AndroidTasksDataSource>,
) : TasksDataSource {
private fun active(): TasksDataSource =
when (resolver.mode()) {
StorageMode.OWN -> room.get()
StorageMode.EXTERNAL -> external.get()
}
override fun taskLists(): List<TaskList> = active().taskLists()
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
override fun task(taskId: Long): Task? = active().task(taskId)
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
active().updateInstance(taskId, occurrenceStart, form)
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
override fun exportTasks(listId: Long): List<ExportTask> = active().exportTasks(listId)
override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed)
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) =
active().setCompletedInstance(taskId, occurrenceStart, completed)
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color)
override fun deleteList(listId: Long) = active().deleteList(listId)
/**
* Unlike every other method here, an observer is registered once and then
* *held* — so it cannot be routed per call, and would otherwise stay bound to
* whichever store was active when the flow started. Switching stores in
* Settings would then leave every open screen listening to the store it is no
* longer reading from.
*
* So the registration moves with the mode, and the switch itself counts as a
* change: the data underneath every live flow has just been replaced.
*/
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
val lock = Any()
var closed = false
var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull()
val modeHandle = resolver.onModeChanged {
synchronized(lock) {
if (!closed) {
handle?.let { runCatching { it.close() } }
handle = runCatching { active().registerObserver(onChange) }.getOrNull()
}
}
onChange()
}
return AutoCloseable {
synchronized(lock) {
closed = true
modeHandle.close()
handle?.let { runCatching { it.close() } }
handle = null
}
}
}
}

View File

@@ -0,0 +1,46 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* The two platform facts [ProviderResolver] needs, behind an interface.
*
* Same seam the data source uses, for the same reason: which store a returning
* user lands on is decided by [ProviderResolver.autoMode], getting it wrong shows
* them an empty app, and that decision is worth testing on the JVM rather than
* only on a device. Everything Android-shaped lives here so the logic above stays
* plain Kotlin.
*/
interface ProviderEnvironment {
/** The package declaring [authority], or `null` when nothing on the device does. */
fun packageDeclaring(authority: String): String?
/** Whether this app currently holds [permission]. */
fun isGranted(permission: String): Boolean
/** [packageName]'s own app name, or null when it cannot be read. */
fun appLabel(packageName: String): String?
}
@Singleton
class AndroidProviderEnvironment @Inject constructor(
@ApplicationContext private val context: Context,
) : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? =
context.packageManager.resolveContentProvider(authority, 0)?.packageName
override fun isGranted(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
override fun appLabel(packageName: String): String? = runCatching {
val pm = context.packageManager
pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
}.getOrNull()
}

View File

@@ -0,0 +1,31 @@
package de.jeanlucmakiola.agendula.data.tasks
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.retryWhen
private const val BASE_RETRY_MS = 1_000L
private const val MAX_RETRY_MS = 30_000L
/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */
private fun retryDelayMs(attempt: Long): Long =
(BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS)
/**
* Recover a provider-backed flow without killing it.
*
* Provider reads fail for reasons that resolve on their own: the read permission
* isn't granted yet (first launch collects before the permission gate), or the
* provider app is mid-update. A terminal `catch` swallows the failure *and*
* cancels the upstream, so the flow never produces again — the screen stays empty
* until the process restarts, even after the user grants the permission.
*
* This emits [fallback] instead and keeps retrying with a capped backoff, so the
* collector recovers on its own once the provider becomes readable.
*/
fun <T> Flow<T>.recoveringFromProviderFailure(fallback: () -> T): Flow<T> =
retryWhen { _, attempt ->
emit(fallback())
delay(retryDelayMs(attempt))
true
}

View File

@@ -1,15 +1,12 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.content.Context import java.util.concurrent.CopyOnWriteArrayList
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every * An external tasks provider Agendula can talk to. Every candidate runs the same
* candidate, so the [TasksContract] columns apply regardless of which is present. * dmfs `TaskProvider`, so the [TasksContract] columns apply to either.
*/ */
data class TaskProvider( data class TaskProvider(
val authority: String, val authority: String,
@@ -19,41 +16,122 @@ data class TaskProvider(
) )
/** /**
* The A/B seam. Detects which tasks provider is installed at runtime and which * Discovers the *external* tasks providers (OpenTasks, tasks.org) that
* permission set it needs, so nothing above the data layer hardcodes an * [StorageMode.EXTERNAL] can be pointed at.
* authority. Under Posture B (bundled provider) this simply finds our own *
* `org.dmfs.tasks` first. See docs/PLAN.md. * This used to be the A/B seam between an external provider and one Agendula
* bundled itself. That second half is gone: [StorageMode.OWN] is a Room database
* with no authority, no ContentResolver and nothing to permit, so there is
* nothing here for it to resolve. See `docs/OWN-STORE.md`.
*
* Which store is active comes from [storageMode]; how that gets decided when the
* user has not chosen is [autoMode].
*/ */
@Singleton @Singleton
class ProviderResolver @Inject constructor( class ProviderResolver @Inject constructor(
@ApplicationContext private val context: Context, private val environment: ProviderEnvironment,
) { ) {
/** The active provider, or `null` when no tasks provider is installed. */ private val modeListeners = CopyOnWriteArrayList<() -> Unit>()
fun resolve(): TaskProvider? {
for (candidate in CANDIDATES) { /**
val info = context.packageManager.resolveContentProvider(candidate.authority, 0) * The user's explicit choice, or `null` while they have not made one (which is
?: continue * the normal state — most people never open Settings). Kept as a plain field
return candidate.copy(packageName = info.packageName) * rather than read from DataStore on demand because [resolve] is called from
* synchronous data-source code on every query, including from the main thread
* via `providerStatus()`. [StorageModeHolder] owns keeping it current.
*
* Assigning a *different* mode notifies [onModeChanged]: every live store
* observer is bound to one store and has to be moved across.
*/
@Volatile
var storageMode: StorageMode? = null
set(value) {
val changed = field != value
field = value
if (changed) modeListeners.forEach { it() }
}
/**
* Observe switches between stores. Fires on the thread that set [storageMode]
* — [StorageModeHolder]'s collector — so listeners must be cheap and must not
* block.
*/
fun onModeChanged(listener: () -> Unit): AutoCloseable {
modeListeners += listener
return AutoCloseable { modeListeners -= listener }
}
/** The active store, resolving the undecided case through [autoMode]. */
fun mode(): StorageMode = storageMode ?: autoMode()
/**
* The provider to query, or `null` — either because [StorageMode.OWN] is
* active and there is no provider involved at all, or because
* [StorageMode.EXTERNAL] is and none is installed. Callers that need to tell
* those apart ask [mode].
*/
fun resolve(): TaskProvider? = when (mode()) {
StorageMode.OWN -> null
StorageMode.EXTERNAL -> resolveExternal()
}
/**
* What to use when the user has not chosen — and the one piece of real
* judgement in this class, because getting it wrong loses people their data.
*
* Ranking our own store first unconditionally would be wrong: someone who
* has been using Agendula over OpenTasks since 0.3.x would update, land on an
* empty database, and reasonably conclude their tasks were deleted.
*
* So the tell is **whether we already hold an external provider's runtime
* permission**. That is a dangerous permission — it can only be there because
* a previous version asked and the user agreed, which is precisely the
* definition of "this person is an existing Posture A user". A fresh install
* never holds it, and gets our own store.
*
* Deliberately cheap and synchronous: a PackageManager lookup and a permission
* check, no database probe. Settings overrides it either way.
*/
fun autoMode(): StorageMode {
val external = resolveExternal()
return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.OWN
}
/** The first installed external candidate, or `null` when none is present. */
fun resolveExternal(): TaskProvider? {
for (candidate in EXTERNAL_CANDIDATES) {
val packageName = environment.packageDeclaring(candidate.authority) ?: continue
return candidate.copy(packageName = packageName)
} }
return null return null
} }
fun hasPermission(provider: TaskProvider): Boolean = fun hasPermission(provider: TaskProvider): Boolean =
granted(provider.readPermission) && granted(provider.writePermission) environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission)
private fun granted(permission: String): Boolean = /**
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED * Whether the active store can be read at all.
*
* [StorageMode.OWN] always can — it is our own database, with nothing to
* install and nothing to grant. Only [StorageMode.EXTERNAL] can be
* unreadable. Callers that gate on `resolve() != null` instead get this wrong
* the moment OWN is active, because OWN resolves to no provider by design.
*/
fun canReadStore(): Boolean = when (mode()) {
StorageMode.OWN -> true
StorageMode.EXTERNAL -> resolveExternal()?.let(::hasPermission) == true
}
companion object { companion object {
/** /**
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
* `org.dmfs.provider.tasks.TaskProvider`, guarded by * `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*`
* `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks` * (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`.
* + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical * OpenTasks is listed first as the canonical authority; on a device with
* authority; on a device with only one installed, order is moot. * only one installed, order is moot.
*/ */
val CANDIDATES: List<TaskProvider> = listOf( val EXTERNAL_CANDIDATES: List<TaskProvider> = listOf(
TaskProvider( TaskProvider(
authority = "org.dmfs.tasks", authority = "org.dmfs.tasks",
readPermission = "org.dmfs.permission.READ_TASKS", readPermission = "org.dmfs.permission.READ_TASKS",

View File

@@ -0,0 +1,46 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* The work that has to finish before anything reads a task store: the stored
* [StorageMode] has to reach [ProviderResolver], and a v0.3.x install's tasks
* have to be imported out of the dmfs provider's file into Room.
*
* Both are startup races with the same shape. Reading before the mode lands
* answers from `autoMode()` instead of the user's choice; reading before the
* import lands shows an upgrading user an empty app, which is the single worst
* thing this migration could do.
*/
@Singleton
class StartupGate @Inject constructor(
private val storageModeHolder: StorageModeHolder,
private val oneShotImport: OneShotImport,
@ApplicationScope private val scope: CoroutineScope,
) {
private val ready = CompletableDeferred<Unit>()
/** Call once, from `Application.onCreate`. */
fun start() {
storageModeHolder.start()
scope.launch {
// Opens the gate even on failure: a store that cannot be imported is
// still better shown empty than not shown at all, and the source file
// is left where it was either way.
runCatching {
storageModeHolder.awaitReady()
oneShotImport.runIfNeeded()
}
ready.complete(Unit)
}
}
suspend fun awaitReady() = ready.await()
}

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.agendula.data.tasks
/**
* Which task store backs the app — the user's choice, per `docs/OWN-STORE.md`.
*
* Only two values, though `docs/STORAGE-AND-SYNC.md` describes three modes.
* **Synced is not a third store**: it is [OWN] with an account attached to a
* list, which is derived state rather than something the user picks. Attaching
* one is a plain `UPDATE task_lists SET account_id = ?` — not the full data
* migration it was under the dmfs provider, whose `ACCOUNT_TYPE` was write-once.
*/
enum class StorageMode {
/**
* Agendula's own Room database. The default, and always available: there is
* no authority, no ContentResolver and no permission to grant.
*/
OWN,
/**
* A tasks provider app already on the device (OpenTasks, tasks.org), synced by
* whatever that provider's engine is — DAVx5 and friends. Still fully
* supported, now a choice rather than the only way. Requires that provider's
* runtime read/write permissions.
*/
EXTERNAL,
}

View File

@@ -0,0 +1,47 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Mirrors the stored [StorageMode] into [ProviderResolver].
*
* The resolver is consulted synchronously from every data-source call and from
* `providerStatus()` on the main thread, so it cannot read DataStore itself.
* This is the one component that bridges the two: it collects the preference for
* the life of the process and pushes each value across.
*
* [awaitReady] exists for the startup race. Until the first DataStore emission
* arrives the resolver's mode is `null` and `ProviderResolver.autoMode` answers
* instead — fine as a steady state, wrong for a user who explicitly chose the
* other mode. Anything that touches the provider before the UI is up (the launch
* reminder re-sync, notably) should wait rather than risk reading the wrong
* store and rescheduling every alarm off it.
*/
@Singleton
class StorageModeHolder @Inject constructor(
private val prefs: SettingsPrefs,
private val resolver: ProviderResolver,
@ApplicationScope private val scope: CoroutineScope,
) {
private val firstValue = CompletableDeferred<Unit>()
/** Starts mirroring. Idempotent in effect; call once, from `Application.onCreate`. */
fun start() {
scope.launch {
prefs.storageMode.collect { mode ->
resolver.storageMode = mode
firstValue.complete(Unit)
}
}
}
/** Suspends until the stored mode has been applied at least once. */
suspend fun awaitReady() = firstValue.await()
}

View File

@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.priorityFromICal import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.statusFromInt import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlin.time.Instant import kotlin.time.Instant
@@ -16,10 +17,17 @@ object TaskMapper {
fun instant(name: String): Instant? = fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) } r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
val instanceId = r.getLong(Tasks.ID) ?: 0L val rowId = r.getLong(Tasks.ID) ?: 0L
// Derived from the rule columns rather than the `is_recurring` column
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
// absent on tasks.org's bundled provider (DB 22), where reading it
// would silently report every recurring task as one-off — and route
// its edits onto the series anchor.
val recurring = r.getString(Tasks.RRULE) != null ||
r.getString(Tasks.RDATE) != null ||
r.getBoolean(Instances.IS_RECURRING)
return Task( return Task(
id = instanceId, taskId = r.getLong(Instances.TASK_ID) ?: rowId,
taskId = r.getLong(Instances.TASK_ID) ?: instanceId,
listId = r.getLong(Tasks.LIST_ID) ?: 0L, listId = r.getLong(Tasks.LIST_ID) ?: 0L,
title = r.getString(Tasks.TITLE).orEmpty(), title = r.getString(Tasks.TITLE).orEmpty(),
description = r.getString(Tasks.DESCRIPTION), description = r.getString(Tasks.DESCRIPTION),
@@ -38,13 +46,66 @@ object TaskMapper {
listName = r.getString(Tasks.LIST_NAME), listName = r.getString(Tasks.LIST_NAME),
accountName = r.getString(Tasks.ACCOUNT_NAME), accountName = r.getString(Tasks.ACCOUNT_NAME),
parentId = r.getLong(Tasks.PARENT_ID), parentId = r.getLong(Tasks.PARENT_ID),
isRecurring = r.getBoolean(Instances.IS_RECURRING), isRecurring = recurring,
occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT), distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
created = instant(Tasks.CREATED), created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED), lastModified = instant(Tasks.LAST_MODIFIED),
) )
} }
/**
* The occurrence's `RECURRENCE-ID` anchor.
*
* `instance_original_time` is the provider's own name for it and is set on
* every occurrence of a recurring task, so it is read first. It is absent on
* older provider schemas, where the fallbacks reconstruct the same value: a
* DTSTART-anchored series instantiates each occurrence at its start, and a
* series carrying only DUE anchors on the due date instead.
*/
fun occurrenceAnchor(r: ColumnReader): Instant? =
(
r.getLong(Instances.INSTANCE_ORIGINAL_TIME)
?: r.getLong(Instances.INSTANCE_START)
?: r.getLong(Instances.INSTANCE_DUE)
)?.let { Instant.fromEpochMilliseconds(it) }
/**
* Maps a row of the **`tasks` table** — a master task, not an occurrence.
*
* Export reads there rather than from `instances` on purpose: in the instances
* view a recurring task appears once per occurrence with its times already
* resolved and no rule attached, so exporting from it would write the same
* task many times over and drop the RRULE that produced them. Here each task
* appears exactly once, carrying the rule itself.
*/
fun exportTask(r: ColumnReader): ExportTask {
fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
return ExportTask(
taskId = r.getLong(Tasks.ID) ?: 0L,
uid = r.getString(Tasks.UID),
title = r.getString(Tasks.TITLE).orEmpty(),
description = r.getString(Tasks.DESCRIPTION),
location = r.getString(Tasks.LOCATION),
url = r.getString(Tasks.URL),
priority = priorityFromICal(r.getInt(Tasks.PRIORITY)),
status = statusFromInt(r.getInt(Tasks.STATUS)),
percentComplete = r.getInt(Tasks.PERCENT_COMPLETE),
// The task's own columns, not the instance view's resolved ones.
start = instant(Tasks.DTSTART),
due = instant(Tasks.DUE),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
completedAt = instant(Tasks.COMPLETED),
created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED),
rrule = r.getString(Tasks.RRULE),
rdate = r.getString(Tasks.RDATE),
parentId = r.getLong(Tasks.PARENT_ID)?.takeIf { it > 0 },
)
}
fun taskList(r: ColumnReader): TaskList = TaskList( fun taskList(r: ColumnReader): TaskList = TaskList(
id = r.getLong(Lists.ID) ?: 0L, id = r.getLong(Lists.ID) ?: 0L,
name = r.getString(Lists.NAME).orEmpty(), name = r.getString(Lists.NAME).orEmpty(),

View File

@@ -1,8 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
/** Column lists requested from the provider. Order is irrelevant; we read by name. */ /** Column lists requested from the provider. Order is irrelevant; we read by name. */
object TaskProjections { object TaskProjections {
@@ -18,31 +16,9 @@ object TaskProjections {
Lists.ACCOUNT_TYPE, Lists.ACCOUNT_TYPE,
) )
/** Read from the `instances` view (inherits all task columns). */ // No `instances` projection on purpose: that read passes `projection = null`
val INSTANCES: Array<String> = arrayOf( // (all columns), because the view's shape differs across provider versions —
Tasks.ID, // tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
Instances.TASK_ID, // here would drift out of sync with the by-name mapper and quietly drop
Tasks.LIST_ID, // columns it depends on. See AndroidTasksDataSource.queryInstances.
Tasks.TITLE,
Tasks.DESCRIPTION,
Tasks.LOCATION,
Tasks.URL,
Tasks.PRIORITY,
Tasks.STATUS,
Tasks.PERCENT_COMPLETE,
Tasks.COMPLETED,
Tasks.IS_ALLDAY,
Tasks.TZ,
Instances.INSTANCE_START,
Instances.INSTANCE_DUE,
Tasks.TASK_COLOR,
Tasks.LIST_COLOR,
Tasks.LIST_NAME,
Tasks.ACCOUNT_NAME,
Tasks.PARENT_ID,
Instances.IS_RECURRING,
Instances.DISTANCE_FROM_CURRENT,
Tasks.CREATED,
Tasks.LAST_MODIFIED,
)
} }

View File

@@ -1,9 +1,21 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.toICal import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Instant
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
private fun Instant.forAllDay(allDay: Boolean): Instant =
if (!allDay) this
else Instant.fromEpochMilliseconds(
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
)
/** /**
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues), * Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
@@ -39,8 +51,17 @@ object TaskWriteMapper {
} }
} }
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0) put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
put(Tasks.DTSTART, form.start?.toEpochMilliseconds()) // All-day tasks are date-only in iCalendar. The provider reads them back
put(Tasks.DUE, form.due?.toEpochMilliseconds()) // through DateTime.toAllDay(), which drops the time-of-day and resolves the
// remaining date against UTC — so a local-midnight instant lands on the
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
// DUE and DURATION are mutually exclusive. The provider's Validating
// processor evaluates the *merged* row (supplied values over the stored
// ones), so writing DUE onto a task that already carries a DURATION throws
// "Only one of DUE or DURATION must be supplied." Clear it alongside.
put(Tasks.DURATION, null)
put(Tasks.PARENT_ID, form.parentId) put(Tasks.PARENT_ID, form.parentId)
// The provider treats a null tz as local time; set it explicitly for // The provider treats a null tz as local time; set it explicitly for
// timed tasks so the stored instant is unambiguous across zones. // timed tasks so the stored instant is unambiguous across zones.
@@ -48,6 +69,16 @@ object TaskWriteMapper {
put(Tasks.TZ, if (timed) tzId else null) put(Tasks.TZ, if (timed) tzId else null)
} }
/**
* Values for an update through the *instances* URI (a recurring occurrence).
* The provider clones the row into an override and strips list/recurrence
* fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than
* written and silently ignored — moving one occurrence between lists or
* parents isn't a thing the override model expresses.
*/
fun instanceValues(form: TaskForm, tzId: String): Map<String, Any?> =
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> = fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
if (completed) { if (completed) {
mapOf( mapOf(
@@ -63,9 +94,26 @@ object TaskWriteMapper {
) )
} }
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf( /**
* A reminder for [taskId], as an Alarm property row. The provider's validator
* requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every
* write, so all three are always present.
*/
fun alarmValues(taskId: Long, minutesBeforeDue: Int): Map<String, Any?> = mapOf(
Properties.TASK_ID to taskId,
Properties.MIMETYPE to Alarm.MIMETYPE,
Alarm.MINUTES_BEFORE to minutesBeforeDue,
Alarm.REFERENCE to Alarm.REFERENCE_DUE,
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
)
/** The user-owned columns of a list — what an edit is allowed to change. */
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
Lists.NAME to name.trim(), Lists.NAME to name.trim(),
Lists.COLOR to color, Lists.COLOR to color,
)
fun localListValues(name: String, color: Int): Map<String, Any?> = listValues(name, color) + mapOf(
Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME, Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE, Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
Lists.VISIBLE to 1, Lists.VISIBLE to 1,

View File

@@ -66,6 +66,9 @@ object TasksContract {
const val IS_ALLDAY = "is_allday" const val IS_ALLDAY = "is_allday"
const val TZ = "tz" const val TZ = "tz"
const val RRULE = "rrule" const val RRULE = "rrule"
const val RDATE = "rdate"
/** Set on an override row — the master occurrence this one replaces. */
const val ORIGINAL_INSTANCE_ID = "original_instance_id"
const val PARENT_ID = "parent_id" const val PARENT_ID = "parent_id"
const val SORTING = "sorting" const val SORTING = "sorting"
const val CREATED = "created" const val CREATED = "created"
@@ -96,8 +99,59 @@ object TasksContract {
const val INSTANCE_DUE_SORTING = "instance_due_sorting" const val INSTANCE_DUE_SORTING = "instance_due_sorting"
const val DISTANCE_FROM_CURRENT = "distance_from_current" const val DISTANCE_FROM_CURRENT = "distance_from_current"
const val IS_RECURRING = "is_recurring" const val IS_RECURRING = "is_recurring"
/**
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
* instantiated at, before any override moved it. Set on every occurrence
* of a recurring task, which is what makes it the occurrence's identity.
*/
const val INSTANCE_ORIGINAL_TIME = "instance_original_time"
} }
/** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */
object Properties {
const val PATH = "properties"
const val PROPERTY_ID = "property_id"
const val TASK_ID = "task_id"
const val MIMETYPE = "mimetype"
}
/**
* An alarm property row — a per-task reminder lead.
*
* Storage and sync format *only*: the provider fires nothing (its alarm
* scheduling is commented out and the internal `alarms` table is never
* populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler]
* still arms the real AlarmManager alarm. Writing it here is what makes the
* lead survive a sync and show up in other OpenTasks clients.
*
* The columns are the generic `dataN` slots; the meanings below are the
* Alarm property's contract for them.
*/
object Alarm {
const val MIMETYPE = "vnd.android.cursor.item/alarm"
/** `data0` — minutes from the reference date; positive means *before* it. */
const val MINUTES_BEFORE = "data0"
/** `data1` — which date to count from. */
const val REFERENCE = "data1"
/** `data2` — optional message shown with the alarm. */
const val MESSAGE = "data2"
/** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */
const val ALARM_TYPE = "data3"
const val REFERENCE_DUE = 1
const val REFERENCE_START = 2
/** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */
const val TYPE_MESSAGE = 1
}
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
// --- status values (TaskColumns.STATUS_*) -------------------------------- // --- status values (TaskColumns.STATUS_*) --------------------------------
const val STATUS_NEEDS_ACTION = 0 const val STATUS_NEEDS_ACTION = 0
const val STATUS_IN_PROCESS = 1 const val STATUS_IN_PROCESS = 1
@@ -109,9 +163,20 @@ object TasksContract {
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority") fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}") fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
fun listUri(authority: String, listId: Long): Uri =
Uri.parse("content://$authority/${Lists.PATH}/$listId")
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}") fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}") fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
/**
* A single occurrence. Updating through this URI is how a *recurring* task is
* edited: the provider clones the row into an override task
* (`original_instance_id` set, recurrence fields stripped) instead of moving
* the series anchor, which is what writing to `tasks/<id>` would do.
*/
fun instanceUri(authority: String, instanceId: Long): Uri =
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
/** Append the sync-adapter params required to write local-account rows. */ /** Append the sync-adapter params required to write local-account rows. */
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri = fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
uri.buildUpon() uri.buildUpon()

View File

@@ -3,6 +3,17 @@ package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import kotlin.time.Instant
/**
* A stored reminder: how long before, and what it counts back from.
*
* [fromStart] matters because both stores can hold a `START`-referenced alarm —
* the dmfs import preserves one, and an external provider's other clients write
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
* to a number here is what silently fired those reminders off the wrong anchor.
*/
data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false)
/** What to fetch from the provider. Smart-list date logic is applied above this. */ /** What to fetch from the provider. Smart-list date logic is applied above this. */
data class TaskQuery( data class TaskQuery(
@@ -23,10 +34,67 @@ interface TasksDataSource {
fun insertTask(form: TaskForm): Long fun insertTask(form: TaskForm): Long
fun updateTask(taskId: Long, form: TaskForm) fun updateTask(taskId: Long, form: TaskForm)
/**
* Update a single occurrence of a recurring task, addressed by the task row and
* the occurrence's `RECURRENCE-ID` anchor ([Task.occurrenceStart]). The store
* forks an override rather than moving the series anchor — which is what
* [updateTask] would do, since a recurring task's start/due are the
* occurrence's resolved times.
*
* Addressing by `(taskId, occurrenceStart)` rather than by a materialised
* instance row id keeps this seam independent of any one store's row
* numbering; External mode maps it back to an instance row itself.
*/
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
/**
* Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm
* property row. The provider never fires it — [de.jeanlucmakiola.agendula
* .data.reminders.ReminderScheduler] does — but persisting it here is what
* syncs the lead and shares it with other OpenTasks clients.
*/
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
/** Every task's reminder, by task id. One query, for the scheduler. */
fun alarms(): Map<Long, TaskReminder>
/**
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
* not occurrences — see [TaskMapper.exportTask] for why that distinction
* matters. Excludes rows the provider has flagged deleted-but-unsynced.
*/
fun exportTasks(listId: Long): List<de.jeanlucmakiola.agendula.domain.export.ExportTask>
fun setCompleted(taskId: Long, completed: Boolean) fun setCompleted(taskId: Long, completed: Boolean)
/**
* Complete (or reopen) **one occurrence** of a recurring task, addressed the
* same way [updateInstance] is. Ticking a series through [setCompleted] would
* close the master row, which takes every past and future occurrence out of
* every list at once.
*
* Implementations fall back to [setCompleted] when the row turns out not to
* be a series master — an override, or a plain task the caller happened to
* hand an anchor for — so the routing above cannot get this wrong.
*/
fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean)
fun deleteTask(taskId: Long) fun deleteTask(taskId: Long)
fun createLocalList(name: String, color: Int): Long fun createLocalList(name: String, color: Int): Long
/** Rename and recolour [listId]. */
fun updateList(listId: Long, name: String, color: Int)
/**
* Delete [listId] **and the tasks in it** — `tasks.list_id` cascades on the
* Room path, and the provider does the same on the External one.
*
* Only ever called for a local, device-only list: a collection that belongs
* to an account is the server's to remove, and neither store expresses a
* collection tombstone yet. The UI gates on [TaskList.isLocal]; this seam
* does not re-check it.
*/
fun deleteList(listId: Long)
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */ /** Observe any change to tasks/lists; [onChange] fires on a background thread. */
fun registerObserver(onChange: () -> Unit): AutoCloseable fun registerObserver(onChange: () -> Unit): AutoCloseable
} }

View File

@@ -37,9 +37,25 @@ interface TasksRepository {
* since the form loaded. Pass `null` to force the write (overwrite-anyway). * since the form loaded. Pass `null` to force the write (overwrite-anyway).
*/ */
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null) suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun setCompleted(taskId: Long, completed: Boolean) /**
* Complete or reopen a task. Pass the occurrence's [Task.occurrenceStart] so a
* recurring series forks a `RECURRENCE-ID` override for that one occurrence
* instead of closing the whole series; `null` completes the row itself.
*/
suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean)
suspend fun deleteTask(taskId: Long) suspend fun deleteTask(taskId: Long)
/**
* The per-task reminder lead in minutes before due, or `null` if the task has
* none (in which case the list's / global setting applies). Read when the edit
* form loads so saving can't silently drop it.
*/
suspend fun reminderFor(taskId: Long): Int?
suspend fun createLocalList(name: String, color: Int): Long suspend fun createLocalList(name: String, color: Int): Long
suspend fun updateList(listId: Long, name: String, color: Int)
/** Deletes the list **and its tasks**. Local lists only — see [TasksDataSource.deleteList]. */
suspend fun deleteList(listId: Long)
/** Synchronous snapshot for the permission/onboarding gate. */ /** Synchronous snapshot for the permission/onboarding gate. */
fun providerStatus(): ProviderStatus fun providerStatus(): ProviderStatus

View File

@@ -28,6 +28,7 @@ import kotlin.time.Instant
class TasksRepositoryImpl @Inject constructor( class TasksRepositoryImpl @Inject constructor(
private val dataSource: TasksDataSource, private val dataSource: TasksDataSource,
private val providerResolver: ProviderResolver, private val providerResolver: ProviderResolver,
private val startupGate: StartupGate,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : TasksRepository { ) : TasksRepository {
@@ -80,22 +81,50 @@ class TasksRepositoryImpl @Inject constructor(
} }
override suspend fun createTask(form: TaskForm): Long = override suspend fun createTask(form: TaskForm): Long =
withContext(io) { dataSource.insertTask(form) } withContext(io) {
val id = dataSource.insertTask(form)
form.reminderMinutesBeforeDue?.let { dataSource.setAlarm(id, it) }
id
}
override suspend fun reminderFor(taskId: Long): Int? =
withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() }
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) = override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
withContext(io) { withContext(io) {
// Conflict-safe overwrite: re-read just before writing and bail if the // Re-read just before writing: it settles the conflict check *and* tells
// provider's last_modified moved since the form captured it (external // us which URI to write through.
// sync / another app). A null baseline means "force / overwrite anyway". val current = dataSource.task(taskId)
// Conflict-safe overwrite: bail if the provider's last_modified moved
// since the form captured it (external sync / another app). A null
// baseline means "force / overwrite anyway".
if (expectedLastModified != null) { if (expectedLastModified != null) {
val current = dataSource.task(taskId)?.lastModified val seen = current?.lastModified
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId) if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
} }
// Write the reminder first: forking a recurring occurrence copies the
// task's properties onto the new override row, so setting the alarm
// beforehand is what carries it across.
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
// A recurring task's start/due are one occurrence's resolved times, so
// writing them back to the task row would re-anchor the whole series.
// Going through the occurrence forks an override instead.
val occurrence = current?.takeIf { it.isRecurring }?.occurrenceStart
if (occurrence != null) {
dataSource.updateInstance(taskId, occurrence, form)
} else {
dataSource.updateTask(taskId, form) dataSource.updateTask(taskId, form)
} }
}
override suspend fun setCompleted(taskId: Long, completed: Boolean) = override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) =
withContext(io) { dataSource.setCompleted(taskId, completed) } withContext(io) {
if (occurrenceStart != null) {
dataSource.setCompletedInstance(taskId, occurrenceStart, completed)
} else {
dataSource.setCompleted(taskId, completed)
}
}
override suspend fun deleteTask(taskId: Long) = override suspend fun deleteTask(taskId: Long) =
withContext(io) { dataSource.deleteTask(taskId) } withContext(io) { dataSource.deleteTask(taskId) }
@@ -103,18 +132,32 @@ class TasksRepositoryImpl @Inject constructor(
override suspend fun createLocalList(name: String, color: Int): Long = override suspend fun createLocalList(name: String, color: Int): Long =
withContext(io) { dataSource.createLocalList(name, color) } withContext(io) { dataSource.createLocalList(name, color) }
override suspend fun updateList(listId: Long, name: String, color: Int) =
withContext(io) { dataSource.updateList(listId, name, color) }
override suspend fun deleteList(listId: Long) =
withContext(io) { dataSource.deleteList(listId) }
override fun providerStatus(): ProviderStatus { override fun providerStatus(): ProviderStatus {
// Our own store is always ready: it ships with the app, needs no provider
// and no grant. The permission gate only ever applied to External mode —
// now that is visibly true rather than a special case inside it.
if (providerResolver.mode() == StorageMode.OWN) return ProviderStatus.READY
val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
else ProviderStatus.NEEDS_PERMISSION else ProviderStatus.NEEDS_PERMISSION
} }
/** /**
* Emits an initial load, then re-loads on every provider change. The observer * Emits an initial load, then re-loads on every store change. The observer
* callback (main thread) only pokes a conflated channel; the actual blocking * callback (main thread) only pokes a conflated channel; the actual blocking
* query runs on [io]. * query runs on [io].
*/ */
private fun <T> observing(load: () -> T): Flow<T> = callbackFlow { private fun <T> observing(load: () -> T): Flow<T> = callbackFlow {
// Nothing reads a store before the stored mode has landed and a v0.3.x
// install has been imported — otherwise the first emission comes from the
// wrong store, or from an empty one.
startupGate.awaitReady()
val ticks = Channel<Unit>(Channel.CONFLATED) val ticks = Channel<Unit>(Channel.CONFLATED)
val handle = dataSource.registerObserver { ticks.trySend(Unit) } val handle = dataSource.registerObserver { ticks.trySend(Unit) }
ticks.trySend(Unit) // prime the initial emission ticks.trySend(Unit) // prime the initial emission

View File

@@ -0,0 +1,384 @@
package de.jeanlucmakiola.agendula.data.tasks.legacy
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.tasks.CursorColumnReader
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/** How much one import moved. */
data class ImportCounts(val lists: Int, val tasks: Int, val alarms: Int)
/** The outcome of [OneShotImport.runIfNeeded] or [OneShotImport.reimportFromArchive]. */
sealed interface ImportResult {
/** The DataStore flag was already set; nothing was read. */
data object AlreadyDone : ImportResult
/** No legacy database on disk — a fresh install, or one already archived. */
data object NothingToImport : ImportResult
data class Imported(val counts: ImportCounts) : ImportResult
/** Nothing landed: the transaction rolled back and the source is untouched. */
data class Failed(val cause: Throwable) : ImportResult
}
/**
* Moves a v0.3.x install's tasks out of the bundled dmfs provider's SQLite file
* and into Room, once (`docs/OWN-STORE.md`, "Migrating existing users").
*
* The file is opened read-only and directly — no provider, no ContentResolver —
* so this keeps working after `:provider` is deleted. Everything lands in one
* Room transaction with verified counts, so a failure leaves Room exactly as it
* was and the source file exactly where it was.
*
* dmfs accounts are not carried over: every list is imported as a device-only
* list (`account_id IS NULL`), including one that sat under a real account —
* only reachable if the user had pointed DAVx5 at our authority. Their task
* `uid`s are preserved, which is what lets those rows be re-attached to an
* account once sync lands.
*/
@Singleton
class OneShotImport @Inject constructor(
@ApplicationContext private val context: Context,
private val database: TasksDatabase,
private val dataStore: DataStore<Preferences>,
) {
/** Whether the import has run. Set before the rename, so both guards hold. */
val isDone: Flow<Boolean> = dataStore.data.map { it[IMPORT_DONE] ?: false }
/**
* Steps 18 of the plan: archive `databases/tasks.db`, import it, record
* completion. Safe to call on every launch.
*
* The archive happens *before* the import, and the import always replaces, so
* that every point this can be killed at re-enters correctly:
*
* - killed after the rename, before the import — the next run finds the
* archive, imports it, and nothing is lost;
* - killed after the import commits, before the flag is written — the next
* run truncates and re-imports the same archive, so the result is the same
* rather than doubled.
*
* Renaming last would leave that second window open: the flag would be unset
* and `tasks.db` still in place, and the next launch would import it a second
* time on top of the first. That is the window the plan's "guarded by a
* DataStore flag *and* by the rename" is meant to close, and only this order
* actually closes it.
*/
suspend fun runIfNeeded(): ImportResult = withContext(Dispatchers.IO) {
if (isDone.first()) return@withContext ImportResult.AlreadyDone
val source = archivedSource() ?: run {
markDone()
return@withContext ImportResult.NothingToImport
}
val counts = runCatching { importFrom(source, replaceExisting = true) }
.getOrElse { return@withContext ImportResult.Failed(it) }
markDone()
ImportResult.Imported(counts)
}
/**
* The rollback path: re-run against the archived `tasks.db.imported`,
* truncating the Room tables first so a second attempt replaces rather than
* merges. Reached by a targeted fix release, not by the app on its own.
*/
suspend fun reimportFromArchive(): ImportResult = withContext(Dispatchers.IO) {
val source = archivedSource() ?: return@withContext ImportResult.NothingToImport
val counts = runCatching { importFrom(source, replaceExisting = true) }
.getOrElse { return@withContext ImportResult.Failed(it) }
markDone()
ImportResult.Imported(counts)
}
/**
* The legacy database as `tasks.db.imported`, archiving it first if it is
* still under its live name. `null` when there is nothing to import.
*/
private fun archivedSource(): File? {
val archive = context.getDatabasePath(ARCHIVE_NAME)
if (archive.exists()) return archive
val live = context.getDatabasePath(LEGACY_NAME)
if (!live.exists()) return null
return if (archive(live)) archive else live
}
/** Clears the completion flag so [runIfNeeded] will import again. */
suspend fun clearCompletion() {
dataStore.edit { it.remove(IMPORT_DONE) }
}
/**
* Steps 26 against an arbitrary dmfs database: read it read-only, then write
* everything in one Room transaction whose counts are verified before it
* commits. Blocking — call it off the main thread.
*/
fun importFrom(source: File, replaceExisting: Boolean = false): ImportCounts {
val snapshot = SQLiteDatabase.openDatabase(source.path, null, SQLiteDatabase.OPEN_READONLY)
.use(::read)
return database.runInTransaction<ImportCounts> {
if (replaceExisting) truncate()
val baseline = tableCounts()
val written = write(snapshot)
verify(written, baseline)
written
}
}
// --- reading the dmfs file ------------------------------------------------
private fun read(db: SQLiteDatabase): LegacySnapshot {
val lists = mutableListOf<LegacyList>()
db.rawQuery("SELECT * FROM Lists ORDER BY _id", null).use { cursor ->
val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) {
val id = r.getLong("_id") ?: continue
lists += LegacyList(
id = id,
entity = TaskListEntity(
name = r.getString("list_name").orEmpty(),
color = r.getInt("list_color") ?: 0,
accountId = null,
isVisible = r.getBoolean("visible"),
isSynced = r.getBoolean("sync_enabled"),
owner = r.getString("list_owner"),
),
)
}
}
val rows = mutableListOf<LegacyTaskRow>()
db.rawQuery("SELECT * FROM Tasks WHERE _deleted IS NULL OR _deleted = 0 ORDER BY _id", null)
.use { cursor ->
val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) {
val id = r.getLong("_id") ?: continue
rows += LegacyTaskRow(
id = id,
listId = r.getLong("list_id") ?: continue,
parentId = r.getLong("parent_id"),
masterId = r.getLong("original_instance_id"),
recurrenceId = r.instant("original_instance_time"),
entity = TaskEntity(
listId = 0,
uid = r.getString("_uid") ?: UUID.randomUUID().toString(),
title = r.getString("title"),
description = r.getString("description"),
location = r.getString("location"),
url = r.getString("url"),
color = r.getInt("task_color"),
status = statusFromInt(r.getInt("status")),
percentComplete = r.getInt("percent_complete"),
completedAt = r.instant("completed"),
priority = r.getInt("priority") ?: PRIORITY_NONE,
classification = r.getInt("class"),
dtstart = r.instant("dtstart"),
due = r.instant("due"),
duration = r.getString("duration"),
isAllDay = r.getBoolean("is_allday"),
timezone = r.getString("tz"),
rrule = r.getString("rrule"),
rdate = r.getString("rdate"),
exdate = r.getString("exdate"),
createdAt = r.instant("created"),
lastModified = r.instant("last_modified"),
),
)
}
}
val alarms = mutableListOf<LegacyAlarm>()
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null)
.use { cursor ->
val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) {
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
val taskId = r.getLong("task_id") ?: continue
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
alarms += LegacyAlarm(
taskId = taskId,
minutesBefore = minutes,
reference = if (r.getString("data1")?.trim() == REFERENCE_START) {
AlarmReference.START
} else {
AlarmReference.DUE
},
message = r.getString("data2"),
)
}
}
return LegacySnapshot(lists, rows, alarms)
}
// --- writing into Room ----------------------------------------------------
/**
* dmfs `list_id`, `parent_id` and `original_instance_id` are old row ids, and
* Room mints its own on insert, so every one of them is remapped through the
* ids the inserts hand back. Tasks are inserted with their links cleared and
* a second pass sets them, because a parent may be a higher `_id` than its
* child.
*/
private fun write(snapshot: LegacySnapshot): ImportCounts {
val listDao = database.taskLists()
val taskDao = database.tasks()
val alarmDao = database.alarms()
val listIds = snapshot.lists.associate { it.id to listDao.insert(it.entity) }
// A task whose list is missing is already invisible in dmfs — its tasks
// view inner-joins Lists — so dropping it loses nothing the user could see.
val importable = snapshot.tasks.filter { it.listId in listIds }
val importableIds = importable.mapTo(mutableSetOf()) { it.id }
val taskIds = mutableMapOf<Long, Long>()
val inserted = mutableListOf<Pair<LegacyTaskRow, TaskEntity>>()
val seen = mutableSetOf<Triple<Long, String, Instant?>>()
for (row in importable) {
val listId = listIds.getValue(row.listId)
val overrides = row.masterId != null && row.masterId in importableIds
val recurrenceId = row.recurrenceId.takeIf { overrides }
// A duplicate (list, uid, recurrence) would abort the whole import on
// the unique index; a fresh uid costs the row nothing it still has.
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
?: UUID.randomUUID().toString()
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
val newId = taskDao.insert(entity)
taskIds[row.id] = newId
inserted += row to entity.copy(id = newId)
}
for ((row, entity) in inserted) {
val parentId = row.parentId?.let(taskIds::get)
val masterId = row.masterId?.let(taskIds::get)
if (parentId == null && masterId == null) continue
taskDao.update(entity.copy(parentId = parentId, masterId = masterId))
}
var alarmCount = 0
for (alarm in snapshot.alarms) {
val taskId = taskIds[alarm.taskId] ?: continue
alarmDao.insert(
TaskAlarmEntity(
taskId = taskId,
minutesBefore = alarm.minutesBefore,
reference = alarm.reference,
message = alarm.message,
),
)
alarmCount++
}
return ImportCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount)
}
private fun verify(written: ImportCounts, before: ImportCounts) {
val after = tableCounts()
check(after.lists - before.lists == written.lists) {
"list count mismatch: ${after.lists - before.lists} != ${written.lists}"
}
check(after.tasks - before.tasks == written.tasks) {
"task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}"
}
check(after.alarms - before.alarms == written.alarms) {
"alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}"
}
}
/** Dropping the lists takes their tasks and alarms with them, by cascade. */
private fun truncate() {
val listDao = database.taskLists()
listDao.lists().forEach { listDao.delete(it.list.id) }
}
private fun tableCounts() = ImportCounts(
lists = count("task_lists"),
tasks = count("tasks"),
alarms = count("task_alarms"),
)
private fun count(table: String): Int =
database.query("SELECT COUNT(*) FROM $table", null).use {
if (it.moveToFirst()) it.getInt(0) else 0
}
// --- the source file ------------------------------------------------------
/**
* Renames the dmfs file, sidecars included, to `tasks.db.imported`. Never
* deletes it: for one release it is the only way back if the import turns out
* to be wrong on someone's device.
*/
private fun archive(source: File): Boolean {
val target = File(source.parentFile, ARCHIVE_NAME)
if (!source.renameTo(target)) return false
for (suffix in SIDECARS) {
val sidecar = File(source.path + suffix)
if (sidecar.exists()) sidecar.renameTo(File(target.path + suffix))
}
return true
}
private suspend fun markDone() {
dataStore.edit { it[IMPORT_DONE] = true }
}
private fun CursorColumnReader.instant(name: String): Instant? =
getLong(name)?.let(Instant::fromEpochMilliseconds)
companion object {
const val LEGACY_NAME = "tasks.db"
const val ARCHIVE_NAME = "tasks.db.imported"
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
private const val REFERENCE_START = "2"
private val SIDECARS = listOf("-journal", "-wal", "-shm")
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
}
}
private class LegacySnapshot(
val lists: List<LegacyList>,
val tasks: List<LegacyTaskRow>,
val alarms: List<LegacyAlarm>,
)
private class LegacyList(val id: Long, val entity: TaskListEntity)
private class LegacyTaskRow(
val id: Long,
val listId: Long,
val parentId: Long?,
val masterId: Long?,
val recurrenceId: Instant?,
val entity: TaskEntity,
)
private class LegacyAlarm(
val taskId: Long,
val minutesBefore: Int,
val reference: AlarmReference,
val message: String?,
)

View File

@@ -0,0 +1,30 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlin.time.Instant
/** Reads and writes over `accounts`. Unused until sync lands. */
@Dao
interface AccountDao {
@Query("SELECT * FROM accounts ORDER BY display_name")
fun all(): List<AccountEntity>
@Query("SELECT * FROM accounts WHERE id = :accountId")
fun account(accountId: Long): AccountEntity?
@Insert
fun insert(account: AccountEntity): Long
@Update
fun update(account: AccountEntity)
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
fun recordSync(accountId: Long, at: Instant?, error: String?)
@Query("DELETE FROM accounts WHERE id = :accountId")
fun delete(accountId: Long): Int
}

View File

@@ -0,0 +1,38 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.TypeConverter
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.statusFromInt
import de.jeanlucmakiola.agendula.domain.toInt
import kotlin.time.Instant
/**
* Storage encodings for the entity types SQLite has no column type for. Time is
* epoch millis; [TaskStatus] goes through the `domain` mappers so that numbering
* keeps its single home.
*
* `PRIORITY` deliberately has no converter — it is stored as the raw iCalendar
* integer, because [de.jeanlucmakiola.agendula.domain.Priority] is a lossy
* bucketing and a converter would apply it before the value reaches disk.
*/
object Converters {
@TypeConverter
fun instantToMillis(value: Instant?): Long? = value?.toEpochMilliseconds()
@TypeConverter
fun instantFromMillis(value: Long?): Instant? = value?.let(Instant::fromEpochMilliseconds)
@TypeConverter
fun statusToInt(value: TaskStatus): Int = value.toInt()
@TypeConverter
fun statusFrom(value: Int): TaskStatus = statusFromInt(value)
@TypeConverter
fun alarmReferenceToString(value: AlarmReference): String = value.name
@TypeConverter
fun alarmReferenceFrom(value: String): AlarmReference =
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
}

View File

@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Folds the write-ahead log back into the database file when the app goes to the
* background.
*
* Room runs in WAL mode, and Auto Backup copies files without checkpointing — so
* a `-wal` sidecar can hold writes the backed-up `.db` does not. The backup rules
* carry all three files, which already makes a restore consistent; this narrows
* the window further by ensuring the `.db` alone is usually current, which is what
* a restore onto a device that drops the sidecars falls back to.
*/
@Singleton
class DatabaseCheckpoint @Inject constructor(
private val database: TasksDatabase,
@ApplicationScope private val scope: CoroutineScope,
) : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
scope.launch(Dispatchers.IO) {
runCatching {
database.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").close()
}
}
}
}

View File

@@ -0,0 +1,213 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* A CalDAV account. Empty until sync lands (`docs/SYNC.md` phase 2), but the FK
* from [TaskListEntity] exists from v1 so turning sync on never needs a
* migration. The app password is never stored here — Keystore only.
*/
@Entity(tableName = "accounts")
data class AccountEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "display_name") val displayName: String,
@ColumnInfo(name = "principal_url") val principalUrl: String? = null,
@ColumnInfo(name = "home_set_url") val homeSetUrl: String? = null,
@ColumnInfo(name = "username") val username: String? = null,
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
)
/**
* A task list. [accountId] is nullable: `NULL` is a device-only list, and
* attaching one to an account later is a plain `UPDATE` rather than a data
* migration.
*
* Deleting an account detaches its lists (`SET NULL`) instead of deleting them,
* for the same reason [TaskEntity.parentId] does — removing an account is not
* an instruction to destroy the tasks it held.
*/
@Entity(
tableName = "task_lists",
foreignKeys = [
ForeignKey(
entity = AccountEntity::class,
parentColumns = ["id"],
childColumns = ["account_id"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [Index(value = ["account_id"])],
)
data class TaskListEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "name") val name: String,
/** ARGB. */
@ColumnInfo(name = "color") val color: Int,
@ColumnInfo(name = "account_id") val accountId: Long? = null,
@ColumnInfo(name = "is_visible", defaultValue = "1") val isVisible: Boolean = true,
@ColumnInfo(name = "is_synced", defaultValue = "1") val isSynced: Boolean = true,
/** CalDAV owner display name. */
@ColumnInfo(name = "owner") val owner: String? = null,
@ColumnInfo(name = "is_read_only", defaultValue = "0") val isReadOnly: Boolean = false,
/** User ordering. */
@ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0,
/** Collection URL, relative to the account root. */
@ColumnInfo(name = "href") val href: String? = null,
@ColumnInfo(name = "ctag") val ctag: String? = null,
/** RFC 6578 sync token, per collection. */
@ColumnInfo(name = "sync_token") val syncToken: String? = null,
@ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false,
)
/**
* A task. Series masters *and* `RECURRENCE-ID` overrides live in this table; an
* override is a row with [recurrenceId] set and [masterId] pointing at its
* master, sharing the master's [uid].
*
* [masterId] and [parentId] are different things: [parentId] is task hierarchy
* (`RELATED-TO;RELTYPE=PARENT`), [masterId] is recurrence. A row can carry both.
*/
@Entity(
tableName = "tasks",
foreignKeys = [
ForeignKey(
entity = TaskListEntity::class,
parentColumns = ["id"],
childColumns = ["list_id"],
onDelete = ForeignKey.CASCADE,
),
// Deleting a series takes its overrides with it — they would otherwise be
// unreachable rows that still sync.
ForeignKey(
entity = TaskEntity::class,
parentColumns = ["id"],
childColumns = ["master_id"],
onDelete = ForeignKey.CASCADE,
),
// Deleting a parent promotes its subtasks to top level rather than
// destroying work the user did not ask to lose.
ForeignKey(
entity = TaskEntity::class,
parentColumns = ["id"],
childColumns = ["parent_id"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [
Index(value = ["list_id", "is_deleted"]),
Index(value = ["parent_id"]),
Index(value = ["master_id", "recurrence_id"]),
Index(value = ["is_dirty"]),
// An override shares its master's UID, so (list_id, uid) alone would
// reject the very rows recurrence depends on. With recurrence_id NULL on
// the master and set on each override this reads as: one master and at
// most one override per occurrence, per UID, per list. Note SQLite treats
// NULLs as distinct in a unique index, so the master half is a statement
// of intent, not an enforced constraint.
Index(value = ["list_id", "uid", "recurrence_id"], unique = true),
],
)
data class TaskEntity(
// identity
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "list_id") val listId: Long,
/** RFC 4122 UUID, minted at creation in every mode, synced or not. */
@ColumnInfo(name = "uid") val uid: String,
@ColumnInfo(name = "href") val href: String? = null,
@ColumnInfo(name = "etag") val etag: String? = null,
// content
@ColumnInfo(name = "title") val title: String? = null,
@ColumnInfo(name = "description") val description: String? = null,
@ColumnInfo(name = "location") val location: String? = null,
@ColumnInfo(name = "url") val url: String? = null,
/** ARGB override for the list colour. */
@ColumnInfo(name = "color") val color: Int? = null,
// state
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
@ColumnInfo(name = "percent_complete") val percentComplete: Int? = null,
@ColumnInfo(name = "completed_at") val completedAt: Instant? = null,
/**
* Raw iCalendar `PRIORITY`: 0 none, 1 highest, 9 lowest. Stored unbucketed —
* [de.jeanlucmakiola.agendula.domain.Priority] folds 14 into HIGH, so
* converting on the way *in* would rewrite a server's `PRIORITY:3` as `1` and
* lose it on the next round-trip. The bucketing belongs to the mapper, which
* is where the UI needs it.
*/
@ColumnInfo(name = "priority", defaultValue = "0") val priority: Int = PRIORITY_NONE,
/** RFC 5545 `CLASS`: 0 public, 1 private, 2 confidential. */
@ColumnInfo(name = "classification") val classification: Int? = null,
// time
@ColumnInfo(name = "dtstart") val dtstart: Instant? = null,
@ColumnInfo(name = "due") val due: Instant? = null,
/** RFC 5545 `DURATION`, verbatim. Mutually exclusive with [due]. */
@ColumnInfo(name = "duration") val duration: String? = null,
@ColumnInfo(name = "is_all_day", defaultValue = "0") val isAllDay: Boolean = false,
@ColumnInfo(name = "timezone") val timezone: String? = null,
// recurrence
@ColumnInfo(name = "rrule") val rrule: String? = null,
@ColumnInfo(name = "rdate") val rdate: String? = null,
@ColumnInfo(name = "exdate") val exdate: String? = null,
/** This row's `RECURRENCE-ID` anchor; `NULL` on a master. */
@ColumnInfo(name = "recurrence_id") val recurrenceId: Instant? = null,
/** The series this row overrides; `NULL` on a master. */
@ColumnInfo(name = "master_id") val masterId: Long? = null,
// hierarchy
@ColumnInfo(name = "parent_id") val parentId: Long? = null,
@ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0,
// audit
@ColumnInfo(name = "created_at") val createdAt: Instant? = null,
@ColumnInfo(name = "last_modified") val lastModified: Instant? = null,
@ColumnInfo(name = "sequence", defaultValue = "0") val sequence: Int = 0,
// sync
@ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false,
/** Tombstone: deleted locally, still owed to a server. */
@ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false,
/**
* Raw unfolded iCalendar lines of every property we do not model, re-emitted
* verbatim on write so a round-trip cannot silently lose a field.
*/
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
)
/** What [TaskAlarmEntity.minutesBefore] counts back from. */
enum class AlarmReference { DUE, START }
/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */
@Entity(
tableName = "task_alarms",
foreignKeys = [
ForeignKey(
entity = TaskEntity::class,
parentColumns = ["id"],
childColumns = ["task_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index(value = ["task_id"])],
)
data class TaskAlarmEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "task_id") val taskId: Long,
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
@ColumnInfo(name = "message") val message: String? = null,
)

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.ColumnInfo
import androidx.room.Embedded
/**
* A list plus its account's display name, which the domain
* [de.jeanlucmakiola.agendula.domain.TaskList] carries and groups by.
* `null` means a device-only list.
*/
data class TaskListRow(
@Embedded val list: TaskListEntity,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
)
/**
* A task plus the three columns of its list the domain
* [de.jeanlucmakiola.agendula.domain.Task] carries, so reading a screenful is
* one query rather than one per list.
*/
data class TaskRow(
@Embedded val task: TaskEntity,
@ColumnInfo(name = "list_name") val listName: String,
@ColumnInfo(name = "list_color") val listColor: Int,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
)

View File

@@ -0,0 +1,114 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.LocalAccount
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import kotlin.time.Instant
/** Account type reported for a list attached to one of ours. */
const val CALDAV_ACCOUNT_TYPE = "caldav"
/** Maps Room rows to domain models. Pure + testable, like [de.jeanlucmakiola.agendula.data.tasks.TaskMapper]. */
object RoomTaskMapper {
fun taskList(row: TaskListRow): TaskList = TaskList(
id = row.list.id,
name = row.list.name,
color = row.list.color,
// TaskList.accountName is non-null and the lists screen groups by it, so a
// list with no account still has to report something to group under.
accountName = row.accountDisplayName ?: LocalAccount.NAME,
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
isSynced = row.list.isSynced,
isVisible = row.list.isVisible,
owner = row.list.owner,
)
/**
* One occurrence of [row]. [occurrenceStart] is the occurrence's
* `RECURRENCE-ID` anchor and `null` for a task that does not recur;
* [start] / [due] are that occurrence's resolved times.
*/
fun task(
row: TaskRow,
occurrenceStart: Instant? = null,
start: Instant? = row.task.dtstart,
due: Instant? = row.task.due,
distanceFromCurrent: Int? = null,
): Task = Task(
taskId = row.task.id,
listId = row.task.listId,
title = row.task.title.orEmpty(),
description = row.task.description,
location = row.task.location,
url = row.task.url,
priority = priorityFromICal(row.task.priority),
status = row.task.status,
percentComplete = row.task.percentComplete,
start = start,
due = due,
isAllDay = row.task.isAllDay,
timeZone = row.task.timezone,
completedAt = row.task.completedAt,
listColor = row.listColor,
taskColor = row.task.color,
listName = row.listName,
accountName = row.accountDisplayName ?: LocalAccount.NAME,
parentId = row.task.parentId,
isRecurring = row.task.isRecurring,
occurrenceStart = occurrenceStart,
distanceFromCurrent = distanceFromCurrent,
created = row.task.createdAt,
lastModified = row.task.lastModified,
)
fun exportTask(task: TaskEntity): ExportTask = ExportTask(
taskId = task.id,
uid = task.uid,
title = task.title.orEmpty(),
description = task.description,
location = task.location,
url = task.url,
priority = priorityFromICal(task.priority),
status = task.status,
percentComplete = task.percentComplete,
start = task.dtstart,
due = task.due,
isAllDay = task.isAllDay,
completedAt = task.completedAt,
created = task.createdAt,
lastModified = task.lastModified,
rrule = task.rrule,
rdate = task.rdate,
parentId = task.parentId?.takeIf { it > 0 },
)
}
/** A row carries a recurrence rule if it has an `RRULE` or an `RDATE`. */
val TaskEntity.isRecurring: Boolean
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/**
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
* dmfs provider instantiated the same series.
*/
val TaskEntity.recurrenceAnchor: Instant?
get() = dtstart ?: due
/** The rule set of this series, or `null` when it does not recur. */
fun TaskEntity.recurrenceSpec(): RecurrenceSpec? {
if (!isRecurring) return null
val anchor = recurrenceAnchor ?: return null
return RecurrenceSpec(
rrule = rrule,
rdate = rdate,
exdate = exdate,
anchor = anchor,
isAllDay = isAllDay,
timeZone = timezone,
)
}

View File

@@ -0,0 +1,282 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.InvalidationTracker
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import java.time.ZoneId
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
/** How far either side of now a series is expanded. */
private val WINDOW_BACK = 365.days
private val WINDOW_FORWARD = 730.days
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
/**
* [TasksDataSource] over Agendula's own Room store.
*
* The one structural difference from [de.jeanlucmakiola.agendula.data.tasks
* .AndroidTasksDataSource]: there is no materialised instances table, so a
* recurring series is expanded here, at read time, by [RecurrenceExpander].
* Nothing above this cares — the repository already filters and sorts in Kotlin.
*/
@Singleton
class RoomTasksDataSource @Inject constructor(
private val database: TasksDatabase,
) : TasksDataSource {
private val clock: Clock = Clock.System
private val tasks get() = database.tasks()
private val lists get() = database.taskLists()
private val alarms get() = database.alarms()
// --- reads ----------------------------------------------------------------
override fun taskLists(): List<TaskList> = lists.lists().map(RoomTaskMapper::taskList)
override fun tasks(query: TaskQuery): List<Task> {
val now = clock.now()
val overrides = tasks.allOverrides(query.listId).groupBy { it.masterId }
return tasks.tasks(query.listId, query.includeCompleted)
.flatMap { occurrencesOf(it, overrides[it.task.id].orEmpty(), now) }
.filter { query.includeCompleted || !it.isClosed }
}
override fun task(taskId: Long): Task? {
val row = tasks.task(taskId) ?: return null
// An override row is one occurrence in its own right; it names the
// occurrence it replaces rather than expanding to a series.
row.task.recurrenceId?.let { return RoomTaskMapper.task(row, occurrenceStart = it) }
val now = clock.now()
val occurrences = occurrencesOf(row, tasks.overrides(taskId), now)
return occurrences.firstOrNull { it.distanceFromCurrent == 0 } ?: occurrences.firstOrNull()
}
override fun subtasks(parentTaskId: Long): List<Task> {
val now = clock.now()
return tasks.subtasks(parentTaskId)
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
}
override fun exportTasks(listId: Long): List<ExportTask> =
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
override fun alarms(): Map<Long, TaskReminder> =
alarms.all().associate {
it.taskId to TaskReminder(
minutesBefore = it.minutesBefore,
fromStart = it.reference == AlarmReference.START,
)
}
/**
* Every occurrence of [row] inside the expansion window, with any
* `RECURRENCE-ID` override substituted for the occurrence it replaces.
*
* A non-recurring task is its own single occurrence and carries a null
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
*/
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
val window = ExpansionWindow(
from = now - WINDOW_BACK,
until = now + WINDOW_FORWARD,
pivot = now,
)
val anchors = RecurrenceExpander.expand(spec, window)
if (anchors.isEmpty()) return emptyList()
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
val byAnchor = overrides.associateBy { it.recurrenceId }
return anchors.mapIndexedNotNull { index, anchor ->
val override = byAnchor[anchor]
if (override != null) {
RoomTaskMapper.task(
row = row.copy(task = override),
occurrenceStart = anchor,
start = override.dtstart,
due = override.due,
distanceFromCurrent = distances[index],
)
} else {
val (start, due) = occurrenceTimes(row.task, anchor)
RoomTaskMapper.task(
row = row,
occurrenceStart = anchor,
start = start,
due = due,
distanceFromCurrent = distances[index],
)
}
}
}
/**
* One occurrence's resolved start and due. A timed series keeps each
* occurrence's duration; a due-anchored one has no start to offset from, so
* the anchor *is* the due date.
*/
private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
if (master.dtstart == null) return null to anchor
val length = master.due?.let { it - master.dtstart }
return anchor to length?.let { anchor + it }
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone())
return tasks.insert(entity)
}
override fun updateTask(taskId: Long, form: TaskForm) {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
tasks.update(TaskFormWriter.apply(current, form, clock.now(), zone()))
}
/**
* Writes one occurrence as a `RECURRENCE-ID` override — RFC 5545's model, and
* what every other CalDAV client expects to receive. The dmfs provider
* detached the occurrence into a brand-new task with its own UID instead,
* which is the model least compatible with sync; the override shares its
* master's UID, which is exactly what makes it an override.
*/
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
val now = clock.now()
val existing = tasks.override(taskId, occurrenceStart)
if (existing != null) {
tasks.update(TaskFormWriter.apply(existing, form, now, zone()))
return
}
val (start, due) = occurrenceTimes(master, occurrenceStart)
val fork = TaskFormWriter.apply(
newOverride(master, taskId, occurrenceStart, start, due),
form,
now,
zone(),
)
// The list and parent come from the master: moving one occurrence between
// lists or parents is not something the override model expresses.
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) }
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
alarms.replaceForTask(
taskId,
minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) },
)
}
override fun setCompleted(taskId: Long, completed: Boolean) {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
}
/**
* Ticking one occurrence forks a `RECURRENCE-ID` override carrying the
* completion — the same model [updateInstance] writes. Writing the status onto
* the master instead would close the series: the master is what
* [TaskDao.tasks] filters on, so every occurrence, past and future, would
* leave every list at once.
*/
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete instance $taskId")
// Not a series master — an override, or a plain task the caller handed an
// anchor for. Either way this row *is* the occurrence.
if (master.recurrenceSpec() == null) return setCompleted(taskId, completed)
val now = clock.now()
tasks.override(taskId, occurrenceStart)?.let {
tasks.update(TaskFormWriter.completed(it, completed, now))
return
}
val (start, due) = occurrenceTimes(master, occurrenceStart)
val fork = TaskFormWriter.completed(newOverride(master, taskId, occurrenceStart, start, due), completed, now)
val id = tasks.insert(fork)
alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) }
}
/**
* A blank override row for one occurrence of [master]: same UID (that is what
* makes it an override rather than a separate task), the series fields
* stripped, and no `href`/`etag` because the server has never seen it.
*/
private fun newOverride(
master: TaskEntity,
masterId: Long,
occurrenceStart: Instant,
start: Instant?,
due: Instant?,
): TaskEntity = master.copy(
id = 0,
masterId = masterId,
recurrenceId = occurrenceStart,
dtstart = start,
due = due,
rrule = null,
rdate = null,
exdate = null,
href = null,
etag = null,
)
/**
* Hard delete for a row no server knows about, tombstone for one that is
* still owed to a collection. `master_id` cascades, so deleting a series
* takes its overrides with it.
*/
override fun deleteTask(taskId: Long) {
val current = tasks.entity(taskId) ?: return
val listAccount = lists.entity(current.listId)?.accountId
if (listAccount == null) tasks.delete(taskId) else tasks.markDeleted(taskId, clock.now())
}
override fun createLocalList(name: String, color: Int): Long =
lists.insert(TaskListEntity(name = name.trim(), color = color))
override fun updateList(listId: Long, name: String, color: Int) {
val current = lists.entity(listId) ?: throw TaskWriteFailedException("update list $listId")
// Only an account-backed collection owes a server a PROPPATCH; a
// device-only list has nothing to be dirty for.
lists.update(
current.copy(
name = name.trim(),
color = color,
isDirty = current.accountId != null,
),
)
}
/** `tasks.list_id` is `ON DELETE CASCADE`, so the list's tasks go with it. */
override fun deleteList(listId: Long) = lists.delete(listId)
// --- observation ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
override fun onInvalidated(tables: Set<String>) = onChange()
}
database.invalidationTracker.addObserver(observer)
return AutoCloseable { database.invalidationTracker.removeObserver(observer) }
}
private fun zone(): String = ZoneId.systemDefault().id
}

View File

@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
/** Reads and writes over `task_alarms`. */
@Dao
interface TaskAlarmDao {
/** Every reminder in the store, for one scheduler pass. */
@Query("SELECT * FROM task_alarms")
fun all(): List<TaskAlarmEntity>
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId")
fun forTask(taskId: Long): List<TaskAlarmEntity>
@Insert
fun insert(alarm: TaskAlarmEntity): Long
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
fun deleteForTask(taskId: Long): Int
/**
* Set the task's only reminder, or clear it with `null`. The row that lands is
* always a new one — the id is cleared so an alarm lifted off another task
* (forking an occurrence copies the master's) inserts instead of colliding.
*/
@Transaction
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) {
deleteForTask(taskId)
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
}
}

View File

@@ -0,0 +1,134 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* Reads and writes over `tasks`.
*
* Reads split masters from overrides on purpose: [tasks] returns the rows a
* recurrence expander expands (a non-recurring task is its own single
* occurrence), and [allOverrides] / [overrides] return the
* `RECURRENCE-ID` rows that replace individual occurrences. Nothing here
* expands anything — that is phase 2's job, in Kotlin.
*/
@Dao
interface TaskDao {
// --- reads ----------------------------------------------------------------
/**
* Master (and non-recurring) rows, optionally narrowed to one list. Closed
* tasks — `COMPLETED` and `CANCELLED` — are excluded unless
* [includeCompleted]; tombstones always are.
*/
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color,
a.display_name AS account_display_name
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
WHERE t.is_deleted = 0
AND t.master_id IS NULL
AND (:listId IS NULL OR t.list_id = :listId)
AND (:includeCompleted = 1 OR t.status NOT IN (2, 3))
"""
)
fun tasks(listId: Long?, includeCompleted: Boolean): List<TaskRow>
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color,
a.display_name AS account_display_name
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
WHERE t.id = :taskId AND t.is_deleted = 0
"""
)
fun task(taskId: Long): TaskRow?
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color,
a.display_name AS account_display_name
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
WHERE t.parent_id = :parentTaskId AND t.is_deleted = 0 AND t.master_id IS NULL
"""
)
fun subtasks(parentTaskId: Long): List<TaskRow>
@Query("SELECT * FROM tasks WHERE id = :taskId")
fun entity(taskId: Long): TaskEntity?
/**
* Every override, optionally narrowed to one list — read alongside [tasks] so
* expansion can replace the occurrences they override in one pass rather than
* querying per series.
*/
@Query(
"""
SELECT * FROM tasks
WHERE master_id IS NOT NULL AND is_deleted = 0
AND (:listId IS NULL OR list_id = :listId)
"""
)
fun allOverrides(listId: Long?): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0")
fun overrides(masterId: Long): List<TaskEntity>
@Query(
"SELECT * FROM tasks WHERE master_id = :masterId AND recurrence_id IS :recurrenceId AND is_deleted = 0"
)
fun override(masterId: Long, recurrenceId: Instant?): TaskEntity?
@Query("SELECT * FROM tasks WHERE list_id = :listId AND uid = :uid AND recurrence_id IS :recurrenceId")
fun byUid(listId: Long, uid: String, recurrenceId: Instant? = null): TaskEntity?
/** Masters only, tombstones excluded — what an `.ics` export writes. */
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
fun exportTasks(listId: Long): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
fun dirty(): List<TaskEntity>
// --- writes ---------------------------------------------------------------
@Insert
fun insert(task: TaskEntity): Long
@Update
fun update(task: TaskEntity): Int
@Query(
"""
UPDATE tasks SET status = :status, percent_complete = :percentComplete,
completed_at = :completedAt, last_modified = :lastModified, is_dirty = :dirty
WHERE id = :taskId
"""
)
fun setCompletion(
taskId: Long,
status: TaskStatus,
percentComplete: Int?,
completedAt: Instant?,
lastModified: Instant?,
dirty: Boolean,
): Int
/** Hard delete. Used when the row was never on a server. */
@Query("DELETE FROM tasks WHERE id = :taskId")
fun delete(taskId: Long): Int
/** Tombstone, for a row a server still knows about. */
@Query("UPDATE tasks SET is_deleted = 1, is_dirty = 1, last_modified = :at WHERE id = :taskId")
fun markDeleted(taskId: Long, at: Instant?): Int
}

View File

@@ -0,0 +1,91 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Instant
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
internal fun Instant.forAllDay(allDay: Boolean): Instant =
if (!allDay) this
else Instant.fromEpochMilliseconds(
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
)
/**
* Applies a [TaskForm] to a [TaskEntity]. Pure, so the semantics below are
* testable on the JVM without a database.
*
* This is the Room counterpart of
* [de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper], which stays for
* External mode. It is a separate object rather than a shared one because most
* of what that mapper does is work around the provider — clearing `DURATION`
* because the provider validates a merged row, writing `STATUS` both ways
* because the provider auto-completes at 100% but will not reopen below it. Here
* those rules are ours to state directly.
*/
object TaskFormWriter {
/** A brand-new task. [uid] is minted by the caller and never null. */
fun newTask(form: TaskForm, uid: String, now: Instant, tzId: String): TaskEntity =
apply(
TaskEntity(listId = form.listId, uid = uid, createdAt = now),
form,
now,
tzId,
)
/** [current] with [form] applied. Identity, recurrence and sync columns are left alone. */
fun apply(current: TaskEntity, form: TaskForm, now: Instant, tzId: String): TaskEntity {
val percent = form.percentComplete?.coerceIn(0, 100)
val timed = !form.isAllDay && (form.start != null || form.due != null)
return current.copy(
listId = form.listId,
title = form.title.trim(),
description = form.description?.trim()?.ifBlank { null },
priority = form.priority.toICal(),
percentComplete = percent,
status = statusFor(percent, current.status),
completedAt = completedAtFor(percent, current, now),
dtstart = form.start?.forAllDay(form.isAllDay),
due = form.due?.forAllDay(form.isAllDay),
// DUE and DURATION are mutually exclusive (RFC 5545 §3.6.2).
duration = null,
isAllDay = form.isAllDay,
timezone = if (timed) tzId else null,
parentId = form.parentId?.takeIf { it > 0 },
lastModified = now,
isDirty = true,
)
}
/** The completion triple, for the standalone complete toggle. */
fun completed(current: TaskEntity, completed: Boolean, now: Instant): TaskEntity = current.copy(
status = if (completed) TaskStatus.COMPLETED else TaskStatus.NEEDS_ACTION,
percentComplete = if (completed) 100 else null,
completedAt = if (completed) now else null,
lastModified = now,
isDirty = true,
)
/**
* A form carrying no percent leaves status alone — the standalone toggle stays
* authoritative. Otherwise progress and status move together in both
* directions, which is the asymmetry the provider never had: it auto-completed
* at 100% but would not reopen below it, stranding a task "done at 75%".
*/
private fun statusFor(percent: Int?, current: TaskStatus): TaskStatus = when {
percent == null -> current
percent >= 100 -> TaskStatus.COMPLETED
percent > 0 -> TaskStatus.IN_PROCESS
else -> TaskStatus.NEEDS_ACTION
}
private fun completedAtFor(percent: Int?, current: TaskEntity, now: Instant): Instant? = when {
percent == null -> current.completedAt
percent >= 100 -> current.completedAt ?: now
else -> null
}
}

View File

@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
/** Reads and writes over `task_lists`. Synchronous, like the seam above it. */
@Dao
interface TaskListDao {
@Query(
"""
SELECT l.*, a.display_name AS account_display_name
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
ORDER BY a.display_name, l.sort_order, l.name
"""
)
fun lists(): List<TaskListRow>
@Query(
"""
SELECT l.*, a.display_name AS account_display_name
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
WHERE l.id = :listId
"""
)
fun list(listId: Long): TaskListRow?
@Query("SELECT * FROM task_lists WHERE id = :listId")
fun entity(listId: Long): TaskListEntity?
@Query("SELECT COUNT(*) FROM task_lists WHERE id = :listId")
fun exists(listId: Long): Int
@Insert
fun insert(list: TaskListEntity): Long
@Update
fun update(list: TaskListEntity)
@Query("UPDATE task_lists SET is_visible = :visible WHERE id = :listId")
fun setVisible(listId: Long, visible: Boolean)
/** Attach a list to an account, or detach it with `null`. */
@Query("UPDATE task_lists SET account_id = :accountId WHERE id = :listId")
fun setAccount(listId: Long, accountId: Long?)
@Query("DELETE FROM task_lists WHERE id = :listId")
fun delete(listId: Long)
}

View File

@@ -0,0 +1,34 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
/**
* Agendula's own task store (`docs/OWN-STORE.md`). Four tables, designed from
* what the app actually reads and writes plus RFC 5545's `VTODO`.
*
* Schemas are exported to `app/schemas/` and committed, so a future version can
* be migration-tested against this one.
*/
@Database(
entities = [
AccountEntity::class,
TaskListEntity::class,
TaskEntity::class,
TaskAlarmEntity::class,
],
version = 1,
exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class TasksDatabase : RoomDatabase() {
abstract fun taskLists(): TaskListDao
abstract fun tasks(): TaskDao
abstract fun alarms(): TaskAlarmDao
abstract fun accounts(): AccountDao
companion object {
const val NAME = "agendula-tasks.db"
}
}

View File

@@ -0,0 +1,42 @@
package de.jeanlucmakiola.agendula.domain
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
/**
* All-day tasks are date-only in iCalendar. OpenTasks reads them back through
* `DateTime.toAllDay()`, which discards the time-of-day and resolves the
* remaining date against UTC — so the storage convention is **UTC midnight of
* the intended calendar date, with a null timezone**. Timed tasks, by contrast,
* are ordinary instants rendered in the device's zone.
*
* These two conventions disagree about which day a given instant is, which is
* why every all-day value needs an explicit conversion rather than a raw
* `Instant` passed straight through.
*/
/** UTC midnight of [date] — the storage form for an all-day value. */
fun allDayInstantOf(date: java.time.LocalDate): Instant =
Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli())
/**
* The calendar date this instant denotes: read in UTC for [allDay] values,
* in [zone] for timed ones.
*/
fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.atZone(if (allDay) ZoneOffset.UTC else zone)
.toLocalDate()
/**
* Move an instant across the two conventions when the all-day switch flips, so
* the day the user is looking at stays put. Without this, toggling all-day off
* turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00,
* in New York) — reading to the user as "the time reset itself".
*/
fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant =
if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone))
else Instant.fromEpochMilliseconds(
calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(),
)

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.agendula.domain package de.jeanlucmakiola.agendula.domain
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import kotlin.time.Instant import kotlin.time.Instant
/** A task list (the `tasklists` table). Lists group under their account. */ /** A task list (the `tasklists` table). Lists group under their account. */
@@ -15,7 +14,8 @@ data class TaskList(
val owner: String?, val owner: String?,
) { ) {
/** A device-only list Agendula (or another app) created locally, not synced. */ /** A device-only list Agendula (or another app) created locally, not synced. */
val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE val isLocal: Boolean
get() = accountType == LocalAccount.TYPE || accountType == LocalAccount.DMFS_TYPE
} }
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED } enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
@@ -24,11 +24,11 @@ enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
enum class Priority { NONE, LOW, MEDIUM, HIGH } enum class Priority { NONE, LOW, MEDIUM, HIGH }
/** /**
* A task occurrence as read from the `instances` view. [id] is the instance row * One occurrence of a task. [taskId] is the underlying task row and the stable
* id; [taskId] is the underlying `tasks._id` and the stable target for edits. * target for edits and navigation; [occurrenceStart] distinguishes occurrences of
* the same series.
*/ */
data class Task( data class Task(
val id: Long,
val taskId: Long, val taskId: Long,
val listId: Long, val listId: Long,
val title: String, val title: String,
@@ -48,7 +48,22 @@ data class Task(
val listName: String?, val listName: String?,
val accountName: String?, val accountName: String?,
val parentId: Long?, val parentId: Long?,
/**
* This row carries a recurrence rule, so it is one occurrence of a series and
* [start]/[due] are that occurrence's resolved times — *not* the master's
* anchor. Edits go through
* [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance], which
* forks a `RECURRENCE-ID` override instead of re-anchoring the series.
*/
val isRecurring: Boolean, val isRecurring: Boolean,
/**
* This occurrence's `RECURRENCE-ID` anchor — what identifies it within its
* series — or `null` when the task does not recur. Together with [taskId] it
* is a stable, collision-free identity for an occurrence, which is what list
* keys and [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance]
* address it by.
*/
val occurrenceStart: Instant? = null,
val distanceFromCurrent: Int?, val distanceFromCurrent: Int?,
val created: Instant?, val created: Instant?,
val lastModified: Instant?, val lastModified: Instant?,
@@ -65,6 +80,15 @@ data class Task(
val isSubtask: Boolean get() = parentId != null && parentId > 0 val isSubtask: Boolean get() = parentId != null && parentId > 0
/** The task's own colour if set, else the list colour. */ /** The task's own colour if set, else the list colour. */
val effectiveColor: Int get() = taskColor ?: listColor val effectiveColor: Int get() = taskColor ?: listColor
/**
* Stable identity for a lazy-list key. Two occurrences of one series can show
* up in the same list, so [taskId] alone is not unique — and folding
* `(taskId, occurrenceStart)` into a Long could collide, which as a Compose
* key is a visible bug.
*/
val occurrenceKey: String
get() = if (occurrenceStart == null) "$taskId" else "$taskId@${occurrenceStart.toEpochMilliseconds()}"
} }
/** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */ /** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
@@ -85,22 +109,22 @@ fun priorityFromICal(value: Int?): Priority = when {
/** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */ /** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */
fun Priority.toICal(): Int = when (this) { fun Priority.toICal(): Int = when (this) {
Priority.NONE -> TasksContract.PRIORITY_NONE Priority.NONE -> PRIORITY_NONE
Priority.HIGH -> 1 Priority.HIGH -> 1
Priority.MEDIUM -> 5 Priority.MEDIUM -> 5
Priority.LOW -> 9 Priority.LOW -> 9
} }
fun statusFromInt(value: Int?): TaskStatus = when (value) { fun statusFromInt(value: Int?): TaskStatus = when (value) {
TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS ICalStatus.IN_PROCESS -> TaskStatus.IN_PROCESS
TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED ICalStatus.COMPLETED -> TaskStatus.COMPLETED
TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED ICalStatus.CANCELLED -> TaskStatus.CANCELLED
else -> TaskStatus.NEEDS_ACTION else -> TaskStatus.NEEDS_ACTION
} }
fun TaskStatus.toInt(): Int = when (this) { fun TaskStatus.toInt(): Int = when (this) {
TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION TaskStatus.NEEDS_ACTION -> ICalStatus.NEEDS_ACTION
TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS TaskStatus.IN_PROCESS -> ICalStatus.IN_PROCESS
TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED TaskStatus.COMPLETED -> ICalStatus.COMPLETED
TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED TaskStatus.CANCELLED -> ICalStatus.CANCELLED
} }

View File

@@ -0,0 +1,30 @@
package de.jeanlucmakiola.agendula.domain
/**
* iCalendar `STATUS` values for a `VTODO`, as integers.
*
* These live in `domain` rather than being read out of a provider contract: the
* numbering is Agendula's own storage encoding as much as it is dmfs's, and the
* domain layer must not depend on the data layer to map its own enums.
*/
object ICalStatus {
const val NEEDS_ACTION = 0
const val IN_PROCESS = 1
const val COMPLETED = 2
const val CANCELLED = 3
}
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (RFC 5545 §3.8.1.9). */
const val PRIORITY_NONE = 0
/** How a device-only list identifies its (non-existent) account. */
object LocalAccount {
/** Shown as the section header above device-only lists. */
const val NAME = "Local"
/** What Agendula's own store reports for a list with no account. */
const val TYPE = "local"
/** What a dmfs-derived provider reports in External mode. */
const val DMFS_TYPE = "org.dmfs.account.LOCAL"
}

View File

@@ -0,0 +1,66 @@
package de.jeanlucmakiola.agendula.domain.export
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* One task as it goes out to iCalendar — a **master** task, not an occurrence.
*
* Deliberately not [de.jeanlucmakiola.agendula.domain.Task]. That model is read
* from the `instances` view, where a recurring task appears once per occurrence
* with resolved times and no rule; exporting from it would write the same task
* fifty times and lose the RRULE that generated them. Export reads the `tasks`
* table instead, and needs two fields the UI never asks for ([uid], [rrule]).
*/
data class ExportTask(
/** `tasks._id` — the fallback identity when [uid] is absent. */
val taskId: Long,
/**
* The iCalendar UID, or `null` for a task created on this device and never
* synced. The dmfs provider only lets a *sync adapter* assign one, so in Local
* mode this is null for everything — see [ICalendarWriter.uidFor], which
* synthesises a stable substitute rather than emitting a VTODO with no UID.
*/
val uid: String?,
val title: String,
val description: String?,
val location: String?,
val url: String?,
val priority: Priority,
val status: TaskStatus,
val percentComplete: Int?,
val start: Instant?,
val due: Instant?,
val isAllDay: Boolean,
val completedAt: Instant?,
val created: Instant?,
val lastModified: Instant?,
/** Raw `RRULE` value as stored, without the `RRULE:` name. Null when non-recurring. */
val rrule: String?,
/** Raw `RDATE` value as stored. Null when absent. */
val rdate: String?,
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
val parentId: Long?,
)
/** A task list and everything in it, ready to become one `.ics` document. */
data class ExportList(
val listId: Long,
val name: String,
val accountName: String,
val tasks: List<ExportTask>,
)
/** A single file the export produced: [fileName] and its finished bytes. */
data class ExportDocument(
val fileName: String,
val content: ByteArray,
) {
// ByteArray gets identity equals/hashCode, which makes this data class lie.
override fun equals(other: Any?): Boolean =
this === other ||
(other is ExportDocument && fileName == other.fileName && content.contentEquals(other.content))
override fun hashCode(): Int = 31 * fileName.hashCode() + content.contentHashCode()
}

View File

@@ -0,0 +1,204 @@
package de.jeanlucmakiola.agendula.domain.export
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.domain.toICal
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.time.Instant
/**
* Writes a task list as an RFC 5545 `VCALENDAR` of `VTODO` components.
*
* Pure Kotlin and deliberately free of any Android type, so the format — the part
* that decides whether an exported backup can actually be read again — is
* unit-testable on the JVM. Serialising tasks is task-domain and stays here; the
* SAF/file plumbing that carries the bytes out is not, and lives in the data
* layer (and is the piece `docs/STORAGE-AND-SYNC.md` marks as a floret-kit
* candidate).
*
* **Times are always written in UTC.** Emitting a local `TZID` would oblige us to
* also emit a matching `VTIMEZONE` component with its full transition rules, and
* a `TZID` referencing an absent definition is what actually breaks importers. UTC
* is unambiguous and universally accepted, so the exported instant is exact even
* though the original wall-clock zone is not carried. All-day values keep their
* `VALUE=DATE` form and stay date-only, which is the only representation that
* survives a timezone change intact.
*/
object ICalendarWriter {
private const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN"
/** RFC 5545 caps a content line at 75 octets, excluding the CRLF. */
private const val MAX_LINE_OCTETS = 75
private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd")
private val DATE_TIME_UTC = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
/** Serialises [list] to a complete `.ics` document. */
fun write(list: ExportList): String = buildString {
line("BEGIN:VCALENDAR")
line("VERSION:2.0")
line("PRODID:$PRODUCT_ID")
line("CALSCALE:GREGORIAN")
// Non-standard but near-universally understood, and the only way the list's
// name survives into a calendar app. Importers that don't know it skip it.
property("X-WR-CALNAME", list.name)
// Parents must be addressable by UID, and a subtask may appear before its
// parent in the list, so resolve every id up front.
val uidsById = list.tasks.associate { it.taskId to uidFor(it) }
list.tasks.forEach { task -> writeTask(task, uidsById) }
line("END:VCALENDAR")
}
/**
* The UID to write for [task].
*
* Local tasks have none, because nothing assigns one: the provider never
* generates a `_uid` itself, and our write path does not set it either, so in
* Local mode every task arrives here with `uid == null`.
*
* Note this is a gap we leave open, not one the provider imposes.
* `processors/tasks/Validating.java:92-96` restricts `_uid` to sync adapters
* on *update* only; `insert` does not check it, so any caller may assign a UID
* at creation. Doing that would be strictly better than synthesising here —
* see `docs/SYNC.md`, where it is a phase-1 item, because a real UID minted at
* creation is what lets a local task later be pushed to CalDAV without
* duplicating.
*
* Until then: a VTODO without a UID is invalid and, worse, un-mergeable —
* re-importing a backup would duplicate every task instead of matching it. So
* we synthesise one from the row id, which is stable for as long as the row
* is, and tag it with our own domain so a synthesised UID is recognisable as
* such.
*/
fun uidFor(task: ExportTask): String =
task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de"
private fun StringBuilder.writeTask(task: ExportTask, uidsById: Map<Long, String>) {
line("BEGIN:VTODO")
property("UID", uidFor(task))
// DTSTAMP is mandatory. It means "when this representation was written",
// which for an export is now — not the task's own timestamps.
property("DTSTAMP", formatUtc(Instant.fromEpochMilliseconds(System.currentTimeMillis())))
property("SUMMARY", task.title)
task.description?.takeIf { it.isNotBlank() }?.let { property("DESCRIPTION", it) }
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
// URL is a URI, not TEXT: it must not be escaped like one.
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) }
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
task.created?.let { rawProperty("CREATED", formatUtc(it)) }
task.lastModified?.let { rawProperty("LAST-MODIFIED", formatUtc(it)) }
// COMPLETED is defined as UTC date-time even for an all-day task.
task.completedAt?.let { rawProperty("COMPLETED", formatUtc(it)) }
rawProperty("STATUS", task.status.toICalName())
if (task.priority != Priority.NONE) rawProperty("PRIORITY", task.priority.toICal().toString())
task.percentComplete?.coerceIn(0, 100)?.let { rawProperty("PERCENT-COMPLETE", it.toString()) }
// Passed through as stored. The provider keeps these in iCalendar form
// already, and re-deriving them would risk changing what the user's
// recurrence actually means.
task.rrule?.takeIf { it.isNotBlank() }?.let { rawProperty("RRULE", it) }
task.rdate?.takeIf { it.isNotBlank() }?.let { rawProperty("RDATE", it) }
// Only emit the link when the parent is in this same document; a
// RELATED-TO pointing outside the file would dangle on import.
task.parentId?.let { uidsById[it] }?.let {
property("RELATED-TO;RELTYPE=PARENT", it)
}
line("END:VTODO")
}
private fun StringBuilder.dateProperty(name: String, instant: Instant, allDay: Boolean) {
if (allDay) {
// Read in UTC, matching the storage convention (see AllDayTime): an
// all-day value *is* UTC midnight of the intended calendar date.
rawProperty("$name;VALUE=DATE", instant.calendarDate(allDay = true).format(DATE))
} else {
rawProperty(name, formatUtc(instant))
}
}
private fun formatUtc(instant: Instant): String =
java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds())
.atZone(ZoneOffset.UTC)
.format(DATE_TIME_UTC)
/** A property whose value is TEXT, and so must be escaped. */
private fun StringBuilder.property(name: String, value: String) =
line("$name:${escapeText(value)}")
/** A property whose value is already in its final form (dates, numbers, URIs, rules). */
private fun StringBuilder.rawProperty(name: String, value: String) = line("$name:$value")
private fun StringBuilder.line(content: String) {
append(fold(content))
append(CRLF)
}
/**
* Escapes a TEXT value per RFC 5545 §3.3.11. Backslash first, or it would
* double the backslashes introduced by the later replacements.
*/
internal fun escapeText(value: String): String = value
.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\r\n", "\\n")
.replace("\n", "\\n")
.replace("\r", "\\n")
/**
* Folds a content line to at most [MAX_LINE_OCTETS] octets, continuing with
* CRLF + a single space.
*
* Counted in **octets, not characters** — the limit is defined that way, and an
* emoji in a task title is four of them. Splits are kept on character
* boundaries so folding can never cut a UTF-8 sequence in half and corrupt the
* text; an importer unfolds by removing CRLF + leading whitespace, recovering
* the original exactly.
*/
internal fun fold(content: String): String {
if (content.utf8Size() <= MAX_LINE_OCTETS) return content
val out = StringBuilder()
var octets = 0
// First line takes the full budget; every continuation loses one octet to
// the leading space.
var budget = MAX_LINE_OCTETS
var index = 0
while (index < content.length) {
val codePoint = content.codePointAt(index)
val charCount = Character.charCount(codePoint)
val size = String(Character.toChars(codePoint)).utf8Size()
if (octets + size > budget) {
out.append(CRLF).append(' ')
octets = 0
budget = MAX_LINE_OCTETS - 1
}
out.append(content, index, index + charCount)
octets += size
index += charCount
}
return out.toString()
}
private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size
private fun TaskStatus.toICalName(): String = when (this) {
TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION"
TaskStatus.IN_PROCESS -> "IN-PROCESS"
TaskStatus.COMPLETED -> "COMPLETED"
TaskStatus.CANCELLED -> "CANCELLED"
}
private const val CRLF = "\r\n"
}

View File

@@ -0,0 +1,179 @@
package de.jeanlucmakiola.agendula.domain.recurrence
import org.dmfs.rfc5545.DateTime
import org.dmfs.rfc5545.recur.RecurrenceRule
import org.dmfs.rfc5545.recurrenceset.RecurrenceList
import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter
import org.dmfs.rfc5545.recurrenceset.RecurrenceSet
import java.time.ZoneId
import java.util.TimeZone
import kotlin.time.Instant
private const val MILLIS_PER_SECOND = 1000L
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** The rule set of one task series, as stored. All strings are raw iCalendar values. */
data class RecurrenceSpec(
val rrule: String?,
val rdate: String?,
val exdate: String?,
/** The series anchor: DTSTART if present, else DUE. Never null for a recurring task. */
val anchor: Instant,
val isAllDay: Boolean,
/** IANA zone id the anchor is expressed in; null means floating/local. */
val timeZone: String?,
)
/**
* The window expansion is bounded to: [from] inclusive, [until] exclusive, and
* never more than [maxOccurrences] results — so an unbounded `RRULE` terminates.
*
* [pivot] is where "now" sits inside the window, and it is what the occurrence
* budget is spent around. Without it a series firing more often than about
* once a day exhausts [maxOccurrences] inside the past alone — an eight-hourly
* task would stop expanding months before today, so it would never appear in
* Today or Upcoming at all. At most a quarter of the budget goes to occurrences
* before [pivot], and the most recent of those are the ones kept.
*/
data class ExpansionWindow(
val from: Instant,
val until: Instant,
val maxOccurrences: Int = 500,
val pivot: Instant = from,
)
/**
* Expands a task series into its occurrences in memory, over `lib-recur`.
*
* There is no materialised instances table behind this: the repository already
* filters and sorts in Kotlin, so occurrences are computed at read time and the
* whole class of staleness bugs a cached table brings never exists.
*/
object RecurrenceExpander {
/**
* Every occurrence of [spec] inside [window], as its `RECURRENCE-ID` anchor —
* the instant identifying that occurrence within the series. Ascending,
* deduplicated, `EXDATE` applied.
*
* The anchor itself is always part of the set (RFC 5545 §3.8.5.3: `DTSTART`
* is the first instance), so a spec with no rule and no `RDATE` expands to
* exactly its anchor. A malformed `RRULE`, `RDATE` or `EXDATE` is dropped
* rather than thrown — a task whose stored rule cannot be parsed still has
* to appear.
*
* [floatingZone] resolves a series with no [RecurrenceSpec.timeZone]; it is a
* parameter rather than a `TimeZone.getDefault()` lookup so expansion is
* deterministic under test.
*/
fun expand(
spec: RecurrenceSpec,
window: ExpansionWindow,
floatingZone: ZoneId = ZoneId.systemDefault(),
): List<Instant> {
val zone = zoneOf(spec, floatingZone)
val anchorMillis = anchorMillis(spec)
val set = RecurrenceSet()
spec.rrule.orNull()?.let { raw -> ruleOf(raw, zone)?.let { set.addInstances(RecurrenceRuleAdapter(it)) } }
spec.rdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addInstances) }
spec.exdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addExceptions) }
val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds())
iterator.fastForward(window.from.toEpochMilliseconds())
// Occurrences arrive ascending, so everything before the pivot lands first
// and `past` is final by the time the first future one appears. Past is a
// sliding window (the newest are the ones worth keeping); the rest of the
// budget then goes to the future, undiminished when there is no past.
val pastCap = window.maxOccurrences / 4
val past = ArrayDeque<Instant>()
val future = ArrayList<Instant>()
var previous = Long.MIN_VALUE
while (iterator.hasNext()) {
val millis = iterator.next()
if (millis == previous) continue
previous = millis
val at = Instant.fromEpochMilliseconds(millis)
if (at < window.pivot) {
if (past.size == pastCap) past.removeFirst()
if (pastCap > 0) past.addLast(at)
} else {
future += at
if (past.size + future.size >= window.maxOccurrences) break
}
}
return past + future
}
/**
* Index of the current occurrence in an ascending [occurrences] list: the
* first one at or after [now], or the last one when the whole series is in
* the past. `-1` when there are no occurrences at all.
*/
fun currentOccurrenceIndex(occurrences: List<Instant>, now: Instant): Int {
if (occurrences.isEmpty()) return -1
val next = occurrences.indexOfFirst { it >= now }
return if (next >= 0) next else occurrences.lastIndex
}
/**
* Each occurrence's distance from the current one, index-aligned with
* [occurrences]. `0` is the current occurrence, negative counts back into the
* past and positive counts forward — the convention `Task.distanceFromCurrent`
* carries and the data sources pick the current occurrence by.
*
* Purely positional: unlike the dmfs provider, which drove the same number off
* each instance's closed state, this knows only times. Completion-aware
* refinement belongs where overrides carry their status.
*/
fun distancesFromCurrent(occurrences: List<Instant>, now: Instant): List<Int> {
val current = currentOccurrenceIndex(occurrences, now)
if (current < 0) return emptyList()
return occurrences.indices.map { it - current }
}
private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone {
if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC"))
val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() }
return TimeZone.getTimeZone(stored ?: floatingZone)
}
/**
* All-day series are date-anchored: pin the anchor to UTC midnight, as it is
* stored. A timed one is floored to the second, because RFC 5545 DATE-TIME
* has no sub-second field — carrying millis in makes lib-recur emit the raw
* anchor *and* its truncated self, doubling the first occurrence, and mints
* `RECURRENCE-ID`s no other client could address.
*/
private fun anchorMillis(spec: RecurrenceSpec): Long {
val millis = spec.anchor.toEpochMilliseconds()
val unit = if (spec.isAllDay) MILLIS_PER_DAY else MILLIS_PER_SECOND
return Math.floorDiv(millis, unit) * unit
}
private fun ruleOf(value: String, zone: TimeZone): RecurrenceRule? = runCatching {
RecurrenceRule(value).also { rule ->
// lib-recur refuses to iterate a floating UNTIL against a zoned start,
// and RFC 5545 §3.3.10 forbids that pairing — but stored rules carry it
// anyway. Re-read the UNTIL's local fields in the series zone.
val until = rule.until
if (until != null && until.isFloating) {
rule.until = DateTime(
zone,
until.year,
until.month,
until.dayOfMonth,
until.hours,
until.minutes,
until.seconds,
)
}
}
}.getOrNull()
private fun datesOf(value: String, zone: TimeZone): RecurrenceList? =
runCatching { RecurrenceList(value, zone) }.getOrNull()
private fun String?.orNull(): String? = this?.trim()?.ifEmpty { null }
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -19,6 +20,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
@@ -39,11 +41,23 @@ fun RootScreen(
ActivityResultContracts.RequestMultiplePermissions(), ActivityResultContracts.RequestMultiplePermissions(),
) { permissionViewModel.refresh() } ) { permissionViewModel.refresh() }
// Re-check on every resume, not just after the in-app request: the user may
// have granted the permission (or installed a provider) in system Settings and
// come back, and otherwise the gate would hold until the process restarts.
OnResume { permissionViewModel.refresh() }
// Neither gate can show in OWN mode (that store is always READY), so the way
// out of one is always our own store. Without it a user whose provider app
// went away is held on this screen with Settings behind it.
val fallback = stringResource(R.string.onboarding_use_own_store)
when (permission.status) { when (permission.status) {
ProviderStatus.NO_PROVIDER -> Gate( ProviderStatus.NO_PROVIDER -> Gate(
modifier = modifier, modifier = modifier,
title = stringResource(R.string.onboarding_no_provider_title), title = stringResource(R.string.onboarding_no_provider_title),
body = stringResource(R.string.onboarding_no_provider_body), body = stringResource(R.string.onboarding_no_provider_body),
secondaryAction = fallback,
onSecondaryAction = permissionViewModel::useOwnStore,
) )
ProviderStatus.NEEDS_PERMISSION -> Gate( ProviderStatus.NEEDS_PERMISSION -> Gate(
modifier = modifier, modifier = modifier,
@@ -51,6 +65,8 @@ fun RootScreen(
body = stringResource(R.string.onboarding_permission_body), body = stringResource(R.string.onboarding_permission_body),
action = stringResource(R.string.onboarding_permission_button), action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
secondaryAction = fallback,
onSecondaryAction = permissionViewModel::useOwnStore,
) )
ProviderStatus.READY -> ReadyGate(modifier = modifier) ProviderStatus.READY -> ReadyGate(modifier = modifier)
} }
@@ -87,6 +103,8 @@ private fun Gate(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
action: String? = null, action: String? = null,
onAction: () -> Unit = {}, onAction: () -> Unit = {},
secondaryAction: String? = null,
onSecondaryAction: () -> Unit = {},
) { ) {
Column( Column(
modifier = modifier.fillMaxSize().padding(24.dp), modifier = modifier.fillMaxSize().padding(24.dp),
@@ -96,5 +114,6 @@ private fun Gate(
Text(title, style = MaterialTheme.typography.headlineSmall) Text(title, style = MaterialTheme.typography.headlineSmall)
Text(body, style = MaterialTheme.typography.bodyMedium) Text(body, style = MaterialTheme.typography.bodyMedium)
if (action != null) Button(onClick = onAction) { Text(action) } if (action != null) Button(onClick = onAction) { Text(action) }
if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) }
} }
} }

View File

@@ -1,167 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.time.formatDateTime
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Event
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.jeanlucmakiola.agendula.R
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
private val zone: ZoneId get() = ZoneId.systemDefault()
internal fun Instant.toLocalDate(): LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
internal fun Instant.toLocalTime(): LocalTime =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
/**
* A labelled date(-time) field for the edit form: a tonal row showing the
* current value (or nothing), tappable to pick a date and — unless [allDay] —
* a time. A clear affordance appears once a value is set. Emits `null` when
* cleared. Styled to match the app's rounded tonal family.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateTimeField(
label: String,
value: Instant?,
allDay: Boolean,
onChange: (Instant?) -> Unit,
modifier: Modifier = Modifier,
) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
var pendingDate by remember { mutableStateOf<LocalDate?>(null) }
Surface(
onClick = { showDatePicker = true },
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set),
style = MaterialTheme.typography.bodyLarge,
)
}
if (value != null) {
IconButton(onClick = { onChange(null) }) {
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
}
}
}
}
if (showDatePicker) {
val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()))
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
showDatePicker = false
val millis = dateState.selectedDateMillis ?: return@TextButton
val date = java.time.Instant.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) {
onChange(localToInstant(date, LocalTime.MIDNIGHT))
} else {
pendingDate = date
showTimePicker = true
}
},
) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
) { DatePicker(state = dateState) }
}
if (showTimePicker) {
val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())
val timeState = rememberTimePickerState(
initialHour = base.toLocalTime().hour,
initialMinute = base.toLocalTime().minute,
)
Dialog(onDismissRequest = { showTimePicker = false }) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TimePicker(state = timeState)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { showTimePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
TextButton(onClick = {
showTimePicker = false
val date = pendingDate ?: return@TextButton
onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
}) { Text(stringResource(android.R.string.ok)) }
}
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.agendula.ui.common
/**
* The colours offered when creating or editing a task list.
*
* Raw ARGB, the way a CalDAV server sends one — every surface that draws a list
* colour runs it through
* [de.jeanlucmakiola.floret.components.pastelize] first, so these are hues
* rather than final fills, chosen to stay distinguishable after that pass. A
* list can still carry any colour a server gives it; this is only the set the
* app hands out.
*/
val ListPalette: List<Int> = listOf(
0xFF7A5C6B.toInt(), // mauve — Agendula's own seed
0xFFD7484A.toInt(), // red
0xFFE8743B.toInt(), // orange
0xFFE0A32E.toInt(), // amber
0xFF7CA83E.toInt(), // olive
0xFF35A06A.toInt(), // green
0xFF19938C.toInt(), // teal
0xFF2A9BC4.toInt(), // cyan
0xFF3C74C8.toInt(), // blue
0xFF6A5CC0.toInt(), // indigo
0xFF9455B8.toInt(), // purple
0xFFC94F8E.toInt(), // pink
)
/** What a new list gets before the user picks anything. */
val DefaultListColor: Int = ListPalette.first()

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Runs [block] on every `ON_RESUME`.
*
* For state the app cannot observe because it is granted, revoked or installed
* outside it — a runtime permission, an exact-alarm allowance, a provider app —
* which otherwise stays stale until the process restarts.
*/
@Composable
fun OnResume(block: () -> Unit) {
val current by rememberUpdatedState(block)
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) current()
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
}

View File

@@ -0,0 +1,20 @@
package de.jeanlucmakiola.agendula.ui.common
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import kotlin.time.Instant
/**
* Zone helpers shared by the date/time pickers. All-day conversions live in
* [de.jeanlucmakiola.agendula.domain.AllDayTime] — these cover the timed case,
* where the device zone is the right frame of reference.
*/
private val zone: ZoneId get() = ZoneId.systemDefault()
internal fun Instant.toLocalTime(): LocalTime =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())

View File

@@ -98,4 +98,7 @@ object ActionShapes {
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */ /** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
/** New list — a sunny burst beside the Lists header. */
val AddList: RoundedPolygon get() = MaterialShapes.Sunny
} }

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -11,7 +12,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -42,14 +42,14 @@ class TaskDetailViewModel @Inject constructor(
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail) if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
} }
.onStart { emit(TaskDetailUiState.Loading) } .onStart { emit(TaskDetailUiState.Loading) }
.catch { emit(TaskDetailUiState.NotFound) } .recoveringFromProviderFailure { TaskDetailUiState.NotFound }
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
fun bind(id: Long) { taskId.value = id } fun bind(id: Long) { taskId.value = id }
fun toggleComplete(task: Task) = viewModelScope.launch { fun toggleComplete(task: Task) = viewModelScope.launch {
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) }
} }
fun delete(task: Task) = viewModelScope.launch { fun delete(task: Task) = viewModelScope.launch {

View File

@@ -99,7 +99,8 @@ import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.agendula.ui.common.localToInstant import de.jeanlucmakiola.agendula.ui.common.localToInstant
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.agendula.ui.common.toLocalDate import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.ui.common.toLocalTime import de.jeanlucmakiola.agendula.ui.common.toLocalTime
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import java.time.LocalTime import java.time.LocalTime
@@ -178,7 +179,7 @@ private fun EditContent(
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
val gap = 12.dp val gap = 12.dp
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) } var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
var showListPicker by rememberSaveable { mutableStateOf(false) } var showListPicker by rememberSaveable { mutableStateOf(false) }
var showParentPicker by rememberSaveable { mutableStateOf(false) } var showParentPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) }
@@ -689,12 +690,15 @@ private fun DateTimePickerFlow(
onResult: (Instant) -> Unit, onResult: (Instant) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) } var pendingDate by rememberSaveable { mutableStateOf<java.time.LocalDate?>(null) }
var showTime by remember { mutableStateOf(false) } var showTime by rememberSaveable { mutableStateOf(false) }
if (!showTime) { if (!showTime) {
// M3's DatePicker speaks UTC millis. An all-day value is already UTC-based,
// a timed one is read in the device zone — calendarDate picks the right frame
// so the dialog opens on the day the rest of the UI shows.
val initialMillis = (initial ?: nowInstant()) val initialMillis = (initial ?: nowInstant())
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() .calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog( DatePickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -703,7 +707,7 @@ private fun DateTimePickerFlow(
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton } val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate() val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) { if (allDay) {
onResult(localToInstant(date, LocalTime.MIDNIGHT)) onResult(allDayInstantOf(date))
} else { } else {
pendingDate = date pendingDate = date
showTime = true showTime = true

View File

@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.populatedFields import de.jeanlucmakiola.agendula.domain.populatedFields
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -70,6 +71,15 @@ class TaskEditViewModel @Inject constructor(
private var editingTaskId: Long? = null private var editingTaskId: Long? = null
/**
* Whether the form has already been populated. The host `LaunchedEffect`
* re-fires whenever the composition restarts — an Activity recreation
* (rotation, theme/font/display-size change, split-screen, unfolding) — while
* this ViewModel survives on the nav back stack. Without this guard the
* rebind would overwrite in-progress edits with the untouched provider row.
*/
private var bound = false
/** `last_modified` captured when the form loaded — the conflict-check baseline. */ /** `last_modified` captured when the form loaded — the conflict-check baseline. */
private var baselineLastModified: Instant? = null private var baselineLastModified: Instant? = null
@@ -78,6 +88,8 @@ class TaskEditViewModel @Inject constructor(
/** Start a fresh task, optionally pre-selecting a list / parent. */ /** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) { fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
if (bound) return
bound = true
editingTaskId = null editingTaskId = null
baselineLastModified = null baselineLastModified = null
viewModelScope.launch { viewModelScope.launch {
@@ -103,6 +115,8 @@ class TaskEditViewModel @Inject constructor(
/** Load an existing task for editing. */ /** Load an existing task for editing. */
fun bindEdit(taskId: Long) { fun bindEdit(taskId: Long) {
if (bound && editingTaskId == taskId) return
bound = true
editingTaskId = taskId editingTaskId = taskId
viewModelScope.launch { viewModelScope.launch {
defaultFields = settingsPrefs.settings.first().defaultEditFields defaultFields = settingsPrefs.settings.first().defaultEditFields
@@ -126,6 +140,7 @@ class TaskEditViewModel @Inject constructor(
priority = task.priority, priority = task.priority,
parentId = task.parentId, parentId = task.parentId,
percentComplete = task.percentComplete, percentComplete = task.percentComplete,
reminderMinutesBeforeDue = repository.reminderFor(taskId),
lists = lists, lists = lists,
parentCandidates = loadParents(task.listId, selfId = taskId), parentCandidates = loadParents(task.listId, selfId = taskId),
), ),
@@ -178,7 +193,19 @@ class TaskEditViewModel @Inject constructor(
fun onStartChange(value: Instant?) = update { it.copy(start = value) } fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) } fun onDueChange(value: Instant?) = update { it.copy(due = value) }
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) } /**
* All-day and timed values use different conventions (UTC midnight vs. a real
* instant in the device zone), so the switch has to move the timestamps too —
* flipping the flag alone makes an all-day task read back as "02:00", which
* looks to the user like the time reset itself.
*/
fun onAllDayChange(value: Boolean) = update {
it.copy(
isAllDay = value,
start = it.start?.rebasedForAllDay(value),
due = it.due?.rebasedForAllDay(value),
)
}
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) } fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) } fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) } fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }

View File

@@ -0,0 +1,182 @@
package de.jeanlucmakiola.agendula.ui.export
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Folder
import androidx.compose.material.icons.rounded.FolderZip
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.export.ExportFailure
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
private const val ZIP_MIME = "application/zip"
private const val ZIP_NAME = "agendula-tasks.zip"
/**
* Export the task lists as iCalendar. Which lists go out is a per-list tick; the
* destination is a folder or a single zip, both picked through SAF so the app
* needs no storage permission.
*/
@Composable
fun ExportScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ExportViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val dark = isSystemInDarkTheme()
val folderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(viewModel::exportToFolder) }
val zipLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(ZIP_MIME),
) { uri -> uri?.let(viewModel::exportToZip) }
val canExport = !state.running && state.selectedCount > 0
CollapsingScaffold(
title = stringResource(R.string.settings_export),
onBack = onBack,
modifier = modifier,
) {
Text(
text = stringResource(R.string.export_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
Spacer(Modifier.height(16.dp))
if (state.lists.isEmpty()) {
GroupedRow(
title = stringResource(R.string.export_no_lists),
position = Position.Alone,
dimmed = true,
)
} else {
state.lists.forEachIndexed { index, list ->
val selected = state.isSelected(list.id)
GroupedRow(
title = list.name,
// The account only says something when it isn't the device itself.
summary = list.accountName.takeIf { !list.isLocal },
position = positionOf(index, state.lists.size),
leading = {
Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
},
trailing = {
Checkbox(checked = selected, onCheckedChange = { viewModel.toggle(list.id) })
},
onClick = { viewModel.toggle(list.id) },
)
}
}
Spacer(Modifier.height(24.dp))
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { folderLauncher.launch(null) },
enabled = canExport,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Rounded.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.export_to_folder))
}
OutlinedButton(
onClick = { zipLauncher.launch(ZIP_NAME) },
enabled = canExport,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Rounded.FolderZip, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.export_to_zip))
}
}
Spacer(Modifier.height(16.dp))
ExportStatus(state = state)
Spacer(Modifier.height(24.dp))
}
}
/** The running spinner, then whatever the last export ended as — it stays put. */
@Composable
private fun ExportStatus(state: ExportUiState) {
val outcome = state.outcome
when {
state.running -> Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(Modifier.size(18.dp))
Text(
text = stringResource(R.string.export_running),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
outcome is ExportOutcome.Success -> StatusText(
text = pluralStringResource(R.plurals.export_done, outcome.fileCount, outcome.fileCount),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
outcome is ExportOutcome.Failure -> StatusText(
text = stringResource(failureMessage(outcome.reason)),
color = MaterialTheme.colorScheme.error,
)
}
}
private fun failureMessage(reason: ExportFailure): Int = when (reason) {
ExportFailure.FOLDER_UNAVAILABLE -> R.string.export_failed_folder
ExportFailure.FOLDER_NOT_WRITABLE -> R.string.export_failed_read_only
ExportFailure.CANNOT_CREATE_FILE -> R.string.export_failed_create
ExportFailure.LOST_ACCESS -> R.string.export_failed_access
ExportFailure.WRITE_FAILED -> R.string.export_failed
}
@Composable
private fun StatusText(text: String, color: Color) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = color,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
)
}

View File

@@ -0,0 +1,122 @@
package de.jeanlucmakiola.agendula.ui.export
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.export.ExportFailedException
import de.jeanlucmakiola.agendula.data.export.ExportFailure
import de.jeanlucmakiola.agendula.data.export.ExportResult
import de.jeanlucmakiola.agendula.data.export.ExportWriter
import de.jeanlucmakiola.agendula.data.export.TaskExporter
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
/** How the last export ended, kept on screen rather than flashed past. */
sealed interface ExportOutcome {
data class Success(val fileCount: Int) : ExportOutcome
data class Failure(val reason: ExportFailure) : ExportOutcome
}
data class ExportUiState(
val lists: List<TaskList> = emptyList(),
/** Lists the user has ticked *off*; everything else is included. */
val excluded: Set<Long> = emptySet(),
val running: Boolean = false,
val outcome: ExportOutcome? = null,
) {
fun isSelected(listId: Long): Boolean = listId !in excluded
val selectedCount: Int get() = lists.count { isSelected(it.id) }
}
/**
* Drives the export screen. Holds the selection as an exclusion set so a list
* that appears while the screen is open is exported too — the natural reading of
* "everything, minus what I unticked".
*/
@HiltViewModel
class ExportViewModel @Inject constructor(
repository: TasksRepository,
resolver: ProviderResolver,
private val exporter: TaskExporter,
private val writer: ExportWriter,
) : ViewModel() {
private val excluded = MutableStateFlow(emptySet<Long>())
private val running = MutableStateFlow(false)
private val outcome = MutableStateFlow<ExportOutcome?>(null)
private var exportJob: Job? = null
// List ids are per-store, and Settings can switch stores with this ViewModel
// still alive — so the selection, the receipt and a write already addressing
// the old store's lists all go with it.
private val modeHandle = resolver.onModeChanged {
exportJob?.cancel()
excluded.value = emptySet()
outcome.value = null
}
override fun onCleared() {
modeHandle.close()
}
val state: StateFlow<ExportUiState> =
combine(
repository.taskLists().recoveringFromProviderFailure { emptyList() },
excluded,
running,
outcome,
) { lists, excluded, running, outcome ->
ExportUiState(lists, excluded, running, outcome)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ExportUiState())
fun toggle(listId: Long) = excluded.update { current ->
if (listId in current) current - listId else current + listId
}
/** Writes one `.ics` per list into a folder the user picked through SAF. */
fun exportToFolder(tree: Uri) = export { documents -> writer.writeToTree(tree, documents) }
/** Writes every list into a single zip the user named through SAF. */
fun exportToZip(target: Uri) = export { documents -> writer.writeZip(target, documents) }
private fun export(write: suspend (List<ExportDocument>) -> ExportResult) {
if (running.value) return
running.value = true
outcome.value = null
exportJob = viewModelScope.launch {
// Null means "every list" to the exporter, and is what an untouched
// screen should send: the flow may not have emitted a list yet.
val selection = excluded.value.takeIf { it.isNotEmpty() }
?.let { skipped -> state.value.lists.map { it.id }.toSet() - skipped }
try {
val result = write(exporter.export(selection))
outcome.value = ExportOutcome.Success(result.fileCount)
} catch (cancelled: CancellationException) {
// Leaving the screen mid-write is not a failed export.
throw cancelled
} catch (error: Exception) {
outcome.value = ExportOutcome.Failure(
(error as? ExportFailedException)?.failure ?: ExportFailure.WRITE_FAILED,
)
} finally {
running.value = false
}
}
}
}

View File

@@ -0,0 +1,295 @@
package de.jeanlucmakiola.agendula.ui.lists
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.ui.common.DefaultListColor
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ListPalette
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
private const val SWATCHES_PER_ROW = 6
/**
* Create or edit a task list: a name field over the palette of list colours, on
* the family's full-screen sheet with the commit in its title bar.
*
* [initial] null is the create case. [onDelete] is null for a list the app must
* not remove — an account's collection belongs to its server — which is also why
* the destructive row only appears when it is non-null.
*/
@Composable
fun ListEditorSheet(
initial: TaskList?,
onSave: (name: String, color: Int) -> Unit,
onDismiss: () -> Unit,
onDelete: (() -> Unit)? = null,
) {
var name by rememberSaveable(initial?.id) { mutableStateOf(initial?.name.orEmpty()) }
var color by rememberSaveable(initial?.id) { mutableIntStateOf(initial?.color ?: DefaultListColor) }
var confirmDelete by rememberSaveable { mutableStateOf(false) }
val valid = name.isNotBlank()
val commit = {
if (valid) {
onSave(name.trim(), color)
onDismiss()
}
}
FullScreenPicker(
title = stringResource(if (initial == null) R.string.list_new_title else R.string.list_edit_title),
onDismiss = onDismiss,
actions = {
Button(
onClick = commit,
enabled = valid,
modifier = Modifier.padding(end = 12.dp),
) { Text(stringResource(R.string.save)) }
},
) {
NameField(
name = name,
color = color,
// A new list opens with the keyboard up: naming it is the whole task.
autoFocus = initial == null,
onNameChange = { name = it },
onImeAction = commit,
)
Spacer(Modifier.height(20.dp))
SectionLabel(stringResource(R.string.list_color))
ColorGrid(selected = color, onSelect = { color = it })
if (onDelete != null) {
Spacer(Modifier.height(24.dp))
DeleteRow(onClick = { confirmDelete = true })
}
Spacer(Modifier.height(24.dp))
}
if (confirmDelete && onDelete != null) {
DeleteListDialog(
listName = initial?.name.orEmpty(),
onConfirm = {
confirmDelete = false
onDelete()
onDismiss()
},
onDismiss = { confirmDelete = false },
)
}
}
/** The name, with the chosen colour beside it so the two read as one thing. */
@Composable
private fun NameField(
name: String,
color: Int,
autoFocus: Boolean,
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
ListColorChip(color)
InlineTextField(
value = name,
onValueChange = onNameChange,
placeholder = stringResource(R.string.list_name_hint),
imeAction = ImeAction.Done,
onImeAction = onImeAction,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
}
}
}
/** The palette as two rows of round swatches; the chosen one carries a check. */
@Composable
private fun ColorGrid(selected: Int, onSelect: (Int) -> Unit) {
val dark = isSystemInDarkTheme()
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
ListPalette.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
row.forEach { swatch ->
Swatch(
color = swatch,
dark = dark,
selected = swatch == selected,
onClick = { onSelect(swatch) },
modifier = Modifier.weight(1f),
)
}
// Keeps a short final row's swatches at the same size as a full
// one's rather than stretching them across the width.
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
}
@Composable
private fun Swatch(
color: Int,
dark: Boolean,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val fill = pastelize(color, dark)
val label = stringResource(colorLabel(color))
Box(
modifier = modifier
.aspectRatio(1f)
.clip(CircleShape)
.background(fill)
// selectable, not clickable: the swatch carries its chosen state in
// semantics, so the check below is decoration rather than the only cue.
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
Icons.Rounded.Check,
contentDescription = null,
tint = if (fill.luminance() > 0.5f) Color.Black else Color.White,
modifier = Modifier.size(22.dp),
)
}
}
}
@Composable
private fun DeleteRow(onClick: () -> Unit) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
onClick = onClick,
color = MaterialTheme.colorScheme.errorContainer,
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringResource(R.string.list_delete),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
@Composable
private fun DeleteListDialog(listName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.list_delete_confirm_title)) },
text = { Text(stringResource(R.string.list_delete_confirm_message, listName)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
@Composable
private fun SectionLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
)
}
/** Names the palette entries for screen readers; anything else is just "colour". */
private fun colorLabel(color: Int): Int = when (ListPalette.indexOf(color)) {
0 -> R.string.list_color_mauve
1 -> R.string.list_color_red
2 -> R.string.list_color_orange
3 -> R.string.list_color_amber
4 -> R.string.list_color_olive
5 -> R.string.list_color_green
6 -> R.string.list_color_teal
7 -> R.string.list_color_cyan
8 -> R.string.list_color_blue
9 -> R.string.list_color_indigo
10 -> R.string.list_color_purple
11 -> R.string.list_color_pink
else -> R.string.list_color
}

View File

@@ -46,6 +46,7 @@ import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -80,6 +81,10 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
import kotlinx.coroutines.delay
import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.common.priorityAccent
@@ -108,10 +113,22 @@ fun ListsScreen(
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
var newList by rememberSaveable { mutableStateOf(false) }
val closeSearch = { val closeSearch = {
query = "" query = ""
searchActive = false searchActive = false
} }
// With no lists there is nowhere to put a task, so the primary action becomes
// making one — otherwise a fresh install's FAB opens a form that cannot save.
val noLists = (state as? ListsUiState.Content)?.groups?.isEmpty() == true
// The sheet closes on save, so a refused write has to report itself here.
val writeFailure by viewModel.writeFailure.collectAsStateWithLifecycle()
LaunchedEffect(writeFailure) {
if (writeFailure != null) {
delay(4_000)
viewModel.clearWriteFailure()
}
}
// System back closes search before leaving the screen. // System back closes search before leaving the screen.
BackHandler(enabled = searchActive, onBack = closeSearch) BackHandler(enabled = searchActive, onBack = closeSearch)
@@ -130,9 +147,11 @@ fun ListsScreen(
// The FAB would otherwise float over the search results. // The FAB would otherwise float over the search results.
if (!searchActive) { if (!searchActive) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onNewTask, onClick = if (noLists) ({ newList = true }) else onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) }, text = {
Text(stringResource(if (noLists) R.string.list_add else R.string.new_task))
},
) )
} }
}, },
@@ -147,6 +166,7 @@ fun ListsScreen(
state = s, state = s,
onOpenFilter = onOpenFilter, onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask, onOpenTask = onOpenTask,
onNewList = { newList = true },
topPadding = 0.dp, topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp, bottomPadding = inner.calculateBottomPadding() + 96.dp,
) )
@@ -166,8 +186,33 @@ fun ListsScreen(
} }
} }
} }
// Anchored beside the FAB, at its height — the same receipt placement
// the task list uses.
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(
start = SnackChipMargin,
bottom = inner.calculateBottomPadding() + SnackChipMargin,
)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
) {
SnackChip(
visible = writeFailure != null,
message = stringResource(R.string.list_save_failed),
)
} }
} }
}
if (newList) {
ListEditorSheet(
initial = null,
onSave = viewModel::createList,
onDismiss = { newList = false },
)
}
} }
@Composable @Composable
@@ -175,6 +220,7 @@ private fun ListsContent(
state: ListsUiState.Content, state: ListsUiState.Content,
onOpenFilter: (TaskFilter) -> Unit, onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Long) -> Unit, onOpenTask: (Long) -> Unit,
onNewList: () -> Unit,
topPadding: androidx.compose.ui.unit.Dp, topPadding: androidx.compose.ui.unit.Dp,
bottomPadding: androidx.compose.ui.unit.Dp, bottomPadding: androidx.compose.ui.unit.Dp,
) { ) {
@@ -215,9 +261,21 @@ private fun ListsContent(
} }
if (state.groups.isEmpty()) { if (state.groups.isEmpty()) {
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) } item { EmptyLists(onNewList = onNewList) }
} else { } else {
item { SectionHeader(stringResource(R.string.lists_header)) } item {
SectionHeader(
text = stringResource(R.string.lists_header),
action = {
ShapedActionButton(
shape = ActionShapes.AddList,
icon = Icons.Rounded.Add,
contentDescription = stringResource(R.string.list_add),
onClick = onNewList,
)
},
)
}
state.groups.forEach { group -> state.groups.forEach { group ->
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) } item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview -> itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
@@ -243,6 +301,28 @@ private fun ListsContent(
} }
} }
/** No lists at all — a fresh install, where nothing else on this screen works yet. */
@Composable
private fun EmptyLists(onNewList: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.lists_empty),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
FilledTonalButton(onClick = onNewList) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.lists_empty_action))
}
}
}
/** /**
* The home top bar. There is no title — the launcher icon already says which app * The home top bar. There is no title — the launcher icon already says which app
* this is. Settings is pinned at the right; the search action sits just left of it * this is. Settings is pinned at the right; the search action sits just left of it
@@ -419,7 +499,7 @@ private fun SearchResults(
} }
} else { } else {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(results, key = { it.id }) { task -> items(results, key = { it.occurrenceKey }) { task ->
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) }) UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
} }
} }
@@ -655,12 +735,25 @@ private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick:
} }
@Composable @Composable
private fun SectionHeader(text: String) { private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 28.dp,
end = if (action == null) 28.dp else 20.dp,
top = if (action == null) 16.dp else 12.dp,
bottom = 4.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp), modifier = Modifier.weight(1f),
) )
action?.invoke()
}
} }
@Composable @Composable

View File

@@ -4,21 +4,30 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.floret.time.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.ZoneId import java.time.ZoneId
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Clock import kotlin.time.Clock
/**
* What a list write failed at. The screens turn this into wording; the view
* models stay free of resources.
*/
enum class ListWriteFailure { SAVE, DELETE }
data class ListOverview(val list: TaskList, val openCount: Int) data class ListOverview(val list: TaskList, val openCount: Int)
data class AccountGroup(val accountName: String, val lists: List<ListOverview>) data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
data class SmartCount(val smart: SmartList, val count: Int) data class SmartCount(val smart: SmartList, val count: Int)
@@ -44,7 +53,7 @@ private const val UPCOMING_PREVIEW = 3
/** The home overview: smart lists with live counts, then user lists by account. */ /** The home overview: smart lists with live counts, then user lists by account. */
@HiltViewModel @HiltViewModel
class ListsViewModel @Inject constructor( class ListsViewModel @Inject constructor(
repository: TasksRepository, private val repository: TasksRepository,
) : ViewModel() { ) : ViewModel() {
val state: StateFlow<ListsUiState> = val state: StateFlow<ListsUiState> =
@@ -56,7 +65,7 @@ class ListsViewModel @Inject constructor(
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)), repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
) { lists, openTasks, completedTasks -> ) { lists, openTasks, completedTasks ->
buildContent(lists, openTasks, completedTasks) as ListsUiState buildContent(lists, openTasks, completedTasks) as ListsUiState
}.catch { emit(ListsUiState.Failure) } }.recoveringFromProviderFailure { ListsUiState.Failure }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
private fun buildContent( private fun buildContent(
@@ -112,4 +121,22 @@ class ListsViewModel @Inject constructor(
allTasks = openTasks + completedTasks, allTasks = openTasks + completedTasks,
) )
} }
private val _writeFailure = MutableStateFlow<ListWriteFailure?>(null)
/** Set when a list write is refused; the screen shows it and clears it. */
val writeFailure: StateFlow<ListWriteFailure?> = _writeFailure.asStateFlow()
fun clearWriteFailure() { _writeFailure.value = null }
/**
* Create a device-only list. The lists flow picks it up on the store change;
* a refusal (External mode, a provider that says no) surfaces through
* [writeFailure] rather than vanishing, because the sheet has already closed.
*/
fun createList(name: String, color: Int) = viewModelScope.launch {
if (name.isBlank()) return@launch
runCatching { repository.createLocalList(name.trim(), color) }
.onFailure { _writeFailure.value = ListWriteFailure.SAVE }
}
} }

View File

@@ -1,13 +1,25 @@
package de.jeanlucmakiola.agendula.ui.permission package de.jeanlucmakiola.agendula.ui.permission
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
data class PermissionUiState( data class PermissionUiState(
@@ -20,23 +32,51 @@ data class PermissionUiState(
* Gates app entry: is a tasks provider installed, and do we hold its permissions? * Gates app entry: is a tasks provider installed, and do we hold its permissions?
* The Composable owns the actual permission-launcher and store intents; this VM * The Composable owns the actual permission-launcher and store intents; this VM
* supplies the [status] and the exact permission strings to ask for. * supplies the [status] and the exact permission strings to ask for.
*
* In the default Own mode this gate never appears at all — the store is our own
* Room database, so there is nothing to install and nothing to grant. It exists
* for External mode, which also makes it the only screen an External user can
* reach once their provider app stops answering: hence [useOwnStore].
*/ */
@HiltViewModel @HiltViewModel
class PermissionViewModel @Inject constructor( class PermissionViewModel @Inject constructor(
private val repository: TasksRepository, private val repository: TasksRepository,
private val providerResolver: ProviderResolver, private val providerResolver: ProviderResolver,
private val prefs: SettingsPrefs,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(PermissionUiState()) private val refreshes = MutableStateFlow(0)
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
init { refresh() } // Re-evaluated when the *resolver's* mode lands, not when the preference is
// written: anything read in between still answers for the store we just left.
// Conflated, as everywhere else this signal is bridged: only the latest mode
// matters, and a full buffer drops it rather than the ones it supersedes.
private val modeChanges: Flow<Unit> = callbackFlow {
trySend(Unit)
val handle = providerResolver.onModeChanged { trySend(Unit) }
awaitClose { handle.close() }
}.buffer(Channel.CONFLATED)
val state: StateFlow<PermissionUiState> =
combine(refreshes, modeChanges) { _, _ -> currentState() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), currentState())
/** Re-read provider + permission state (call after returning from a request). */ /** Re-read provider + permission state (call after returning from a request). */
fun refresh() { fun refresh() = refreshes.update { it + 1 }
/**
* Leave a store this device can no longer read. The provider app can be
* uninstalled, or its permission revoked, after External was chosen — and the
* gate is then the only screen reachable, Settings included. Our own store
* always reads, so it is the way out.
*/
fun useOwnStore() = viewModelScope.launch { prefs.setStorageMode(StorageMode.OWN) }
private fun currentState(): PermissionUiState {
val provider = providerResolver.resolve() val provider = providerResolver.resolve()
_state.value = PermissionUiState( return PermissionUiState(
status = repository.providerStatus(), status = repository.providerStatus(),
// Null in OWN mode, where there is no provider and nothing to grant.
permissionsToRequest = provider permissionsToRequest = provider
?.let { listOf(it.readPermission, it.writePermission) } ?.let { listOf(it.readPermission, it.writePermission) }
.orEmpty(), .orEmpty(),

View File

@@ -49,6 +49,7 @@ import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Percent import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -56,7 +57,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -75,13 +75,11 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
import de.jeanlucmakiola.floret.components.AboutCard import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.AboutLink import de.jeanlucmakiola.floret.components.AboutLink
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
@@ -96,10 +94,22 @@ import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, TaskForm, Reminders } private enum class SettingsSection {
Appearance,
TaskForm,
Reminders,
Storage,
Export,
;
/** Where back goes: Export is opened from Storage, not from the hub. */
val parent: SettingsSection?
get() = if (this == Export) Storage else null
}
/** /**
* Token-based accent for a leading icon chip (container / on-container pair), * Token-based accent for a leading icon chip (container / on-container pair),
@@ -125,7 +135,7 @@ fun SettingsScreen(
// Inside a sub-screen, system back (button or gesture) returns to the hub // Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview. // rather than popping the whole Settings destination to the lists overview.
BackHandler(enabled = section != null) { section = null } BackHandler(enabled = section != null) { section = section?.parent }
Box( Box(
modifier = modifier modifier = modifier
@@ -143,6 +153,19 @@ fun SettingsScreen(
SlideInSection(visible = section == SettingsSection.Reminders) { SlideInSection(visible = section == SettingsSection.Reminders) {
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null }) RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
} }
// Storage stays composed under Export, so the deeper screen slides over it.
val storageOpen = section == SettingsSection.Storage ||
section?.parent == SettingsSection.Storage
SlideInSection(visible = storageOpen) {
StorageScreen(
viewModel = viewModel,
onOpenExport = { section = SettingsSection.Export },
onBack = { section = null },
)
}
SlideInSection(visible = section == SettingsSection.Export) {
ExportScreen(onBack = { section = SettingsSection.Storage })
}
} }
} }
@@ -212,6 +235,13 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) }, onClick = { onOpenSection(SettingsSection.Reminders) },
) )
GroupedRow(
title = stringResource(R.string.settings_section_storage),
summary = stringResource(R.string.settings_storage_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Storage) },
)
LanguageRow(position = Position.Middle) LanguageRow(position = Position.Middle)
ReportProblemRow(position = Position.Bottom) ReportProblemRow(position = Position.Bottom)
@@ -670,16 +700,11 @@ private fun rememberExactAlarmAllowed(context: Context): Boolean {
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(), context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
) )
} }
val lifecycle = LocalLifecycleOwner.current.lifecycle OnResume {
DisposableEffect(lifecycle) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
} }
} }
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
return allowed return allowed
} }

View File

@@ -3,18 +3,27 @@ package de.jeanlucmakiola.agendula.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -23,6 +32,22 @@ data class SettingsUiState(
val lists: List<TaskList> = emptyList(), val lists: List<TaskList> = emptyList(),
) )
/**
* The storage half of Settings: which store is active, and what picking the
* other one would mean on this device.
*
* Kept apart from [SettingsUiState] because that one is collected for the whole
* Activity lifetime to drive the theme, and re-probing PackageManager on every
* theme emission would be work for nothing.
*/
data class StorageUiState(
val mode: StorageMode,
/** The external provider installed here, or null when there is none to pick. */
val external: TaskProvider? = null,
/** That provider's own app name, for a row that names what it is switching to. */
val externalLabel: String? = null,
)
/** /**
* Drives both the Settings screen and the app theme (MainActivity collects the * Drives both the Settings screen and the app theme (MainActivity collects the
* same instance), so a theme change applies app-wide at once. * same instance), so a theme change applies app-wide at once.
@@ -30,14 +55,48 @@ data class SettingsUiState(
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs, private val prefs: SettingsPrefs,
private val resolver: ProviderResolver,
private val environment: ProviderEnvironment,
@IoDispatcher io: CoroutineDispatcher,
repository: TasksRepository, repository: TasksRepository,
) : ViewModel() { ) : ViewModel() {
// MainActivity collects this for the theme, above the permission gate and for
// the whole Activity lifetime — so the list flow must survive the pre-grant
// SecurityException and recover once permission is given, not die for good.
val state: StateFlow<SettingsUiState> = val state: StateFlow<SettingsUiState> =
combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists -> combine(
prefs.settings,
repository.taskLists().recoveringFromProviderFailure { emptyList() },
) { settings, lists ->
SettingsUiState(settings, lists) SettingsUiState(settings, lists)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
// Bumped to re-probe the device; installing a provider or granting its
// permission happens outside the app, so nothing else would emit.
private val providerProbe = MutableStateFlow(0)
// Null until the first emission lands: the mode comes from DataStore and the
// rest from PackageManager, off the main thread, so any seeded default would
// name the wrong store for the first frames. flowOn, because every field here
// costs a PackageManager lookup or a permission check.
val storage: StateFlow<StorageUiState?> =
combine(prefs.storageMode, providerProbe) { stored, _ ->
val external = resolver.resolveExternal()
StorageUiState(
// No stored choice is the normal state; show what autoMode resolves
// to rather than a default that may not be the store in use.
mode = stored ?: resolver.autoMode(),
external = external,
externalLabel = external?.packageName?.let(environment::appLabel),
)
}.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** Re-read the device's provider state, after a permission request or a resume. */
fun refreshStorage() = providerProbe.update { it + 1 }
fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) }
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }

View File

@@ -0,0 +1,203 @@
package de.jeanlucmakiola.agendula.ui.settings
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.PhoneAndroid
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
/**
* Where the tasks live: the store picker the resolver's `autoMode()` has always
* assumed, plus the way out of a store that lives in our own private storage.
*/
@Composable
internal fun StorageScreen(
viewModel: SettingsViewModel,
onOpenExport: () -> Unit,
onBack: () -> Unit,
) {
val context = LocalContext.current
val storage by viewModel.storage.collectAsStateWithLifecycle()
var showPicker by remember { mutableStateOf(false) }
var denied by remember { mutableStateOf(false) }
// The mode is committed only once the grant is in — switching first drops the
// user on the app-wide permission gate.
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
) { grants ->
viewModel.refreshStorage()
val granted = grants.isNotEmpty() && grants.values.all { it }
denied = !granted
if (granted) viewModel.setStorageMode(StorageMode.EXTERNAL)
}
// A provider can be installed, or its permission revoked, while we're away.
// Deliberately does not clear [denied]: this fires on returning from the
// permission dialog too, and would wipe the refusal before it is read.
OnResume { viewModel.refreshStorage() }
CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) {
GroupedRow(
title = stringResource(R.string.settings_task_store),
summary = storage?.let { storeLabel(it) },
position = Position.Top,
// Nothing to pick until the stored mode has landed; opening the picker
// on the seeded state would offer the wrong store as the current one.
onClick = storage?.let {
{
denied = false
showPicker = true
}
},
)
GroupedRow(
title = stringResource(R.string.settings_export),
summary = stringResource(R.string.settings_export_hint),
position = Position.Bottom,
onClick = onOpenExport,
)
if (denied) {
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_store_permission_denied),
summary = stringResource(R.string.settings_store_permission_denied_hint),
position = Position.Alone,
onClick = { context.openAppSettings() },
)
}
}
storage?.let { state ->
if (showPicker) {
StorePicker(
storage = state,
onSelect = { mode ->
val external = state.external
if (mode == StorageMode.EXTERNAL && external != null) {
// Asked even when the grant looks held: an already-granted
// request returns at once, a stale belief would strand them.
permissionLauncher.launch(
arrayOf(external.readPermission, external.writePermission),
)
} else {
viewModel.setStorageMode(mode)
}
},
onDismiss = { showPicker = false },
)
}
}
}
/**
* The two stores, as rows. External is offered only when a provider is actually
* installed — dimmed and inert otherwise, because a mode with nothing behind it
* empties the app.
*/
@Composable
private fun StorePicker(
storage: StorageUiState,
onSelect: (StorageMode) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(title = stringResource(R.string.settings_task_store), onDismiss = onDismiss) {
Text(
text = stringResource(R.string.settings_task_store_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
Spacer(Modifier.height(8.dp))
val select: (StorageMode) -> Unit = { chosen ->
onSelect(chosen)
onDismiss()
}
val external = storage.external
GroupedRow(
title = stringResource(R.string.settings_store_own),
summary = stringResource(R.string.settings_store_own_hint),
position = Position.Top,
selected = storage.mode == StorageMode.OWN,
leading = { Icon(Icons.Rounded.PhoneAndroid, contentDescription = null) },
trailing = if (storage.mode == StorageMode.OWN) {
{ SelectedCheck() }
} else {
null
},
onClick = { select(StorageMode.OWN) },
)
GroupedRow(
title = externalTitle(external, storage.externalLabel),
summary = stringResource(
if (external == null) {
R.string.settings_store_external_missing
} else {
R.string.settings_store_external_hint
},
),
position = Position.Bottom,
selected = storage.mode == StorageMode.EXTERNAL,
dimmed = external == null,
leading = { Icon(Icons.Rounded.Apps, contentDescription = null) },
trailing = if (storage.mode == StorageMode.EXTERNAL) {
{ SelectedCheck() }
} else {
null
},
onClick = if (external != null) ({ select(StorageMode.EXTERNAL) }) else null,
)
Spacer(Modifier.height(24.dp))
}
}
/** The active store, named the way the picker names it. */
@Composable
private fun storeLabel(storage: StorageUiState): String = when (storage.mode) {
StorageMode.OWN -> stringResource(R.string.settings_store_own)
StorageMode.EXTERNAL -> externalTitle(storage.external, storage.externalLabel)
}
/** The provider's own app name, its authority, or the generic wording. */
@Composable
private fun externalTitle(provider: TaskProvider?, label: String?): String =
label ?: provider?.authority ?: stringResource(R.string.settings_store_external)
private fun Context.openAppSettings() {
runCatching {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri()),
)
}
}

View File

@@ -44,6 +44,7 @@ import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
@@ -97,6 +98,8 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SnackChip import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight import de.jeanlucmakiola.floret.components.SnackChipHeight
@@ -126,8 +129,22 @@ fun TaskListScreen(
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val content = state as? TaskListUiState.Content val content = state as? TaskListUiState.Content
val listName = content?.listName val list = content?.list
val listName = list?.name
val listId = (filter as? TaskFilter.OfList)?.listId val listId = (filter as? TaskFilter.OfList)?.listId
// Editing is offered for a device-only list. A collection that belongs to an
// account is the server's to rename or remove, not ours.
var editingList by rememberSaveable { mutableStateOf(false) }
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
// The list this screen is about is gone; there is nothing left to show.
LaunchedEffect(listDeleted) { if (listDeleted) onBack() }
LaunchedEffect(listWriteFailure) {
if (listWriteFailure != null) {
delay(4_000)
viewModel.clearListWriteFailure()
}
}
// One add affordance, never two: a real list with the setting on gets a pinned // One add affordance, never two: a real list with the setting on gets a pinned
// bottom quick-add bar; everything else (incl. smart lists, which have no single // bottom quick-add bar; everything else (incl. smart lists, which have no single
// target list) gets the floating "New task" button. // target list) gets the floating "New task" button.
@@ -162,6 +179,16 @@ fun TaskListScreen(
) )
} }
}, },
actions = {
if (list != null && list.isLocal) {
IconButton(onClick = { editingList = true }) {
Icon(
Icons.Rounded.Edit,
contentDescription = stringResource(R.string.list_edit_title),
)
}
}
},
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },
@@ -199,6 +226,10 @@ fun TaskListScreen(
.height(SnackChipHeight), .height(SnackChipHeight),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
// One chip, one anchor: the undo receipt takes precedence, and a
// refused list write reports itself once the undo window is clear.
val failure = listWriteFailure
if (undoTarget != null || failure == null) {
SnackChip( SnackChip(
visible = undoTarget != null, visible = undoTarget != null,
message = stringResource(R.string.task_deleted), message = stringResource(R.string.task_deleted),
@@ -208,9 +239,30 @@ fun TaskListScreen(
undoTarget = null undoTarget = null
}, },
) )
} else {
SnackChip(
visible = true,
message = stringResource(listWriteFailureMessage(failure)),
)
} }
} }
} }
}
if (editingList && list != null) {
ListEditorSheet(
initial = list,
onSave = { name, color -> viewModel.updateList(list.id, name, color) },
onDismiss = { editingList = false },
onDelete = { viewModel.deleteList(list.id) },
)
}
}
/** Wording for a refused list write. */
private fun listWriteFailureMessage(failure: ListWriteFailure): Int = when (failure) {
ListWriteFailure.SAVE -> R.string.list_save_failed
ListWriteFailure.DELETE -> R.string.list_delete_failed
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@@ -773,20 +825,20 @@ private fun SubtaskExpandButton(expanded: Boolean, onToggle: () -> Unit) {
/** A visual row in a flattened section run: a top-level task, or one of its subtasks. */ /** A visual row in a flattened section run: a top-level task, or one of its subtasks. */
private sealed interface ListRow { private sealed interface ListRow {
val key: Long val key: String
data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow { data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow {
override val key: Long get() = task.taskId override val key: String get() = task.occurrenceKey
} }
data class Sub(val task: Task) : ListRow { data class Sub(val task: Task) : ListRow {
override val key: Long get() = task.taskId override val key: String get() = task.occurrenceKey
} }
/** The inline "add a subtask" row that closes an expanded group. */ /** The inline "add a subtask" row that closes an expanded group. */
data class AddSub(val parent: Task) : ListRow { data class AddSub(val parent: Task) : ListRow {
// Negative so it never collides with a real (positive) provider task id. // Prefixed so it never collides with the task row it belongs to.
override val key: Long get() = -parent.taskId override val key: String get() = "add-${parent.occurrenceKey}"
} }
} }

View File

@@ -5,14 +5,17 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@@ -28,13 +31,13 @@ sealed interface TaskListUiState {
data object Failure : TaskListUiState data object Failure : TaskListUiState
/** /**
* [listName] is the real list's name when the filter is a * [list] is the real list when the filter is a [TaskFilter.OfList] — it
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists * titles the bar and backs the edit action — and `null` for smart lists,
* the screen falls back to the smart label in that case. * where the screen falls back to the smart label.
*/ */
data class Content( data class Content(
val tasks: List<Task>, val tasks: List<Task>,
val listName: String? = null, val list: TaskList? = null,
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */ /** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
val showAddSubtaskRow: Boolean = true, val showAddSubtaskRow: Boolean = true,
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */ /** Whether a real list uses the bottom quick-add bar instead of the FAB. */
@@ -65,8 +68,8 @@ class TaskListViewModel @Inject constructor(
val tasks = repository.tasks(f) val tasks = repository.tasks(f)
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) { val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
is TaskFilter.OfList -> is TaskFilter.OfList ->
combine(tasks, repository.taskLists()) { list, lists -> combine(tasks, repository.taskLists()) { rows, lists ->
TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name) TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId })
} }
is TaskFilter.Smart -> is TaskFilter.Smart ->
tasks.map { TaskListUiState.Content(it) } tasks.map { TaskListUiState.Content(it) }
@@ -87,7 +90,10 @@ class TaskListViewModel @Inject constructor(
} }
} }
.onStart { emit(TaskListUiState.Loading) } .onStart { emit(TaskListUiState.Loading) }
.catch { emit(TaskListUiState.Failure) } // Recover rather than terminate: a provider hiccup (mid-update,
// permission not yet granted) shows Failure but keeps retrying,
// so the screen heals itself instead of staying stuck.
.recoveringFromProviderFailure { TaskListUiState.Failure }
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
@@ -112,6 +118,9 @@ class TaskListViewModel @Inject constructor(
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() } combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
} }
} }
// Without this an exception here escapes stateIn's coroutine, past
// viewModelScope's SupervisorJob, and crashes the process.
.recoveringFromProviderFailure { emptyMap() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** The screen reports which expanded parents need their children fetched. */ /** The screen reports which expanded parents need their children fetched. */
@@ -120,7 +129,7 @@ class TaskListViewModel @Inject constructor(
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter } fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
fun toggleComplete(task: Task) = viewModelScope.launch { fun toggleComplete(task: Task) = viewModelScope.launch {
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) }
} }
/** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */ /** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */
@@ -149,6 +158,36 @@ class TaskListViewModel @Inject constructor(
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) } runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
} }
private val _listWriteFailure = MutableStateFlow<ListWriteFailure?>(null)
/** Set when a list write is refused; the screen shows it and clears it. */
val listWriteFailure: StateFlow<ListWriteFailure?> = _listWriteFailure.asStateFlow()
private val _listDeleted = MutableStateFlow(false)
/** Flips once the list this screen shows is really gone, so it can leave. */
val listDeleted: StateFlow<Boolean> = _listDeleted.asStateFlow()
fun clearListWriteFailure() { _listWriteFailure.value = null }
/** Rename / recolour the list this screen is showing. */
fun updateList(listId: Long, name: String, color: Int) = viewModelScope.launch {
if (name.isBlank()) return@launch
runCatching { repository.updateList(listId, name.trim(), color) }
.onFailure { _listWriteFailure.value = ListWriteFailure.SAVE }
}
/**
* Delete the list **and its tasks**. The screen navigates away on
* [listDeleted], not on the call — leaving first would strand a refusal on a
* screen that no longer exists.
*/
fun deleteList(listId: Long) = viewModelScope.launch {
runCatching { repository.deleteList(listId) }
.onSuccess { _listDeleted.value = true }
.onFailure { _listWriteFailure.value = ListWriteFailure.DELETE }
}
/** Inline "add subtask" from an expanded list group — files it under [parent]. */ /** Inline "add subtask" from an expanded list group — files it under [parent]. */
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch { fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
if (title.isBlank() || parent.listId <= 0L) return@launch if (title.isBlank() || parent.listId <= 0L) return@launch

View File

@@ -122,7 +122,32 @@
<string name="lists_header">Lists</string> <string name="lists_header">Lists</string>
<string name="new_task">New task</string> <string name="new_task">New task</string>
<string name="lists_failure">Could not read your tasks.</string> <string name="lists_failure">Could not read your tasks.</string>
<string name="lists_empty">No task lists yet. Add one in your tasks app or with the + button.</string> <string name="lists_empty">No task lists yet.</string>
<string name="lists_empty_action">Create a list</string>
<!-- Task lists: create, edit, delete -->
<string name="list_add">New list</string>
<string name="list_new_title">New list</string>
<string name="list_edit_title">Edit list</string>
<string name="list_name_hint">List name</string>
<string name="list_color">Colour</string>
<string name="list_delete">Delete list</string>
<string name="list_save_failed">Could not save the list.</string>
<string name="list_delete_failed">Could not delete the list.</string>
<string name="list_delete_confirm_title">Delete list?</string>
<string name="list_delete_confirm_message">“%1$s” and all of its tasks will be deleted. This can\'t be undone.</string>
<string name="list_color_mauve">Mauve</string>
<string name="list_color_red">Red</string>
<string name="list_color_orange">Orange</string>
<string name="list_color_amber">Amber</string>
<string name="list_color_olive">Olive</string>
<string name="list_color_green">Green</string>
<string name="list_color_teal">Teal</string>
<string name="list_color_cyan">Cyan</string>
<string name="list_color_blue">Blue</string>
<string name="list_color_indigo">Indigo</string>
<string name="list_color_purple">Purple</string>
<string name="list_color_pink">Pink</string>
<string name="smart_today">Today</string> <string name="smart_today">Today</string>
<string name="smart_overdue">Overdue</string> <string name="smart_overdue">Overdue</string>
<string name="smart_upcoming">Upcoming</string> <string name="smart_upcoming">Upcoming</string>
@@ -221,6 +246,37 @@
<string name="settings_bottom_add_bar">Bottom quick-add bar</string> <string name="settings_bottom_add_bar">Bottom quick-add bar</string>
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string> <string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
<string name="onboarding_use_own_store">Use this device\'s storage instead</string>
<!-- Storage and export -->
<string name="settings_section_storage">Storage</string>
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
<string name="settings_task_store">Task store</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — export first if you want a copy.</string>
<string name="settings_store_own">On this device</string>
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
<string name="settings_store_external">Another task app</string>
<string name="settings_store_external_hint">Share tasks with the app that syncs them</string>
<string name="settings_store_external_missing">No compatible task app is installed</string>
<string name="settings_store_permission_denied">Permission denied</string>
<string name="settings_store_permission_denied_hint">The other app\'s tasks stay unreachable until you allow access. Tap to open app settings.</string>
<string name="settings_export">Export tasks</string>
<string name="settings_export_hint">Save your lists as iCalendar files</string>
<string name="export_hint">One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip.</string>
<string name="export_no_lists">No lists to export</string>
<string name="export_to_folder">Save to a folder</string>
<string name="export_to_zip">Save as a zip file</string>
<string name="export_running">Exporting…</string>
<plurals name="export_done">
<item quantity="one">Exported %1$d list</item>
<item quantity="other">Exported %1$d lists</item>
</plurals>
<string name="export_failed">The export could not be written</string>
<string name="export_failed_folder">The chosen folder could not be opened</string>
<string name="export_failed_read_only">The chosen folder is not writable</string>
<string name="export_failed_create">A file could not be created in the chosen folder</string>
<string name="export_failed_access">Access to the chosen location was lost</string>
<!-- Reminder lead times (custom amounts) --> <!-- Reminder lead times (custom amounts) -->
<plurals name="reminder_minutes"> <plurals name="reminder_minutes">
<item quantity="one">%1$d minute before</item> <item quantity="one">%1$d minute before</item>

View File

@@ -1,4 +1,22 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<full-backup-content> <full-backup-content>
<!-- No file-based backups; settings live in DataStore which is backed up by default. --> <!--
Agendula's own task store. Room runs in WAL mode and Auto Backup copies
files without checkpointing, so the `-wal` sidecar can hold writes the
`.db` alone does not — all three go in together, and the app checkpoints
on ON_STOP so a restore is consistent either way.
Naming any <include> makes everything else excluded by default, so the
archived dmfs database (`tasks.db.imported`, kept one release as the
import's rollback path) is already left out. An explicit <exclude> for it
would be redundant *and* rejected — lint's FullBackupContent check errors
on an exclude that sits under no included path.
Settings live in DataStore, which this exclusion now also covers, so its
sharedpref file is listed back in.
-->
<include domain="database" path="agendula-tasks.db" />
<include domain="database" path="agendula-tasks.db-wal" />
<include domain="database" path="agendula-tasks.db-shm" />
<include domain="file" path="datastore/" />
</full-backup-content> </full-backup-content>

View File

@@ -1,8 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules> <data-extraction-rules>
<!--
See backup_rules.xml: the WAL sidecars travel with the database, and
naming any <include> makes everything else excluded by default — which is
what keeps the archived `tasks.db.imported` out without an <exclude> that
lint would reject.
-->
<cloud-backup> <cloud-backup>
<!-- Allow DataStore backup, exclude nothing extra. --> <include domain="database" path="agendula-tasks.db" />
<include domain="database" path="agendula-tasks.db-wal" />
<include domain="database" path="agendula-tasks.db-shm" />
<include domain="file" path="datastore/" />
</cloud-backup> </cloud-backup>
<device-transfer> <device-transfer>
<include domain="database" path="agendula-tasks.db" />
<include domain="database" path="agendula-tasks.db-wal" />
<include domain="database" path="agendula-tasks.db-shm" />
<include domain="file" path="datastore/" />
</device-transfer> </device-transfer>
</data-extraction-rules> </data-extraction-rules>

View File

@@ -0,0 +1,61 @@
package de.jeanlucmakiola.agendula.data.export
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The export file name. The user picks the folder, so whatever comes out of here
* is what they will be looking at in a file manager a year from now.
*/
class TaskExporterTest {
private fun name(listName: String, id: Long = 3L) = TaskExporter.fileNameFor(listName, id)
@Test
fun `keeps a plain name readable`() {
assertThat(name("Groceries")).isEqualTo("Groceries-3.ics")
}
@Test
fun `replaces characters a filesystem would reject`() {
// SAF can land on FAT32 (an SD card), where these are simply illegal.
val result = name("Work / Home: notes?")
assertThat(result).doesNotContain("/")
assertThat(result).doesNotContain(":")
assertThat(result).doesNotContain("?")
assertThat(result).endsWith("-3.ics")
}
@Test
fun `keeps the id so same-named lists cannot collide`() {
// Two accounts may each have a list called "Personal"; without the id one
// export would silently overwrite the other.
assertThat(name("Personal", 1)).isNotEqualTo(name("Personal", 2))
}
@Test
fun `falls back when the name has nothing usable in it`() {
assertThat(name("///")).isEqualTo("list-3.ics")
assertThat(name("")).isEqualTo("list-3.ics")
}
@Test
fun `does not leave dangling separators`() {
assertThat(name(" Shopping ")).isEqualTo("Shopping-3.ics")
}
@Test
fun `caps the length`() {
// Many filesystems stop at 255 bytes for a name; a pathological list title
// should not be the thing that fails an export.
assertThat(name("x".repeat(500)).length).isAtMost(80)
}
@Test
fun `keeps non-latin names instead of blanking them`() {
// isLetterOrDigit is Unicode-aware, so these survive rather than collapsing
// to the "list" fallback.
assertThat(name("Einkäufe")).isEqualTo("Einkäufe-3.ics")
assertThat(name("買い物")).isEqualTo("買い物-3.ics")
}
}

View File

@@ -0,0 +1,198 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
/**
* The storage-mode decision, which is the part of [ProviderResolver] with real
* consequences: pick wrong for a returning user and the app opens on an empty
* store where their tasks used to be.
*/
class ProviderResolverTest {
/**
* A [ProviderEnvironment] with no Android in it.
*
* @param installed authority -> declaring package, i.e. what is on the device.
* @param granted permissions this app currently holds.
*/
private class FakeEnvironment(
val installed: Map<String, String> = emptyMap(),
val granted: Set<String> = emptySet(),
) : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = installed[authority]
override fun isGranted(permission: String): Boolean = permission in granted
override fun appLabel(packageName: String): String? = packageName
}
private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" }
private fun resolver(
installed: Map<String, String> = emptyMap(),
granted: Set<String> = emptySet(),
mode: StorageMode? = null,
) = ProviderResolver(FakeEnvironment(installed, granted)).apply { storageMode = mode }
private val openTasksInstalled = mapOf("org.dmfs.tasks" to "org.dmfs.tasks")
private val openTasksGranted = setOf(openTasks.readPermission, openTasks.writePermission)
@Nested
inner class OwnStore {
@Test
fun `is readable without anything installed or granted`() {
// Guards a real regression: the reminder engine used to gate on
// resolve() != null, which is exactly what OWN returns, so every
// reminder was cleared the moment our own store became the default.
assertThat(resolver(mode = StorageMode.OWN).canReadStore()).isTrue()
}
@Test
fun `resolves to no provider at all`() {
// Room has no authority and no ContentResolver, so there is nothing
// here to resolve — which is the point. Callers that need to tell this
// apart from "External, none installed" ask mode().
val resolver = resolver(mode = StorageMode.OWN)
assertThat(resolver.resolve()).isNull()
assertThat(resolver.mode()).isEqualTo(StorageMode.OWN)
}
}
@Nested
inner class AutoMode {
@Test
fun `a fresh install with nothing else present gets our own store`() {
assertThat(resolver().autoMode()).isEqualTo(StorageMode.OWN)
}
@Test
fun `an upgrading user who already granted OpenTasks stays on it`() {
// Holding a dangerous permission means a previous version asked and they
// agreed — the signature of an existing Posture A user. Sending them to
// our empty bundled store would read as data loss.
val resolver = resolver(installed = openTasksInstalled, granted = openTasksGranted)
assertThat(resolver.autoMode()).isEqualTo(StorageMode.EXTERNAL)
assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks")
}
@Test
fun `OpenTasks merely installed is not enough`() {
// Someone who has OpenTasks for unrelated reasons, and never granted us
// anything, has no data with us there. Our own store is right for them.
assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.OWN)
}
@Test
fun `a half-granted external provider does not count`() {
val resolver = resolver(
installed = openTasksInstalled,
granted = setOf(openTasks.readPermission),
)
assertThat(resolver.autoMode()).isEqualTo(StorageMode.OWN)
}
}
@Nested
inner class ExplicitChoice {
@Test
fun `overrides the automatic answer in both directions`() {
val wouldBeExternal = FakeEnvironment(openTasksInstalled, openTasksGranted)
val forcedOwn = ProviderResolver(wouldBeExternal).apply { storageMode = StorageMode.OWN }
assertThat(forcedOwn.mode()).isEqualTo(StorageMode.OWN)
assertThat(forcedOwn.resolve()).isNull()
val forcedExternal = ProviderResolver(FakeEnvironment()).apply { storageMode = StorageMode.EXTERNAL }
assertThat(forcedExternal.mode()).isEqualTo(StorageMode.EXTERNAL)
assertThat(forcedExternal.resolve()).isNull()
}
@Test
fun `external with no provider installed resolves to nothing`() {
// Drives the "install a tasks provider" gate rather than silently
// falling back to our own store behind the user's back.
assertThat(resolver(mode = StorageMode.EXTERNAL).resolve()).isNull()
}
@Test
fun `external is unreadable until a provider is installed and granted`() {
assertThat(resolver(mode = StorageMode.EXTERNAL).canReadStore()).isFalse()
assertThat(
resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL).canReadStore(),
).isFalse()
assertThat(
resolver(
installed = openTasksInstalled,
granted = openTasksGranted,
mode = StorageMode.EXTERNAL,
).canReadStore(),
).isTrue()
}
@Test
fun `external still requires the runtime permission`() {
val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL)
val provider = resolver.resolve()
assertThat(provider).isNotNull()
assertThat(resolver.hasPermission(provider!!)).isFalse()
}
}
@Nested
inner class ExternalCandidates {
@Test
fun `prefer OpenTasks over tasks_org when both are installed`() {
val resolver = resolver(
installed = mapOf(
"org.dmfs.tasks" to "org.dmfs.tasks",
"org.tasks.opentasks" to "org.tasks",
),
mode = StorageMode.EXTERNAL,
)
assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks")
}
@Test
fun `fall through to tasks_org when OpenTasks is absent`() {
val resolver = resolver(
installed = mapOf("org.tasks.opentasks" to "org.tasks"),
mode = StorageMode.EXTERNAL,
)
assertThat(resolver.resolve()?.packageName).isEqualTo("org.tasks")
}
@Test
fun `a mode change notifies listeners once, and only on a real change`() {
// What store observers hang off: a live flow is bound to one store, so
// it has to be told when the store underneath it is swapped.
val resolver = resolver(mode = StorageMode.OWN)
var fired = 0
val handle = resolver.onModeChanged { fired++ }
resolver.storageMode = StorageMode.OWN
assertThat(fired).isEqualTo(0)
resolver.storageMode = StorageMode.EXTERNAL
assertThat(fired).isEqualTo(1)
handle.close()
resolver.storageMode = StorageMode.OWN
assertThat(fired).isEqualTo(1)
}
@Test
fun `never name an authority of ours`() {
// EXTERNAL must mean "somebody else's store", and Agendula publishes no
// provider at all any more.
assertThat(
ProviderResolver.EXTERNAL_CANDIDATES.none {
it.authority.startsWith("de.jeanlucmakiola")
},
).isTrue()
}
}
}

View File

@@ -36,7 +36,6 @@ class TaskMapperTest {
val task = TaskMapper.task(reader) val task = TaskMapper.task(reader)
assertThat(task.id).isEqualTo(42L)
assertThat(task.taskId).isEqualTo(7L) assertThat(task.taskId).isEqualTo(7L)
assertThat(task.listId).isEqualTo(3L) assertThat(task.listId).isEqualTo(3L)
assertThat(task.title).isEqualTo("Buy milk") assertThat(task.title).isEqualTo("Buy milk")
@@ -50,6 +49,65 @@ class TaskMapperTest {
assertThat(task.isSubtask).isTrue() assertThat(task.isSubtask).isTrue()
} }
@Test
fun `an occurrence is identified by its recurrence-id anchor`() {
fun occurrence(columns: Map<String, Any?>) =
TaskMapper.task(MapColumnReader(columns + (Tasks.RRULE to "FREQ=DAILY")))
// instance_original_time is the provider's own RECURRENCE-ID and wins.
val anchored = occurrence(
mapOf(
Instances.TASK_ID to 7L,
Instances.INSTANCE_ORIGINAL_TIME to 500L,
Instances.INSTANCE_START to 900L,
),
)
assertThat(anchored.occurrenceStart?.toEpochMilliseconds()).isEqualTo(500L)
assertThat(anchored.occurrenceKey).isEqualTo("7@500")
// Older provider schemas omit it; the occurrence's start reconstructs it.
val byStart = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_START to 900L))
assertThat(byStart.occurrenceStart?.toEpochMilliseconds()).isEqualTo(900L)
// A series carrying only DUE anchors on the due date instead.
val byDue = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_DUE to 1_200L))
assertThat(byDue.occurrenceStart?.toEpochMilliseconds()).isEqualTo(1_200L)
}
@Test
fun `a non-recurring task has no occurrence anchor and keys by task id`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 4L, Instances.INSTANCE_START to 500L)),
)
assertThat(task.occurrenceStart).isNull()
assertThat(task.occurrenceKey).isEqualTo("4")
}
@Test
fun `recurrence is detected from rrule when is_recurring is absent`() {
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
// reading it alone would report the series as one-off and send its edits
// to the master row, re-anchoring the whole thing.
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `recurrence is detected from rdate alone`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `a plain task is not recurring`() {
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
assertThat(task.isRecurring).isFalse()
}
@Test @Test
fun `falls back to instance id when task_id missing, and list color when no task color`() { fun `falls back to instance id when task_id missing, and list color when no task color`() {
val task = TaskMapper.task( val task = TaskMapper.task(

View File

@@ -85,6 +85,57 @@ class TaskWriteMapperTest {
assertThat(values[Tasks.TZ]).isNull() assertThat(values[Tasks.TZ]).isNull()
} }
@Test
fun `all-day timestamps are pinned to UTC midnight`() {
// 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2).
// The provider resolves all-day dates against UTC, so storing this as-is
// would land the task on the 20th for anyone reading it back.
val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true),
tzId = "Europe/Berlin",
)
val dayMs = 24L * 60 * 60 * 1000
assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L)
assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L)
}
@Test
fun `timed timestamps are written untouched`() {
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
tzId = "Europe/Berlin",
)
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
}
@Test
fun `duration is always cleared so it cannot collide with due`() {
// The provider validates the *merged* row and throws "Only one of DUE or
// DURATION must be supplied" if the stored row still carries a duration.
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)),
tzId = "UTC",
)
assertThat(values.containsKey(Tasks.DURATION)).isTrue()
assertThat(values[Tasks.DURATION]).isNull()
}
@Test
fun `instance values drop list and parent, which an override cannot express`() {
val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L))
val values = TaskWriteMapper.instanceValues(form, tzId = "UTC")
assertThat(values.containsKey(Tasks.LIST_ID)).isFalse()
assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse()
// …but still carries the edit itself.
assertThat(values[Tasks.TITLE]).isEqualTo("x")
assertThat(values[Tasks.DUE]).isEqualTo(1_000L)
}
@Test @Test
fun `completion sets status, percent and timestamp, un-completion clears them`() { fun `completion sets status, percent and timestamp, un-completion clears them`() {
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L) val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
@@ -97,6 +148,22 @@ class TaskWriteMapperTest {
assertThat(undone[Tasks.COMPLETED]).isNull() assertThat(undone[Tasks.COMPLETED]).isNull()
} }
@Test
fun `alarm carries every column the provider's validator demands`() {
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L)
assertThat(values[TasksContract.Properties.MIMETYPE])
.isEqualTo("vnd.android.cursor.item/alarm")
assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30)
// REFERENCE must be present and non-negative, ALARM_TYPE present and
// non-zero (0 is excluded from the provider's has_alarms count).
assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE)
assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE)
// property_id must be absent or the insert is rejected.
assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse()
}
@Test @Test
fun `local list uses the LOCAL account`() { fun `local list uses the LOCAL account`() {
val values = TaskWriteMapper.localListValues("Inbox", 0x123) val values = TaskWriteMapper.localListValues("Inbox", 0x123)

View File

@@ -0,0 +1,53 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.toICal
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ConvertersTest {
@Test
fun `round-trips an instant through epoch millis`() {
val value = Instant.fromEpochMilliseconds(1_700_000_000_123)
val stored = Converters.instantToMillis(value)
assertThat(stored).isEqualTo(1_700_000_000_123)
assertThat(Converters.instantFromMillis(stored)).isEqualTo(value)
}
@Test
fun `maps null time both ways`() {
assertThat(Converters.instantToMillis(null)).isNull()
assertThat(Converters.instantFromMillis(null)).isNull()
}
@Test
fun `round-trips every status through the domain encoding`() {
TaskStatus.entries.forEach { status ->
assertThat(Converters.statusFrom(Converters.statusToInt(status))).isEqualTo(status)
}
}
@Test
fun `priority is stored raw, so an off-bucket value survives`() {
// There is no priority converter on purpose: PRIORITY:3 is a legitimate
// value a server can send, and Priority buckets 1..4 into HIGH. Bucketing
// on the way in would rewrite it as 1 and lose it on the next round-trip.
assertThat(priorityFromICal(3)).isEqualTo(Priority.HIGH)
assertThat(Priority.HIGH.toICal()).isEqualTo(1)
}
@Test
fun `round-trips an alarm reference and falls back on an unknown one`() {
AlarmReference.entries.forEach { reference ->
assertThat(Converters.alarmReferenceFrom(Converters.alarmReferenceToString(reference)))
.isEqualTo(reference)
}
assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE)
}
}

View File

@@ -0,0 +1,152 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import kotlin.time.Instant
private val NOW = Instant.fromEpochMilliseconds(1_768_467_600_000)
private const val ZONE = "Europe/Berlin"
private fun task(
status: TaskStatus = TaskStatus.NEEDS_ACTION,
percentComplete: Int? = null,
completedAt: Instant? = null,
) = TaskEntity(
id = 1,
listId = 1,
uid = "uid-1",
status = status,
percentComplete = percentComplete,
completedAt = completedAt,
)
private fun form(
title: String = "task",
percentComplete: Int? = null,
start: Instant? = null,
due: Instant? = null,
isAllDay: Boolean = false,
) = TaskForm(
title = title,
listId = 1,
percentComplete = percentComplete,
start = start,
due = due,
isAllDay = isAllDay,
)
class TaskFormWriterTest {
@Test
fun `mints the task with its uid and creation time`() {
val entity = TaskFormWriter.newTask(form(title = " Buy milk "), "uid-9", NOW, ZONE)
assertThat(entity.uid).isEqualTo("uid-9")
assertThat(entity.title).isEqualTo("Buy milk")
assertThat(entity.createdAt).isEqualTo(NOW)
assertThat(entity.lastModified).isEqualTo(NOW)
assertThat(entity.isDirty).isTrue()
}
@Test
fun `progress and status move together in both directions`() {
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status)
.isEqualTo(TaskStatus.COMPLETED)
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 40), NOW, ZONE).status)
.isEqualTo(TaskStatus.IN_PROCESS)
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 0), NOW, ZONE).status)
.isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun `dropping below 100 percent reopens the task`() {
// The provider auto-completed at 100% but would not reopen below it, which
// stranded a task "done at 75%". TaskWriteMapper works around that for
// External mode; on our own store the rule is simply symmetric.
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW)
val reopened = TaskFormWriter.apply(completed, form(percentComplete = 75), NOW, ZONE)
assertThat(reopened.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(reopened.completedAt).isNull()
}
@Test
fun `a form with no percent leaves the completion state alone`() {
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW)
val saved = TaskFormWriter.apply(completed, form(title = "renamed"), NOW, ZONE)
assertThat(saved.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(saved.completedAt).isEqualTo(NOW)
}
@Test
fun `re-saving a finished task keeps its original completion time`() {
val earlier = Instant.fromEpochMilliseconds(1_000_000)
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
val saved = TaskFormWriter.apply(completed, form(percentComplete = 100), NOW, ZONE)
assertThat(saved.completedAt).isEqualTo(earlier)
}
@Test
fun `all-day times are pinned to UTC midnight`() {
// Date-only in iCalendar. Storing a local-midnight instant would land on the
// previous day for anyone west of UTC.
val midMorning = Instant.fromEpochMilliseconds(1_768_467_600_000)
val saved = TaskFormWriter.apply(
task(),
form(start = midMorning, due = midMorning, isAllDay = true),
NOW,
ZONE,
)
assertThat(saved.dtstart!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0)
assertThat(saved.due!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0)
assertThat(saved.timezone).isNull()
}
@Test
fun `a timed task records the zone, an undated one does not`() {
val timed = TaskFormWriter.apply(task(), form(due = NOW), NOW, ZONE)
assertThat(timed.timezone).isEqualTo(ZONE)
val undated = TaskFormWriter.apply(task(), form(), NOW, ZONE)
assertThat(undated.timezone).isNull()
}
@Test
fun `writing a due date clears any duration`() {
// RFC 5545 §3.6.2: DUE and DURATION are mutually exclusive.
val withDuration = task().copy(duration = "PT1H")
assertThat(TaskFormWriter.apply(withDuration, form(due = NOW), NOW, ZONE).duration).isNull()
}
@Test
fun `the complete toggle sets and clears the whole triple`() {
val done = TaskFormWriter.completed(task(), completed = true, now = NOW)
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(done.percentComplete).isEqualTo(100)
assertThat(done.completedAt).isEqualTo(NOW)
val reopened = TaskFormWriter.completed(done, completed = false, now = NOW)
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(reopened.percentComplete).isNull()
assertThat(reopened.completedAt).isNull()
}
@Test
fun `priority is written as the raw iCalendar integer`() {
assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.HIGH), NOW, ZONE).priority)
.isEqualTo(1)
assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.NONE), NOW, ZONE).priority)
.isEqualTo(0)
}
}

View File

@@ -0,0 +1,60 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
class AllDayTimeTest {
private val berlin = ZoneId.of("Europe/Berlin") // UTC+2 in July
private val newYork = ZoneId.of("America/New_York") // UTC-4 in July
private val julyTwentieth = LocalDate.of(2026, 7, 20)
@Test
fun `an all-day instant is UTC midnight of its date`() {
val instant = allDayInstantOf(julyTwentieth)
assertThat(instant.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0L)
assertThat(instant.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
}
@Test
fun `an all-day date reads the same everywhere, unlike a timed one`() {
val allDay = allDayInstantOf(julyTwentieth)
// The whole point: zone must not change which day an all-day value denotes.
assertThat(allDay.calendarDate(allDay = true, zone = berlin)).isEqualTo(julyTwentieth)
assertThat(allDay.calendarDate(allDay = true, zone = newYork)).isEqualTo(julyTwentieth)
// Read as a timed value in New York it would slip to the 19th — the bug.
assertThat(allDay.calendarDate(allDay = false, zone = newYork)).isEqualTo(julyTwentieth.minusDays(1))
}
@Test
fun `toggling all-day off keeps the day and lands on local midnight`() {
val allDay = allDayInstantOf(julyTwentieth)
val timed = allDay.rebasedForAllDay(allDay = false, zone = berlin)
assertThat(timed.calendarDate(allDay = false, zone = berlin)).isEqualTo(julyTwentieth)
val local = java.time.Instant.ofEpochMilli(timed.toEpochMilliseconds()).atZone(berlin)
assertThat(local.toLocalTime()).isEqualTo(java.time.LocalTime.MIDNIGHT)
}
@Test
fun `toggling all-day on keeps the day the user was looking at`() {
// 2026-07-20T23:30 in Berlin — late enough that a naive UTC read slips a day.
val lateEvening = Instant.fromEpochMilliseconds(
julyTwentieth.atTime(23, 30).atZone(berlin).toInstant().toEpochMilli(),
)
val allDay = lateEvening.rebasedForAllDay(allDay = true, zone = berlin)
assertThat(allDay.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
}
@Test
fun `round-tripping the toggle is stable`() {
val original = allDayInstantOf(julyTwentieth)
val there = original.rebasedForAllDay(allDay = false, zone = newYork)
val back = there.rebasedForAllDay(allDay = true, zone = newYork)
assertThat(back).isEqualTo(original)
}
}

View File

@@ -17,7 +17,7 @@ class TaskSortingTest {
val sorted = listOf(completed, noDate, dueLater, dueSooner).sortedWith(TaskSorting.DEFAULT) val sorted = listOf(completed, noDate, dueLater, dueSooner).sortedWith(TaskSorting.DEFAULT)
assertThat(sorted.map { it.id }).containsExactly(3L, 2L, 4L, 1L).inOrder() assertThat(sorted.map { it.taskId }).containsExactly(3L, 2L, 4L, 1L).inOrder()
} }
@Test @Test
@@ -27,6 +27,6 @@ class TaskSortingTest {
val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT) val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT)
assertThat(sorted.map { it.id }).containsExactly(2L, 1L).inOrder() assertThat(sorted.map { it.taskId }).containsExactly(2L, 1L).inOrder()
} }
} }

View File

@@ -11,7 +11,6 @@ fun testTask(
priority: Priority = Priority.NONE, priority: Priority = Priority.NONE,
due: Instant? = null, due: Instant? = null,
): Task = Task( ): Task = Task(
id = id,
taskId = id, taskId = id,
listId = listId, listId = listId,
title = title, title = title,
@@ -32,6 +31,7 @@ fun testTask(
accountName = null, accountName = null,
parentId = null, parentId = null,
isRecurring = false, isRecurring = false,
occurrenceStart = null,
distanceFromCurrent = 0, distanceFromCurrent = 0,
created = null, created = null,
lastModified = null, lastModified = null,

View File

@@ -0,0 +1,313 @@
package de.jeanlucmakiola.agendula.domain.export
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.time.LocalDate
import kotlin.time.Instant
/**
* The export format. Worth testing closely: an export is only as good as its
* ability to be read back, and nothing about a malformed `.ics` is obvious until
* someone actually needs the backup.
*/
class ICalendarWriterTest {
private fun task(
taskId: Long = 1L,
uid: String? = null,
title: String = "Buy oat milk",
description: String? = null,
location: String? = null,
url: String? = null,
priority: Priority = Priority.NONE,
status: TaskStatus = TaskStatus.NEEDS_ACTION,
percentComplete: Int? = null,
start: Instant? = null,
due: Instant? = null,
isAllDay: Boolean = false,
completedAt: Instant? = null,
created: Instant? = null,
lastModified: Instant? = null,
rrule: String? = null,
rdate: String? = null,
parentId: Long? = null,
) = ExportTask(
taskId, uid, title, description, location, url, priority, status, percentComplete,
start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId,
)
private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String =
ICalendarWriter.write(ExportList(1L, name, "local", tasks.toList()))
/** Unfolds the way an importer does, so assertions can read logical lines. */
private fun String.unfolded(): String = replace("\r\n ", "")
private fun linesOf(ics: String): List<String> = ics.unfolded().split("\r\n").filter { it.isNotEmpty() }
@Nested
inner class Structure {
@Test
fun `wraps the todos in a calendar`() {
val lines = linesOf(write(task()))
assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR")
assertThat(lines.last()).isEqualTo("END:VCALENDAR")
assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO")
}
@Test
fun `uses CRLF line endings`() {
// RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected
// by a strict importer while looking perfectly fine in an editor.
val ics = write(task())
assertThat(ics).contains("\r\n")
assertThat(ics.replace("\r\n", "")).doesNotContain("\n")
}
@Test
fun `carries the list name`() {
assertThat(linesOf(write(task(), name = "Shopping"))).contains("X-WR-CALNAME:Shopping")
}
@Test
fun `every todo has a UID and a DTSTAMP`() {
// Both are mandatory; a VTODO missing either is invalid.
val lines = linesOf(write(task(), task(taskId = 2)))
assertThat(lines.count { it.startsWith("UID:") }).isEqualTo(2)
assertThat(lines.count { it.startsWith("DTSTAMP:") }).isEqualTo(2)
}
}
@Nested
inner class Identity {
@Test
fun `prefers the synced UID`() {
assertThat(linesOf(write(task(uid = "abc-123@example.org"))))
.contains("UID:abc-123@example.org")
}
@Test
fun `synthesises a stable UID for a local task`() {
// Local tasks never get a UID from the provider (only a sync adapter may
// assign one), and re-importing UID-less todos would duplicate rather
// than match them.
val first = ICalendarWriter.uidFor(task(taskId = 42))
val second = ICalendarWriter.uidFor(task(taskId = 42))
assertThat(first).isEqualTo(second)
assertThat(first).contains("42")
}
@Test
fun `distinct tasks get distinct UIDs`() {
assertThat(ICalendarWriter.uidFor(task(taskId = 1)))
.isNotEqualTo(ICalendarWriter.uidFor(task(taskId = 2)))
}
@Test
fun `blank stored UID falls back to the synthesised one`() {
assertThat(ICalendarWriter.uidFor(task(taskId = 7, uid = " "))).contains("7")
}
}
@Nested
inner class Dates {
private val noon = Instant.fromEpochMilliseconds(1_754_136_000_000L) // 2025-08-02T12:00:00Z
@Test
fun `timed values are written in UTC`() {
assertThat(linesOf(write(task(due = noon)))).contains("DUE:20250802T120000Z")
}
@Test
fun `all-day values are date-only`() {
// A DATE-TIME here would drift by a day for anyone east or west of UTC —
// the exact bug the AllDayTime convention exists to prevent.
val due = allDayInstantOf(LocalDate.of(2026, 8, 2))
val lines = linesOf(write(task(due = due, isAllDay = true)))
assertThat(lines).contains("DUE;VALUE=DATE:20260802")
}
@Test
fun `completion is always a UTC date-time even when all-day`() {
val lines = linesOf(
write(task(isAllDay = true, status = TaskStatus.COMPLETED, completedAt = noon)),
)
assertThat(lines).contains("COMPLETED:20250802T120000Z")
}
@Test
fun `absent dates emit no property at all`() {
val lines = linesOf(write(task()))
assertThat(lines.none { it.startsWith("DUE") }).isTrue()
assertThat(lines.none { it.startsWith("DTSTART") }).isTrue()
}
}
@Nested
inner class Fields {
@Test
fun `maps every status to its iCalendar name`() {
fun statusLine(status: TaskStatus) =
linesOf(write(task(status = status))).first { it.startsWith("STATUS:") }
assertThat(statusLine(TaskStatus.NEEDS_ACTION)).isEqualTo("STATUS:NEEDS-ACTION")
assertThat(statusLine(TaskStatus.IN_PROCESS)).isEqualTo("STATUS:IN-PROCESS")
assertThat(statusLine(TaskStatus.COMPLETED)).isEqualTo("STATUS:COMPLETED")
assertThat(statusLine(TaskStatus.CANCELLED)).isEqualTo("STATUS:CANCELLED")
}
@Test
fun `omits priority when there is none`() {
// PRIORITY:0 means "undefined" but reads as a real value to some
// importers; leaving it out is unambiguous.
assertThat(linesOf(write(task())).none { it.startsWith("PRIORITY") }).isTrue()
assertThat(linesOf(write(task(priority = Priority.HIGH)))).contains("PRIORITY:1")
}
@Test
fun `clamps percent complete into range`() {
assertThat(linesOf(write(task(percentComplete = 140)))).contains("PERCENT-COMPLETE:100")
assertThat(linesOf(write(task(percentComplete = -5)))).contains("PERCENT-COMPLETE:0")
}
@Test
fun `passes recurrence through unchanged`() {
val lines = linesOf(write(task(rrule = "FREQ=WEEKLY;BYDAY=MO,WE")))
assertThat(lines).contains("RRULE:FREQ=WEEKLY;BYDAY=MO,WE")
}
@Test
fun `does not escape a URL`() {
// URL is a URI, not TEXT. Escaping its commas would corrupt the address.
val lines = linesOf(write(task(url = "https://example.org/a,b;c")))
assertThat(lines).contains("URL:https://example.org/a,b;c")
}
@Test
fun `skips blank optional fields`() {
val lines = linesOf(write(task(description = " ", location = "", url = "")))
assertThat(lines.none { it.startsWith("DESCRIPTION") }).isTrue()
assertThat(lines.none { it.startsWith("LOCATION") }).isTrue()
assertThat(lines.none { it.startsWith("URL") }).isTrue()
}
}
@Nested
inner class Subtasks {
@Test
fun `links a child to its parent by UID`() {
val parent = task(taskId = 1, uid = "parent@example.org")
val child = task(taskId = 2, parentId = 1)
assertThat(linesOf(write(parent, child)))
.contains("RELATED-TO;RELTYPE=PARENT:parent@example.org")
}
@Test
fun `resolves a parent that appears after the child`() {
// Nothing guarantees provider order, and a forward reference must still
// resolve or half the hierarchy silently disappears.
val child = task(taskId = 2, parentId = 1)
val parent = task(taskId = 1, uid = "parent@example.org")
assertThat(linesOf(write(child, parent)))
.contains("RELATED-TO;RELTYPE=PARENT:parent@example.org")
}
@Test
fun `drops a link to a parent outside this list`() {
// A RELATED-TO pointing at a UID not in the file would dangle on import.
val orphan = task(taskId = 2, parentId = 999)
assertThat(linesOf(write(orphan)).none { it.startsWith("RELATED-TO") }).isTrue()
}
}
@Nested
inner class Escaping {
@Test
fun `escapes the special characters`() {
assertThat(ICalendarWriter.escapeText("a;b,c")).isEqualTo("a\\;b\\,c")
assertThat(ICalendarWriter.escapeText("line\nbreak")).isEqualTo("line\\nbreak")
assertThat(ICalendarWriter.escapeText("CRLF\r\nhere")).isEqualTo("CRLF\\nhere")
}
@Test
fun `escapes backslashes first`() {
// Doing it later would re-escape the backslashes the other rules add,
// turning "a;b" into "a\\;b".
assertThat(ICalendarWriter.escapeText("back\\slash")).isEqualTo("back\\\\slash")
assertThat(ICalendarWriter.escapeText("a\\;b")).isEqualTo("a\\\\\\;b")
}
@Test
fun `a multiline description stays one logical line`() {
val ics = write(task(description = "first\nsecond"))
assertThat(ics.unfolded()).contains("DESCRIPTION:first\\nsecond")
}
}
@Nested
inner class Folding {
@Test
fun `short lines are untouched`() {
assertThat(ICalendarWriter.fold("SUMMARY:short")).isEqualTo("SUMMARY:short")
}
@Test
fun `long lines are folded to 75 octets`() {
val folded = ICalendarWriter.fold("SUMMARY:" + "a".repeat(200))
folded.split("\r\n").forEachIndexed { index, segment ->
val octets = segment.toByteArray(Charsets.UTF_8).size
assertThat(octets).isAtMost(if (index == 0) 75 else 76) // 75 + the leading space
}
}
@Test
fun `folding round-trips`() {
val original = "DESCRIPTION:" + "long text ".repeat(40)
assertThat(ICalendarWriter.fold(original).replace("\r\n ", "")).isEqualTo(original)
}
@Test
fun `never splits a multi-byte character`() {
// The limit is in octets but an emoji is four of them; splitting mid
// sequence would emit invalid UTF-8 and mangle the title.
val emoji = "SUMMARY:" + "🌼".repeat(40)
val folded = ICalendarWriter.fold(emoji)
assertThat(folded.replace("\r\n ", "")).isEqualTo(emoji)
folded.split("\r\n").forEach { segment ->
// A broken surrogate pair round-trips through UTF-8 as U+FFFD.
assertThat(segment.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8))
.isEqualTo(segment)
}
}
@Test
fun `a long title survives the full write`() {
val title = "Remember to ".repeat(20)
assertThat(write(task(title = title)).unfolded()).contains("SUMMARY:$title")
}
}
@Nested
inner class EmptyList {
@Test
fun `still produces a valid calendar`() {
// An empty list is a real answer; a missing file is indistinguishable
// from a failed export.
val lines = linesOf(ICalendarWriter.write(ExportList(1L, "Empty", "local", emptyList())))
assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR")
assertThat(lines.last()).isEqualTo("END:VCALENDAR")
assertThat(lines.none { it == "BEGIN:VTODO" }).isTrue()
}
}
}

View File

@@ -0,0 +1,59 @@
package de.jeanlucmakiola.agendula.domain.recurrence
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class DistanceFromCurrentTest {
private fun at(text: String) = Instant.parse(text)
private val occurrences = listOf(
at("2025-01-05T09:00:00Z"),
at("2025-01-06T09:00:00Z"),
at("2025-01-07T09:00:00Z"),
at("2025-01-08T09:00:00Z"),
)
@Test
fun `the current occurrence is the first one at or after now`() {
val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T10:00:00Z"))
assertThat(index).isEqualTo(2)
}
@Test
fun `an occurrence exactly at now is the current one`() {
val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T09:00:00Z"))
assertThat(index).isEqualTo(1)
}
@Test
fun `past occurrences count down and later ones count up`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-06T10:00:00Z"))
assertThat(distances).containsExactly(-2, -1, 0, 1).inOrder()
}
@Test
fun `a series entirely in the future is current at its first occurrence`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2024-12-01T00:00:00Z"))
assertThat(distances).containsExactly(0, 1, 2, 3).inOrder()
}
@Test
fun `a series entirely in the past is current at its last occurrence`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2026-01-01T00:00:00Z"))
assertThat(distances).containsExactly(-3, -2, -1, 0).inOrder()
}
@Test
fun `exactly one occurrence is ever the current one`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-07T00:00:00Z"))
assertThat(distances.count { it == 0 }).isEqualTo(1)
}
@Test
fun `an empty series has no distances`() {
assertThat(RecurrenceExpander.distancesFromCurrent(emptyList(), at("2025-01-01T00:00:00Z"))).isEmpty()
assertThat(RecurrenceExpander.currentOccurrenceIndex(emptyList(), at("2025-01-01T00:00:00Z"))).isEqualTo(-1)
}
}

View File

@@ -0,0 +1,454 @@
package de.jeanlucmakiola.agendula.domain.recurrence
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.ZoneId
import kotlin.time.Instant
/**
* The provider only ever materialised the single next occurrence, so there is no
* provider behaviour to compare multi-occurrence expansion against. These cases
* assert against RFC 5545 §3.8.5 directly.
*/
class RecurrenceExpanderTest {
private val berlin = "Europe/Berlin"
private val newYork = ZoneId.of("America/New_York")
private fun at(text: String) = Instant.parse(text)
private fun spec(
rrule: String? = null,
rdate: String? = null,
exdate: String? = null,
anchor: String,
isAllDay: Boolean = false,
timeZone: String? = berlin,
) = RecurrenceSpec(rrule, rdate, exdate, at(anchor), isAllDay, timeZone)
private fun window(
from: String = "2000-01-01T00:00:00Z",
until: String = "2100-01-01T00:00:00Z",
max: Int = 500,
) = ExpansionWindow(at(from), at(until), max)
private fun expand(
spec: RecurrenceSpec,
window: ExpansionWindow = window(),
floatingZone: ZoneId = newYork,
) = RecurrenceExpander.expand(spec, window, floatingZone).map { it.toString() }
// --- frequencies ---------------------------------------------------------
@Test
fun `daily rule yields consecutive days at the same local time`() {
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `interval skips the intervening days`() {
val result = expand(spec(rrule = "FREQ=DAILY;INTERVAL=2;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-09T08:00:00Z",
"2025-01-11T08:00:00Z",
).inOrder()
}
@Test
fun `weekly by day expands to the named weekdays`() {
// The anchor is a Tuesday, which BYDAY=MO,WE,FR does not name. DTSTART is
// the first instance of the set regardless (RFC 5545 §3.8.5.3), so the
// Tuesday leads and the pattern takes over from there.
val result = expand(
spec(rrule = "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z", // Tue, the anchor
"2025-01-08T08:00:00Z", // Wed
"2025-01-10T08:00:00Z", // Fri
"2025-01-13T08:00:00Z", // Mon
"2025-01-15T08:00:00Z", // Wed
).inOrder()
}
@Test
fun `monthly by day expands to the nth weekday of the month`() {
// 2025-01-07 is the first Tuesday, so BYDAY=2TU lands on the 14th; the
// anchor still leads.
val result = expand(spec(rrule = "FREQ=MONTHLY;BYDAY=2TU;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-14T08:00:00Z",
"2025-02-11T08:00:00Z",
).inOrder()
}
@Test
fun `monthly by month day skips months that lack the day`() {
val result = expand(spec(rrule = "FREQ=MONTHLY;BYMONTHDAY=31;COUNT=3", anchor = "2025-01-31T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-31T08:00:00Z",
"2025-03-31T07:00:00Z", // February and April have no 31st; March is already CEST
"2025-05-31T07:00:00Z",
).inOrder()
}
@Test
fun `yearly rule repeats on the anniversary`() {
val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2026-01-07T08:00:00Z",
"2027-01-07T08:00:00Z",
).inOrder()
}
@Test
fun `yearly rule on a leap day only recurs in leap years`() {
val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2024-02-29T08:00:00Z"))
assertThat(result).containsExactly(
"2024-02-29T08:00:00Z",
"2028-02-29T08:00:00Z",
"2032-02-29T08:00:00Z",
).inOrder()
}
// --- limits --------------------------------------------------------------
@Test
fun `COUNT limits the series`() {
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).hasSize(2)
}
@Test
fun `UNTIL includes an occurrence falling exactly on it`() {
val result = expand(
spec(rrule = "FREQ=DAILY;UNTIL=20250109T080000Z", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `a floating UNTIL is read in the series zone`() {
// RFC 5545 §3.3.10 requires UNTIL in UTC when DTSTART carries a zone, and
// lib-recur throws outright on the mismatch. Stored rules break the rule
// anyway, so 09:00 floating has to mean 09:00 in Berlin.
val result = expand(
spec(rrule = "FREQ=DAILY;UNTIL=20250109T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `an unbounded rule stops at the occurrence ceiling`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(max = 4),
)
assertThat(result).hasSize(4)
assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z")
}
@Test
fun `a sub-daily series spends most of the ceiling on occurrences from the pivot on`() {
// The real shape: an eight-hourly task, a year of window behind now and a
// 500-occurrence ceiling. Filling the budget from the window start would
// exhaust it ~166 days before now, so the task would never appear in Today
// or Upcoming at all.
val result = expand(
spec(rrule = "FREQ=HOURLY;INTERVAL=8", anchor = "2024-01-01T00:00:00Z"),
ExpansionWindow(
from = at("2024-06-01T00:00:00Z"),
until = at("2026-06-01T00:00:00Z"),
maxOccurrences = 500,
pivot = at("2025-06-01T00:00:00Z"),
),
)
assertThat(result).hasSize(500)
// A quarter of the budget looks back, and it keeps the *most recent* of
// the past — not the oldest, which is what filling from the window start
// would have kept. (ISO-8601 UTC sorts lexicographically.)
assertThat(result.count { it < "2025-06-01T00:00:00Z" }).isEqualTo(125)
assertThat(result.first()).isGreaterThan("2025-04-01T00:00:00Z")
assertThat(result.last()).isGreaterThan("2025-09-01T00:00:00Z")
}
@Test
fun `the ceiling goes entirely to the future when nothing precedes the pivot`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
ExpansionWindow(
from = at("2025-01-01T00:00:00Z"),
until = at("2030-01-01T00:00:00Z"),
maxOccurrences = 4,
pivot = at("2025-01-01T00:00:00Z"),
),
)
assertThat(result).hasSize(4)
assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z")
}
@Test
fun `an unbounded rule stops at the window end`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(until = "2025-01-10T00:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `the window end is exclusive`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(until = "2025-01-09T08:00:00Z"),
)
assertThat(result).doesNotContain("2025-01-09T08:00:00Z")
}
@Test
fun `occurrences before the window start are skipped`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(from = "2025-06-01T00:00:00Z", until = "2025-06-04T00:00:00Z"),
)
assertThat(result).containsExactly(
"2025-06-01T07:00:00Z",
"2025-06-02T07:00:00Z",
"2025-06-03T07:00:00Z",
).inOrder()
}
// --- DST and all-day -----------------------------------------------------
@Test
fun `a daily series keeps its local time across a DST boundary`() {
// Europe/Berlin springs forward on 2025-03-30, so 09:00 local moves from
// 08:00Z to 07:00Z while the wall-clock time the user set stays put.
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=4", anchor = "2025-03-28T08:00:00Z"))
assertThat(result).containsExactly(
"2025-03-28T08:00:00Z",
"2025-03-29T08:00:00Z",
"2025-03-30T07:00:00Z",
"2025-03-31T07:00:00Z",
).inOrder()
}
@Test
fun `an all-day series pins every occurrence to UTC midnight`() {
// Date-anchored, matching TaskWriteMapper's forAllDay: the stored zone and
// the device zone are both irrelevant, and no DST shift reaches it.
val result = expand(
spec(
rrule = "FREQ=DAILY;COUNT=3",
anchor = "2025-03-29T22:45:00Z",
isAllDay = true,
timeZone = berlin,
),
)
assertThat(result).containsExactly(
"2025-03-29T00:00:00Z",
"2025-03-30T00:00:00Z",
"2025-03-31T00:00:00Z",
).inOrder()
}
@Test
fun `an all-day weekly series stays on the same weekday`() {
val result = expand(
spec(rrule = "FREQ=WEEKLY;COUNT=3", anchor = "2025-01-15T00:00:00Z", isAllDay = true, timeZone = null),
)
assertThat(result).containsExactly(
"2025-01-15T00:00:00Z",
"2025-01-22T00:00:00Z",
"2025-01-29T00:00:00Z",
).inOrder()
}
@Test
fun `a series with no zone expands in the floating zone`() {
val spec = spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = null)
// 09:00 in New York, over the 2025-03-09 US DST switch.
assertThat(expand(spec, floatingZone = newYork)).containsExactly(
"2025-03-08T14:00:00Z",
"2025-03-09T13:00:00Z",
).inOrder()
}
// --- RDATE / EXDATE ------------------------------------------------------
@Test
fun `RDATE adds occurrences the rule does not produce`() {
val result = expand(
spec(
rrule = "FREQ=DAILY;COUNT=2",
rdate = "20250115T140000",
anchor = "2025-01-07T08:00:00Z",
),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-15T13:00:00Z", // 14:00 Berlin
).inOrder()
}
@Test
fun `an RDATE before the anchor is part of the set`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "20250101T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result.first()).isEqualTo("2025-01-01T08:00:00Z")
}
@Test
fun `an RDATE repeating a rule instance is not emitted twice`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", rdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `multiple RDATEs are comma-separated`() {
val result = expand(
spec(rdate = "20250110T090000,20250112T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-10T08:00:00Z",
"2025-01-12T08:00:00Z",
).inOrder()
}
@Test
fun `EXDATE removes an occurrence`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `EXDATE can remove the anchor itself`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250107T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `a UTC EXDATE matches a zoned occurrence at the same instant`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T080000Z", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `an all-day EXDATE removes the matching date`() {
val result = expand(
spec(
rrule = "FREQ=DAILY;COUNT=3",
exdate = "20250116",
anchor = "2025-01-15T00:00:00Z",
isAllDay = true,
),
)
assertThat(result).containsExactly(
"2025-01-15T00:00:00Z",
"2025-01-17T00:00:00Z",
).inOrder()
}
// --- degenerate input ----------------------------------------------------
@Test
fun `a spec with no rule expands to just its anchor`() {
assertThat(expand(spec(anchor = "2025-01-07T08:00:00Z")))
.containsExactly("2025-01-07T08:00:00Z")
}
@Test
fun `a malformed rule degrades to the anchor instead of throwing`() {
assertThat(expand(spec(rrule = "FREQ=NONSENSE", anchor = "2025-01-07T08:00:00Z")))
.containsExactly("2025-01-07T08:00:00Z")
}
@Test
fun `a malformed RDATE is dropped and the rule still expands`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "not-a-date", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
).inOrder()
}
@Test
fun `an unknown zone id falls back to the floating zone`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = "Mars/Olympus"),
floatingZone = newYork,
)
assertThat(result).containsExactly(
"2025-03-08T14:00:00Z",
"2025-03-09T13:00:00Z",
).inOrder()
}
@Test
fun `a sub-second anchor does not double the first occurrence`() {
// RFC 5545 DATE-TIME has second precision, but a task created from
// Clock.now() carries millis. Left un-floored, lib-recur emits the raw
// anchor *and* its truncated self, so the series starts twice.
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00.081Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `a window that ends before the anchor yields nothing`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(until = "2024-01-01T00:00:00Z"),
)
assertThat(result).isEmpty()
}
}

View File

@@ -1,6 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false alias(libs.plugins.hilt) apply false

View File

@@ -8,23 +8,36 @@ This document describes how Agendula is built **as it stands today**. For the
## 1. The thesis in one sentence ## 1. The thesis in one sentence
Agendula is a Material 3 Expressive **front-end** over the OpenTasks Agendula is a Material 3 Expressive task app that reads, writes and reminds
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store against a task store the user chooses: **its own database** (the default) or an
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over external provider app already on the device (OpenTasks, tasks.org) synced by
CalDAV. **Agendula owns no database and no sync stack.** It is the task-list DAVx5, SmoothSync, DecSync CC and the like. It is the task-list sibling to
sibling to [Calendula](https://codeberg.org/jlmakiola/calendula), [Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing
which does the same thing for `CalendarContract`. for `CalendarContract`.
**Agendula owns storage but not, yet, sync.** That is a deliberate change from
the original "owns no database" thesis, settled in
[`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md): depending on a provider app being
installed made someone else's roadmap a gate on the app working at all. The
store is a **Room database of our own**, designed against RFC 5545's `VTODO` and
against what a CalDAV sync adapter will need — the reasoning is in
[`STORAGE-DECISION.md`](STORAGE-DECISION.md), the architecture and the plan it
implements in [`OWN-STORE.md`](OWN-STORE.md). It replaces a vendored copy of the
dmfs task provider, which was deleted along with the `:provider` module it lived
in. External mode is untouched by that and still speaks the dmfs
`TaskContract`. A sync adapter of our own is the 1.x arc, designed in
[`SYNC.md`](SYNC.md).
The whole design hangs off one rule: The whole design hangs off one rule:
> The entire app talks to a `TasksRepository`. Only the data layer knows there > The entire app talks to a `TasksRepository`. Only the data layer knows there
> is a `ContentResolver`, a `TaskContract`, or an authority string behind it. > is a Room database, a `ContentResolver`, a `TaskContract` or an authority
> **Provider column names and the authority string never leak above the data > string behind it. **Table and column names and the authority string never leak
> layer.** > above the data layer.**
This is what lets "Posture A" (front-end over an installed provider) become That rule is what let the entire store be swapped without a rewrite: replacing
"Posture B" (bundle the Apache-2.0 provider, be self-contained) without touching the provider with Room changed no UI, no ViewModel and exactly one domain field
the UI, the ViewModels, or the domain. See §7. (`Task.id``Task.occurrenceStart`, §4.8). See §7.
--- ---
@@ -38,30 +51,36 @@ the UI, the ViewModels, or the domain. See §7.
│ domain models + Flows only │ domain models + Flows only
┌───────────────▼──────────────────────────────┐ ┌───────────────▼──────────────────────────────┐
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │ Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
DayWindow (pure Kotlin, no Android) TaskSections, RecurrenceExpander
│ (pure Kotlin, no Android) │
└───────────────┬──────────────────────────────┘ └───────────────┬──────────────────────────────┘
│ TasksRepository (interface) │ TasksRepository (interface)
┌───────────────▼──────────────────────────────┐ ┌───────────────▼──────────────────────────────┐
Data │ TasksRepositoryImpl │ Data │ TasksRepositoryImpl │
│ └ TasksDataSource (interface) │ │ └ TasksDataSource (interface) │
│ └ AndroidTasksDataSource │ └ ModeRoutingTasksDataSource │
└ ContentResolver / TaskContract / ├ RoomTasksDataSource (OWN)
ProviderResolver / ContentObserver └ AndroidTasksDataSource (EXTERNAL)
│ reminders/ prefs/ di/ demo/ │ │ reminders/ prefs/ di/ demo/ │
└───────────────┬──────────────────────────────┘ └────────┬────────────────────────┬────────────┘
│ content:// + dangerous perms │ Room DAOs │ content://
┌───────────────▼──────────────────────────────┐ ─────────────▼──────────┐ ┌──────────▼─────────────┐
ExternalOpenTasks provider ←sync← DAVx5 / DecSync… Sto- │ Own mode (default): │ External mode:
└──────────────────────────────────────────────┘ rage │ agendula-tasks.db — │ OpenTasks / tasks.org │
│ four tables in our │ │ ←sync← DAVx5 / … │
│ own data directory, │ │ dangerous perms, │
│ nothing to permit │ │ asked at point of use │
└────────────────────────┘ └────────────────────────┘
``` ```
The seam that matters is the pair of interfaces in the data layer: The seam that matters is the pair of interfaces in the data layer:
- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend - **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend
writes. (`data/tasks/TasksRepository.kt`) writes. (`data/tasks/TasksRepository.kt`)
- **`TasksDataSource`** — the JVM-testable interface that does the actual - **`TasksDataSource`** — the JVM-testable, domain-shaped interface that does the
provider work; `AndroidTasksDataSource` is the only Android-coupled actual store work. Two implementations: `RoomTasksDataSource` for our own
implementation. store and `AndroidTasksDataSource` for an external provider, picked per call by
`ModeRoutingTasksDataSource` (§4.1).
Both are bound in Hilt in `data/di/DataModule.kt`. Both are bound in Hilt in `data/di/DataModule.kt`.
@@ -69,84 +88,252 @@ Both are bound in Hilt in `data/di/DataModule.kt`.
## 3. Module & package layout ## 3. Module & package layout
Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`. One module, `:app`, plus the `floret-kit` included build (`includeBuild` in
`settings.gradle.kts`, consumed as `de.jeanlucmakiola.floret:*`). The
`:provider` module — the vendored dmfs task provider — was deleted with the own
store; `provider/PROVENANCE.md` went with it, its content preserved as a
postscript in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Package root
`de.jeanlucmakiola.agendula`.
| Package | Contents | | Package (`:app`) | 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. | | `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskConstants` (status/priority/local-account constants), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `TaskSections` (due-date sectioning), `AllDayTime` (the two date conventions). No Android imports; local-midnight maths comes from floret-kit's `DayWindow`. |
| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. | | `domain/recurrence/` | `RecurrenceExpander` — a stored rule set → its occurrences, over `lib-recur`. Pure Kotlin. |
| `domain/export/` | `ExportModels` + `ICalendarWriter` — VTODO serialization. Pure Kotlin, so the format is JVM-testable. |
| `data/tasks/` | `StorageMode` + `StorageModeHolder` + `ProviderResolver` + `ProviderEnvironment` (which store, §4.1), `ModeRoutingTasksDataSource`, `StartupGate`, `TasksDataSource`, `TasksRepository` + `Impl`, `Failures`; and the External-mode half — `TasksContract` (vendored subset), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `AndroidTasksDataSource`. |
| `data/tasks/room/` | Agendula's own store: `Entities` (the four tables), a DAO per table, `TasksDatabase`, `Converters`, `RoomTasksDataSource`, `RoomTaskMapper` (row→domain), `TaskFormWriter` (form→entity), `DatabaseCheckpoint`. |
| `data/tasks/legacy/` | `OneShotImport` — a v0.3.x install's tasks out of the dmfs provider's file and into Room, once (§4.4). |
| `data/export/` | `TaskExporter` (lists → `.ics` documents), `ExportWriter` (SAF plumbing; a floret-kit candidate). |
| `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. | | `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. |
| `data/prefs/` | `SettingsPrefs` (DataStore). | | `data/prefs/` | `SettingsPrefs` (DataStore). |
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). | | `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). |
| `data/demo/` | `DemoSeeder` (debug-only sample data). | | `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`. | | `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/` (hub + sub-screens, `StorageScreen` among them), `export/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. |
| root | `AgendulaApp` (Hilt app), `MainActivity`. | | root | `AgendulaApp` (Hilt app), `MainActivity`. |
--- ---
## 4. The data layer (the heart) ## 4. The data layer (the heart)
### 4.1 Provider targeting — `ProviderResolver` ### 4.1 Which store — `StorageMode` and `ProviderResolver`
`ProviderResolver.resolve()` walks a preference-ordered candidate list and `StorageMode` has two values, `OWN` and `EXTERNAL`, and
returns the first provider actually installed (via `ModeRoutingTasksDataSource` picks the implementation **per call** — the mode is
`PackageManager.resolveContentProvider`), or `null` if none is. Each candidate a setting the user can change while the process lives, so binding it once would
is a `TaskProvider(authority, readPermission, writePermission, packageName)`. mean rebuilding the object graph to honour a change.
| Provider | Authority | Permissions | | Mode | Store | Authority | Permissions |
|---|---|---| |---|---|---|---|
| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | | **Own** (default) | our Room database, `agendula-tasks.db` | — none, it is not a provider | **none** |
| tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | | External | OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
| External | tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` |
Both are backed by the same dmfs `TaskProvider`, so the **same `TaskContract` `ProviderResolver` is now only about the second and third rows: it discovers the
columns apply** regardless of which is present. `null` from `resolve()` drives *external* providers a device has. `resolve()` returns `null` in `OWN` mode —
the "install a tasks provider" onboarding gate. `hasPermission()` checks both there is no authority to resolve — and callers that need to tell that apart from
runtime perms for the active provider. "External, and nothing installed" ask `mode()`. `TaskProvider` no longer carries
an `isOwn` flag; there is no own provider to flag.
### 4.2 `TasksContract` `providerStatus()` is unconditionally `READY` in `OWN` mode. The permission gate
only ever applied to External, and that is now visibly true rather than a
same-uid special case inside `hasPermission()`. In External mode `null` from
`resolve()` is `NO_PROVIDER` and a missing runtime permission is
`NEEDS_PERMISSION`, which is what drives onboarding.
**Choosing the mode.** An explicit choice is stored in `SettingsPrefs` and
mirrored into the resolver by `StorageModeHolder` — the resolver is consulted
synchronously on every query and cannot read DataStore itself. When there is no
explicit choice (the normal case), `autoMode()` decides:
> **External if we already hold an external provider's runtime permission,
> otherwise Own.**
That permission is dangerous-level, so it can only be there because an earlier
version asked and the user agreed — the signature of an existing Posture A user,
who must not be dropped onto an empty store and left to conclude their tasks were
deleted. A fresh install holds nothing and gets our own store.
A stored `LOCAL` — the old name for the bundled provider — is read as `OWN`
(`SettingsPrefs.kt:104`) rather than as an unparseable value. Left to fall
through to `autoMode()`, someone who had explicitly chosen local storage *and*
holds an OpenTasks grant would be sent to OpenTasks, away from the data the
import just moved.
The platform calls sit behind `ProviderEnvironment` so this decision is unit
tested on the JVM (`ProviderResolverTest`) rather than only on a device.
**Synced is still not a third mode.** `STORAGE-AND-SYNC.md` describes three; a
synced list is `OWN` with an account attached, which is derived state rather than
something the user picks. Attaching one is a plain `UPDATE task_lists SET
account_id = ?``account_id` is a nullable FK from v1, so turning sync on for
an existing list is not a migration. (Under the dmfs provider it was:
`ACCOUNT_NAME`/`ACCOUNT_TYPE` were write-once, so tasks had to be moved into new
lists. That constraint left with the provider.)
### 4.2 The own store — four Room tables
`TasksDatabase` (v1, schema exported to `app/schemas/` and committed) holds
`task_lists`, `tasks`, `task_alarms` and `accounts`. The columns and the
reasoning behind each are in [`OWN-STORE.md`](OWN-STORE.md); what matters
structurally:
- **`accounts` is empty until sync lands**, but the nullable `task_lists
.account_id` FK exists from v1 — that is what makes §4.1's "attaching an
account is an `UPDATE`" true. Deleting an account `SET NULL`s its lists rather
than deleting them.
- **Masters and `RECURRENCE-ID` overrides share the `tasks` table.** An override
is a row with `recurrence_id` set and `master_id` pointing at its master,
sharing the master's `uid`. The unique index is therefore
`(list_id, uid, recurrence_id)`; SQLite treats NULLs as distinct, so it
enforces the override half and states the master half as intent.
- **`uid` is `NOT NULL`**, minted at creation in either mode, synced or not — so
a task has a stable identity before a server ever sees it.
- `priority` is stored as the raw iCalendar integer (0 none, 1 highest, 9
lowest). Bucketing into `Priority` on the way *in* would rewrite a server's
`PRIORITY:3` as `1`; the bucketing belongs to the mapper.
- Cascades: deleting a list takes its tasks, deleting a series takes its
overrides (`master_id`), deleting a parent promotes its subtasks
(`parent_id` → `SET NULL`).
`RoomTaskMapper` maps a row to a domain `Task`; `TaskFormWriter` applies a
validated `TaskForm` to an entity. `TaskFormWriter` is the Room counterpart of
External mode's `TaskWriteMapper`, and deliberately not shared with it: most of
what that mapper does is work around the provider (clearing `DURATION` because
it validates a merged row; writing `STATUS` explicitly in both directions because
it auto-completes at 100% but will not reopen below it). Here the completion
rules are stated
directly — progress and status move together in both directions, so a task can
no longer strand itself "done at 75%".
Deletes are hard when the list has no account and tombstones (`is_deleted`) when
it does — a row a server still knows about has to survive long enough to be
withdrawn from it.
### 4.3 Recurrence — expanded at read, not materialised
There is **no instances table**. The dmfs provider maintained one, recomputed on
every write, and still only ever materialised one upcoming occurrence. Agendula
expands a series in memory instead:
```
tasks (masters + overrides) ──► RecurrenceExpander ──► List<Task>
rrule/rdate/exdate (lib-recur, in memory) occurrences
```
This costs nothing because `TasksRepositoryImpl` already filters and sorts in
Kotlin, not SQL — nothing depended on the database ordering by instance time —
and the whole class of staleness bugs a cached table brings never exists.
Expansion is bounded twice over: a window of **1 year back, 2 years forward**
(`RoomTasksDataSource.WINDOW_BACK`/`WINDOW_FORWARD`) and a hard per-series
occurrence ceiling, so an unbounded `RRULE` terminates. The iterator is
fast-forwarded to the window start first, so a `FREQ=MINUTELY` series anchored
years back does not scan millions of instances to emit one. A malformed
`RRULE`/`RDATE`/`EXDATE` is dropped rather than thrown: a task whose stored rule
cannot be parsed still has to appear.
`RecurrenceExpander` returns each occurrence as its **`RECURRENCE-ID` anchor**.
`RoomTasksDataSource.occurrencesOf` then substitutes any override for the
occurrence it replaces; a timed series carries each occurrence's length across,
while 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.
`lib-recur` is pinned at **0.12.2** (0.16.0 removed `RecurrenceSet`) and is a
direct `:app` dependency now rather than something `:provider` dragged in. It is
still Apache-2.0 dmfs code, so the attribution is still owed — as a normal
third-party dependency.
**Editing one occurrence writes a `RECURRENCE-ID` override** — RFC 5545's model
(a): a second `tasks` row with the master's `uid`, a `recurrence_id` naming the
occurrence, `master_id` pointing at the series, and the edit applied. 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
choosing it, and this is the choice.
### 4.4 Startup — the import and the gate
`OneShotImport` moves a v0.3.x install's tasks out of the bundled provider's
`databases/tasks.db` and into Room, once. The file is opened read-only and
directly — no provider, no `ContentResolver` — so it keeps working now that
`:provider` is gone, and everything lands in one verified Room transaction, so a
failure leaves both Room and the source file exactly as they were. dmfs row ids
are remapped in two passes, because a parent can carry a higher `_id` than its
child, and `RECURRENCE-ID` overrides are carried across as `master_id` /
`recurrence_id` rather than imported as second masters, which would collide on
the unique index. The source is archived to `tasks.db.imported` **before** the
import, and the import always replaces: that ordering is what makes every kill
point re-enter correctly.
`StartupGate` holds the first store read until the stored mode has reached
`ProviderResolver` *and* the import has run — `TasksRepositoryImpl.observing()`
awaits it before its first load, and `AgendulaApp` awaits it before the launch
reminder re-sync. Both are the same race: reading early answers from
`autoMode()` instead of the user's choice, or shows an upgrading user an empty
app.
### 4.5 `TasksContract` — External mode only
A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names, A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
table paths, status/priority constants, the local-account type. Agendula does table paths, status/priority constants, the local-account type. Agendula does
**not** take a runtime dependency on OpenTasks; the authority is injected from **not** take a runtime dependency on OpenTasks; the authority is injected from
`ProviderResolver`, never hardcoded in the contract. `ProviderResolver`, never hardcoded in the contract. Nothing in `OWN` mode
touches it: the domain's own status/priority/local-account constants live in
`domain/TaskConstants.kt`, so the domain layer never reaches into the data layer
to map its own enums.
### 4.3 Reads — Instances + `ContentObserver` ### 4.6 Reads and reactivity
`AndroidTasksDataSource` queries the denormalized **instances** view (so each In `OWN` mode `RoomTasksDataSource` reads through the DAOs and expands
occurrence is a row with the joined list colour, account, etc.), maps each recurrences (§4.3). In `EXTERNAL` mode `AndroidTasksDataSource` queries the
cursor row through `ColumnReader``TaskMapper` → domain `Task`, and exposes denormalized **instances** view (so each occurrence is a row with the joined list
the result as a `Flow`. A `ContentObserver` on the active authority's colour, account, etc.) and maps each cursor row through `ColumnReader` →
Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change `TaskMapper` → domain `Task`.
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 `TasksDataSource.registerObserver(onChange): AutoCloseable` is unchanged and
backed differently on each side: Room's `InvalidationTracker` over the four
tables in `OWN` mode, a `ContentObserver` on the active authority's
Tasks/TaskLists URIs in `EXTERNAL`. Either way `TasksRepositoryImpl.observing()`
bridges it into a `Flow` via `callbackFlow`, so **any change re-emits** —
Agendula's own writes *and*, in External mode, DAVx5 pulling new tasks.
### 4.7 Writes — repository API
```kotlin ```kotlin
interface TasksRepository { interface TasksRepository {
fun taskLists(): Flow<List<TaskList>> fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>> fun tasks(filter: TaskFilter): Flow<List<Task>>
fun subtasks(parentId: Long): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?> fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long suspend fun createTask(form: TaskForm): Long
suspend fun updateTask(taskId: Long, form: TaskForm) suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
suspend fun deleteTask(taskId: Long) suspend fun deleteTask(taskId: Long)
suspend fun reminderFor(taskId: Long): Int?
suspend fun createLocalList(name: String, color: Int): Long suspend fun createLocalList(name: String, color: Int): Long
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
} }
``` ```
`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion `updateTask` on an occurrence of a recurring task routes to
sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back `TasksDataSource.updateInstance(taskId, occurrenceStart, form)` rather than
out as a normal VTODO status change. Writes to local/unsynced lists use the moving the series anchor. `expectedLastModified` re-checks the stored timestamp
sync-adapter URI form where the provider requires it. first and throws `TaskConflictException` when something changed underneath the
form.
### 4.5 Domain model notes Completion sets `STATUS = COMPLETED` (+ percent/completed timestamp); in
External mode DAVx5 syncs that back out as a normal VTODO status change.
- `Task.id` is the **instance** row id; `Task.taskId` is the underlying ### 4.8 Domain model notes
`tasks._id` and the stable target for edits/completion.
- `Task.taskId` is the task row and the stable target for edits, completion and
navigation. There is no `Task.id` any more — it was the materialised instance
row id, and materialised instances are gone.
- `Task.occurrenceStart` is the occurrence's `RECURRENCE-ID` anchor, `null` for a
non-recurring task; `Task.occurrenceKey` (`"$taskId@$millis"`) is what lazy
lists key by. Two occurrences of one series can appear in the same list, so
`taskId` alone would collide there — as a Compose key that is a visible bug.
- Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`); - Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`);
`TaskDetail` bundles a task with its direct children. `TaskDetail` bundles a task with its direct children.
- `effectiveColor` = the task's own colour, else the list colour. - `effectiveColor` = the task's own colour, else the list colour.
@@ -160,7 +347,7 @@ sync-adapter URI form where the provider requires it.
`TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists — `TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists —
`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due `ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due
dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking
`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it `todayStart`/`todayEnd` (local-midnight bounds from floret-kit's `DayWindow`), so it
unit-tests with a fixed clock. `TaskSorting` orders within a list (due / 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 priority / etc.). None of this touches Android, which is why it's all in
`domain/`. `domain/`.
@@ -181,10 +368,24 @@ providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`)
`set` when `canScheduleExactAlarms()` is false), keyed by `taskId`. `set` when `canScheduleExactAlarms()` is false), keyed by `taskId`.
- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel, - **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel,
`POST_NOTIFICATIONS` gate, dedupe-by-tag). `POST_NOTIFICATIONS` gate, dedupe-by-tag).
- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and - **Re-sync triggers:** app start (after `StartupGate`), **`BootReceiver`**
**`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external (re-arm after reboot), and **`ProviderChangeReceiver`**. The store lets each
sync changed the data). The store lets each run diff like Calendula diffs run diff like Calendula diffs reminder rows.
reminder rows.
`sync()` gates on **`ProviderResolver.canReadStore()`**, not on a provider
resolving. `OWN` is always readable; only `EXTERNAL` can fail, and only for the
two reasons it ever could (nothing installed, or no grant). Gating on
`resolve() != null` — as it did briefly — clears every alarm the moment `OWN` is
active, because `OWN` resolves to no provider by design. `ProviderResolverTest`
covers both directions.
`ProviderChangeReceiver`'s manifest filter now lists **only the two external
authorities**: Agendula publishes no provider and broadcasts no
`ACTION_PROVIDER_CHANGED`, so there is nothing of ours to listen for. 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 calls
`ReminderScheduler.sync()` itself — that is the replacement for the broadcast,
and it belongs in the sync work.
This is the single largest piece of genuinely-new code in Agendula. This is the single largest piece of genuinely-new code in Agendula.
@@ -192,17 +393,46 @@ This is the single largest piece of genuinely-new code in Agendula.
## 7. The A / B seam (why the layering is shaped this way) ## 7. The A / B seam (why the layering is shaped this way)
- **Posture A (today):** front-end over whatever provider is installed. Ships Both terms were **redefined** by [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md).
fast; requires a provider app present (the "needs DAVx5/OpenTasks" onboarding They no longer mean what earlier drafts of this document said.
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 - **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org).
front-end over open backends either way. Still fully supported; it stopped being the only option and became a user
choice, `StorageMode.EXTERNAL`.
- **Posture B (shipped, then rebuilt)** — a store of our own, `StorageMode.OWN`.
It first shipped as the `:provider` module, the Apache-2.0 dmfs provider
vendored under our own authority; it is now a Room database and the module is
deleted. What made the vendored provider worth keeping was the sync bookkeeping
it appeared to hand us for free, and the phase-1 sync audit measured that
bookkeeping and found most of it broken, absent or unusable — the argument and
the costing are in [`STORAGE-DECISION.md`](STORAGE-DECISION.md).
> **Dead end, do not revisit:** publishing a task provider under dmfs's *own*
> authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the
> same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same
> `<permission>` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with
> OpenTasks installed simply could not have installed Agendula. Account
> visibility is also keyed by *package*, not authority, which would have left such
> a provider seeing zero accounts and pruning synced lists as orphaned. Full
> reasoning in `STORAGE-AND-SYNC.md`.
The seam earned its keep twice over. `ProviderResolver` is still the only thing
that knows an authority exists and `AndroidTasksDataSource` the only thing that
touches a resolver, so vendoring an entire content provider changed nothing above
the data layer — and **replacing** it with a database of our own changed no UI,
no ViewModel and exactly one domain field (`Task.id` → `occurrenceStart`), which
was forced by dropping materialised instances rather than by the store swap
itself.
⚠️ **The authority is gone, and that is a breaking change.**
`de.jeanlucmakiola.agendula.tasks` and both custom permissions no longer exist,
so anyone who had pointed DAVx5 or another app at that authority loses it.
External mode is the answer for them; it needs saying in the release notes.
Owning the store owns **storage, not sync**. Our own sync adapter is a separate,
later piece of work — designed in [`SYNC.md`](SYNC.md), and it lands *underneath*
this same seam: it writes through the DAOs, so the layers above stay untouched a
third time.
--- ---
@@ -216,21 +446,38 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`,
`RootScreen` is the entry composable: it gates on `ProviderStatus` `RootScreen` is the entry composable: it gates on `ProviderStatus`
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` → (`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` →
`ListsScreen`). The remaining screens are being built one at a time — their `AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is
ViewModels exist and are tested against the real data layer; navigation only ever seen in External mode — and it offers a way back to our own store,
callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the because it is the only screen an External user can reach once their provider app
`material-3` skill for component choices (M3 `ListItem` rows, expressive stops answering. Routes are the `Dest` table in `ui/navigation/` (lists → task
checkbox/FAB/swipe motion). list → detail / edit, plus settings). Follow the `material-3` skill for component
choices (M3 `ListItem` rows, expressive checkbox/FAB/swipe motion).
Settings is a hub of sliding sub-screens rather than routes; **Storage** is the
one with teeth. It holds the §4.1 store picker — which asks for an external
provider's runtime permission *before* writing the mode, so a denial leaves the
readable store in place instead of stranding the user on the gate — and the
export screen (`ui/export/`, one `.ics` per ticked list, written through SAF to a
folder or a single zip). Because the mode is now switchable while the process
lives, `AgendulaApp` re-arms reminders on `ProviderResolver.onModeChanged`: an
alarm is scheduled off whichever store was active at the time, so the whole set
has to be rebuilt against the new one.
--- ---
## 9. Dependency injection ## 9. Dependency injection
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module (`TasksRepository`
(`TasksDataSource``AndroidTasksDataSource`, `TasksRepository` → `TasksRepositoryImpl`, `ProviderEnvironment` → `AndroidProviderEnvironment`)
`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore, and a `@Provides` module (the `agendula_prefs` DataStore, `TasksDatabase`, the
the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; `@IoDispatcher`, the `@ApplicationScope`). `TasksDataSource` is `@Provides`
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected. rather than `@Binds`, because it is a `ModeRoutingTasksDataSource` over
`Provider<RoomTasksDataSource>` and `Provider<AndroidTasksDataSource>` — both
singletons, so it picks between two long-lived objects rather than building
either. `AgendulaApp` is the `@HiltAndroidApp` entry point and pulls
`StartupGate`, `DatabaseCheckpoint` and `ReminderScheduler` through an
`@EntryPoint`; `MainActivity` is `@AndroidEntryPoint`. ViewModels get the
repository injected.
--- ---
@@ -241,8 +488,9 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
| Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 | | 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 | | 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) | | 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 | | Store | Room 2.8.4 (KSP, `room.schemaLocation = app/schemas`, WAL), `org.dmfs:lib-recur` 0.12.2 pinned (0.16.0 removed `RecurrenceSet`; `rfc5545-datetime` arrives with it as part of its API surface) |
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam | | Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines, the `floret-kit` included build |
| Tests | JVM: JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source, `ProviderEnvironment`, `TaskFormWriter` and `RecurrenceExpander` as the JVM-testable seams. Instrumented (`app/src/androidTest`, AndroidJUnitRunner + Truth): the Room schema, `RoomTasksDataSource` and `OneShotImport` — the last against `assets/tasks-v23.db`, a fixture written by `scripts/make_import_fixture.py` in the provider's DATABASE_VERSION 23 schema, since the provider it came from no longer exists to test against. |
| 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). | | 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). | | 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 | | Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs |
@@ -252,12 +500,27 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
## 11. Manifest surface ## 11. Manifest surface
- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms - **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms
declared statically (the active set is requested at runtime); (static manifest, so they are always declared; requested at runtime only in
`POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
(`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). (`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). `OWN` mode needs
- **`<queries>`** for package visibility: both provider authorities + a LAUNCHER nothing here at all: it is a database in our own data directory.
intent (so `resolveContentProvider` works and onboarding can open the - **No `<provider>` and no custom permissions.** Agendula publishes no content
provider / a store listing). provider; `de.jeanlucmakiola.agendula.tasks`, the
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, `de.jeanlucmakiola.agendula.permission.*` pair and their permission group all
`ProviderChangeReceiver` (both authorities). No `EVENT_REMINDER` receiver — went with the `:provider` module.
that's a Calendula thing that doesn't apply here. - **Deliberately absent:** `GET_ACCOUNTS`, and `INTERNET`, which stays undeclared
until sync actually ships. Export needs no storage permission at all; SAF hands
us a `Uri` the user picked.
- **`<queries>`** for package visibility: both external provider authorities +
a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open
the provider / a store listing).
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, and
`ProviderChangeReceiver` — the latter filtering on the two *external*
authorities only (an intent-filter host must be a literal). No `EVENT_REMINDER`
receiver — that's a Calendula thing that doesn't apply here.
- **Backup:** `agendula-tasks.db` plus its `-wal` and `-shm` sidecars are
included in both `backup_rules.xml` and `data_extraction_rules.xml`;
`tasks.db.imported` is excluded, since it is a copy of data already imported.
Room runs in WAL mode and Auto Backup copies files without checkpointing, so
`DatabaseCheckpoint` runs `PRAGMA wal_checkpoint(TRUNCATE)` on `ON_STOP` to
keep the `.db` alone current for a restore that drops the sidecars.

621
docs/OWN-STORE.md Normal file
View File

@@ -0,0 +1,621 @@
# Agendula's own task store — architecture and plan
**Branch:** `feat/own-store`
**Decision:** taken. `docs/STORAGE-DECISION.md` costed it; this is the build.
**Supersedes:** the "keep `:provider`" position in `STORAGE-AND-SYNC.md` and the
"Settled" section of `SYNC.md`.
---
## The decision, in one paragraph
Agendula stops vendoring the dmfs OpenTasks provider. The `:provider` module —
14,555 lines of Java, 1.66× the size of the app itself — is **deleted**. In its
place the app gets its own Room database, designed for the two things Agendula
actually does: show tasks, and sync them over CalDAV. Support for *external*
providers (OpenTasks, tasks.org) **stays**, unchanged, as a user choice — so
anyone already syncing through DAVx5 keeps working exactly as they do today.
Agendula becomes a normal Android app with a normal database, plus an optional
compatibility path into somebody else's ContentProvider.
---
## What changes and what does not
```
BEFORE AFTER
UI / ViewModels UI / ViewModels
│ │
TasksRepository TasksRepository ← unchanged
│ │
TasksDataSource (interface) TasksDataSource ← one method
╲ changes
AndroidTasksDataSource RoomTasksDataSource AndroidTasksDataSource
│ │ │
ContentResolver Room / SQLite ContentResolver
│ │ │
┌────┴─────┐ our tables ┌──────┴──────┐
│ │ │ │
:provider OpenTasks OpenTasks tasks.org
(deleted) tasks.org (external, unchanged)
```
**Unchanged above the data layer.** Every ViewModel, every screen. Verified:
exactly one file outside `data/tasks` references `TasksContract`
(`domain/Models.kt`, for five constants), and it stops doing so in phase 0.
Navigation addresses tasks by `taskId` throughout (`Destinations.kt:59`,
`TaskDetailScreen.kt:202,384`) — never by the instance id — so the change below
does not reach the UI.
**One seam method changes.** `TasksDataSource.updateInstance(instanceId, form)`
becomes `updateInstance(taskId, occurrenceStart, form)`, and `Task` gains
`occurrenceStart: Instant?`. See *Instance identity* — this is the one place the
"nothing above the data layer changes" claim needed qualifying, and
`TasksRepositoryImpl.updateTask` is the only caller.
**Deleted.** The `:provider` Gradle module, its manifest `<provider>`, its two
custom permissions, its 84 Java files, its 13 translated string resources, and
its three dmfs runtime dependencies from `:provider`'s own build file.
**Kept for External mode.** `TasksContract.kt`, `ColumnReader.kt`,
`TaskMapper.kt`, `TaskWriteMapper.kt`, `AndroidTasksDataSource.kt`,
`ProviderResolver.kt`, `ProviderEnvironment.kt`, `TaskProjections.kt`. These
describe *somebody else's* schema and are exactly right for that job.
---
## Storage modes after the change
```kotlin
enum class StorageMode {
/** Agendula's own Room database. The default; always available. */
OWN,
/** A tasks provider app already installed — OpenTasks, tasks.org. */
EXTERNAL,
}
```
`LOCAL` (meaning "our bundled dmfs provider") is gone. `ProviderResolver.own`
and the `TaskProvider(isOwn = true)` case go with it: in `OWN` mode there is no
authority, no ContentResolver and no permission to grant.
> **This rename happens in phase 5, not phase 0.** Between phases 1 and 4 both
> stores exist, so the enum carries `LOCAL` (dmfs), `OWN` (Room) and `EXTERNAL`
> simultaneously. Renaming `LOCAL` → `OWN` up front would make `OWN` mean *dmfs*
> for four phases and *Room* afterwards, which is exactly the kind of thing that
> gets misread six weeks later.
`ProviderResolver` narrows to what it was always really for — **discovering
external providers** — and `ProviderStatus.READY` becomes unconditional in `OWN`
mode.
### Consequences worth stating plainly
- **No runtime permission is needed for the default path.** Today's permission
prompt only ever applied to External mode; now that is visibly true.
- **Third-party apps can no longer read Agendula's tasks.** We publish no
ContentProvider. Users who need interop pick External mode, or wait for a
possible read-only facade (explicitly out of scope — see *Deliberately not
doing*).
- **Local lists still report an account name.** `TaskList.accountName` is a
non-null String that `ListsViewModel.kt:98` groups by and
`ListsScreen.kt:222` renders as a section header. `RoomTasksDataSource` maps
`account_id IS NULL` to `accountName = "Local"`, `accountType =
"local"`, so the existing grouping and `TaskList.isLocal` keep working with no
UI change. (`isLocal` moves off `TasksContract.LOCAL_ACCOUNT_TYPE` in phase 0
and compares against a `domain` constant instead.)
Knock-on, benign: `TaskEditViewModel.kt:101` picks the first *non*-local list
as the edit form's default. In `OWN` mode with no account configured every
list is local, so it falls through to `firstOrNull()`. Same practical result,
worth knowing before someone reports it as a bug.
- **Auto Backup gets simpler and safer.** One Room file we control, with a
documented restore path, instead of a provider database whose `cleanUpLists`
routine could delete restored lists whose accounts no longer exist.
⚠️ With one caveat that has to be handled, not assumed away: **Room enables
write-ahead logging by default**, and Auto Backup copies files without
checkpointing. A `-wal` sidecar can hold writes the backed-up `.db` does not.
We checkpoint (`PRAGMA wal_checkpoint(TRUNCATE)`) on `ON_STOP` and include
`.db`, `-wal` and `-shm` together in the backup rules, so a restore is
consistent either way. Phase 6 tests this, because "our backup is safer" is
the kind of claim that is worth exactly as much as its test.
---
## The schema
Four tables. Designed from Agendula's actual reads and writes plus RFC 5545's
`VTODO`, not inherited from a 2013 schema.
### `task_lists`
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER PK | |
| `name` | TEXT NOT NULL | |
| `color` | INTEGER NOT NULL | ARGB |
| `account_id` | INTEGER NULL | FK → `accounts`, NULL = device-only |
| `is_visible` | INTEGER NOT NULL | default 1 |
| `is_synced` | INTEGER NOT NULL | default 1 |
| `owner` | TEXT NULL | CalDAV owner display name |
| `is_read_only` | INTEGER NOT NULL | **new** — the provider could not express this at all |
| `sort_order` | INTEGER NOT NULL | user ordering, which the provider also lacked |
| `href` | TEXT NULL | collection URL, relative to the account root |
| `ctag` | TEXT NULL | |
| `sync_token` | TEXT NULL | RFC 6578, **per collection** — not squatted into a shared slot |
| `is_dirty` | INTEGER NOT NULL | a real boolean, not dmfs's monotonic counter |
> `account_id` being nullable is the single most important schema change. In the
> dmfs provider `ACCOUNT_TYPE` is write-once and throws on change, which made
> "turn sync on" a full data migration. Here, attaching a local list to an
> account is `UPDATE task_lists SET account_id = ?`.
### `tasks`
Master rows *and* recurrence overrides live here. An override is a row with
`recurrence_id` set and `master_id` pointing at its series master.
> `master_id` and `parent_id` are different things and must not be conflated.
> **`parent_id`** is task hierarchy — a subtask's parent, the thing
> `RELATED-TO;RELTYPE=PARENT` carries. **`master_id`** is recurrence — which
> series an override belongs to. A row can have both: a subtask can itself
> recur.
| Group | Columns |
|---|---|
| identity | `id`, `list_id`, `uid` (NOT NULL, minted at creation), `href`, `etag` |
| content | `title`, `description`, `location`, `url`, `color` |
| state | `status`, `percent_complete`, `completed_at`, `priority`, `classification` |
| time | `dtstart`, `due`, `duration`, `is_all_day`, `timezone` |
| recurrence | `rrule`, `rdate`, `exdate`, `recurrence_id`, `master_id` |
| hierarchy | `parent_id`, `sort_order` |
| audit | `created_at`, `last_modified`, `sequence` |
| sync | `is_dirty`, `is_deleted`, `unknown_properties` |
Two entries deserve explanation.
**`uid` is NOT NULL and assigned at creation.** Every task gets a real
RFC 4122 UUID the moment it is inserted, in every mode, synced or not. This
closes the gap `ICalendarWriter.uidFor` currently papers over by synthesising
`agendula-<rowid>@…`, and it means any local task can later be pushed to a
server without duplicating. The provider never assigned one.
**`unknown_properties`** holds the raw unfolded iCalendar lines of every
property we do not model — `ATTENDEE`, `CATEGORIES`, `X-*`, `GEO`, and anything
a future RFC adds. On write we re-emit them verbatim after the properties we do
own. This is what makes an honest round-trip possible, and it replaces the
provider's `data0``data15` bag with something that cannot silently lose a field
it has no column for.
Indices: `(list_id, is_deleted)`, `(parent_id)`, `(master_id, recurrence_id)`,
`(is_dirty)`, and **unique on `(list_id, uid, recurrence_id)`**.
> The unique index deliberately includes `recurrence_id`. An override **shares
> its master's UID** — that is what makes it an override rather than a separate
> task — so a unique index on `(list_id, uid)` alone would reject the very rows
> the recurrence design depends on. With `recurrence_id` NULL on the master and
> set on each override, the constraint says the right thing: one master and at
> most one override per occurrence, per UID, per list.
**Cascades.** `master_id` is `ON DELETE CASCADE` — deleting a series deletes its
overrides, which would otherwise become unreachable rows that still sync.
`parent_id` is `ON DELETE SET NULL`: deleting a parent promotes its subtasks to
top level rather than destroying work the user did not ask to lose. `task_id` on
`task_alarms` cascades.
**Type converters.** Every time column is `kotlin.time.Instant` in the entity
and INTEGER epoch-millis in SQLite, via one `@TypeConverter` pair. `status` and
`priority` convert through the existing `domain` enums, so `statusFromInt` /
`toInt()` keep their single home.
### `task_alarms`
| Column | Notes |
|---|---|
| `id`, `task_id` | FK, `ON DELETE CASCADE` |
| `minutes_before` | positive = before the reference |
| `reference` | `DUE` or `START` |
| `message` | optional |
Replaces `AlarmHandler` (133 lines of Java) and the `dataN` slot convention.
Delete-and-reinsert stops being necessary — the provider's re-validate-everything
behaviour was the only reason `setAlarm` worked that way.
### `accounts`
| Column | Notes |
|---|---|
| `id`, `display_name`, `principal_url`, `home_set_url` |
| `username` | the app password is **not** here — Keystore only, per `SYNC.md` |
| `last_sync_at`, `last_sync_error` |
Not populated until `SYNC.md` phase 2, but the FK exists from v1 so enabling
sync never requires a schema migration.
---
## Recurrence: expand at read, not on write
The provider maintained a materialised `instances` table, recomputed by
`Instantiating.java` on every write — and still only ever materialised **one**
upcoming occurrence.
Agendula expands lazily instead:
```
tasks (masters + overrides) ──► RecurrenceExpander ──► List<Task>
rrule/rdate/exdate (lib-recur, in memory) occurrences
```
This is the right call here because **the repository already filters and sorts
in Kotlin, not SQL**. `TasksRepositoryImpl.loadTasks` reads the whole set,
applies `TaskFiltering.matches`, then `TaskSorting.DEFAULT`. Nothing depends on
the database being able to order by instance time, so nothing is lost — and a
materialised table's entire class of staleness bugs never exists.
- Bounded window: expansion is capped (default: 1 year back, 2 years forward,
hard ceiling of N occurrences per series) so an unbounded `RRULE` cannot hang
the UI.
- `lib-recur` **pinned at 0.12.2** — 0.16.0 removed `RecurrenceSet`. We pin
because we chose to, and the pin is now ours to lift on our own schedule.
- Client-side expansion is required for CalDAV regardless: server-side
`CALDAV:expand` on `VTODO` is broken on every server `SYNC.md` targets.
### Instance identity
Deleting the materialised `instances` table deletes the instance **row id**, and
two things use it today:
- `TasksRepositoryImpl.updateTask``dataSource.updateInstance(current.id, …)`
- `ListsScreen.kt:422``items(results, key = { it.id })`
The Compose key is the constraint that decides the design. Two occurrences of
one series can appear in the same list, so `taskId` alone is not unique, and a
hash of `(taskId, start)` folded into a `Long` can collide — which as a Compose
key is a visible bug, not a theoretical one.
So we address occurrences by what they actually are:
```kotlin
data class Task(
val taskId: Long, // the master row — unchanged, what navigation uses
val occurrenceStart: Instant?, // null for a non-recurring task
)
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
```
`Task.id` is dropped; the Compose key becomes `"$taskId@${occurrenceStart}"`,
which is unique by construction and stable across reloads.
**External mode absorbs this without loss.** `AndroidTasksDataSource` maps
`(taskId, occurrenceStart)` back to a real instance row with one query —
`WHERE task_id = ? AND instance_start = ?` — before writing through the
instances URI. One extra query on an operation the user performs by hand, in
exchange for a seam that does not depend on a foreign table's row ids.
This is the **only** change to `TasksDataSource`, and
`TasksRepositoryImpl.updateTask` is its only caller.
### Completing one occurrence of a recurring task
The provider's `Detaching.java` implemented **model (d): detach the occurrence
as a brand-new task with its own UID**. We inherited that without ever choosing
it, and it is the model least compatible with CalDAV.
**We implement model (a): a `RECURRENCE-ID` override.** Completing one
occurrence writes a second `tasks` row with the same `uid`, a `recurrence_id`
naming the occurrence, `master_id` pointing at the series, and the completed
state. This is what RFC 5545 specifies and what every other CalDAV client
expects to receive.
This decision is now made explicitly, recorded here, and testable.
---
## Reactivity
`TasksDataSource.registerObserver(onChange: () -> Unit): AutoCloseable` **stays
as-is**. The Room implementation backs it with `InvalidationTracker.Observer`
over the four tables; the External implementation keeps its `ContentObserver`.
One interface, two mechanisms, `TasksRepositoryImpl.observing()` untouched.
Going Flow-native in the DAOs is a later, optional refinement. Doing it now
would change the interface and therefore the External path, for no user-visible
gain.
---
## Migrating existing users
Anyone on v0.3.x has their tasks inside the bundled provider's SQLite file at
`/data/data/de.jeanlucmakiola.agendula/databases/tasks.db` (dmfs schema
version 23). Removing the Gradle module does **not** remove that file — an app
update leaves the data directory intact.
So the migration reads the file directly, with no provider and no
ContentResolver involved:
```
OneShotImport
1. does databases/tasks.db exist? no → nothing to do, mark done
2. open SQLiteDatabase.OPEN_READONLY
3. read tasklists → task_lists (account_type LOCAL → account_id NULL)
4. read tasks → tasks (skip _deleted = 1; mint uid where NULL)
5. read properties → task_alarms (mimetype = …/alarm only)
6. verify counts, inside one Room transaction
7. record completion in DataStore
8. rename tasks.db → tasks.db.imported (kept one release, then deleted)
```
Rules that make this safe:
- **Read-only, single transaction, verified counts.** Either the whole import
lands or none of it does.
- **The source file is renamed, never deleted**, for one release. If the import
is wrong we can still recover from a user's device.
- **Idempotent.** Guarded by a DataStore flag *and* by the rename, so a crash
mid-import cannot double-import.
- **Runs before first UI read**, gated the same way `StorageModeHolder.awaitReady()`
already gates the launch reminder re-sync.
- Tasks that were in an *external* account inside our bundled provider (only
possible if the user had pointed DAVx5 at our authority) are imported as
local lists, with their `uid` preserved. Rare, but preserving the UID is what
lets them be re-attached to an account later.
`tasks.db.imported` is excluded from Auto Backup; the new Room database (with
its `-wal` and `-shm` sidecars) is included, which is the whole point of owning
it.
### If the import goes wrong in production
The rename is not just tidiness — it is the rollback. `tasks.db.imported` is a
complete, untouched dmfs database, so recovery does not need `:provider` to
still exist:
1. `OneShotImport` can be re-run against `tasks.db.imported` as well as
`tasks.db`; the DataStore flag is clearable by a targeted fix release.
2. Re-import truncates the Room tables first and re-runs in one transaction, so
a second attempt is not a merge and cannot duplicate.
3. Only after a release with no import defects reported does a subsequent
version delete `tasks.db.imported`.
This is the reason phase 5 (deleting `:provider`) ships *after* phase 4 rather
than with it — and the reason the deletion is its own release.
---
## Effects on the sync plan
`SYNC.md`'s phase list was written against the provider. Owning the store
deletes work from it outright:
| `SYNC.md` item | Fate |
|---|---|
| "Assign UIDs at creation" (phase 0) | **gone**`uid` is NOT NULL from v1 |
| Auto Backup / `cleanUpLists` data-loss guard (phase 0) | **gone** — no `cleanUpLists` |
| `lib-recur` pin rationale (phase 0) | reduced to a normal version choice |
| Local → Synced migration (phase 3) | **gone**`account_id` is a nullable FK |
| ETag / href / CTag squats into `SYNC1``SYNC8` | **gone** — real columns |
| Per-collection sync token (phase 3) | **gone** — real column |
| `_DIRTY` set-on-delete workaround (phase 3) | **gone** — tombstones are ours |
| `CALLER_IS_SYNCADAPTER` ignored by instances URI | **gone** — no URIs |
| `Moving` dual-UID collision (phase 4) | **gone** |
| Read-only collections (phase 4) | now *possible*`is_read_only` exists |
| Recurring-completion model | **decided here** — RECURRENCE-ID override |
| Byte-stable round-trip | improved — `unknown_properties` preserves the rest |
Everything platform-level and protocol-level in `SYNC.md` is untouched: the
`targetSdk 34` sync-framework gate, the stub sync-adapter pattern, credential
storage, Play compliance, discovery, conditional `PUT`, conflict policy, and
every per-server quirk in the server-reality table.
---
## Plan
### Phase 0 — Untangle (0.5 wk)
- `domain/Models.kt` stops importing `TasksContract`; the status, priority and
local-account constants move into `domain`. This is the last contract
reference above the data layer.
- `Task.id``Task.occurrenceStart`; `updateInstance(taskId, occurrenceStart,
form)`. `AndroidTasksDataSource` gains the lookup query, so the *existing*
provider path exercises the new signature before Room ever does.
- Add `StorageMode.OWN` as a **third** value alongside `LOCAL` and `EXTERNAL`.
- Add Room + `room.schemaLocation` to the version catalog (KSP is already
applied to `:app` for Hilt).
Deliberately **not** here — both were in an earlier draft and both were wrong:
- *Renaming `LOCAL` → `OWN`.* The provider is still the store until phase 4;
renaming now makes `OWN` mean dmfs for four phases and Room afterwards.
Phase 5.
- *Dropping our authority from `ProviderChangeReceiver`'s manifest filter.* That
receiver is what re-syncs reminders while the app is backgrounded
(`ProviderChangeReceiver.kt:47`). Removing the filter while the provider is
still live would silently stop background reminder updates. Phase 5.
**Done when:** the app builds and behaves identically, provider still present,
still default, and the seam change is proven on the provider path.
### Phase 1 — Schema and DAOs (1 wk)
- The four entities above, plus DAOs, plus `schemas/` exported for migration
testing (`room.schemaLocation`, committed).
- `RoomTasksDataSource` implementing all 14 `TasksDataSource` methods except the
recurrence-dependent ones, which throw until phase 2.
- `DataModule` binds by `StorageMode`.
**Done when:** a JVM test creates lists and non-recurring tasks through
`TasksDataSource` against an in-memory Room database and reads them back.
### Phase 2 — Recurrence (1.52 wk)
The hard phase. Budget accordingly.
- `RecurrenceExpander` over `lib-recur`: `RRULE`, `RDATE`, `EXDATE`, overrides,
all-day handling, bounded window, `distanceFromCurrent`.
- `RECURRENCE-ID` override creation on single-occurrence edit and completion.
- A test suite that is the deliverable, not an afterthought: daily/weekly/
monthly-by-day/yearly, `COUNT` and `UNTIL`, DST boundaries, all-day series,
a series with an override, a series with an exception, and an unbounded rule
hitting the window ceiling.
**Done when:** the expansion suite is green and single-occurrence editing forks
correctly.
⚠️ **Parity against the provider is only partly available, and the earlier draft
of this plan overclaimed it.** The provider materialises exactly one upcoming
occurrence, so there is no multi-occurrence behaviour to compare against. The
split:
| Behaviour | Reference |
|---|---|
| Multi-occurrence expansion | RFC 5545 §3.8.5 and `lib-recur` directly — **no provider parity exists** |
| The single next occurrence | provider parity, while it is still in-tree |
| Editing one occurrence (forking) | provider parity — *except* model (a) vs (d), enumerated as explicit difference tests |
| All-day and DST handling | provider parity |
That partial availability is still the reason deletion is phase 5 rather than
phase 0. It is just not the blanket safety net it was described as.
### Phase 3 — Semantics parity (1 wk)
- Completion coherence: `status` ↔ `percent_complete` ↔ `completed_at` ↔ closed,
replacing `AutoCompleting.java` and — importantly — the reopen asymmetry that
`TaskWriteMapper` currently works around in the app.
- Parent/child integrity: `parent_id` is `ON DELETE SET NULL`, so deleting a
parent promotes its subtasks rather than destroying them.
- Validation: `DUE` xor `DURATION`, `due >= dtstart`, all-day pinned to UTC
midnight, list must exist.
- Delete semantics: hard delete when `account_id IS NULL`, tombstone when set;
`master_id` cascades so a deleted series takes its overrides with it.
- `ICalendarWriter.uidFor`'s synthesis branch becomes dead on the Room path
(`uid` is NOT NULL). It stays for External, where UIDs really can be absent —
the KDoc gets updated to say which path each branch now serves.
**Done when:** `TaskWriteMapper`'s provider-quirk workarounds are demonstrably
unnecessary on the Room path (they stay for External).
### Phase 4 — Import and cutover (1 wk)
- `OneShotImport` per the rules above, with tests over a fixture `tasks.db`
captured from a real v0.3.x install.
- `OWN` becomes the default for new installs and for upgraders after import.
- Backup rules updated: include the Room database **and its `-wal`/`-shm`
sidecars**, exclude `tasks.db.imported` and the Keystore blob. WAL checkpoint
on `ON_STOP`.
**Done when:** an upgrade from a v0.3.2 APK with seeded data lands every task,
list and reminder in Room, verified by count and by content.
### Phase 5 — Delete `:provider` (0.5 wk)
- Remove the module, its `settings.gradle.kts` include, its `:app` dependency,
the three dmfs deps it pulled in, `provider/PROVENANCE.md`.
- Add `lib-recur` (and `rfc5545-datetime`) directly to `:app`.
- `StorageMode`: `LOCAL` is deleted, `OWN` is what remains beside `EXTERNAL`.
`ProviderResolver` loses `own` / `isOwn`.
- `ProviderChangeReceiver`'s manifest filter drops our own authority — safe
*now*, because nothing of ours broadcasts `ACTION_PROVIDER_CHANGED` any more.
In `OWN` mode the in-app `InvalidationTracker` observer covers foreground
changes, and until sync exists 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, not here.
- Attribution screen: dmfs code is gone, but `lib-recur` stays and is
Apache-2.0. `PROVENANCE.md` is replaced by a short note in
`STORAGE-DECISION.md` recording that the fork existed and why it ended.
- **Release note, user-facing:** dropping the `<provider>` also drops the
`de.jeanlucmakiola.agendula.tasks` authority and both custom permissions.
Anyone who pointed DAVx5 or another app at that authority loses it silently —
it has to be called out in the release, with External mode as the answer.
**Done when:** `./gradlew build` is green with `:provider` absent, and the APK
declares no ContentProvider and no custom permissions.
### Phase 6 — Harden (1 wk)
- Room migration test infrastructure (`MigrationTestHelper`) wired up, so v1 →
v2 is cheap when sync adds columns.
- Restore-path test: Auto Backup restore into a fresh install, **including the
WAL case** — write, background, restore, verify the last write survived.
- Performance check at 5,000 tasks with 20 recurring series.
### Total
| Phase | | |
|---|---|---:|
| 0 | Untangle | 0.5 |
| 1 | Schema and DAOs | 1 |
| 2 | Recurrence | 1.52 |
| 3 | Semantics parity | 1 |
| 4 | Import and cutover | 1 |
| 5 | Delete `:provider` | 0.5 |
| 6 | Harden | 1 |
| | | **6.57 wk** |
Against which `SYNC.md`'s own estimate drops by 2.54 weeks, so the net cost of
owning the store is roughly **+2.5 to +4.5 weeks** — before counting the bugs
that stop being unfixable.
> This does not contradict `STORAGE-DECISION.md`'s 4.56 week figure; it
> supersedes it. That estimate costed only the new store (schema, expansion,
> semantics, import, tests) on the assumption `:provider` would be *kept*
> alongside it. This plan deletes the provider, which adds phase 0's untangling
> and phase 5's removal — work the earlier figure never had to include.
---
## Testing posture
| Layer | How |
|---|---|
| Entities, DAOs, migrations | Room in-memory + `MigrationTestHelper`, JVM |
| `RecurrenceExpander` | pure JVM, no Android — the largest suite |
| Semantics (completion, hierarchy, validation) | JVM through `TasksDataSource` |
| `OneShotImport` | fixture `tasks.db` committed as a test resource |
| External mode | unchanged; existing `TaskMapper` / `TaskWriteMapper` tests stay |
The 93 existing app tests must stay green throughout. The 56 provider tests
leave with the module in phase 5 — replaced, not abandoned: phases 2 and 3 owe
equivalent coverage of the behaviour those tests protected. Note the limit
recorded in phase 2: parity covers the single next occurrence, forking and
all-day/DST handling. Multi-occurrence expansion has no provider behaviour to
compare against and is tested against RFC 5545 and `lib-recur` directly.
---
## Risks
| Risk | Mitigation |
|---|---|
| **Recurrence is subtler than estimated** | The likeliest overrun, and the least mitigated — provider parity does not cover multi-occurrence expansion, so the reference is the RFC. Phase 2 is isolated and pure-JVM, so it can overrun without blocking phases 34. |
| **Import loses a user's data** | Read-only source, single transaction, count verification, source renamed not deleted, re-runnable from `tasks.db.imported`, fixture-based tests. |
| **Regression in a behaviour nobody documented** | Partial: phase 2 and 3 parity tests run against the provider while it is still present, for the behaviours where parity exists at all. That is why deletion is phase 5. |
| **A restore silently loses recent writes** | WAL checkpoint on `ON_STOP`, sidecars included in the backup rules, and a phase 6 test that exercises exactly this. |
| **Losing third-party interop** | External mode covers users who need it. Called out in the phase 5 release note. A read-only facade stays possible later; nothing here forecloses it. |
| **Room + KSP build cost** | KSP is already in the build for Hilt; Room adds one processor. |
---
## Deliberately not doing
- **An exported ContentProvider facade over Room.** Possible later (~11.5 wk),
not now. Shipping one would recreate the public-API surface whose validation
and URI plumbing is most of what we are deleting.
- **A domain-native schema.** The table shapes above stay recognisably close to
`TaskContract` where `TaskContract` was right, because it is a proven design
for `VTODO` and because it keeps a future facade cheap.
- **Flow-native DAOs.** Later refinement; changes the interface for no
user-visible gain today.
- **FTS / search.** The provider carried 798 lines of it. The app has never
called it. If search is wanted it is a feature request, designed on its own
terms.
- **Categories and attendees as first-class tables.** They round-trip through
`unknown_properties` until a feature actually needs them.

View File

@@ -1,5 +1,24 @@
# Agendula — implementation plan # Agendula — implementation plan
> ⚠️ **Historical document.** This is the original design plan, kept for the
> reasoning behind decisions that are still in force — the layering, the data
> model, the reminder engine, what transfers from Calendula. It is **not** a
> description of the app as it stands.
>
> Two things here have since been overturned, both by
> [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md), which supersedes this document
> wherever they disagree:
>
> 1. **"No own storage."** Agendula now ships its own bundled task provider, and
> depending on an external provider app is a user choice rather than a
> requirement.
> 2. **"Posture B = bundle OpenTasks under `org.dmfs.tasks`."** That is a dead
> end, not a later step — two apps cannot declare the same authority or
> permission name. Posture B shipped under *our own* authority instead.
>
> For the current picture see [`ARCHITECTURE.md`](ARCHITECTURE.md); for status,
> [`ROADMAP.md`](ROADMAP.md).
> A modern Material 3 Expressive **task** app for Android. Reads, writes, and > A modern Material 3 Expressive **task** app for Android. Reads, writes, and
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync / > reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
> DecSync over CalDAV), with no own sync stack. > DecSync over CalDAV), with no own sync stack.

View File

@@ -1,8 +1,10 @@
# Agendula — documentation # Agendula — documentation
Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over Agendula is a Material 3 Expressive **task** app for Android. It **carries its
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync own task store** — the dmfs task provider vendored under our own authority — so
over CalDAV), with no own database or sync stack. Sibling to it is complete and local-first with nothing else installed; an external provider
(OpenTasks / tasks.org, synced by DAVx5 / SmoothSync / DecSync) is a user choice
rather than a requirement, and our own CalDAV sync is the 1.x arc. Sibling to
[Calendula](https://codeberg.org/jlmakiola/calendula). See the [Calendula](https://codeberg.org/jlmakiola/calendula). See the
top-level [`../README.md`](../README.md) for the project pitch. top-level [`../README.md`](../README.md) for the project pitch.
@@ -12,15 +14,24 @@ top-level [`../README.md`](../README.md) for the project pitch.
|---|---| |---|---|
| [`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. | | [`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 (M0M6 + Posture B), what's done, open decisions, how to build/verify. | | [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0M6 + Posture B), what's done, open decisions, how to build/verify. |
| [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md) | **Where task data lives** — the decision to ship our own provider, the storage modes, permissions, distribution, and the dead ends. Supersedes `PLAN.md` on storage. |
| [`SYNC.md`](SYNC.md) | **How data reaches a server** — the CalDAV sync adapter: the VTODO ↔ `TaskContract` mapper, Nextcloud sign-in, the engine, libraries and their licenses. Step 5 of `STORAGE-AND-SYNC.md`. |
| [`STORAGE-DECISION.md`](STORAGE-DECISION.md) | **Keep the vendored provider, or build our own?** The measured cost of both. **Decided: build our own.** |
| [`OWN-STORE.md`](OWN-STORE.md) | **Agendula's own Room store** — the schema, recurrence design, migration off the vendored provider, and the six-phase plan that deletes `:provider`. Supersedes the "keep the provider" position in `STORAGE-AND-SYNC.md`. |
| [`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". | | [`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. | | [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. |
| [`../provider/PROVENANCE.md`](../provider/PROVENANCE.md) | What the vendored `:provider` module is, where it came from, and **every** deviation from upstream dmfs. |
Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections
feed the release notes). feed the release notes).
## How the docs relate ## How the docs relate
- **PLAN** is the design decisions (mostly stable; the "why"). - **PLAN** is the original design decisions (the "why"), left as the historical
record. On storage it is **superseded by STORAGE-AND-SYNC**.
- **STORAGE-AND-SYNC** and **SYNC** are the standing decision documents: the
first settles where data lives, the second how it syncs. Both record rejected
alternatives on purpose, so decisions don't get relitigated.
- **ARCHITECTURE** is the current shape of the code (kept in sync with the - **ARCHITECTURE** is the current shape of the code (kept in sync with the
source as it grows). source as it grows).
- **ROADMAP** is the moving status layer (update as milestones land). - **ROADMAP** is the moving status layer (update as milestones land).

View File

@@ -10,16 +10,20 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
## Current state (one line) ## Current state (one line)
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract` Agendula now **carries its own task store, and it is one we wrote**: a Room
provider is **done and unit-tested**, and the Material 3 Expressive UI is built database designed against `VTODO`, with recurrence expanded at read time. The
through **M5**: lists → task list (swipe gestures, inline add, smart-list section vendored dmfs provider and the `:provider` module that held it are deleted, a
headers) → detail / edit with full CRUD, date-time pickers, priority, v0.3.x install's tasks are imported on first launch, and an external provider
percent-complete, conflict-safe saves, per-task reminders, and subtask (OpenTasks / tasks.org) is a user choice rather than a requirement. Export to
create + reparent — plus a one-time reminder onboarding step and a Settings iCalendar has landed, and the Material 3 Expressive UI is built through **M5**:
screen (theme, dynamic colour, due-reminder master toggle + default offset + lists → task list (swipe gestures, inline add, smart-list section headers) →
exact-alarm status, default list, and the add-a-subtask-row opt-out). Remaining detail / edit with full CRUD, date-time pickers, priority, percent-complete,
work is M6 (Glance widget, translations, F-Droid release; the Settings screen conflict-safe saves, per-task reminders, and subtask create + reparent — plus a
landed early with M5 and still needs a language entry). one-time reminder onboarding step and a Settings screen. The frontend surfaces
for the new store have landed too: a Settings **Storage** section with the
store picker and an **export screen**. Remaining work is verifying all of it on
a device, then M6 (Glance widget, translations, F-Droid release) and the sync
adapter.
--- ---
@@ -128,11 +132,125 @@ The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
- ⬜ Translations — only `res/values/` (English); no `values-XX`. - ⬜ Translations — only `res/values/` (English); no `values-XX`.
- ⬜ Finalize F-Droid metadata, confirm CI release flow. - ⬜ Finalize F-Droid metadata, confirm CI release flow.
### Posture B (separate track, later) ### Posture B — our own task store
Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`; Agendula stopped depending on a provider app being installed. Direction and
`ProviderResolver` defaults to our own `org.dmfs.tasks`; add sync-adapter reasoning in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md); note it **redefined**
permissions; ship self-contained. UI / repository / domain untouched — see what Posture B means (our own store, coexisting with everything — *not* squatting
[`ARCHITECTURE.md`](ARCHITECTURE.md) §7. `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** — Settings gained a **Storage** section
holding both: a full-screen store picker (Own / an installed external provider,
which is dimmed when none is present) that asks for the provider's runtime
permission *before* committing the switch, and an export screen with a per-list
tick and the two SAF destinations, a folder or a single zip. Two consequences
of the mode becoming switchable at runtime came with it: reminders are re-armed
against the new store on every switch (`AgendulaApp` listens on
`ProviderResolver.onModeChanged`; previously only a restart, a boot or an edit
resynced them), and the permission gate offers a way back to our own store —
otherwise a user whose provider app went away is held on a gate with Settings
behind it.
- ⬜ 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`](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`](STORAGE-DECISION.md), architecture and six-phase plan in
[`OWN-STORE.md`](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.
- ✅ Fallout, second pass — four defects a review of the branch turned up:
**completing one occurrence closed the whole series** (`setCompleted` wrote the
master, which is the row `TaskDao.tasks` filters on, so every occurrence left
every list); 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; and `registerObserver` bound
a live flow to whichever store was active at subscription, so a Settings
store switch would have left every screen listening to the store it had
stopped reading. `setCompletedInstance` now forks a `RECURRENCE-ID` override
the way `updateInstance` does — phase 2 always specified this, only the edit
half had it.
-**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.
### ✅ Managing lists in the app
Owning the store made this mandatory: there is no longer a provider app to
create a list in, so a fresh install had no lists, no way to make one, and
therefore no way to save a task. The seam gained `updateList` / `deleteList`
beside the existing `createLocalList`, implemented on both the Room and the
External path (which addresses the row as its own account's sync adapter, the
only caller the provider lets write `tasklists`).
-`ListEditorSheet` — the family's full-screen sheet with a name field, the
12-colour palette and, when editing, a destructive row behind a confirm.
- ✅ Entry points: a "New list" row under the home Lists section, a real empty
state with a create button, and the home FAB switching to "New list" while
there are none. Editing is the pencil in a list's own top bar.
- ✅ Deleting a list deletes its tasks — `tasks.list_id` cascades — and is
offered only for device-only lists; an account's collection is its server's.
--- ---
@@ -144,12 +262,24 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through
2. ~~**tasks.org provider authority**~~ verified on device: 2. ~~**tasks.org provider authority**~~ verified on device:
`org.tasks.opentasks` + `org.tasks.permission.*`. `org.tasks.opentasks` + `org.tasks.permission.*`.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? 3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
(Not in the candidate list today.) (Not in the candidate list today.) Note this is now downstream of
4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Agendula a [`SYNC.md`](SYNC.md) open question 3: if External mode is retired once we sync
*replacement* for OpenTasks (one authority owner per device). Intended, but a ourselves, the question disappears with it.
conscious choice. 4. ~~**Posture B authority choice**~~ moot: Agendula publishes no provider and
5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists); holds no authority at all. The question was live while the store was a
recurrence-aware editing is out of scope for v1. 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 UX**~~ resolved: `autoMode()` picks the
default (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1) and Settings → Storage
→ Task store is the override it always assumed.
7. ~~**Sync protocol coverage**, account model, conflict resolution — the next
design discussion.~~ Taken up in [`SYNC.md`](SYNC.md); the remaining opens
live on that document's list.
--- ---
@@ -157,7 +287,12 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through
- Build: `./gradlew :app:assembleDebug` - Build: `./gradlew :app:assembleDebug`
- Unit tests: `./gradlew :app:testDebugUnitTest` - Unit tests: `./gradlew :app:testDebugUnitTest`
- Run on a device/emulator that has **OpenTasks** or **tasks.org** installed (and - Instrumented tests: `./gradlew :app:connectedDebugAndroidTest` — the Room
ideally DAVx5 syncing a CalDAV task list) so the read/write paths have real schema, `RoomTasksDataSource` and `OneShotImport` (the last against
data. Debug builds use `DemoSeeder` for sample data when no provider data is `app/src/androidTest/assets/tasks-v23.db`, regenerated by
present. `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.

View File

@@ -1,12 +1,26 @@
# Agendula — storage and sync # Agendula — storage and sync
> ⚠️ **Partly superseded, 2026-08-13.** The core decision below — *vendor the
> dmfs provider in-tree as `:provider`* — has been **reversed**. Agendula builds
> its own Room store and deletes the vendored provider; External mode (OpenTasks,
> tasks.org) is unaffected and everything this document says about it still
> stands. See [`STORAGE-DECISION.md`](STORAGE-DECISION.md) for why and
> [`OWN-STORE.md`](OWN-STORE.md) for what replaces it. The permissions,
> distribution, storage-mode and dead-end sections below remain accurate; treat
> the "our own provider" sections as the historical record of a decision that was
> made, shipped, and then costed properly.
> Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B = > Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B =
> bundle OpenTasks" working notes, which are withdrawn (see > bundle OpenTasks" working notes, which are withdrawn (see
> [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to > [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to
> `ARCHITECTURE.md` §7 and the `ProviderResolver` comments, **and it redefines > `ARCHITECTURE.md` §7 and the `ProviderResolver` comments, **and it redefines
> what Posture B means** — those two need a follow-up edit. > what Posture B means.**
> `ROADMAP.md` / `PLAN.md` remain known-stale and are due a deliberate pass; >
> this document does not attempt it. > **Status, 2026-08-02: steps 13 are done** — see
> [Sequencing](#sequencing). `ARCHITECTURE.md` and `ROADMAP.md` have had their
> follow-up pass and now match what shipped; `PLAN.md` is the original design
> document and is left as the historical record. What is built, and what is still
> only described here, is marked step by step below.
## The plan, in short ## The plan, in short
@@ -24,13 +38,13 @@
**In what order** **In what order**
| # | Step | Why now | | # | Step | Why now | Status |
|---|---|---| |---|---|---|---|
| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | | 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | ✅ done |
| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | | 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done |
| 3 | Export / backup | our data now lives only in our app's private storage | | 3 | Export / backup | our data now lives only in our app's private storage | ✅ done, UI included |
| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | | 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ |
| 5 | Sync adapter | the 1.x arc; design discussion pending | | 5 | Sync adapter | the 1.x arc; designed in [`SYNC.md`](SYNC.md), not yet built | ⬜ |
Everything below is the reasoning behind those choices, the alternatives that Everything below is the reasoning behind those choices, the alternatives that
were rejected, and the constraints they have to survive. were rejected, and the constraints they have to survive.
@@ -64,8 +78,8 @@ Plus a standing rule: **anything that isn't task-domain goes to floret-kit.**
### The vocabulary, redefined ### The vocabulary, redefined
`ARCHITECTURE.md` §7 and `ProviderResolver`'s KDoc still describe Posture B as `ARCHITECTURE.md` §7 and `ProviderResolver`'s KDoc used to describe Posture B as
"bundle OpenTasks and find `org.dmfs.tasks` first." Replace with: "bundle OpenTasks and find `org.dmfs.tasks` first." Both now read as below:
- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org). - **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org).
Still fully supported; it stops being the default and becomes a **user Still fully supported; it stops being the default and becomes a **user
@@ -170,22 +184,45 @@ feature — see [Storage modes](#storage-modes--the-users-choice).
| **Synced** | our bundled provider | our sync adapter | network + an account the user configures | | **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 | | **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, Local and Synced are the same store — but ⚠️ **switching on sync *is* a
so switching on sync is not a migration. migration, contrary to what this document said until 2026-08-13.** The provider
enforces `ACCOUNT_NAME` and `ACCOUNT_TYPE` as **write-once** on a task list
(`processors/lists/Validating.java:68-76`, which throws), so a list created under
`org.dmfs.account.LOCAL` can never be re-pointed at a real account. Enabling sync
means creating new lists under the account and moving tasks into them. See
[`SYNC.md`](SYNC.md) — it is a costed deliverable there, not a free consequence.
**Resolver ordering needs deciding.** Today `ProviderResolver.CANDIDATES` is a **Resolver ordering decided, and it went both ways as expected.**
fixed priority list and the first hit wins. Once we bundle our own provider, `ProviderResolver` now takes an explicit `StorageMode` from Settings when there
"first hit" is the wrong rule: someone who used Agendula locally and *later* is one, and otherwise calls `autoMode()`. The auto rule turned out to be sharper
installs DAVx5 + OpenTasks would see an external candidate outrank the provider than "rank ours first whenever it's non-empty", and needs no database probe:
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 > **External if we already hold an external provider's runtime permission,
net for "uninstall OpenTasks" — that scenario no longer exists. It's data > otherwise Local.**
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 permission is dangerous-level, so it can only be there because an earlier
that's the majority. version asked and the user agreed — which is exactly what "existing Posture A
user" means. A fresh install holds nothing and gets local-first. ✅ The Settings
override the rule assumes is built: Storage → Task store, which asks for the
external provider's permission before committing the switch rather than after.
**Note on the mode vocabulary.** The code has two modes, not three:
`StorageMode.LOCAL` and `StorageMode.EXTERNAL`. As this document says two
paragraphs up, Synced *is* Local with an account attached — so it is derived
state, and giving it its own constant would imply switching sync on is a
migration when the whole point is that it isn't.
**Export/backup is a v1 feature.** ✅ Built, screen included. 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.
One `.ics` per list — a list is a CalDAV collection, and that's the unit other
clients understand — written through SAF to either a folder or a single zip.
Local tasks have no `_uid` (only a sync adapter may assign one), so the writer
synthesises a stable UID per task; without it a re-imported backup would
duplicate every task instead of matching it.
--- ---
@@ -214,10 +251,17 @@ 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 for them. Say that explicitly. "Here's a change that can't break anything" lands
very differently from "please support my app." very differently from "please support my app."
**Open — the next discussion.** Protocol coverage ("support as much as **The design is now written out in [`SYNC.md`](SYNC.md)** — protocol coverage,
possible"), the account model, conflict resolution, and where the DAV/iCalendar the account model, conflict resolution, the VTODO ↔ `TaskContract` mapper, and
work lives. One constraint to settle early: we're MIT; `dav4jvm` is Apache-2.0 where the DAV/iCalendar work lives. Two corrections to what this section
and fine, but **verify `ical4android`'s license** before assuming it's usable. originally said, both verified 2026-08-13:
- `dav4jvm` is **MPL-2.0**, not Apache-2.0. Still fine against our MIT (file-level
copyleft), but it is ⚠️ **JitPack-only**, which collides with our
`FAIL_ON_PROJECT_REPOS` + `google()`/`mavenCentral()` policy and with the
JitPack dead end below. `SYNC.md` open question 1.
- `ical4android` is **superseded by `synctools`, which is GPLv3** — so it is
unusable, and the open question below is closed. We write the mapper in-house.
--- ---
@@ -241,10 +285,11 @@ mechanisms, and conflating them is how apps end up over-permissioned:
| `INTERNET` | **don't declare it until sync ships** | | `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 | | `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` / ~~**Work item:** the permission gate … needs a bypass for our own provider.~~
`ProviderResolver.hasPermission` currently assumes an external provider always ✅ Done. `ProviderResolver.hasPermission` short-circuits to `true` when
needs a grant. It needs a bypass for our own provider. Modest, but it's the `TaskProvider.isOwn`, so `ProviderStatus.NEEDS_PERMISSION` cannot fire in
exact flow `fix/provider-interaction-review` just touched — merge that first. Local/Synced mode, and `PermissionViewModel` never offers our own permissions to
the request launcher. Covered by `ProviderResolverTest`.
--- ---
@@ -259,7 +304,7 @@ task-domain, it goes to the kit.
| 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. | | 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 | | 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 | | 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 | | Export/backup plumbing — SAF, file writing, share-out | kit | the *serialization* of tasks is domain; the plumbing isn't. **Built app-local for now** (`data/export/ExportWriter`), on the kit's own principle of not extracting before a second consumer exists — the seam is in place, so moving it is a file move. `ICalendarWriter` stays app-local permanently: it's domain. |
| Sync-adapter/account scaffolding | kit, probably | the `AbstractThreadedSyncAdapter` + authenticator boilerplate is identical everywhere; the delta logic is domain | | 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 **Stays app-local:** the vendored `:provider` module (task-specific, and
@@ -314,6 +359,13 @@ Not on either yet; both are targets, so build *for* them rather than retrofittin
throwaway spike* (a library string resource can be overridden from the app throwaway spike* (a library string resource can be overridden from the app
module, so the authority rename works), but not for anything we ship. module, so the authority rename works), but not for anything we ship.
⚠️ **This one comes back.** It is a dead end *for the provider*, where
vendoring was mandatory anyway. `dav4jvm` is JitPack-only, so the sync adapter
has to answer the same question on its own terms — and F-Droid turns out not to
be the obstacle (its inclusion policy trusts jitpack.io for freely-licensed
artifacts); our own trust-surface policy is. See [`SYNC.md`](SYNC.md) open
question 1.
--- ---
## Sequencing ## Sequencing
@@ -336,15 +388,39 @@ the roadmap should say so rather than inheriting the old estimate.
## Open questions ## Open questions
1. **Sync protocol coverage**, account model, conflict resolution — the next 1. **Sync protocol coverage**, account model, conflict resolution — ✅ taken up in
discussion. [`SYNC.md`](SYNC.md). The account model is answered there (`AccountManager`,
2. **Resolver ordering / mode selection UX** once our provider coexists with which `PROVENANCE.md` change 1 already assumes); what stays open moves to that
external ones (see [Storage modes](#storage-modes--the-users-choice)). document's own list — dav4jvm's distribution, conflict policy, External mode's
3. **Does the vendored provider work with no account at all?** Local-only mode future, and recurring-task completion.
depends on it entirely. First thing the vendoring work should prove. 2. **Resolver ordering** — ✅ decided, see
4. **`ical4android` licensing** vs our MIT. [Storage modes](#storage-modes--the-users-choice). The **mode-selection UX**
is still open: `autoMode()` picks a default, but the Settings override it
assumes does not exist yet.
3. **Does the vendored provider work with no account at all?** ✅ Answered, with
a caveat about *how* it was answered.
By construction: `cleanUpLists` exempts local lists explicitly (upstream's own
rule), and our rework restricts pruning to account types this package
authenticates — currently none — so nothing can be pruned at all.
`ProviderAccountCleanupTest` creates a local list and a task in it with zero
accounts present and reads both back.
⚠️ **But that test is Robolectric, and it skips on ARM64**, where Robolectric
has no SQLite backend in either mode. It runs on x86_64 CI. It is not a
substitute for a device, and this remains on the device-verification list.
4. ~~**`ical4android` licensing** vs our MIT.~~ ✅ Closed: `ical4android` is
superseded by `synctools`, which is **GPLv3**, so it is out — as is
`cert4android`. The export path never depended on it anyway (`ICalendarWriter`
is our own ~200 lines). The sync adapter's iCalendar parsing goes through an
in-house mapper over `ical4j`/`biweekly`; see [`SYNC.md`](SYNC.md).
5. **jtx Board** as an additional External-mode candidate — richer contract, 5. **jtx Board** as an additional External-mode candidate — richer contract,
later. (`PLAN.md` decision #3, still open.) later. (`PLAN.md` decision #3, still open.)
6. **The vendored provider's timezone-change behaviour** — upstream's receiver
has a comment describing `break`s that were never written. We preserved the
observed behaviour and wrote it out explicitly; which of code or comment is the
bug wants a device to settle. Change 3 in
[`provider/PROVENANCE.md`](../provider/PROVENANCE.md).
--- ---

284
docs/STORAGE-DECISION.md Normal file
View File

@@ -0,0 +1,284 @@
# Storage: keep the vendored provider, or build our own?
**Status:** **decided — build our own.** See [`OWN-STORE.md`](OWN-STORE.md) for
the architecture and plan; this document is the reasoning that got there.
The decision went further than the recommendation below: `:provider` is not kept
alongside a Room store, it is **deleted**. External mode (OpenTasks, tasks.org)
stays. The staged sequencing survives in a different form — the provider remains
in-tree until `OWN-STORE.md` phase 5 so recurrence parity can be tested against
it, then goes.
This reopens a question `SYNC.md` marked settled. It is reopened on purpose: the
argument that settled it was *"the provider hands us the sync bookkeeping for
free"*, and the phase-1 audit found most of that bookkeeping broken, absent, or
unusable for our purposes. A conclusion is only as good as its premise.
---
## The three options
| | What it means | Store | Exported provider |
|---|---|---|---|
| **A** | Keep `:provider` as-is | dmfs `TaskProvider` | yes, ours today |
| **B** | Room, same `TaskContract` shape | our Room DB | dropped, or a later facade |
| **C** | Room, clean domain schema, `TaskContract` only as an export format | our Room DB | no |
External mode (talking to OpenTasks / tasks.org) is orthogonal and survives all
three. It is the reason nothing below gets deleted.
---
## What the swap actually touches — measured, not estimated
### The seam is already there, and it is clean
`TasksDataSource` (`data/tasks/TasksDataSource.kt`, 58 lines) is a **14-method,
domain-shaped interface**. It takes and returns `Task`, `TaskList`, `TaskForm`
no `Cursor`, no `Uri`, no `ContentValues`.
Above it, **17 files** import from `data.tasks`. What they import:
```
8 × TasksRepository 4 × TasksDataSource 3 × ProviderResolver
4 × recoveringFromProviderFailure 2 × ProviderStatus
1 each: StorageMode, StorageModeHolder, ProviderEnvironment, TaskQuery, …
```
**Exactly one file outside the data package touches `TasksContract` at all**
`domain/Models.kt`, and only for four status integers, one priority constant and
one account-type string. Ten lines. Nothing else above the data layer knows a
ContentProvider exists.
> The whole UI, all five milestones of it, is untouched by a storage swap.
> That is not luck — `AndroidTasksDataSource`'s own KDoc says the seam exists so
> that *"swapping the provider never reaches above this file."* It holds.
### Nothing gets deleted
| File | Lines | Under a Room store |
|---|---|---|
| `AndroidTasksDataSource.kt` | 220 | **kept** — External mode still needs it |
| `TasksContract.kt` | 178 | **kept** — External mode speaks it |
| `TasksRepositoryImpl.kt` | 151 | unchanged |
| `ProviderResolver.kt` | 143 | unchanged |
| `TaskWriteMapper.kt` | 118 | **kept** for External |
| `TaskMapper.kt` | 102 | **kept** for External |
| `TasksDataSource.kt` | 58 | unchanged — it is the interface |
| `TasksRepository.kt` | 53 | unchanged |
| `ProviderEnvironment.kt` | 51 | unchanged |
| `StorageModeHolder.kt` | 47 | unchanged |
| `ColumnReader.kt` | 40 | **kept** for External |
| `ProviderFlow.kt` | 31 | unchanged |
| `StorageMode.kt` | 30 | one new constant |
| `TaskProjections.kt` | 24 | **kept** for External |
| `Failures.kt` | 16 | unchanged |
| | **1,262** | **0 removed** |
The work is **additive**: a second `TasksDataSource` implementation, a third
`StorageMode`, and one `@Binds` becoming a dispatcher. `DataModule.kt` has a
single binding to change.
This reframes the question. It is not *rewrite vs. keep*. It is **write a second
backend behind an interface that exists for exactly this purpose, and run both
until one wins.**
---
## What the new backend has to do
Room entities and DAOs for the ~50 columns the app actually uses across four
tables are mechanical. The real work is the behaviour the provider's processors
perform. Measured against `:provider`'s Java:
| Behaviour | Provider | Notes |
|---|---:|---|
| Instance expansion | ~1,070 | `Instantiating` + `instancedata` + iterables. **The hard one.** |
| Recurring-instance edit | 337 | `Detaching` — this *is* the recurrence-model decision |
| Completion coherence | 210 | `AutoCompleting`: status ↔ percent ↔ completed ↔ is_closed |
| Validation | 601 | three processors, mostly defending a *public* API |
| Parent / child | 269 | we use `parent_id` only |
| Alarm property rows | 133 | one Room entity |
| | **~2,620** | |
And what we would **not** write, of the 14,555 vendored lines:
| Not needed | Lines | Why |
|---|---:|---|
| `TaskDatabaseHelper` | 895 | 23 migrations from a 2013 schema. We start at v1. |
| `FTSDatabaseHelper` + ngrams | 798 | **the app never searches the provider** — verified, zero call sites |
| `model/adapters` | 1,581 | a type-safe layer over `ContentValues`. Room entities delete the problem. |
| `model` | 1,811 | cursor ↔ entity adaptation. Room's job. |
| `TaskProvider` + `SQLiteContentProvider` | 1,772 | URI matching, permissions, batch ops — for a public API |
| `CategoryHandler` + `RelationHandler` | 553 | unused |
| `utils` (most) | ~800 | dmfs jems idiom → Kotlin stdlib |
| **≈ 8,200 lines we would simply not have** | | |
Two things make instance expansion less frightening than its line count:
1. **We need client-side recurrence expansion regardless.** Server-side
`CALDAV:expand` on `VTODO` is broken on every server we target (`SYNC.md`),
so `lib-recur` is in the build either way.
2. **We would use the same eight `lib-recur` classes the provider does**
`RecurrenceRule`, `RecurrenceSet`, `RecurrenceSetIterator`, `RecurrenceList`,
`RecurrenceRuleAdapter`, `DateTime`, `Duration`,
`InvalidRecurrenceRuleException`. The algorithm is in the library, not in the
provider.
3. And the provider's expansion **materialises only one upcoming occurrence
anyway** — it is not the complete implementation its size suggests.
---
## The cost, both directions
### Building it
| | |
|---|---:|
| Schema, entities, DAOs | 1 wk |
| Instance expansion on `lib-recur`, with a real test suite | 1.52 wk |
| Completion / parent / validation semantics | 1 wk |
| Recurring-edit model — *shared cost, phase 1 either way* | (0.51 wk) |
| Migrating existing users' local data out of the provider | 0.5 wk |
| Tests to parity with the current 93 + 56 | 1 wk |
| **Net additional** | **4.56 wk** |
> ⚠️ Superseded by [`OWN-STORE.md`](OWN-STORE.md)'s **6.57 wk**. The figure
> above costed the new store only, on the assumption `:provider` would be kept
> beside it. The decision taken was to delete the provider, which adds the
> untangling (phase 0) and the removal (phase 5) that this estimate never had to
> include. The costing logic stands; the total does not.
### What it removes from the sync plan
Roughly sixteen of the phase-1 audit's storage findings are **provider-imposed**
— they exist only because we run dmfs's implementation, and vanish when we own
the store:
- `_DIRTY` not set on delete, and defaulting to `1`
- `TaskLists._DIRTY` as a monotonic counter, not a flag
- the instances URI ignoring `CALLER_IS_SYNCADAPTER`
- no home for a per-collection sync token, href, ETag or CTag — all four squat
into generic `SYNC1``SYNC8` slots
- **read-only collections cannot be represented at all** (`ACCESS_LEVEL` inert)
- sync-adapter delete ignoring the account parameters it forces you to supply
- `Moving` leaving a dual-UID collision
- `ACCOUNT_TYPE` write-once → enabling sync is a full data migration
- Auto Backup restore arming `cleanUpLists` → silent task loss
- `Detaching` deciding the recurring-completion model for us
- the `lib-recur` version trap (0.16.0 removed `RecurrenceSet`) — we pin because
the provider does, not because we want to
Conservatively that is **2.54 weeks** off phases 0, 3 and 4 of the 11.515 week
sync plan, plus a class of bug that is currently *unfixable without patching
vendored Java*.
### Net
**≈ +1 to +3.5 weeks**, for a store we control, in exchange for two real losses.
---
## The honest case for keeping it (Option A)
Not nothing, and it should not be waved away:
- **It works, and it has 56 passing JVM tests** over recurrence, reparenting,
instances and observers. A Room reimplementation is *new code with new bugs*,
in the layer that holds the user's only copy of their data. That risk is real
and it points at A.
- **Tombstones actually work.** Soft delete for account rows, hard delete for
sync adapters, hidden from normal queries, undelete refused. Of all the sync
bookkeeping, this is the piece that held up under audit.
- **The exported provider under our own authority** — third-party apps can read
Agendula's tasks, and asking DAVx5 to sync us stays possible.
- Eleven local modification sites, all marked `AGENDULA CHANGE`, all documented
in `provider/PROVENANCE.md`. The fork is under control today.
And the case against keeping it:
- **14,555 lines of Java — 1.66× the entire app** (8,783 lines of Kotlin). We
carry, build, lint, translate and ship all of it to use maybe a third.
- Upstream is effectively dormant; every future `targetSdk` bump and every
Android SQLite behaviour change lands on us, in someone else's code, in a
language the rest of the app does not use.
- It makes behavioural decisions on our behalf (`Detaching`, `AutoCompleting`)
that we then have to reverse-engineer before we can honour them over CalDAV.
---
## Recommendation
**Option B — build our own store on Room, keeping the `TaskContract` *shape* as
the internal model — and keep `:provider` in-tree while we do.**
Three reasons, in order of weight:
1. **The seam already exists and the work is additive.** Nothing is deleted,
nothing above `data/tasks` changes, and both backends can ship side by side
behind `StorageMode`. The "big rewrite" this decision was originally weighed
against does not exist.
2. **The premise that settled it is gone.** The provider was kept for sync
bookkeeping we have since measured as broken. Sixteen findings deep, keeping
it is now a *cost* to the sync plan, not a saving.
3. **The window is now.** After phase 1 the mapper and engine are written against
whichever store won, and this stops being a two-file change.
Keeping the `TaskContract` *shape* rather than going domain-native (Option C) is
deliberate: it is a proven schema for exactly this problem, other engines
understand it, and it keeps a future exported facade cheap — without obliging us
to run a 2015 Java implementation of it.
### Sequencing that keeps the risk low
1. Add `StorageMode.OWN` and a Room `TasksDataSource`. Both backends live.
2. Ship it behind a setting; the vendored provider stays the default.
3. Run the sync engine against Room only.
4. Once Room has real production mileage, decide whether the exported
ContentProvider is worth re-implementing as a thin facade (~11.5 wk) or
whether External mode already covers everyone who wanted it.
Step 4 is a genuinely open question and does not need answering now. That is the
point of sequencing it last.
---
## Open
- **Is an exported provider worth keeping at all?** It matters only if third
parties should read our tasks, or if we want DAVx5 to sync our store. External
mode arguably already serves the second. Undecided.
- **The Room estimate is mine, not measured.** Instance expansion is the item
that could overrun; everything else is well-bounded.
---
## Postscript: the fork existed, and how it ended
`provider/PROVENANCE.md` recorded the vendored dmfs task provider in detail.
Both are gone; this is what is worth keeping.
The module was `opentasks-provider` plus `opentasks-contract` from
[dmfs/opentasks](https://github.com/dmfs/opentasks) **1.4.2**, commit
`49ebf80b1eeee52a611e5a22f24f849852a6255f` (2021-03-21), Apache-2.0, database
version 23. It was vendored in-tree rather than pulled as an artifact because the
permission names are hardcoded in the upstream AAR's manifest, and shipping under
dmfs's own names would have made Agendula and OpenTasks mutually uninstallable
(`INSTALL_FAILED_DUPLICATE_PERMISSION`). In-tree also satisfied F-Droid's
from-source requirement.
It was deleted in the `feat/own-store` work (`docs/OWN-STORE.md` phase 5) once
Room was the default and every v0.3.x install had been imported. 14,555 lines of
Java left with it.
**What survives, and why.** `lib-recur` (Apache-2.0, dmfs) is still a direct
dependency — it is what expands recurrences — so dmfs's attribution is still
owed, now through an ordinary third-party dependency rather than vendored source.
`TasksContract.kt` and the External-mode mappers also stay: they describe
*somebody else's* schema, which is exactly what they were always right for.
**One detail the fork's provenance file carried that still binds us.** DB 23 is
the first to have `is_recurring`; tasks.org's fork is DB 22 and lacks it. That is
why `TaskMapper.task` derives recurrence from `rrule`/`rdate` rather than trusting
that column, and it must keep doing so for as long as External mode supports
tasks.org.

Some files were not shown because too many files have changed in this diff Show More