42 Commits
Author SHA1 Message Date
Jean-Luc Makiola 671910f54b Merge pull request 'Translations update from Weblate' (#21) from jlmakiola_weblate_bot/agendula:weblate-agendula-agendula into main
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 14m26s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 7s
2026-09-24 21:56:30 +02:00
Weblate d986d5cdda Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/
2026-09-24 19:52:13 +00:00
Jean-Luc Makiola 00a1e9da15 Translations update from Weblate (#19)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Failing after 6m53s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/19
2026-09-24 16:37:36 +02:00
Jean-Luc Makiolaandmakiolaj ac01993d41 Release 1.0.0 (#20)
First stable release. Merging this bumps versionName to 1.0.0 and triggers the release pipeline (F-Droid, Codeberg, Play).

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

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

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

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

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/20
2026-09-24 16:36:49 +02:00
Louis Leblanc 88f7a16bff Translated using Weblate (French)
Currently translated at 99.4% (181 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/fr/
2026-09-24 05:37:11 +00:00
Weblate ffdcf1de19 Translated using Weblate (French)
Currently translated at 99.4% (181 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/fr/
2026-09-24 05:37:11 +00:00
Weblate 9e9fd106e9 Translated using Weblate (French)
Currently translated at 99.4% (181 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/fr/
2026-09-24 05:37:11 +00:00
Jean-Luc Makiola 7bbdbe60e3 Translations update from Weblate (#11)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/11
2026-09-23 21:34:19 +02:00
Louis Leblanc 2baaa2d516 Added translation using Weblate (French) 2026-09-23 05:03:06 +00:00
Sup99999 13433aeca5 Translated using Weblate (Arabic)
Currently translated at 23.6% (43 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-09-23 05:03:06 +00:00
Sup99999 1f67de18ae Translated using Weblate (Arabic)
Currently translated at 6.5% (12 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-09-23 05:03:06 +00:00
Sup99999 fccdbd825c Added translation using Weblate (Arabic) 2026-09-23 05:03:06 +00:00
Jean-Luc Makiola 44489c5665 privacy: point the controller contact at support@ (#13)
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 30s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Skipped
2026-09-15 21:00:52 +02:00
Jean-Luc Makiolaandmakiolaj 633ec5b5d3 Move the privacy policy into the repo (#12)
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Skipped
The policy had no copy in this repository — it existed only inside the Astro page on jeanlucmakiola.de. This file becomes the single copy: the website build checks this repo out beside itself and renders `docs/PRIVACY.md` through a content collection, so the published page and the app's own documentation cannot drift. Same arrangement as calendula#293.

Shaped as the content entry the site expects: `title` / `description` / `updated` frontmatter, an HTML maintainer note that cannot render, and a body starting below the `h1` the page supplies.

The text is the published page carried over in full — controller and postal address, the two storage modes, CalDAV sync (what is stored, what is transmitted, RFC 6764 discovery, Nextcloud Login Flow v2, the user-CA trade-off), reminders and export, backups, crash reports, external links, permissions, distribution channels, deletion paths and GDPR rights.

Two things differ from the older short version that lived on `feat/caldav-sync`:

- Contact is `business@jeanlucmakiola.de`, matching the site and Calendula's policy, rather than `mail@`.
- Cleartext HTTP is described as refused outright. `CalDavDiscovery.allowCleartext` is `false` with nothing wiring it true, and `network_security_config.xml` sets `cleartextTrafficPermitted="false"`, so the previous "unless you explicitly opt in for a specific account" described a feature that does not exist. It goes back if a per-account opt-in ships.

Split out of `feat/caldav-sync` so the website change is not waiting on the whole sync branch. No issue to close — there is no open privacy/policy issue to reference.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/12
2026-09-09 16:52:17 +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
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m30s
Renovate / renovate (push) Failing after 40s
2026-08-31 19:02:24 +02:00
makiolajandClaude Opus 5 dfb0a694de release: cut 0.4.0 — the first translated release
Ships the first community translations: German (98%) and Brazilian
Portuguese (100%), both from Weblate, plus the translation infrastructure
and the Codeberg forge move that had accumulated under Unreleased.

A minor bump rather than a patch: two new user-facing languages is a
feature, the way 0.3.0 was cut for Reminders.

versionCode is the derived 400 (MAJOR*10000 + MINOR*100 + PATCH), and
scripts/sync_changelog_to_fastlane.sh generated changelogs/400.txt so the
official F-Droid listing shows this version's "What's New". At 908 chars
it exceeds the script's 500-char advisory and may be truncated in-client
— consistent with past feature releases (0.2.0 ran 1811, 0.3.0 692).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146mhMLZ5AqJSZkADDc92Jz
2026-08-31 18:27:25 +02:00
makiolajandClaude Opus 5 79970b5c65 l10n: make German and Brazilian Portuguese selectable
The Weblate merge (#8) landed values-de and values-pt-rBR, but
locales_config.xml still listed only `en`. That file is the single source
of truth for which languages the app ships: AppLanguage parses it for the
in-app picker (Settings -> App language) and android:localeConfig points
the system per-app language settings at it. So both translations were
compiled into the APK and reachable by nothing — you could not pick either
one, and only a device already set to de/pt-BR would ever have seen them.

Adds the two BCP-47 tags. Note the tags are `de` and `pt-BR` while the
resource folders are values-de and values-pt-rBR; the `r` is an Android
resource-qualifier artefact and must not appear here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146mhMLZ5AqJSZkADDc92Jz
2026-08-31 18:27:14 +02:00
Jean-Luc Makiola 5b31067e43 Translations update from Weblate (#8)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/8
2026-08-31 18:19:53 +02:00
Bernardo Krzysczak c69ff049a0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (182 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/pt_BR/
2026-08-21 07:41:09 +00:00
Bernardo Krzysczak 81220d53d1 Added translation using Weblate (Portuguese (Brazil)) 2026-08-20 13:18:29 +00:00
makiolaj b3091a5aa3 Translated using Weblate (German)
Currently translated at 98.9% (180 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-18 01:04:09 +00:00
Weblate b1bde2fab5 Translated using Weblate (German)
Currently translated at 98.9% (180 of 182 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-18 01:04:09 +00:00
makiolaj abc381ca24 Translated using Weblate (German)
Currently translated at 98.8% (177 of 179 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-14 13:22:24 +00:00
Weblate 1fbd91fe82 Translated using Weblate (German)
Currently translated at 98.8% (177 of 179 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-08-14 13:22:24 +00:00
makiolaj c2087de639 Added translation using Weblate (German) 2026-08-14 13:22:24 +00:00
Jean-Luc Makiola c47653c9cb Merge pull request 'Use floret-kit's shared snack chip' (#6) from feat/shared-snack-chip into main
Renovate / renovate (push) Failing after 43s
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-08-02 20:46:11 +02:00
makiolaj e639e250b7 Use floret-kit's shared snack chip
The undo chip moves into the kit so Calendula's reschedule confirmation is
the same component. Re-pins the kit onto the branch that carries it.
2026-08-02 20:45:39 +02:00
Jean-Luc Makiola 58a50512bf docs: settle the storage and sync direction (#5)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/5
2026-08-02 17:58:13 +02:00
Jean-Luc Makiola 1add1fcadb chore: re-pin floret-kit and pass allDay at the date call sites (#4)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/4
2026-08-02 17:57:58 +02:00
Jean-Luc Makiolaandmakiolaj 217d5d7afd Move the canonical forge to Codeberg, and add Renovate + Weblate (#2)
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Renovate / renovate (push) Failing after 43s
Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline. Ports the setup Calendula already runs on, adapted where Agendula genuinely differs.

**This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home.

### 1 · Workflows split by directory

Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync:

| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them |

The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API.

CI also gains three fixes: an explicit `SKIP_RE` skip-list that names the build-relevant files in the log, base-ref normalisation, and a fully-qualified `android-actions/setup-android` — Codeberg resolves bare `uses:` refs against `data.forgejo.org`, which does not carry that action.

### 2 · Three release-pipeline safety changes

- `detect` and the Renovate job get an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running these on the contributor-facing runner, without secrets.
- `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not.
- **The Codeberg publish step pushes the tag itself** instead of waiting for it to arrive by mirror. That wait was correct while Gitea mirrored *to* Codeberg; under Codeberg-canonical the mirror runs the other way and it would never resolve. Attaching the release to an already-pushed ref (no `target_commitish`) is what avoids the empty-bodied 500s, and the create call retries with backoff because Codeberg 500s on a tag it has only just received. The step stays **fail-loud**, not `continue-on-error` — it reported green through 0.2.1–0.3.2 while never once publishing, and that must not be possible again.

### 3 · Renovate

`renovate.json5` plus a Gitea-side job targeting Codeberg's API. `managerFilePatterns` covers **both** workflow directories, so the pinned Renovate image tag and the action versions in either file keep getting bumped.

Needs two new Gitea secrets: `RENOVATE_TOKEN` (Codeberg bot, repo read/write + PR scope) and `GITHUB_COM_TOKEN` (read-only github.com PAT, for changelog lookups).

### 4 · Weblate

A parity check (`scripts/check_translations.py`) runs on every PR without a path filter, so the required `Translations` status is always reported. Partial translations are expected, so `MissingTranslation` and `MissingQuantity` become informational — `ExtraTranslation` stays fatal. Agendula had no `lint` block at all, so the first locale to land would otherwise have failed the build.

**Settings → App language** now opens a picker carrying a "Help translate" header. That is why it drops floret-kit's `LanguagePickerRow` for a local row: the shared recipe has no `header` slot, and the framing is app-specific rather than a family primitive.

### 5 · Links repointed

In-app Source / License / report-issue URLs, F-Droid metadata, README (now with a Codeberg CI badge), and the docs.

`floret-kit` follows suit — `.gitmodules` points at `codeberg.org/jlmakiola/floret-kit`, so a clone no longer needs to reach the personal Gitea instance to resolve it. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases still resolve.

### 6 · Housekeeping

Drops `release-notes.md` — a release-pipeline scratch file that got committed — and gitignores the five others the release job writes into the workspace.

### Not in this PR

The Codeberg → Gitea push mirror, the Weblate component, and the Codeberg bot account (all browser-side). Until the mirror is flipped, merging this does **not** reach the Gitea runner.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/2
2026-08-01 16:34:20 +02:00
makiolajandClaude Opus 4.8 26628dc0bb chore: drop stray Codeberg release scratch files
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
cb-payload.json and cb-response.json are generated at runtime by the
release workflow; they were committed by accident from a local run and
still held stale v0.3.1 data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:30:13 +02:00
makiolaj d2e3832ef2 Merge branch 'chore/floret-kit-multi-reminders' 2026-07-20 21:04:05 +02:00
makiolaj 93857135b3 Merge branch 'home-today-ring-upcoming' 2026-07-20 21:04:02 +02:00
makiolajandClaude Opus 4.8 36beb2d0ad ci(release): wait for the mirrored tag, never mint it
The Codeberg publish has never once succeeded — 0.2.1, 0.2.2, 0.3.0, 0.3.1
and 0.3.2 all failed, and 0.3.0 was published by hand. Neither previous fix
could have worked, because the cause isn't in this file: ref writes to
jlmakiola/agendula on Codeberg fail. A tag push returns "cannot lock
references" and POST /tags returns an empty-bodied 500, while the identical
calls succeed on jlmakiola/calendula with the same token. Creating a release
mints a tag, so it inherited the same failure.

Attaching a release to a tag that is ALREADY there needs no ref write and
returns 201. So split the responsibility the way it should have been: the
push mirror owns delivering the tag, this step only attaches to it. Poll for
the tag, then POST with no target_commitish so the API attaches rather than
resolves a commit and mints one. If the tag never lands, fail with a pointer
at the mirror instead of trying to create it.

Drop continue-on-error. It reported green across five releases that never
published, which is how 0.3.1's crash fix reached F-Droid while the Codeberg
and Obtainium users who hit the crash got nothing.

Tested against the live API before committing (unlike its predecessors):
tag-present attaches 201, tag-absent exits 1 with the diagnostic. Note this
does not by itself restore publishing — the mirror cannot write the v0.3.2
tag either, so Codeberg must repair the repo's ref store first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:50:57 +02:00
makiolajandClaude Opus 4.8 976d496d21 reminders: adopt floret-kit multi-value override model
Bumps the floret-kit submodule to the multi-value core-reminders API
(ReminderOverride.Minutes now a List<Int>) and adapts the per-list override
plumbing + pickers to it. Agendula stays single-reminder: overrides are
one-element (or empty) lists, reminderLeadFor takes firstOrNull, and the
single-select picker wraps/unwraps listOf. On-disk format is unchanged (single
values round-trip byte-identically). Verified: :app:compileDebugKotlin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:47:50 +02:00
makiolajandClaude Opus 4.8 245f1db536 ui: move the action press flourish onto the shape, spin the gear
The press animation turned the glyph; put it on the scalloped cookie
container instead and make it stronger (scale 1→0.82, rotate 0→40°). The
glyph holds upright via a counter-rotation, so the shape spins while a
magnifier or list icon stays readable. New spinIcon opts a glyph into its own
quarter turn — the settings gear uses it, so it reads as a gear cranking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:01:07 +02:00
makiolajandClaude Opus 4.8 623e533547 home: unfurl search from a fixed search icon, drop the title
Rework the top bar into a custom row with no app title (the launcher icon
already names the app). Settings stays pinned at the right and the search
action sits just left of it; neither moves. Tapping search unfurls a pill
leftward from the magnifier (expandHorizontally anchored at the end) holding
the auto-focused query field and a clear button, with the search icon
remaining as the bar's fixed trailing icon. Results render over the home
content as you type; a blank query leaves the home screen visible. Tapping
the icon again or system back closes search; the FAB hides while searching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:00:42 +02:00
makiolajandClaude Opus 4.8 2e50356f81 icon: centre the launcher mark on the canvas
The first pass shifted the mark off-centre. Scale 0.66 about the canvas
centre, and centre the task CARD (not the card+bloom bounding box, which the
overhanging bloom badge drags low): pivot the Y-scale at the card's centre
(y=242.76) and translate +13.24 so the card sits dead-centre, horizontally
and vertically, with the bloom badging out to the lower-right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:35:03 +02:00
makiolajandClaude Opus 4.8 e6f503c02a home: move search behind a top-bar action, not an always-on bar
The inline search bar was permanently visible. Replace it with a search
action button (a 6-sided cookie shape, sibling to the settings cookie) to
the left of settings: the search bar is absent until tapped, then opens
expanded and auto-focused, covering the home content with live results.
Back arrow or system back closes it; the FAB hides only while searching.
Pass windowInsets = 0 so the bar, already below the app bar, does not
re-apply the status-bar inset and float with a large top gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:34:54 +02:00
makiolajandClaude Opus 4.8 c53511196d icon: real launcher mark — task card, check, calendula bloom
Replace the placeholder check-mark foreground with the finished agendula
mark (converted from design/icon/agendula_icon.svg): a rounded line-art task
card with a check, plus a small Calendula bloom badge in the open
bottom-right corner — the sibling of Calendula's calendar mark.

Strokes render in Calendula's off-white (#FAF6F0) over agendula's existing
plum background (#7A5C6B, the hue-rotated counterpart of Calendula's slate),
so the two apps read as a family while staying distinct. Scaled 0.66 to
match Calendula's footprint and ~2.8dp stroke weight; reused as the
<monochrome> slot for themed icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:07:25 +02:00
makiolajandClaude Opus 4.8 a8595e26b4 home: inline expanding search over all tasks
Add a Material 3 SearchBar overlaying the top of the overview. Collapsed it
is a "Search tasks" bar below the title row; tapping expands it in place to
cover the home content with live results, filtering every task (open and
completed) by title, case-insensitive. The leading icon flips to a back
arrow while expanded, a clear button empties the query, and the FAB hides so
it does not float over the results. Results reuse the upcoming preview row.

ListsViewModel.Content now carries allTasks (open + completed) as the search
corpus; filtering stays in memory so the provider query is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:07:12 +02:00
makiolajandClaude Opus 4.8 411e27659f home: Today progress ring + live Upcoming preview
Rework the overview's 2x2 smart grid into a daily-momentum layout:

- Promote Today into a full-width hero with an M3 Expressive
  CircularWavyProgressIndicator over "x of y done" for tasks due today.
  Empty/all-done states read as a calm finished state, not a bare 0.
- Drop the white "Upcoming 0" tile (it shouted loudest while carrying the
  least) in favour of a live preview of the next few upcoming tasks, each a
  slim row with the quiet meta line and a tap-through to the task.
- Overdue + All fall back to a 2-up of the existing tonal tiles.

ListsViewModel now combines a Smart(COMPLETED) flow so the ring can count
the tasks already ticked off today (the open smart lists drop them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:47:02 +02:00
490 changed files with 55312 additions and 2663 deletions
+24
View File
@@ -0,0 +1,24 @@
# Kept enabled so anything that doesn't fit the four templates still has a way
# in.
blank_issues_enabled: true
contact_links:
- name: Translate Agendula
url: https://weblate.dev.jeanlucmakiola.de/engage/agendula/
about: >-
Translations are managed on Weblate, not here — it owns every values-*
file, so a hand-edited translation gets overwritten on the next sync.
No coding needed: pick or request a language and translate in the browser.
- name: Contributing guide
url: https://codeberg.org/jlmakiola/agendula/src/branch/main/CONTRIBUTING.md
about: >-
Before opening a pull request: how to build (there's a submodule), where
code goes, and the one architectural rule a change is reviewed against.
- name: Sync sources and scope
url: https://codeberg.org/jlmakiola/agendula/src/branch/main/README.md
about: >-
Agendula is a front-end over the OpenTasks provider, so it works with
DAVx5, SmoothSync, DecSync and friends. Google Tasks and Microsoft To Do
are out of scope by design — check here before requesting a backend.
+42
View File
@@ -0,0 +1,42 @@
<!--
Thanks for contributing to Agendula!
Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/agendula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong:
• The one architectural rule — provider column names, `TaskContract`,
`ContentResolver` and the authority string never leak above `data/tasks/`.
• Don't bump `versionName` / `versionCode`. That bump reaching `main` is what
cuts a release, so it belongs only in a release PR.
-->
### What this changes
### Why
<!-- Closes #123 — link the issue this implements or fixes. -->
### How it was tested
<!--
Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes, and for anything touching the provider
read/write paths (OpenTasks / tasks.org installed).
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
python3 scripts/check_translations.py
-->
### Checklist
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
- [ ] Provider details stay inside `data/tasks/`
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
- [ ] No `versionName` / `versionCode` bump
- [ ] No planning or design documents committed
@@ -37,14 +37,52 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs,
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
# Gradle work below but still report a green `ci`.
# Also cheap, also always-on. Two failures in one: the script exits
# non-zero if this version's changelog is over the 500-character limit,
# and the porcelain check below catches a version with no committed
# changelog, which would otherwise ship the CHANGELOG.md section instead
# of a hand-written summary.
- name: Changelog fits the stores, and is committed
run: |
set -e
bash scripts/sync_changelog_to_fastlane.sh
DIRTY=$(git status --porcelain fastlane/metadata/android/en-US/changelogs)
if [ -n "$DIRTY" ]; then
echo "$DIRTY"
echo "ERROR: no committed What's New for this version." >&2
echo "Write fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt" >&2
echo "(under 500 chars, every shipped locale) and commit it." >&2
exit 1
fi
# The fastlane tree feeds F-Droid and Play alike; Play rejects oversized
# text and off-spec graphics at upload time, so catch that on the PR.
- name: Store listing fits both stores
run: python3 scripts/check_store_listing.py
# Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`.
- name: Classify change scope
id: scope
env:
# Deliberately a skip-list, not a build-list: a path nobody thought
# about defaults to building. Only paths the Gradle build provably
# never reads belong here — note that the workflows themselves, the
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^Gemfile$|^design/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
run: |
set -e
BASE="${{ github.base_ref }}"
# Normally the bare branch name; tolerate a full ref, which would
# otherwise make the merge-base lookup fail and quietly degrade this
# guard into "always build".
BASE="${BASE#refs/heads/}"
if [ -z "$BASE" ]; then
echo "No base branch on this event — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Full (not --depth=1) base fetch so the merge-base is present even when
# the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts.
@@ -58,11 +96,15 @@ jobs:
fi
CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
if [ -n "$RELEVANT" ]; then
# Naming them makes "why did my docs PR build for four minutes?"
# answerable from the log alone.
echo "Build-relevant changes:"; echo "$RELEVANT"
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
echo "code=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Java
@@ -72,9 +114,14 @@ jobs:
distribution: 'zulu'
java-version: '17'
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
# setup-java and cache all exist there, but android-actions/setup-android
# does not, and the job dies with "repository not found". Gitea's instance
# defaults to GitHub, which is why this never surfaced before the split.
- name: Setup Android SDK
if: steps.scope.outputs.code == 'true'
uses: android-actions/setup-android@v3
uses: https://github.com/android-actions/setup-android@v3
with:
# Default ("tools platform-tools") drags in the Android Emulator
# (~300 MB) which the build never uses.
@@ -117,9 +164,12 @@ jobs:
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
# that task would compile the vendored suite and run none of it, which is
# the whole safety argument in dav/PROVENANCE.md.
- name: Unit tests
if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest
run: ./gradlew testDebugUnitTest :dav:test :caldav:test
- name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
+39
View File
@@ -0,0 +1,39 @@
name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
#
# Runs on every PR (no path filter) so the required "Translations / check"
# status is always reported — like the `ci` job. A path-filtered workflow is
# skipped on unrelated PRs and never posts its status, which leaves that
# required check pending forever and blocks the merge of any code-only PR into a
# release/* branch. The check itself is cheap and simply passes when the
# committed translations are consistent, so always running it costs nothing.
on:
pull_request:
concurrency:
group: translations-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure python3
run: |
if ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y python3
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache python3
fi
fi
python3 --version
- name: Check translation parity
run: python3 scripts/check_translations.py
+250 -55
View File
@@ -1,14 +1,25 @@
name: Release — F-Droid repo + Gitea/Codeberg release
name: Release — F-Droid repo + Gitea/Codeberg release + Play
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and publishes
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing.
#
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
# and separate because Play can reject a good build for reasons the pipeline
# can't see, and that must not endanger a release which already shipped to
# F-Droid and Codeberg. It skips cleanly until PLAY_SERVICE_ACCOUNT_JSON exists.
#
# This file lives in .gitea/workflows on purpose: Codeberg is canonical for git,
# issues, PRs and releases, but every secret (app key, F-Droid repo key, Hetzner
# credentials) lives on the self-hosted Gitea instance, and this is the only
# directory Codeberg cannot see. Contributor-triggerable work lives in
# .forgejo/workflows and references no secret. See docs/RELEASING.md.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
# without building an APK or creating a release. Used for key rotation / repo
@@ -27,6 +38,14 @@ jobs:
# whether this push actually cuts a new release (no tag for it yet). Keeps the
# heavy job from running on every merge to main.
detect:
# Gitea only. The workflow directory split already keeps this file invisible
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
# exists — but that only holds while .forgejo/ is non-empty. Move the last
# file out of it and Codeberg would fall back to .gitea/workflows and start
# running the release pipeline on the contributor-facing runner, with no
# secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj'
runs-on: docker
outputs:
is_release: ${{ steps.v.outputs.is_release }}
@@ -42,8 +61,16 @@ jobs:
- name: Resolve version and whether it is a new release
id: v
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
# Tags are read from Codeberg, which is canonical — deliberately NOT
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
# source does not have. A tag minted here on Gitea is therefore wiped
# by the next sync (Codeberg does not have it yet) and only reappears
# once the tag push at the end of this workflow propagates back.
# Asking Gitea inside that window would report "no tag" for a release
# that already shipped, and cut it a second time.
# Public repo, so this read needs no token.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
run: |
set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
@@ -65,15 +92,38 @@ jobs:
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
if [ "$STATUS" = "200" ]; then
echo "Tag v$VERSION already exists — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
else
echo "No tag for v$VERSION yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
fi
#
# Anything other than a clean 200/404 is treated as fatal rather than
# as "no tag". A Codeberg outage or a network blip would otherwise
# read as absent and re-cut a release that has already shipped —
# republishing to F-Droid. Failing here is recoverable; a duplicate
# release is not.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
exit 1
;;
esac
# Before a single Gradle task runs: F-Droid truncates the in-client
# changelog, so an over-long one would reach users cut off mid-sentence.
# The script exits non-zero past the limit. Cheap enough to sit in the
# gate job, where failing costs nothing and publishes nothing — the step
# further down that regenerates the file for the repo would otherwise be
# the first thing to notice, after the build and the signing.
- name: Changelog fits the stores
if: steps.v.outputs.is_release == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
# Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and
@@ -360,17 +410,21 @@ jobs:
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# Mirror the release to the Codeberg mirror as a direct-download channel
# for users who don't want F-Droid. Gitea already push-mirrors branches +
# tags to Codeberg, but releases aren't git objects so they don't sync —
# we create the release there over the API and attach the signed APK plus
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
# key), so this adds no trust surface. Best-effort: a Codeberg outage
# (it 504s under load) must never fail an already-published F-Droid
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
# Publish the release on Codeberg, which is canonical for tags and
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
# to Gitea, but releases aren't git objects and don't sync in either
# direction — so this step pushes the tag straight to Codeberg and creates
# the release there over the API, attaching the signed APK plus a SHA-256
# checksum as the direct-download channel for users who don't want
# F-Droid. The APK is identical to the F-Droid one (same app key), so this
# adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
# cleanly if unset.
- name: Publish release to Codeberg
if: env.IS_RELEASE == 'true'
continue-on-error: true
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
# 0.3.0, 0.3.1 and 0.3.2 while never once publishing, which is how a
# crash-fix release reached F-Droid but not the Codeberg/Obtainium
# users who needed it. A broken mirror must fail the release loudly.
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
@@ -399,29 +453,26 @@ jobs:
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# Forgejo 500s on POST /releases when the tag ALREADY exists, and a
# bare tag is not a release — so GET /releases/tags then 404s and the
# upsert has no id to fall back on ("Could not resolve Codeberg
# release id", the 0.3.1 failure). Pushing the tag first therefore
# guarantees the 500 rather than avoiding it; 0.3.0 published because
# it POSTed while the tag was still absent. So: POST with the tag
# ABSENT and let the API mint tag + release together from
# target_commitish. Branches mirror reliably, so the commit is already
# on Codeberg — only a tag the mirror raced in ahead of us is in the
# way, and clearing it is safe precisely because no release owns it.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then
curl -s -o /dev/null -w "codeberg tag DELETE HTTP %{http_code}\n" -X DELETE \
-H "Authorization: token $TOKEN" "$API/tags/$TAG"
fi
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > cb-payload.json
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
# from Gitea (what 0.3.2 did) would wait forever. The tag this
# pipeline minted on Gitea is in fact *deleted* by the next mirror
# sync until Codeberg has it — so pushing it here is what makes it
# durable on both forges.
#
# Pushing the ref first and attaching with NO target_commitish is
# deliberate: a release POST carrying a target_commitish for a commit
# or tag Codeberg hasn't received yet is what produced the
# empty-bodied 500s. Attaching to a ref that already exists doesn't
# need the API to write one.
git tag -f "$TAG" "$SHA"
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
"refs/tags/$TAG"
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
import json, sys
tag, sha, pre = sys.argv[1:4]
tag, pre = sys.argv[1:3]
print(json.dumps({
"tag_name": tag,
# Recreate the tag as part of the release; the commit is on
# Codeberg already via the branch mirror.
"target_commitish": sha,
"name": tag,
"body": open("release-notes.md").read(),
"draft": False,
@@ -429,20 +480,31 @@ jobs:
"prerelease": pre == "true",
}))
PY
# Upsert (re-run safe): a release that already exists is PATCHed in
# place — the delete above is skipped in that case, so re-running
# never disturbs a published release.
if [ -n "$ID" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
# Create (or update) the release. Codeberg 500s on a POST/GET against a
# tag it has only just received — the release request outruns the
# indexing of the ref we pushed a moment ago — so a single attempt can
# fail even though the very same call succeeds seconds later. Retry
# with backoff, and PATCH in place if a release already exists (re-run
# safe, so re-running never disturbs a published release). A 5xx body
# still exits curl 0, so the loop, not `set -e`, controls the flow.
ID=""
for attempt in 1 2 3 4 5 6; do
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$EXIST" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$ID"
else
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases"
-d @cb-payload.json "$API/releases")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
fi
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi
[ -n "$ID" ] && break
sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do
@@ -454,3 +516,136 @@ jobs:
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
# Play takes an App Bundle, not the APK: a second artifact from the same
# source and signing config. Play treats the release key only as the
# upload key and re-signs with its own (Play App Signing), so Play and
# F-Droid installs carry different signatures and can't update each other.
#
# Built last and continue-on-error: everything above has already shipped,
# and nothing Play-related may take it down. The AAB never touches the
# F-Droid repo or the releases. AGP embeds the R8 mapping in the bundle,
# so Play gets deobfuscated stacktraces without a separate upload.
- name: Build release AAB
if: env.IS_RELEASE == 'true'
continue-on-error: true
run: ./gradlew bundleRelease
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
# check. Pinned to a commit — a third-party action in the signing
# pipeline must not change under us.
- name: Hand the AAB to the Play job
if: env.IS_RELEASE == 'true'
continue-on-error: true
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
with:
name: release-aab-${{ needs.detect.outputs.version }}
path: app/build/outputs/bundle/release/app-release.aab
if-no-files-found: error
retention-days: 14
# Google Play channel. A separate job after the F-Droid publish and both forge
# releases, so a Play rejection (policy review, API outage, listing rules)
# shows up as one red job next to a release that already shipped.
#
# Not a `container:` job: act_runner provides no node inside custom job
# containers, so JavaScript actions (checkout, download-artifact) can't run.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path; never touch Play.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
# The release itself is the gate (a bumped versionName only reaches main
# after on-device review), so it goes straight to production. Override
# with repo variables to stage instead.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
# true validates the edit against the API and discards it.
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
BUNDLE_PATH: vendor/bundle
steps:
- name: Checkout
uses: actions/checkout@v4
# Skip cleanly when Play isn't configured yet, same contract as the
# Codeberg publish.
- name: Write the Play service-account key
id: key
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
set -euo pipefail
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload."
echo "configured=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \
|| { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; }
echo "configured=true" >> "$GITHUB_OUTPUT"
# Same GHES-detection fix as the upload side.
- name: Download the AAB
if: steps.key.outputs.configured == 'true'
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
with:
name: release-aab-${{ needs.detect.outputs.version }}
path: dist
- name: Install Ruby
if: steps.key.outputs.configured == 'true'
run: |
set -euo pipefail
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
$SUDO apt-get update
# Several fastlane dependencies build native extensions.
$SUDO apt-get install -y ruby-full ruby-dev build-essential
ruby -v
- name: Cache bundled gems
if: steps.key.outputs.configured == 'true'
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
restore-keys: |
${{ runner.os }}-gems-
- name: Install fastlane
if: steps.key.outputs.configured == 'true'
run: |
set -euo pipefail
gem install bundler --no-document
bundle config set --local path vendor/bundle
bundle install --jobs 4
bundle exec fastlane --version
- name: Upload to Play
if: steps.key.outputs.configured == 'true'
env:
SUPPLY_JSON_KEY: play-service-account.json
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: |
set -euo pipefail
# Absolute: a lane body runs from fastlane/, not the workspace root.
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
bundle exec fastlane deploy \
aab:"$AAB" \
track:"$PLAY_TRACK" \
release_status:"$PLAY_RELEASE_STATUS" \
dry_run:"$PLAY_DRY_RUN"
echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track."
# The workspace is reused on a self-hosted runner; the key must not
# outlive the job.
- name: Shred the service-account key
if: always()
run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json
+61
View File
@@ -0,0 +1,61 @@
name: Renovate
on:
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
schedule:
- cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab.
workflow_dispatch:
# Never let two Renovate runs touch the repo at once.
concurrency:
group: renovate
cancel-in-progress: false
jobs:
renovate:
# Gitea only — same guard, and the same reason, as release.yaml's `detect`:
# this file is invisible to Codeberg only while .forgejo/ is non-empty, and
# a repo-write token must never run on the contributor-facing runner.
if: github.repository_owner == 'makiolaj'
runs-on: docker
# Run the Renovate image *as* the job container and invoke the `renovate`
# binary directly. The renovatebot/github-action wrapper is a thin Node
# action that shells out to `docker run …` — it needs a Docker CLI + socket
# inside the job, which the Gitea runner's plain node container has not, so
# it died on "Unable to locate executable file: docker". Running the image
# directly drops the docker-in-docker requirement entirely.
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
container:
image: ghcr.io/renovatebot/renovate:43.232.0
steps:
- name: Run Renovate
run: renovate
env:
# Renovate targets Codeberg (canonical) while still RUNNING on the
# Gitea runner. Moving the job to Codeberg would put a repo-write
# token on the contributor-facing runner, which is exactly what the
# .forgejo/ vs .gitea/ split exists to prevent — so the token stays
# where the other secrets live and only the API calls cross over.
#
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
# pinned image ships a distinct forgejo platform module.
RENOVATE_PLATFORM: forgejo
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
# PR scope on jlmakiola/agendula.
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
# Scope to this repo only — no org-wide autodiscovery.
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_REPOSITORIES: '["jlmakiola/agendula"]'
# Commits/PRs authored as the bot, not a real maintainer. This address
# must be a verified email on the Codeberg bot account, otherwise the
# commits show up unattributed there.
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
# Read-only github.com PAT (no scopes needed). Nearly every dependency
# is *released* on GitHub, and without this, changelog/release-note
# lookups hit the 60/h anonymous rate limit and PRs arrive with an
# empty "Release Notes" section.
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
LOG_LEVEL: info
+30
View File
@@ -53,5 +53,35 @@ Thumbs.db
# F-Droid local artifacts (the pipeline generates them in CI)
/fdroid/
# Release-pipeline scratch files. release.yaml writes these into the workspace
# while cutting a release; a self-hosted runner reuses that workspace, so they
# must never end up committed (release-notes.md did, through 0.3.2).
/release-notes.md
/payload.json
/existing.json
/response.json
/cb-payload.json
/cb-response.json
# KSP
.ksp/
# Local agent notes: machine-specific build setup and on-device rules, not
# anything the project itself depends on.
/CLAUDE.md
# Scratch backlog. Says so in its own header — dumped items get turned into
# real work, not committed as a list.
/req_changes.md
# Google Play service-account key (fastlane/Appfile). Never committed.
/play-service-account.json
# fastlane run output
/fastlane/report.xml
/fastlane/README.md
/vendor/bundle/
/.bundle/
# Emulator captures (scripts/emulator_screenshot.sh); regenerate from design/store/sample.
/design/store/raw/
/design/store/framed/
+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "floret-kit"]
path = floret-kit
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
url = https://codeberg.org/jlmakiola/floret-kit.git
+32
View File
@@ -7,6 +7,38 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
## [1.0.0] - 2026-09-21
### Added
- CalDAV sync built in: Nextcloud, Radicale, Baïkal and more.
- Agendula keeps your tasks itself, no other app needed. Copy them over from
OpenTasks or tasks.org in Settings → Storage.
- Repeating tasks, several reminders per task, lists managed in the app,
iCalendar import and export, a home-screen widget and a Quick Settings tile.
### Changed
- New settings for all-day reminders, snooze length, sync interval, time format
and week start.
## [0.4.0] - 2026-08-31
### Added
- Agendula now speaks **German** and **Brazilian Portuguese**, the first
community translations. Pick one under **Settings → App language**, or in
Android's own per-app language settings; leave it on *System default* and
Agendula follows your phone. Anything not yet translated falls back to
English.
- Agendula can now be translated. Pick or request a language on Weblate and
translate in the browser — the link sits at the top of the language picker in
**Settings → App language**. Partial translations are fine; anything
untranslated falls back to English.
### Changed
- Agendula's home is now **Codeberg** (`jlmakiola/agendula`) — that's where the
source, issues, pull requests and releases live. The Source and License links
in Settings, the issue-reporting link and the F-Droid metadata all point there
now. The self-hosted Gitea instance stays as build infrastructure.
## [0.3.2] - 2026-07-20
### Fixed
+52 -32
View File
@@ -1,29 +1,29 @@
# Contributing to Agendula
Thanks for your interest in Agendula — a Material 3 Expressive task app that's a
pure front-end over the OpenTasks `TaskContract` provider, with no own database
or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
[`docs/PLAN.md`](docs/PLAN.md) (the design rationale). This file covers the
practical how.
Thanks for your interest in Agendula — a Material 3 Expressive task app with its
own Room task store and its own CalDAV sync, which can also work on top of the
OpenTasks `TaskContract` provider. This file covers the practical how.
## The one architectural rule
Everything above the data layer talks to `TasksRepository` and sees only domain
types and Flows. **Provider column names, `TaskContract`, `ContentResolver`, and
the authority string never leak above `data/tasks/`.** This is what keeps
"Posture B" (bundling the provider later) an additive change instead of a
rewrite — see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §7. If a change
would expose provider details to a ViewModel or the UI, it's in the wrong layer.
types and Flows. **Room entities, provider column names, `TaskContract`,
`ContentResolver` and the authority string never leak above `data/tasks/`.**
That seam is what let the store move from the provider to Room without touching
a screen, and what lets both stores sit behind one `TasksDataSource`. If a
change would expose storage details to a ViewModel or the UI, it's in the wrong
layer.
## Prerequisites
- JDK 17
- Android SDK: compileSdk 37, build-tools 36.0.0 (the Gradle wrapper handles AGP/Kotlin)
- A device or emulator with **OpenTasks** or **tasks.org** installed for
anything touching the read/write paths (ideally with DAVx5 syncing a CalDAV
task list, so there's real data). Debug builds fall back to `DemoSeeder` for
sample data.
- Any device or emulator for the default path — the store is Agendula's own.
Debug builds seed an "Agendula Demo" list via `DemoSeeder`. For External
mode, one with **OpenTasks** or **tasks.org** installed; for sync, a CalDAV
account (a local Radicale is the quickest).
- Clone with `--recurse-submodules`: the `floret-kit` component library is a
submodule.
## Build, test, lint
@@ -33,16 +33,40 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
./gradlew lintDebug # Android lint (CI runs this on every PR)
```
CI (`.gitea/workflows/ci.yaml`) runs a reproducible-release invariant check,
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
then lint → unit tests → debug build on every pull request, so run these locally
before opening a PR. Keep CI green.
## Translations
**Never edit a `values-*/strings.xml` file in a pull request.** Translations are
owned by a self-hosted Weblate that writes to this repository directly, and a
hand-edit is overwritten on the next sync.
→ **[Translate Agendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
picks it up and offers it to translators. Partial translations are expected and
fine — missing keys are informational. Stale and orphaned keys are not, so run
```sh
python3 scripts/check_translations.py
```
before pushing. It reports those more clearly than lint's `MissingTranslation`
does, and it's what the `Translations` check runs on every PR.
A new language also needs one `<locale>` line in
`app/src/main/res/xml/locales_config.xml` — that file is the single source of
truth for both the in-app picker and the Android 13+ per-app language setting.
## Where to put code
| Layer | Lives in | Rule of thumb |
|---|---|---|
| Pure logic (models, filtering, sorting, form validation, date maths) | `domain/` | No Android imports — must be JVM-unit-testable. |
| Provider access | `data/tasks/` | The only place that knows about the provider. New provider work goes through `TasksDataSource`. |
| Task storage | `data/tasks/` (`room/` for our own store) | The only place that knows about Room or the provider. New storage work goes through `TasksDataSource`, for both stores. |
| CalDAV sync | `data/sync/` | Accounts, the engine, scheduling and sync notices. |
| Reminders, prefs, DI, demo data | `data/reminders/`, `data/prefs/`, `data/di/`, `data/demo/` | |
| Screens | `ui/<area>/` | One ViewModel + immutable `UiState` per area; Compose for the screen. |
@@ -51,20 +75,20 @@ before opening a PR. Keep CI green.
- **Kotlin**, 4-space indent, LF line endings, final newline, no trailing
whitespace — all enforced by `.editorconfig` (2-space for yaml/toml/json/md).
Match the surrounding code.
- **Material 3 Expressive** for all UI: use `MaterialExpressiveTheme`, the
colour-scheme tokens (never hardcoded colours), and canonical M3 components
(e.g. `ListItem` for rows). Consult the `material-3` skill before designing a
new screen or component.
- Prefer the domain layer for anything testable; keep `AndroidTasksDataSource`
the only Android-coupled data implementation so the rest stays JVM-testable.
- **Material 3 Expressive** for all UI, built from **floret-kit** components
first (`CollapsingScaffold`, `GroupedRow`, `InlineTextField`,
`FullScreenPicker` / `OptionPicker`, …) and colour-scheme tokens (never
hardcoded colours). If a floret-kit component is nearly right, add the
parameter there rather than dropping to raw Material 3.
- Prefer the domain layer for anything testable.
## Tests
- New domain logic (mappers, filters, sorting, forms, value mapping) **must**
come with JVM unit tests under `app/src/test/`. The data source is the
JVM-testable seam — mock or fake it rather than reaching for instrumentation.
- Add an instrumented test only when a path genuinely needs a real
`ContentResolver`.
- Add an instrumented test only when a path genuinely needs Android: the Room
data source, migrations and the import paths live under `app/src/androidTest/`.
## Commits & PRs
@@ -73,9 +97,6 @@ before opening a PR. Keep CI green.
- Update [`CHANGELOG.md`](CHANGELOG.md) under `[Unreleased]` for any
user-visible change — its sections feed the release notes and F-Droid "What's
New" (see [`docs/RELEASING.md`](docs/RELEASING.md)).
- If your change shifts the architecture or completes a milestone, update
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
in the same PR.
- Don't bump `versionName` / `versionCode` in a regular PR — the committed
`versionName` is bumped only when **cutting a release** (that bump reaching
`main` is what triggers the release; the pipeline then mints the tag). See
@@ -83,10 +104,9 @@ before opening a PR. Keep CI green.
## Scope
Agendula stays true to its thesis: a front-end over **open** task backends
(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's
Agendula stays on **open** standards: CalDAV and iCalendar, through its own
sync or through the OpenTasks provider (OpenTasks and tasks.org). Proprietary
backends (Google Tasks, Microsoft To Do) are out of scope by design. jtx's
richer contract is a possible later addition.
## License
+6
View File
@@ -0,0 +1,6 @@
source "https://rubygems.org"
# fastlane is used ONLY as the Google Play Developer API client (see
# fastlane/Fastfile). It never builds and never signs. Pinned exactly, no
# Gemfile.lock: it resolves an uploader's deps, not the app's.
gem "fastlane", "2.237.0"
+129 -20
View File
@@ -3,42 +3,151 @@
<h1>Agendula</h1>
<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
sync stack.</p>
Syncs over CalDAV, or keeps your tasks on the device. Open standards, no account
required.</p>
<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>
<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/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
</p>
<p>
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
</p>
</div>
Agendula is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula).
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
database, no reinvented sync.
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
It keeps its own task store, designed around RFC 5545's `VTODO`, and syncs it
with any CalDAV server — Nextcloud, Radicale, Baïkal and the rest. No account is
needed to use it: without one, your tasks simply stay on the phone.
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;
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.)
> **Status: data layer done, UI in progress.** The full non-visual stack over
> the `TaskContract` provider — provider resolution, live-updating reads,
> writes, smart-list filtering, and a self-scheduled reminder engine — is built
> 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,
> [`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.
## What it does
## Sync sources (by design)
- **Lists** you create, colour, reorder and delete in the app, plus smart lists:
Today, Upcoming, Overdue, No date, All and Completed.
- **Tasks** with due and start dates, all-day tasks, subtasks, priorities,
progress, location and URL, and cancel/restore.
- **Repeating tasks**, expanded per RFC 5545. Edit or delete one occurrence,
this and the following ones, or the whole series.
- **Reminders** that fire at the exact time: several per task, separate defaults
for timed and all-day tasks (globally and per list), Done and Snooze right on
the notification, and they survive reboots.
- **iCalendar import and export** — open an `.ics` or a zip of them, or write any
list out as standard `.ics` files.
- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
"share to Agendula" to turn text from another app into a task.
- **Material 3 Expressive** throughout, with dynamic colour, expressive motion
and shapes.
Agendula works with anything that writes to the tasks provider — **DAVx5**
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
adapter — because it builds on the provider, not on any one sync app. Google
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
## Sync
Add a CalDAV account under **Settings → Accounts** and choose which of its task
lists to sync. Nextcloud signs in through its own login flow in the browser, so
Agendula never sees your password; any other server takes an address, a user
name and a password — ideally an app password, which you can revoke on its own.
Edits you make are sent within about half a minute. Changes from the server
arrive on a background interval you choose (15 minutes to a day, or only when
you tap sync), and every time you open the app. Several accounts can sync side
by side, and lists can be created, renamed and deleted on the server from the
app.
Sync uses `sync-collection` (RFC 6578) where the server supports it and falls
back to a full comparison where it does not. Everything the store does not model
is kept verbatim and sent back unchanged, so passing your tasks through Agendula
does not quietly lose fields another client wrote.
## Where your tasks live
| | Where | Sync | Needs |
|---|---|---|---|
| **In Agendula** *(default)* | Agendula's own database | Agendula's own CalDAV sync, if you add an account | nothing — 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, so it **coexists with
OpenTasks rather than replacing it**. If you already sync through a provider,
Agendula can work on top of it exactly as before.
Switching between the two moves nothing — each store keeps its own tasks — so
**Settings → Storage** asks before it switches, and offers to **copy** a
provider's tasks into Agendula's own store when you want to move over. The copy
is taken once and the originals stay where they are.
Google Tasks and Microsoft To Do are out of scope by design: they are
proprietary, and open standards — CalDAV and iCalendar — are the lane.
## Install
### F-Droid repository
Every release is built, signed and published to a self-hosted F-Droid
repository. Add it once and your F-Droid client handles updates from then on:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
```
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
```
2. Refresh, search for **Agendula**, install.
### Codeberg release / Obtainium
Every release is also published on
**[Codeberg](https://codeberg.org/jlmakiola/agendula/releases)** with the signed
APK and a `.sha256` checksum attached — the same APK the F-Droid repository
serves. For automatic updates from there, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** and
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
### Build from source
```sh
git clone --recurse-submodules https://codeberg.org/jlmakiola/agendula.git
cd agendula
./gradlew :app:assembleDebug
```
JDK 17 and the Android SDK are all it needs; see
[`CONTRIBUTING.md`](CONTRIBUTING.md) for tests and lint.
## Translations
Translations are managed on a self-hosted **Weblate**, and partial ones are
fine — an untranslated string simply falls back to English. Agendula ships in
English, German and Brazilian Portuguese so far.
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
No coding needed: register on the Weblate server, pick (or request) a language,
and translate the strings in your browser. You can also reach this link in the
app from the top of **Settings → App language**.
## Contributing
Issues and pull requests live on
**[Codeberg](https://codeberg.org/jlmakiola/agendula)**.
[`CONTRIBUTING.md`](CONTRIBUTING.md) covers the practical how.
## Privacy
No analytics, no advertising, no tracking, no third-party SDK, and no server of
the developer's. Your tasks stay on your device unless you add a CalDAV account
yourself, and then they go only to the server you chose.
**→ [Privacy policy](https://jeanlucmakiola.de/agendula/privacy)**
## License
+98 -11
View File
@@ -27,12 +27,21 @@ android {
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
versionCode = 302
versionName = "0.3.2"
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
// pipeline graduates it on its own. See docs/RELEASING.md.
versionCode = 10000
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// The sync-adapter and authenticator XML descriptors cannot read
// BuildConfig, so the two identifiers they need are generated here.
// Derived from applicationId so the debug and releaseTest builds get
// their own and can be installed alongside the real app without their
// accounts colliding. Must stay in step with SyncContract.
resValue("string", "account_type", "de.jeanlucmakiola.agendula.caldav")
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
}
signingConfigs {
@@ -66,6 +75,8 @@ android {
debug {
applicationIdSuffix = ".debug"
isMinifyEnabled = false
resValue("string", "account_type", "de.jeanlucmakiola.agendula.debug.caldav")
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.debug.sync")
}
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
// and resource shrinking, but debug-signed and given its own applicationId
@@ -82,6 +93,8 @@ android {
isMinifyEnabled = true
isShrinkResources = true
matchingFallbacks += "release"
resValue("string", "account_type", "de.jeanlucmakiola.agendula.releasetest.caldav")
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.releasetest.sync")
}
}
@@ -93,6 +106,10 @@ android {
buildFeatures {
compose = true
buildConfig = true
// The account type and sync authority are generated per variant so the
// debug and releaseTest builds do not fight the real app over ownership
// of an account type. AGP 9 requires opting in.
resValues = true
}
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
@@ -109,6 +126,24 @@ android {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
// Ship the prebuilt .so files (datastore's shared counter) exactly as the
// AAR has them. AGP strips them only when an NDK happens to be installed,
// so CI and F-Droid's buildserver would otherwise disagree on the bytes.
jniLibs {
keepDebugSymbols += "**/*.so"
}
}
lint {
// Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on
// it. Likewise a translated <plurals> may not fill every CLDR quantity
// form its locale defines (e.g. Arabic needs "zero"); the missing form
// falls back to "other" at runtime, so MissingQuantity is informational
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer,
// translator-facing messages.
informational += listOf("MissingTranslation", "MissingQuantity")
}
testOptions {
@@ -117,6 +152,10 @@ android {
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 {
@@ -125,11 +164,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 {
// 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.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
@@ -142,22 +197,53 @@ dependencies {
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences)
// Sync runs in WorkManager, triggered *through* the sync-adapter framework.
// hilt-work supplies the HiltWorkerFactory; its compiler generates the
// @HiltWorker plumbing.
implementation(libs.androidx.work.runtime.ktx)
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
implementation(libs.androidx.browser)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
implementation(libs.unifiedpush.connector)
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
implementation(libs.dmfs.lib.recur)
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
implementation(project(":dav"))
// Discovery, auth and Nextcloud Login Flow v2.
implementation(project(":caldav"))
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
// with no framework, and android.jar's stub factory returns null under
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
// real implementation here.
testImplementation(libs.xpp3)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.androidx.documentfile)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)
implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
implementation(libs.floret.core.time)
implementation(libs.floret.core.reminders)
implementation(libs.floret.core.locale)
implementation(libs.floret.core.crash)
implementation(libs.floret.identity)
implementation(libs.floret.components)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
@@ -173,6 +259,7 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.truth)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
}
+11 -10
View File
@@ -5,17 +5,18 @@
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
# nothing calls it directly — Room then throws InstantiationException, reported
# as "Failed to create an instance of ...". We pull Room in transitively via
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
# at startup, so the app died on launch in every minified build (issue #1).
# as "Failed to create an instance of ...". This first bit us through a
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
# issue #1); Glance is gone and Room is now our own task store, so the rule
# matters more, not less — TasksDatabase is built on the first store read.
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# WorkManager likewise looks its workers up by name and calls this constructor
# reflectively — same pruning, but it only bites once a worker actually runs
# (Glance's widget updates), so keep it explicitly rather than wait for it.
-keep class * extends androidx.work.ListenableWorker {
<init>(android.content.Context, androidx.work.WorkerParameters);
}
# Compose Compiler may keep its own; defaults are fine
-dontwarn org.jetbrains.annotations.**
# dnsjava (CalDAV SRV/TXT discovery) references JNA, JNDI, Lombok and SLF4J
# bindings that only exist on desktop JVMs; its Android resolver needs none.
-dontwarn com.sun.jna.**
-dontwarn javax.naming.**
-dontwarn lombok.Generated
-dontwarn org.slf4j.impl.StaticLoggerBinder
@@ -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.
@@ -0,0 +1,123 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestName
import org.junit.runner.RunWith
/**
* The credential store, against a real Keystore.
*
* Instrumented rather than Robolectric because the thing under test *is* the
* platform: a shadowed Keystore would encrypt and decrypt happily and prove
* nothing about whether the key spec is usable for background sync.
*
* The case that matters most is the last one. A restored backup carries the
* ciphertext but not the key — Keystore keys are non-exportable — so the blob
* becomes permanently undecryptable. That must surface as "sign in again", never
* as a crash and never as a silently non-syncing account, which is why
* `backup_rules.xml` excludes this file in the first place.
*/
@RunWith(AndroidJUnit4::class)
class CredentialStoreTest {
@get:Rule val testName = TestName()
private lateinit var scope: CoroutineScope
private lateinit var dataStore: DataStore<Preferences>
private lateinit var store: CredentialStore
private val context: Context get() = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
// ⚠️ A file per test, and a scope we can cancel. DataStore's FileStorage
// keeps a process-wide set of active files and refuses a second
// connection to one ("There are multiple DataStores active for the same
// file"); the entry is released only when the owning scope's job
// completes, and the factory's default scope is never cancelled. Sharing
// one file across methods therefore fails every test after the first.
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
dataStore = PreferenceDataStoreFactory.create(scope = scope) {
context.preferencesDataStoreFile("credential_store_test_${testName.methodName}")
}
store = CredentialStore(dataStore)
}
@After
fun tearDown() {
runTest { store.clearAll() }
scope.cancel()
context.preferencesDataStoreFile("credential_store_test_${testName.methodName}").delete()
}
@Test
fun anAppPasswordRoundTrips() = runTest {
assertThat(store.put(accountId = 1L, appPassword = "s3cret-app-pw")).isTrue()
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("s3cret-app-pw"))
}
@Test
fun aNonLatin1PasswordSurvives() = runTest {
// The same charset trap the Basic interceptor has: anything that silently
// mangles "ä" produces a 401 the user reads as a wrong password.
store.put(accountId = 1L, appPassword = "pä§§wörd-🔐")
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("pä§§wörd-🔐"))
}
@Test
fun accountsDoNotShareACredential() = runTest {
store.put(1L, "first")
store.put(2L, "second")
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("first"))
assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second"))
}
@Test
fun anUnknownAccountIsAbsentRatherThanAnError() = runTest {
assertThat(store.get(99L)).isEqualTo(CredentialStore.Secret.Absent)
}
@Test
fun clearingRemovesOnlyThatAccount() = runTest {
store.put(1L, "first")
store.put(2L, "second")
store.clear(1L)
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Absent)
assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second"))
}
@Test
fun aCiphertextThisDeviceCannotDecryptMeansReAuthenticate() = runTest {
// Stands in for the restored-backup case: the blob is present and
// well-formed Base64, but was not produced by this device's key.
dataStore.edit {
it[stringPreferencesKey("caldav_app_password_1")] =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java)
}
@Test
fun aBlobThatIsNotBase64AtAllIsAlsoRecoverable() = runTest {
dataStore.edit { it[stringPreferencesKey("caldav_app_password_1")] = "not base64 !!" }
assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java)
}
}
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.R
import org.junit.Test
import org.junit.runner.RunWith
/**
* The account type and authority exist in two places that cannot see each other:
* `SyncContract`, derived from `BuildConfig.APPLICATION_ID`, and the `resValue`
* strings the XML descriptors read. Drift between them is invisible at build
* time and shows up as an account the sync framework will not trigger — the
* silent no-op, with nothing in the log.
*/
@RunWith(AndroidJUnit4::class)
class SyncContractTest {
private val context: Context get() = ApplicationProvider.getApplicationContext()
@Test
fun theAccountTypeMatchesTheAuthenticatorDescriptor() {
assertThat(SyncContract.ACCOUNT_TYPE)
.isEqualTo(context.getString(R.string.account_type))
}
@Test
fun theAuthorityMatchesTheSyncAdapterDescriptor() {
assertThat(SyncContract.AUTHORITY)
.isEqualTo(context.getString(R.string.sync_authority))
}
@Test
fun theAuthorityMatchesTheStubProviderInTheManifest() {
// The provider is what makes the authority real; a mismatch here means
// requestSync addresses nothing.
val provider = context.packageManager
.resolveContentProvider(SyncContract.AUTHORITY, 0)
assertThat(provider).isNotNull()
assertThat(provider!!.name).isEqualTo(SyncStubProvider::class.java.name)
}
}
@@ -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
}
}
@@ -0,0 +1,542 @@
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.data.tasks.TaskReminder
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)
/** A list that belongs to an account, so writes owe a server something. */
private fun syncedList(): Long {
val accountId = db.accounts().insert(
AccountEntity(displayName = "me@example.com", username = "me"),
)
return db.taskLists().insert(
TaskListEntity(name = "Work", color = 0, accountId = accountId, href = "https://s/w/"),
)
}
/** 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 aWriteNamesTheListsItTouched() {
val touched = mutableListOf<Set<Long>>()
val observed = RoomTasksDataSource(db) { touched += it }
val work = syncedList()
val id = observed.insertTask(form().copy(listId = work))
observed.updateTask(id, form().copy(listId = listId))
assertThat(touched).containsExactly(setOf(work), setOf(work, listId)).inOrder()
}
@Test
fun movingASyncedTaskLeavesATombstoneInTheOldList() {
val work = syncedList()
val id = source.insertTask(form().copy(listId = work))
db.tasks().markSynced(listOf(id), "https://s/w/a.ics", "e1")
source.updateTask(id, form().copy(listId = listId))
val moved = db.tasks().entity(id)!!
assertThat(moved.listId).isEqualTo(listId)
assertThat(moved.href).isNull()
val tombstone = db.tasks().allIn(work).single()
assertThat(tombstone.isDeleted).isTrue()
assertThat(tombstone.href).isEqualTo("https://s/w/a.ics")
assertThat(tombstone.etag).isEqualTo("e1")
}
@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 cancellingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
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.setCancelledInstance(id, target.occurrenceStart!!, cancelled = true)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.CANCELLED)
source.setCancelledInstance(id, target.occurrenceStart, cancelled = false)
assertThat(db.tasks().overrides(id)).hasSize(1)
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@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))
// The whole reminder, not just the minute count: collapsing it to a bare
// Int is what fired an imported START-referenced alarm off DUE, and an
// alarm this seam sets from the UI is always due-referenced.
source.setAlarm(id, 30)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30))
source.setAlarm(id, 60)
assertThat(db.alarms().forTask(id)).hasSize(1)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
source.setAlarm(id, null)
assertThat(source.alarms()).doesNotContainKey(id)
}
@Test
fun settingTheEditableReminderLeavesTheOthersAlone() {
val id = source.insertTask(form(due = now + 1.days))
val start = TaskReminder(minutesBefore = 10, fromStart = true)
source.setReminders(id, listOf(start, TaskReminder(30), TaskReminder(120)))
source.setAlarm(id, 45)
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30), TaskReminder(45)).inOrder()
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(45))
source.setAlarm(id, null)
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30))
}
@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 aForkedOccurrenceCarriesTheSeriesReminder() {
val id = source.insertTask(form())
makeRecurring(id, now)
source.setAlarm(id, minutesBeforeDue = 30)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
// The fork copies the master's properties, which is what carries the
// reminder across — the repository is what puts the series' own back.
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
}
@Test
fun anOccurrenceCannotBeMovedOutOfItsSeriesList() {
val other = source.createLocalList("Work", 0)
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"))
val override = db.tasks().override(id, target.occurrenceStart)!!
source.updateTask(override.id, TaskForm(title = "moved", listId = other))
// ⚠️ list_id = B with master_id in list A is invisible in both — the task
// query skips non-null master_id, and the override query finds no master
// in B — while still uploading as part of A's resource.
assertThat(db.tasks().entity(override.id)!!.listId).isEqualTo(listId)
assertThat(db.tasks().entity(override.id)!!.title).isEqualTo("moved")
}
@Test
fun deletingASyncedSeriesTombstonesItsOverridesToo() {
val syncedList = syncedList()
val id = source.insertTask(TaskForm(title = "Standup", listId = syncedList))
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = syncedList))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, TaskForm(title = "moved", listId = syncedList))
source.deleteTask(id)
// ⚠️ master_id cascades on delete, and a tombstone deletes nothing — so
// a master marked alone left the resource reading as partly deleted, the
// DELETE was never sent, and the task stayed on the server for ever.
val rows = db.tasks().allIn(syncedList)
assertThat(rows).hasSize(2)
assertThat(rows.all { it.isDeleted && it.isDirty }).isTrue()
}
@Test
fun deletingOneOccurrenceExceptsItOnTheMaster() {
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"))
val override = db.tasks().override(id, target.occurrenceStart)!!
source.deleteTask(override.id)
// ⚠️ Dropping the override row un-overrides the occurrence, and the
// master's RRULE regenerates it. The EXDATE is the deletion.
assertThat(db.tasks().entity(override.id)).isNull()
assertThat(db.tasks().entity(id)!!.exdate).isNotEmpty()
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart })
.doesNotContain(target.occurrenceStart)
}
@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()
}
}
@@ -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"
}
}
@@ -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
}
}
@@ -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")
}
}
@@ -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
}
}
@@ -0,0 +1,324 @@
package de.jeanlucmakiola.agendula.data.tasks.transfer
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 com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.export.ExportTask
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 javax.inject.Provider
import kotlin.time.Instant
/**
* The copy out of an external provider and into Room — the upgrade path every
* released install actually needs, since no release ever bundled the provider
* `OneShotImport` reads.
*
* The source is a fake [TasksDataSource] rather than a live OpenTasks: what is
* worth testing is the write half — id remapping, uid collisions, verified
* counts, the once-only guard — and pinning that to a device with a third-party
* app installed would mean it never ran. Instrumented all the same, because the
* destination is a real Room database in a real transaction.
*/
@RunWith(AndroidJUnit4::class)
class ExternalImportTest {
@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 source: FakeExternalStore
private lateinit var importer: ExternalImport
@Before
fun setUp() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
prefs = PreferenceDataStoreFactory.create(scope = scope) {
temp.newFile("transfer-${counter++}.preferences_pb").also(File::delete)
}
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
.allowMainThreadQueries()
.build()
source = FakeExternalStore()
importer = ExternalImport(
external = Provider { source },
resolver = ProviderResolver(NoProviderInstalled),
database = db,
dataStore = prefs,
io = Dispatchers.IO,
)
}
@After
fun tearDown() {
db.close()
scope.cancel()
}
@Test
fun copiesListsTasksAndAlarms() = runBlocking {
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
source.tasks = mapOf(
7L to listOf(task(100, "Milk"), task(101, "Bread")),
9L to listOf(task(200, "Invoice")),
)
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
val result = importer.run()
assertThat(result).isEqualTo(
TransferResult.Copied(TransferCounts(lists = 2, tasks = 3, alarms = 1)),
)
assertThat(db.taskLists().lists().map { it.list.name })
.containsExactly("Errands", "Work")
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.title })
.containsExactly("Milk", "Bread", "Invoice")
assertThat(importer.hasRun.first()).isTrue()
}
/** Every list arrives device-only: the account belongs to the sync app. */
@Test
fun importedListsAreDeviceOnly() = runBlocking {
source.lists = listOf(list(7, "Shared", accountName = "me@example.org"))
source.tasks = mapOf(7L to listOf(task(100, "Milk")))
importer.run()
assertThat(db.taskLists().lists().single().list.accountId).isNull()
}
/** Provider row ids are the source's; Room mints its own and the link follows. */
@Test
fun remapsParentIdsOntoTheNewRowIds() = runBlocking {
source.lists = listOf(list(7, "Errands"))
// Child before parent, so a naive single pass would not find the parent.
source.tasks = mapOf(
7L to listOf(task(100, "Subtask", parentId = 200), task(200, "Parent")),
)
importer.run()
val rows = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task }
val parent = rows.single { it.title == "Parent" }
val child = rows.single { it.title == "Subtask" }
assertThat(child.parentId).isEqualTo(parent.id)
assertThat(child.parentId).isNotEqualTo(200L)
}
/**
* A `RECURRENCE-ID` override reaches the read seam as another master-shaped row
* sharing its series' uid. The unique index on (list, uid, recurrence_id)
* would reject it and take the whole copy down, so it gets a fresh uid.
*/
@Test
fun aDuplicateUidDoesNotAbortTheCopy() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(
7L to listOf(
task(100, "Weekly", uid = "shared-uid"),
task(101, "Weekly, that one week", uid = "shared-uid"),
),
)
val result = importer.run()
assertThat(result).isInstanceOf(TransferResult.Copied::class.java)
val uids = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.uid }
assertThat(uids).hasSize(2)
assertThat(uids.toSet()).hasSize(2)
assertThat(uids).contains("shared-uid")
}
/** A START-referenced reminder must not come across as a before-due one. */
@Test
fun preservesTheAlarmReference() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(7L to listOf(task(100, "Standup")))
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 10, fromStart = true))
importer.run()
val alarm = db.alarms().all().single()
assertThat(alarm.reference).isEqualTo(AlarmReference.START)
assertThat(alarm.minutesBefore).isEqualTo(10)
}
@Test
fun anEmptySourceWritesNothingAndIsNotMarkedDone() = runBlocking {
val result = importer.run()
assertThat(result).isEqualTo(TransferResult.NothingToCopy)
assertThat(db.taskLists().lists()).isEmpty()
// Still on offer: there was nothing to copy, not a copy that happened.
assertThat(importer.hasRun.first()).isFalse()
}
/** A read that blows up must leave Room exactly as it was. */
@Test
fun aFailedReadRollsBackAndLeavesTheGuardOpen() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.failOnExport = true
val result = importer.run()
assertThat(result).isInstanceOf(TransferResult.Failed::class.java)
assertThat(db.taskLists().lists()).isEmpty()
assertThat(importer.hasRun.first()).isFalse()
}
@Test
fun previewCountsWhatARunWouldWrite() = runBlocking {
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
source.tasks = mapOf(
7L to listOf(task(100, "Milk"), task(101, "Bread")),
9L to listOf(task(200, "Invoice")),
)
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
// preview() resolves the provider itself, so it needs one to be installed.
val withProvider = ExternalImport(
external = Provider { source },
resolver = ProviderResolver(OpenTasksInstalledAndGranted),
database = db,
dataStore = prefs,
io = Dispatchers.IO,
)
assertThat(withProvider.preview())
.isEqualTo(TransferCounts(lists = 2, tasks = 3, alarms = 1))
}
@Test
fun previewIsNullWithoutAReadableProvider() = runBlocking {
assertThat(importer.preview()).isNull()
}
// --- fixtures --------------------------------------------------------------
private fun list(id: Long, name: String, accountName: String = "Device") = TaskList(
id = id,
name = name,
color = 0xFF7E57C2.toInt(),
accountName = accountName,
accountType = "org.dmfs.account.LOCAL",
isSynced = true,
isVisible = true,
owner = null,
)
private fun task(
id: Long,
title: String,
uid: String? = "uid-$id",
parentId: Long? = null,
) = ExportTask(
taskId = id,
uid = uid,
title = title,
description = null,
location = null,
url = null,
priority = Priority.NONE,
status = TaskStatus.NEEDS_ACTION,
percentComplete = null,
start = null,
due = Instant.fromEpochMilliseconds(1_800_000_000_000),
isAllDay = false,
completedAt = null,
created = null,
lastModified = null,
rrule = null,
rdate = null,
parentId = parentId,
)
private companion object {
var counter = 0
}
}
/** Only the three reads the copy makes; everything else is out of scope. */
private class FakeExternalStore : TasksDataSource {
var lists: List<TaskList> = emptyList()
var tasks: Map<Long, List<ExportTask>> = emptyMap()
var alarms: Map<Long, TaskReminder> = emptyMap()
var failOnExport = false
override fun taskLists(): List<TaskList> = lists
override fun exportTasks(listId: Long): List<ExportTask> {
if (failOnExport) error("provider went away mid-read")
return tasks[listId].orEmpty()
}
override fun alarms(): Map<Long, TaskReminder> = alarms
override fun tasks(query: TaskQuery): List<Task> = unused()
override fun task(taskId: Long): Task? = unused()
override fun subtasks(parentTaskId: Long): List<Task> = unused()
override fun insertTask(form: TaskForm): Long = unused()
override fun updateTask(taskId: Long, form: TaskForm) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
override fun deleteTask(taskId: Long) = unused()
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = unused()
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
override fun createLocalList(name: String, color: Int): Long = unused()
override fun updateList(listId: Long, name: String, color: Int) = unused()
override fun deleteList(listId: Long) = unused()
override fun registerObserver(onChange: () -> Unit): AutoCloseable = unused()
private fun unused(): Nothing = error("the copy does not call this")
}
/** No tasks provider on the device: `preview()` has nothing to read. */
private object NoProviderInstalled : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = null
override fun isGranted(permission: String): Boolean = false
override fun appLabel(packageName: String): String? = null
}
private object OpenTasksInstalledAndGranted : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? =
"org.dmfs.tasks".takeIf { authority == "org.dmfs.tasks" }
override fun isGranted(permission: String): Boolean = permission.startsWith("org.dmfs.permission.")
override fun appLabel(packageName: String): String = "OpenTasks"
}
@@ -0,0 +1,20 @@
package de.jeanlucmakiola.agendula.data.demo
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import de.jeanlucmakiola.agendula.data.di.LaunchHook
import javax.inject.Provider
/** Sample data on `am start … --ez agendula_seed true`; debug builds only. */
@Module
@InstallIn(SingletonComponent::class)
object DemoSeedModule {
@Provides
@IntoSet
fun demoSeedHook(seeder: Provider<DemoSeeder>): LaunchHook = LaunchHook { intent ->
if (intent.getBooleanExtra("agendula_seed", false)) seeder.get().seed()
}
}
@@ -50,7 +50,7 @@ class DemoSeeder @Inject constructor(
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)))
repository.setCompleted(done, completed = true)
repository.setCompleted(done, occurrenceStart = null, completed = true)
}
private companion object {
+197 -10
View File
@@ -2,20 +2,48 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
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
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.WRITE_TASKS" />
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
but it shows in F-Droid's permission diff, so declare it deliberately
rather than letting it appear from nowhere.
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
(whose failure mode is a fatal RemoteServiceException) and a Play
requirement for a video demo.
Two more permissions appear in the merged manifest without being
declared here, and both come from work-runtime: WAKE_LOCK, and
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
API 31 WorkManager implements expedited work with a foreground service,
and minSdk is 29, so it is load-bearing for the "Sync now" button.
Removing it with tools:node="remove" would break expedited work on
exactly the older devices that need it most. Noted because it shows in
F-Droid's permission diff and would otherwise look unexplained. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Exact due-time reminders: USE_EXACT_ALARM on 33+, SCHEDULE on 31-32. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
alarm-clock and calendar apps on Play. Without it reminders go inexact. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- Package visibility (Android 11+): see the tasks providers so
resolveContentProvider works, and launchable apps so we can open the
@@ -27,6 +55,12 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<!-- Custom Tabs provider detection. Without this entry it silently finds
nothing on API 30+, and the Nextcloud login flow falls back to an
external browser for no visible reason. -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application
@@ -37,6 +71,7 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Agendula"
@@ -50,8 +85,39 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Shared text becomes a new task's title. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- An .ics opened or shared from another app goes to the import screen. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="text/calendar" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/calendar" />
</intent-filter>
</activity>
<!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
<service
android:name=".qs.NewTaskTileService"
android:exported="true"
android:icon="@drawable/ic_qs_new_task"
android:label="@string/qs_tile_new_task"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!-- Standalone crash-report surface; MainActivity routes here on a
startup crash-loop. Not exported, kept out of recents. -->
<activity
@@ -65,27 +131,148 @@
android:name=".data.reminders.DueReminderReceiver"
android:exported="false" />
<!-- Re-arm alarms after reboot. -->
<!-- Done / Snooze on a reminder, and the snooze re-show (internal). -->
<receiver
android:name=".data.reminders.ReminderActionReceiver"
android:exported="false" />
<!-- Re-arm alarms after reboot, an update, a clock/zone change, or an exact-alarm grant. -->
<receiver
android:name=".data.reminders.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
</intent-filter>
</receiver>
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
Targets both known authorities; the host must be static. -->
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
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 — one filter each. Two <data> tags in a single filter would
mean the same thing (Android takes the cross product of every data
attribute in a filter), but reads as if it might not, which is what
lint's IntentFilterUniqueDataAttributes warns about. -->
<receiver
android:name=".data.reminders.ProviderChangeReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PROVIDER_CHANGED" />
<data android:scheme="content" android:host="org.tasks.opentasks" />
<data android:scheme="content" android:host="org.dmfs.tasks" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PROVIDER_CHANGED" />
<data android:scheme="content" android:host="org.tasks.opentasks" />
</intent-filter>
</receiver>
<!-- Home-screen task widget (Glance). Exported: the launcher binds it. -->
<receiver
android:name=".widget.TaskWidgetReceiver"
android:label="@string/widget_tasks_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_tasks" />
</receiver>
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
<activity
android:name=".widget.TaskWidgetConfigActivity"
android:exported="true"
android:excludeFromRecents="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<!-- Redraws the widget on the day boundary (the app's own ROLLOVER alarm,
sent by explicit PendingIntent) and on a language change; the rest
re-arm that alarm. Task changes are handled in-process instead. -->
<receiver
android:name=".widget.WidgetUpdateReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.DATE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.LOCALE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- Sync plumbing. The stub provider exists only to give the sync
adapter an authority to register against: Agendula publishes no real
ContentProvider since :provider was deleted, and without an authority
ContentService.hasAuthorityAccess() makes every ContentResolver sync
call a silent no-op at targetSdk >= 34. -->
<provider
android:name=".data.sync.SyncStubProvider"
android:authorities="${applicationId}.sync"
android:exported="false"
android:syncable="true" />
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
<service
android:name=".data.sync.AuthenticatorService"
android:exported="true"
android:permission="android.permission.ACCOUNT_MANAGER">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".data.sync.SyncAdapterService"
android:exported="true"
android:permission="android.permission.BIND_SYNC_ADAPTER">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
</service>
<!-- UnifiedPush: the connector binds this to deliver endpoints and
WebDAV-Push messages. Not exported; the connector's own receiver is
what distributors talk to. -->
<service
android:name=".data.sync.push.AgendulaPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<!-- WorkManager's on-demand initialisation. Removing the default
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
@HiltWorker workers can take injected dependencies. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- Persists the per-app language on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service
@@ -1,18 +1,29 @@
package de.jeanlucmakiola.agendula
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.Configuration
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
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.agendula.widget.TaskWidgetUpdater
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
/**
* Application entry point. Registered as android:name=".AgendulaApp". Besides
@@ -20,7 +31,20 @@ import kotlinx.coroutines.launch
* so alarms reflect tasks synced while the app was closed.
*/
@HiltAndroidApp
class AgendulaApp : Application() {
class AgendulaApp : Application(), Configuration.Provider {
/**
* Lets `@HiltWorker` workers take injected dependencies. The manifest removes
* WorkManager's default initializer so this one is used instead; without both
* halves, a worker with a constructor argument fails to instantiate at
* runtime rather than at build time.
*/
@Inject lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
@@ -32,20 +56,71 @@ class AgendulaApp : Application() {
appLabel = getString(R.string.app_name),
newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_url),
issueTitle = getString(R.string.crash_report_issue_title),
),
)
val scheduler = EntryPointAccessors
.fromApplication(this, ReminderEntryPoint::class.java)
.reminderScheduler()
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
runCatching { scheduler.sync() }
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
val scheduler = entryPoint.reminderScheduler()
// Mirror the stored storage mode into ProviderResolver and import a
// v0.3.x install's tasks, both before anything reads a store.
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() } }
runCatching { entryPoint.taskNotifier().refreshChannel() }
}
}
startupGate.start()
refreshNotificationChannels()
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()
}
runCatching { entryPoint.taskWidgetUpdater().start() }
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
}
}
@EntryPoint
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
refreshNotificationChannels()
// Language or dark mode changed: the widget's strings and list colours follow.
runCatching {
EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java).taskWidgetUpdater().requestRefresh()
}
}
/** Channel names are stored by the system in whatever language created them. */
private fun refreshNotificationChannels() {
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
runCatching {
entryPoint.taskNotifier().refreshChannel()
entryPoint.syncNoticeNotifier().refreshChannel()
}
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ReminderEntryPoint {
interface AppEntryPoint {
fun reminderScheduler(): ReminderScheduler
fun startupGate(): StartupGate
fun providerResolver(): ProviderResolver
@ApplicationScope
fun applicationScope(): CoroutineScope
fun databaseCheckpoint(): DatabaseCheckpoint
fun taskNotifier(): TaskNotifier
fun syncNoticeNotifier(): SyncNoticeNotifier
fun taskWidgetUpdater(): TaskWidgetUpdater
}
}
@@ -3,23 +3,40 @@ package de.jeanlucmakiola.agendula
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.format.DateFormat
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
import de.jeanlucmakiola.agendula.data.di.LaunchHook
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
@@ -30,19 +47,30 @@ import javax.inject.Inject
/**
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
* (replaceable) functional scaffold over the real data layer. Task-detail intent
* routing for reminder taps lands with the full UI.
* (replaceable) functional scaffold over the real data layer. Notification taps
* arrive as a [NavRequest] (see [navRequestOf]).
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var demoSeeder: DemoSeeder
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
@Inject lateinit var accounts: AccountRepository
@Inject lateinit var syncTrigger: SyncTrigger
@Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
@Inject lateinit var push: PushRegistrar
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
private var pendingCrashReport by mutableStateOf<String?>(null)
// A notification tap's destination, handed to the nav host and cleared once taken.
private var navRequest by mutableStateOf<NavRequest?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -56,14 +84,44 @@ class MainActivity : ComponentActivity() {
}
enableEdgeToEdge()
AppShortcuts.publish(this)
// Only on a fresh launch: after a rotation or process restore the intent
// is the same old one and the back stack already holds its destination.
val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
if (savedInstanceState == null && !fromHistory) navRequest = navRequestOf(intent)
// Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
// local (non-syncing) demo list once; no-op without the extra.
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
// Sync hard on app open: the periodic worker's interval is a floor, and
// in the `rare` and `restricted` App Standby buckets it may not have run
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
// schedule lost to "clear app data" or to a restore.
//
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
// so onCreate runs again on every rotation, theme switch, locale change
// and font-scale change — each of which would otherwise start a fresh
// network sync the moment the previous one finished.
if (savedInstanceState == null) {
lifecycleScope.launch {
runCatching {
accounts.rescheduleAll()
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
// A login flow the previous process died in the middle of.
// Its password, if the user approved, exists nowhere else.
pendingLoginFlows.reclaim()
}
// Last and on its own: it waits on the network, and must not
// hold up the reclaim above. Re-registering on open is what
// the connector recommends.
runCatching { push.updateAll() }
}
}
// Variant hooks: the demo seeder in debug builds, nothing in release.
if (savedInstanceState == null) {
lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -73,26 +131,44 @@ class MainActivity : ComponentActivity() {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
val context = LocalContext.current
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek()
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
RootScreen(modifier = Modifier.fillMaxSize())
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this@MainActivity, report)
CrashReporter.clearReport(this@MainActivity)
pendingCrashReport = null
},
onDismiss = {
CrashReporter.dismissPrompt(this@MainActivity)
pendingCrashReport = null
},
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalFirstDayOfWeek provides firstDayOfWeek,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
navRequest = navRequest,
onNavRequestConsumed = { navRequest = null },
)
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this@MainActivity, report)
CrashReporter.clearReport(this@MainActivity)
pendingCrashReport = null
},
onDismiss = {
CrashReporter.dismissPrompt(this@MainActivity)
pendingCrashReport = null
},
)
}
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
navRequestOf(intent)?.let { navRequest = it }
}
override fun onResume() {
super.onResume()
// A successful start breaks any loop; reset the timing trail so a later
@@ -102,13 +178,79 @@ class MainActivity : ComponentActivity() {
companion object {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
private const val EXTRA_SEED = "agendula_seed"
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
fun taskIntent(context: Context, taskId: Long): Intent =
/**
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
* picks the occurrence of a recurring task.
*/
fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): Intent =
Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId)
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/** Opens Settings → Accounts, where the sync notice's detail lives. */
fun openIntent(context: Context): Intent =
Intent(context, MainActivity::class.java)
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** The launcher shortcut's and Quick Settings tile's "New task". */
fun newTaskIntent(context: Context): Intent =
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
fun todayIntent(context: Context): Intent =
Intent(ACTION_TODAY, null, context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** Opens one smart list; the data URI keeps each widget's PendingIntent apart. */
fun smartListIntent(context: Context, list: SmartList): Intent =
Intent(ACTION_OPEN_SMART, "agendula://smart/${list.name}".toUri(), context, MainActivity::class.java)
.putExtra(EXTRA_SMART_LIST, list.name)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** Opens one real list. */
fun listIntent(context: Context, listId: Long): Intent =
Intent(ACTION_OPEN_LIST, "agendula://list/$listId".toUri(), context, MainActivity::class.java)
.putExtra(EXTRA_LIST_ID, listId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
internal fun navRequestOf(intent: Intent?): NavRequest? {
if (intent == null) return null
intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenAccount(it) }
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
when (intent.action) {
ACTION_NEW_TASK -> return NavRequest.NewTask()
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
?.let { return NavRequest.OpenSmart(it) }
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenList(it) }
}
importIntentUri(intent)?.let { return NavRequest.Import(it) }
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
?: intent.getStringExtra(Intent.EXTRA_TEXT)
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT))
}
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
.takeIf { it != NO_OCCURRENCE }
return NavRequest.OpenTask(taskId, occurrence)
}
}
}
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
@@ -10,29 +12,111 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
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.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Provider
import javax.inject.Singleton
/**
* ⚠️ Every one of these needs a corruption handler, and without one a truncated
* `preferences_pb` is a **crash at every launch**: `DataStore.data` throws
* `CorruptionException` on collection, and the collectors here are root
* coroutines in a scope with no handler. A file half-written by a kill during
* `edit` is the ordinary way to get one.
*
* Starting empty is the only recovery available and it is a mild one: the
* settings store falls back to defaults, the sync-state store to "never
* reconciled", and the credential store to accounts that ask to be signed in
* again — all states the app already knows how to be in, unlike a launch loop.
*/
private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "agendula_prefs",
corruptionHandler = replaceCorrupted(),
)
/** See [CredentialsDataStore] for why this is a separate file. */
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
name = CREDENTIALS_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/** See [SyncStateDataStore] for why this is a separate file. */
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
name = SYNC_STATE_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
*/
const val CREDENTIALS_DATASTORE = "agendula_credentials"
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
*/
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
@Module
@InstallIn(SingletonComponent::class)
abstract class DataBindModule {
@Binds
@Singleton
abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
@Binds
@Singleton
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
@Binds
@Singleton
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
@Binds
@Singleton
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
@Binds
@Singleton
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
@Binds
@Singleton
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
// Deliberately unqualified-free of the routing above: this is the external
// store itself, for the one caller that has to read it while another store is
// the active one.
@Binds
@Singleton
@ExternalStore
abstract fun bindExternalTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
}
@Module
@@ -44,7 +128,53 @@ object DataProvideModule {
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.agendulaDataStore
@Provides
@Singleton
@CredentialsDataStore
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.credentialsDataStore
@Provides
@Singleton
@SyncStateDataStore
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.syncStateDataStore
@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
@IoDispatcher
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)
}
@@ -0,0 +1,23 @@
package de.jeanlucmakiola.agendula.data.di
import android.content.Intent
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
/**
* Something a build variant wants to run when the app is launched with [Intent].
* Release builds contribute none; the debug source set adds the demo seeder, so
* that class never ships.
*/
fun interface LaunchHook {
suspend fun onLaunch(intent: Intent)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class LaunchHookModule {
@Multibinds
abstract fun launchHooks(): Set<LaunchHook>
}
@@ -6,3 +6,58 @@ import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
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
/**
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
*
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
* `datastore/`, and a restored ciphertext is permanently undecryptable because
* Keystore keys are non-exportable. Its own file is what lets the backup rules
* exclude the credentials and nothing else — excluding the whole database or
* all of DataStore would trade a latent bug for a live one.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class CredentialsDataStore
/**
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
* counters and the full-reconciliation clock.
*
* Its own file for the same reason the credentials have one: Auto Backup
* includes `datastore/`, and every value in here is a statement about *this*
* device's conversation with a server. Restored onto a new install they are all
* lies, and two of them are dangerous — a restored "reconciled recently" makes
* the engine trust a sync token for another day, which is precisely the silently
* pruned change log the full path exists to catch, and a restored quarantine
* count silently skips resources that were never tried here.
*
* Not user data, so nothing is lost by excluding it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncStateDataStore
/**
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
* whichever store the active mode selects.
*
* Only the one-time copy into our own store needs to name a store this way;
* everything else goes through the routed source and must keep doing so. Having
* it as a binding rather than depending on the concrete class is also what lets
* that copy be tested against a fake.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ExternalStore
@@ -0,0 +1,133 @@
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 is translated
* (see `res/xml/locales_config.xml`), so the wording has to come from a string
* resource rather than being built here.
*/
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.
*
* 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"
}
}
@@ -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"
}
}
}
@@ -8,25 +8,59 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
import de.jeanlucmakiola.floret.reminders.normalizeReminders
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
import kotlinx.coroutines.flow.Flow
import java.time.DayOfWeek
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** Clock convention: AUTO follows the device's 24-hour switch. */
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
TimeFormatPref.AUTO -> systemIs24Hour
TimeFormatPref.TWELVE_HOUR -> false
TimeFormatPref.TWENTY_FOUR_HOUR -> true
}
const val DEFAULT_SNOOZE_MINUTES = 10
/** The snooze lengths Settings offers. */
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
/** Minutes between background syncs; 0 = only when asked. */
const val DEFAULT_SYNC_INTERVAL_MINUTES = 240
/** The sync intervals Settings offers; 15 minutes is WorkManager's floor. */
val SYNC_INTERVAL_PRESETS = listOf(15, 30, 60, 120, 240, 720, 1_440, 0)
/** 09:00. */
const val DEFAULT_ALL_DAY_REMINDER_MINUTE = 9 * 60
data class Settings(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
/** The list a new task defaults to; `null` = first available. */
val defaultListId: Long? = null,
/** Default minutes before due to remind; 0 = at due time. */
val reminderLeadMinutes: Int = 0,
/** Default reminders, as minutes before due (0 = at due time); empty = none. */
val defaultReminderMinutes: List<Int> = listOf(0),
/** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */
val defaultAllDayReminderMinutes: List<Int> = listOf(0),
/** Local time (minutes from midnight) an all-day task's reminder counts back from. */
val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
/** Master switch for due reminders; off clears every scheduled alarm. */
val remindersEnabled: Boolean = true,
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
@@ -38,22 +72,52 @@ data class Settings(
*/
val bottomAddBar: Boolean = false,
/**
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
* overrides the global default (a null value = no reminder); absent = inherit.
* Per-list overrides of [defaultReminderMinutes]: a list present in the map
* overrides the global default (an empty list = no reminder); absent =
* inherit.
*/
val perListReminderOverride: Map<Long, Int?> = emptyMap(),
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */
val perListAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(),
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
/** How long a reminder's "Snooze" action puts it off. */
val snoozeMinutes: Int = DEFAULT_SNOOZE_MINUTES,
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** The first day of the week; `null` follows the locale. */
val weekStart: DayOfWeek? = null,
/** Put the cursor in the title (and raise the keyboard) when a new task opens. */
val autofocusTitle: Boolean = true,
/** Minutes between background syncs of every account; 0 = manual only. */
val syncIntervalMinutes: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
/** Take server changes by push when a UnifiedPush distributor is installed. */
val pushEnabled: Boolean = true,
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
val hiddenFromSmartLists: Set<Long> = emptySet(),
) {
/** The lead time for a task in [listId]: its override if set, else the global default. */
fun reminderLeadFor(listId: Long): Int? =
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
if (filter is TaskFilter.Smart && hiddenFromSmartLists.isNotEmpty()) {
tasks.filter { it.listId !in hiddenFromSmartLists }
} else {
tasks
}
/** The lead times for a task in [listId]: its override if set, else the global default. */
fun reminderLeadsFor(listId: Long): List<Int> =
perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes)
/** The lead times for an all-day task in [listId]. */
fun allDayReminderLeadsFor(listId: Long): List<Int> =
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
}
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@Singleton
class SettingsPrefs @Inject constructor(
private val dataStore: DataStore<Preferences>,
private val resolver: ProviderResolver,
) {
val settings: Flow<Settings> = dataStore.data.map { p ->
Settings(
@@ -61,29 +125,108 @@ class SettingsPrefs @Inject constructor(
?: ThemeMode.SYSTEM,
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
// The single lead of earlier versions carries over until a list is saved.
defaultReminderMinutes = p[DEFAULT_REMINDERS]?.let(::parseMinutes)
?: listOf(p[REMINDER_LEAD] ?: 0),
defaultAllDayReminderMinutes = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseMinutes) ?: listOf(0),
allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 }
?: DEFAULT_ALL_DAY_REMINDER_MINUTE,
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]),
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(),
sortOrder = p[SORT_ORDER]?.let { runCatching { TaskSortOrder.valueOf(it) }.getOrNull() }
?: TaskSortOrder.DUE,
snoozeMinutes = p[SNOOZE_MINUTES]?.takeIf { it > 0 } ?: DEFAULT_SNOOZE_MINUTES,
timeFormat = p[TIME_FORMAT]?.let { runCatching { TimeFormatPref.valueOf(it) }.getOrNull() }
?: TimeFormatPref.AUTO,
autofocusTitle = p[AUTOFOCUS_TITLE] ?: true,
syncIntervalMinutes = p[SYNC_INTERVAL]?.takeIf { it == 0 || it >= 15 } ?: DEFAULT_SYNC_INTERVAL_MINUTES,
weekStart = p[WEEK_START]?.let { runCatching { DayOfWeek.valueOf(it) }.getOrNull() },
pushEnabled = p[PUSH_ENABLED] ?: true,
hiddenFromSmartLists = modeOf(p).name.let { mode ->
p[SMART_LIST_HIDDEN].orEmpty()
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
.toSet()
},
)
}
suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
suspend fun setSyncIntervalMinutes(minutes: Int) = dataStore.edit { it[SYNC_INTERVAL] = minutes }
suspend fun setPushEnabled(enabled: Boolean) = dataStore.edit { it[PUSH_ENABLED] = enabled }
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
if (day == null) it.remove(WEEK_START) else it[WEEK_START] = day.name
}
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
}
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
suspend fun setDefaultReminderMinutes(minutes: List<Int>) = dataStore.edit {
it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",")
}
/** One-time reminder onboarding gate; false until the step has been shown. */
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = dataStore.edit {
it[DEFAULT_ALL_DAY_REMINDERS] = minutes.normalizeReminders().joinToString(",")
}
suspend fun setReminderOnboardingDone() = dataStore.edit { it[REMINDER_ONBOARDING_DONE] = true }
suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) =
dataStore.edit { it[ALL_DAY_REMINDER_MINUTE] = minuteOfDay.coerceIn(0, 24 * 60 - 1) }
/**
* 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 -> storedMode(p[STORAGE_MODE]) }
private fun storedMode(stored: String?): StorageMode? = when (stored) {
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()
}
/** The store list ids belong to; each store numbers its lists on its own. */
private fun modeOf(p: Preferences): StorageMode = storedMode(p[STORAGE_MODE]) ?: resolver.autoMode()
suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name }
/**
* One-time first-run gate; false until the flow has been walked through.
*
* The key still says `reminder_onboarding_done` — it gated a single reminder
* step before the flow grew around it, and renaming it would drag every
* existing install back through onboarding.
*/
val onboardingDone: Flow<Boolean> = dataStore.data.map { it[ONBOARDING_DONE] ?: false }
suspend fun setOnboardingDone() = dataStore.edit { it[ONBOARDING_DONE] = true }
suspend fun setRemindersEnabled(enabled: Boolean) = dataStore.edit { it[REMINDERS_ENABLED] = enabled }
@@ -98,6 +241,19 @@ class SettingsPrefs @Inject constructor(
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
}
/** Set (or clear) a list's all-day reminder override. */
suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
current.applyReminderOverride(listId, override)
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
}
suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p ->
val entry = "${modeOf(p).name}:$listId"
val current = p[SMART_LIST_HIDDEN].orEmpty()
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
}
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
@@ -107,12 +263,25 @@ class SettingsPrefs @Inject constructor(
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
val DEFAULT_REMINDERS = stringPreferencesKey("default_reminder_minutes")
val DEFAULT_ALL_DAY_REMINDERS = stringPreferencesKey("default_all_day_reminder_minutes")
val LIST_ALL_DAY_REMINDER_OVERRIDE = stringPreferencesKey("list_all_day_reminder_override")
val ALL_DAY_REMINDER_MINUTE = intPreferencesKey("all_day_reminder_minute")
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
val ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
val STORAGE_MODE = stringPreferencesKey("storage_mode")
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
val SORT_ORDER = stringPreferencesKey("sort_order")
val SNOOZE_MINUTES = intPreferencesKey("snooze_minutes")
val TIME_FORMAT = stringPreferencesKey("time_format")
val WEEK_START = stringPreferencesKey("week_start")
val AUTOFOCUS_TITLE = booleanPreferencesKey("autofocus_title")
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
}
}
@@ -122,3 +291,7 @@ class SettingsPrefs @Inject constructor(
* data migration.
*/
private val reminderCodec = ReminderOverrideCodec.DEFAULT
/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */
private fun parseMinutes(stored: String): List<Int> =
stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders()
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.reminders
import android.app.AlarmManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -10,20 +11,46 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Re-arms all reminder alarms after a reboot (alarms don't survive it). */
/**
* Re-arms reminder alarms after a reboot (alarms don't survive it), an app update, a clock or
* zone change, or an exact-alarm grant — which only upgrades alarms set after it, so the whole
* set is cancelled and armed again.
*/
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject lateinit var scheduler: ReminderScheduler
@Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
if (intent.action == AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED) {
val pending = goAsync()
scope.launch {
try {
runCatching { scheduler.sync(rearmAll = true) }
} finally {
pending.finish()
}
}
return
}
val afterReboot = when (intent.action) {
Intent.ACTION_BOOT_COMPLETED -> true
// All-day reminders fire at a local wall-clock time, so their instant moves with the zone.
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_TIME_CHANGED,
-> false
else -> return
}
ReminderMaintenanceWorker.schedule(context)
val pending = goAsync()
scope.launch {
try {
scheduler.sync()
runCatching { scheduler.sync(afterReboot = afterReboot) }
if (afterReboot) runCatching { snoozeScheduler.rearm() }
} finally {
pending.finish()
}
@@ -3,9 +3,12 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -24,6 +27,7 @@ class DueReminderReceiver : BroadcastReceiver() {
@Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var notifier: TaskNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var scheduler: ReminderScheduler
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -33,9 +37,15 @@ class DueReminderReceiver : BroadcastReceiver() {
val pending = goAsync()
scope.launch {
try {
if (!settingsPrefs.settings.first().remindersEnabled) return@launch
val task = runCatching { dataSource.task(taskId) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task)
val triggerAt = intent.getLongExtra(EXTRA_TRIGGER_AT, -1L)
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE, ScheduledReminder.NO_OCCURRENCE)
if (triggerAt >= 0L) {
runCatching { scheduler.markFired(ScheduledReminder(taskId, triggerAt, occurrence)) }
}
val settings = settingsPrefs.settings.first()
if (!settings.remindersEnabled) return@launch
val task = runCatching { dataSource.occurrence(taskId, occurrence) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task, settings)
} finally {
pending.finish()
}
@@ -44,8 +54,37 @@ class DueReminderReceiver : BroadcastReceiver() {
companion object {
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
private const val EXTRA_TRIGGER_AT = "de.jeanlucmakiola.agendula.extra.TRIGGER_AT"
private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
fun intent(context: Context, taskId: Long): Intent =
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)
/**
* The trigger 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, reminder: ScheduledReminder): Intent =
Intent(context, DueReminderReceiver::class.java)
.setData("agendula://reminder/${reminder.taskId}/${reminder.triggerAt}".toUri())
.putExtra(EXTRA_TASK_ID, reminder.taskId)
.putExtra(EXTRA_TRIGGER_AT, reminder.triggerAt)
.putExtra(EXTRA_OCCURRENCE, reminder.occurrenceStart)
}
}
/**
* The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the
* task's current one when there is no anchor or it can no longer be found.
* [TasksDataSource.task] alone resolves a series to whichever occurrence is
* current, which is not necessarily the one a reminder was armed for.
*/
internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? {
val current = task(taskId) ?: return null
if (occurrenceStart == ScheduledReminder.NO_OCCURRENCE ||
current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
) {
return current
}
return tasks(TaskQuery(listId = current.listId, includeCompleted = true))
.firstOrNull { it.taskId == taskId && it.occurrenceStart?.toEpochMilliseconds() == occurrenceStart }
?: current
}
@@ -3,7 +3,9 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -20,10 +22,25 @@ import javax.inject.Inject
class ProviderChangeReceiver : BroadcastReceiver() {
@Inject lateinit var scheduler: ReminderScheduler
@Inject lateinit var providerResolver: ProviderResolver
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
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()
scope.launch {
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
}
}
@@ -0,0 +1,99 @@
package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Instant
/**
* The "Done" and "Snooze" buttons on a reminder, plus the re-show when a snooze
* elapses. All app-internal intents, so the receiver is not exported.
*
* - **Done** completes the task — only the reminded occurrence, for a series.
* - **Snooze** hides the notification and arms [ReminderSnoozeScheduler]'s
* alarm, which lives outside [ScheduledReminderStore] so no scheduler pass
* can cancel it.
* - **Show** re-posts it, if the task is still open by then.
*/
@AndroidEntryPoint
class ReminderActionReceiver : BroadcastReceiver() {
@Inject lateinit var notifier: TaskNotifier
@Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
@Inject lateinit var reminderScheduler: ReminderScheduler
@Inject lateinit var repository: TasksRepository
@Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var startupGate: StartupGate
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE, ScheduledReminder.NO_OCCURRENCE)
val action = intent.action ?: return
if (action == ACTION_DONE || action == ACTION_SNOOZE) notifier.cancel(taskId)
val pending = goAsync()
scope.launch {
try {
when (action) {
ACTION_DONE -> runCatching {
startupGate.awaitReady()
val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE }
?.let(Instant::fromEpochMilliseconds)
repository.setCompleted(taskId, anchor, completed = true)
reminderScheduler.sync()
}
ACTION_SNOOZE -> runCatching {
val minutes = settingsPrefs.settings.first().snoozeMinutes
snoozeScheduler.schedule(taskId, occurrence, System.currentTimeMillis() + minutes * 60_000L)
}
ACTION_SHOW -> runCatching {
snoozeScheduler.clear(taskId, occurrence)
val settings = settingsPrefs.settings.first()
if (!settings.remindersEnabled) return@runCatching
startupGate.awaitReady()
val task = dataSource.occurrence(taskId, occurrence)
if (task != null && !task.isClosed) notifier.postDue(task, settings)
}
}
} finally {
pending.finish()
}
}
}
companion object {
const val ACTION_DONE = "de.jeanlucmakiola.agendula.reminders.DONE"
const val ACTION_SNOOZE = "de.jeanlucmakiola.agendula.reminders.SNOOZE"
const val ACTION_SHOW = "de.jeanlucmakiola.agendula.reminders.SHOW"
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
/**
* The data URI is what keeps two reminders' PendingIntents apart —
* `filterEquals` never compares extras.
*/
fun intent(context: Context, action: String, taskId: Long, occurrenceStart: Long): Intent =
Intent(context, ReminderActionReceiver::class.java)
.setAction(action)
.setData("agendula://reminder-action/$taskId/$occurrenceStart".toUri())
.putExtra(EXTRA_TASK_ID, taskId)
.putExtra(EXTRA_OCCURRENCE, occurrenceStart)
}
}
@@ -0,0 +1,32 @@
package de.jeanlucmakiola.agendula.data.reminders
/**
* What one scheduler pass does to the armed set.
*
* `previous` is only what the store *believes* is armed. AlarmManager forgets
* everything on reboot and force-stop, and a restore brings the store back on a
* device that never armed any of it, so each entry is checked against `isArmed`
* first. `fired` entries already went off and count as live, so they are
* neither re-armed nor re-fired.
*/
internal data class ReminderDiff(
val cancel: Set<ScheduledReminder>,
val arm: Set<ScheduledReminder>,
val keep: Set<ScheduledReminder>,
) {
companion object {
fun of(
previous: Set<ScheduledReminder>,
desired: Set<ScheduledReminder>,
fired: Set<ScheduledReminder> = emptySet(),
isArmed: (ScheduledReminder) -> Boolean,
): ReminderDiff {
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) }
return ReminderDiff(
cancel = live - desired,
arm = desired - live,
keep = desired intersect live,
)
}
}
}
@@ -0,0 +1,43 @@
package de.jeanlucmakiola.agendula.data.reminders
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
/**
* A daily re-sync under the alarms. The scheduler only arms a rolling window,
* so without a pass now and then a user who never opens the app runs off its
* far edge; it also repairs alarms a device dropped without a reboot to say so.
*/
@HiltWorker
class ReminderMaintenanceWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val scheduler: ReminderScheduler,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
runCatching { scheduler.sync() }
return Result.success()
}
companion object {
private const val WORK_NAME = "reminder-maintenance"
/** Idempotent; every launch may call it. */
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
.setInitialDelay(1, TimeUnit.DAYS)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
}
}
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.agendula.data.reminders
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.calendarDate
import java.time.LocalTime
import java.time.ZoneId
import kotlin.time.Instant
/** Which alarms the scheduler wants armed, from the tasks and settings alone. */
internal object ReminderPlanner {
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
/**
* Alarms this app will hold at once.
*
* Android 12+ throws at 500 per uid. Well under it, because the count is
* per *uid* and this is not the only thing in the process that can arm
* one — and because the alarms nearest in time are the ones that matter,
* while the far edge of the window is re-armed by the next sync.
*/
const val MAX_ALARMS = 400
fun plan(
tasks: List<Task>,
perTask: Map<Long, List<TaskReminder>>,
settings: Settings,
now: Long,
zone: ZoneId,
): Set<ScheduledReminder> = tasks
// One reminder per *occurrence* and lead: a recurring series yields a row
// per occurrence, all sharing a taskId, so this is a Set rather than a
// taskId-keyed Map.
.filter { !it.isClosed && it.due != null }
.flatMap { task ->
// Reminders set on the task itself win — every one of them, not just
// the one the editor shows; otherwise the task's list may override the
// global lead, or opt out entirely (override = null). All-day tasks
// have their own day-scale defaults.
val reminders = perTask[task.taskId].orEmpty().ifEmpty {
val leads = if (task.isAllDay) settings.allDayReminderLeadsFor(task.listId)
else settings.reminderLeadsFor(task.listId)
leads.map { TaskReminder(it) }
}
reminders.map { reminder ->
// 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*.
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
ScheduledReminder(
taskId = task.taskId,
triggerAt = triggerAt(
anchor,
task.isAllDay,
reminder.minutesBefore,
settings.allDayReminderMinuteOfDay,
zone,
),
occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
)
}
}
// The lower bound trails `now` so a reminder missed while the device was
// off still fires once on boot instead of being silently dropped.
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..(now + WINDOW_MS) }
// ⚠️ Bounded, soonest first: Android 12+ throws at 500 concurrent exact
// alarms per app. What falls off is the far edge of the window, which the
// next sync arms as it comes closer.
.sortedBy { it.triggerAt }
.take(MAX_ALARMS)
.toSet()
/**
* When a reminder [leadMinutes] before [anchor] fires.
*
* ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a
* storage convention and not a moment — firing off it rang at 02:00 in
* Berlin and the evening before in New York. It is read as its calendar date
* at [allDayMinuteOfDay] local time instead, and the lead counts back from that.
*/
fun triggerAt(
anchor: Instant,
allDay: Boolean,
leadMinutes: Int,
allDayMinuteOfDay: Int,
zone: ZoneId,
): Long {
val base = if (allDay) {
val time = LocalTime.of(allDayMinuteOfDay / 60 % 24, allDayMinuteOfDay % 60)
anchor.calendarDate(allDay = true).atTime(time).atZone(zone).toInstant().toEpochMilli()
} else {
anchor.toEpochMilliseconds()
}
return base - leadMinutes.coerceAtLeast(0) * 60_000L
}
}
@@ -12,15 +12,19 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/**
* The self-scheduled due-reminder engine. Tasks providers don't deliver
* reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager]
* alarm each, within a rolling window. Re-run on app start, boot and provider
* change; it diffs against [ScheduledReminderStore] so only changed alarms move.
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
* and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run
* 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
class ReminderScheduler @Inject constructor(
@@ -29,71 +33,127 @@ class ReminderScheduler @Inject constructor(
private val settingsPrefs: SettingsPrefs,
private val store: ScheduledReminderStore,
private val providerResolver: ProviderResolver,
private val snoozeScheduler: ReminderSnoozeScheduler,
@IoDispatcher private val io: CoroutineDispatcher,
) {
suspend fun sync() = withContext(io) {
val provider = providerResolver.resolve()
private val syncLock = Mutex()
/**
* 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(afterReboot: Boolean = false, rearmAll: Boolean = false) =
withContext(io) { syncLock.withLock { syncLocked(afterReboot, rearmAll) } }
/** Record that [reminder] went off, so a later pass neither re-arms nor re-fires it. */
suspend fun markFired(reminder: ScheduledReminder) =
withContext(io) { syncLock.withLock { store.markFired(reminder) } }
/**
* @param afterReboot every alarm is gone, so nothing in the store is armed;
* skips asking the system about each one.
* @param rearmAll cancel every recorded alarm and arm the set afresh — after an
* exact-alarm grant, which leaves alarms set before it inexact.
*/
private suspend fun syncLocked(afterReboot: Boolean, rearmAll: Boolean) {
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()
return@withContext
return
}
val now = System.currentTimeMillis()
val horizon = now + WINDOW_MS
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return@withContext }
.getOrElse { return }
// Per-task leads. One query for all of them.
val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() }
val desired = ReminderPlanner.plan(
tasks = tasks,
perTask = perTask,
settings = settings,
now = System.currentTimeMillis(),
zone = ZoneId.systemDefault(),
)
val desired = tasks
.filter { !it.isClosed && it.due != null }
.mapNotNull { task ->
// The task's list may override the global lead, or opt out entirely
// (override = null), in which case it gets no reminder at all.
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
}
.toMap()
.filterValues { it in now..horizon }
val previous = store.all()
(previous.keys - desired.keys).forEach { cancel(it) }
desired.forEach { (taskId, triggerAt) ->
if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
// ⚠️ Revoking exact alarms cancels them without telling us, and a grant
// leaves the old ones inexact — either way the armed set can't be trusted.
val canExact = canScheduleExact()
val rearm = rearmAll || store.armedExact() != canExact
if (rearm) {
store.all().forEach { cancel(it) }
runCatching { snoozeScheduler.rearm() }
}
store.replace(desired)
// ⚠️ The store survives reboot, force-stop and restore; the alarms don't.
// Trusting it alone left every reminder after a reboot unarmed for good.
val fired = store.fired()
val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) }
diff.cancel.forEach { cancel(it) }
// ⚠️ Only what was actually armed. A throw mid-loop used to skip the
// write below entirely, so every alarm set on that pass went unrecorded
// — uncancellable, and firing for tasks that no longer exist — and the
// exception escaped into BootReceiver's goAsync().
val armed = diff.arm.filter { schedule(it) }
store.replace(diff.keep + armed, fired = fired intersect desired, exact = canExact)
}
/** Whether the system still holds this alarm's PendingIntent. */
private fun isArmed(reminder: ScheduledReminder): Boolean = pendingIntent(reminder, create = false) != null
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? {
private fun canScheduleExact(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager().canScheduleExactAlarms()
private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags)
return PendingIntent.getBroadcast(
context,
reminder.requestCode,
DueReminderReceiver.intent(context, reminder),
flags,
)
}
private fun schedule(taskId: Long, triggerAt: Long) {
val pi = pendingIntent(taskId, create = true) ?: return
/** @return whether the alarm is now armed, and so worth recording. */
private fun schedule(reminder: ScheduledReminder): Boolean {
val triggerAt = reminder.triggerAt
val pi = pendingIntent(reminder, create = true) ?: return false
val am = alarmManager()
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
if (canExact) {
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
} else {
am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi)
val canExact = canScheduleExact()
return try {
if (canExact) {
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
} else {
am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi)
}
true
} catch (_: IllegalStateException) {
// The concurrent-alarm ceiling, which [ReminderPlanner.MAX_ALARMS] keeps us under —
// but the count is per uid and nothing here owns all of it.
false
} catch (_: SecurityException) {
// Exact-alarm permission revoked between the check and the call.
false
}
}
private fun cancel(taskId: Long) {
pendingIntent(taskId, create = false)?.let {
private fun cancel(reminder: ScheduledReminder) {
pendingIntent(reminder, create = false)?.let {
alarmManager().cancel(it)
it.cancel()
}
}
private suspend fun clearAll() {
store.all().keys.forEach { cancel(it) }
store.replace(emptyMap())
}
private companion object {
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
store.all().forEach { cancel(it) }
store.replace(emptySet(), fired = emptySet())
}
}
@@ -0,0 +1,77 @@
package de.jeanlucmakiola.agendula.data.reminders
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* A one-off alarm that re-shows a snoozed reminder. Deliberately separate from
* [ReminderScheduler]'s diffed set: the reminder it re-shows has already fired,
* so the next scheduler pass would otherwise treat it as stale and cancel it.
*
* Pending snoozes are persisted, since alarms do not survive a reboot; [rearm]
* puts them back, and one whose time passed while the device was off fires at once.
*/
@Singleton
class ReminderSnoozeScheduler @Inject constructor(
@ApplicationContext private val context: Context,
private val dataStore: DataStore<Preferences>,
) {
suspend fun schedule(taskId: Long, occurrenceStart: Long, triggerAtMillis: Long) {
dataStore.edit { p ->
p[KEY] = p[KEY].orEmpty().filterNot { it.startsWith("$taskId|$occurrenceStart|") }.toSet() +
"$taskId|$occurrenceStart|$triggerAtMillis"
}
arm(taskId, occurrenceStart, triggerAtMillis)
}
/** Forget a snooze once it has re-shown. */
suspend fun clear(taskId: Long, occurrenceStart: Long) {
dataStore.edit { p ->
p[KEY] = p[KEY].orEmpty().filterNot { it.startsWith("$taskId|$occurrenceStart|") }.toSet()
}
}
suspend fun rearm() {
val now = System.currentTimeMillis()
dataStore.data.first()[KEY].orEmpty().forEach { entry ->
val parts = entry.split('|').map { it.toLongOrNull() }
val (taskId, occurrence, triggerAt) = parts.takeIf { it.size == 3 && null !in it } ?: return@forEach
arm(taskId!!, occurrence!!, maxOf(triggerAt!!, now))
}
}
private fun arm(taskId: Long, occurrenceStart: Long, triggerAtMillis: Long) {
val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return
val pendingIntent = PendingIntent.getBroadcast(
context,
taskId.toInt(),
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, taskId, occurrenceStart),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
try {
if (canExact) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
} else {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
}
} catch (_: SecurityException) {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
}
}
private companion object {
/** `taskId|occurrenceStart|triggerAt` per pending snooze. */
val KEY = stringSetPreferencesKey("pending_snoozes")
}
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.reminders
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.first
@@ -9,29 +10,80 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Remembers which task reminders are currently scheduled (taskId → trigger time),
* so [ReminderScheduler] can diff against a fresh computation and cancel only the
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so
* the trigger time is part of the identity — keying by task alone would collapse
* a daily task down to a single reminder.
*/
data class ScheduledReminder(
val taskId: Long,
val triggerAt: Long,
/** The occurrence's `RECURRENCE-ID` anchor in epoch millis, or [NO_OCCURRENCE]. */
val occurrenceStart: Long = NO_OCCURRENCE,
) {
/**
* 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()
companion object {
const val NO_OCCURRENCE = -1L
}
}
/**
* Remembers which task reminders are currently armed, so [ReminderScheduler] can
* diff against a fresh computation and touch only the alarms that changed, and
* which of them already fired. Persisted in DataStore as sets of
* `taskId|trigger|occurrence` strings.
*/
@Singleton
class ScheduledReminderStore @Inject constructor(
private val dataStore: DataStore<Preferences>,
) {
suspend fun all(): Map<Long, Long> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
val parts = entry.split('|')
val id = parts.getOrNull(0)?.toLongOrNull()
val at = parts.getOrNull(1)?.toLongOrNull()
if (id != null && at != null) id to at else null
}.toMap()
suspend fun all(): Set<ScheduledReminder> = read(KEY)
suspend fun replace(scheduled: Map<Long, Long>) {
/** Reminders that went off, and so must not be armed again while still desired. */
suspend fun fired(): Set<ScheduledReminder> = read(FIRED_KEY)
/** Whether the last pass could arm exact alarms; null before the first pass that recorded it. */
suspend fun armedExact(): Boolean? = dataStore.data.first()[EXACT_KEY]
suspend fun replace(
scheduled: Set<ScheduledReminder>,
fired: Set<ScheduledReminder>? = null,
exact: Boolean? = null,
) {
dataStore.edit { prefs ->
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
prefs[KEY] = encode(scheduled)
if (fired != null) prefs[FIRED_KEY] = encode(fired)
if (exact != null) prefs[EXACT_KEY] = exact
}
}
private companion object {
val KEY = stringSetPreferencesKey("scheduled_reminders")
suspend fun markFired(reminder: ScheduledReminder) {
dataStore.edit { prefs ->
prefs[FIRED_KEY] = prefs[FIRED_KEY].orEmpty() + encode(setOf(reminder))
}
}
private suspend fun read(key: Preferences.Key<Set<String>>): Set<ScheduledReminder> =
dataStore.data.first()[key].orEmpty().mapNotNull(::decode).toSet()
internal companion object {
private val KEY = stringSetPreferencesKey("scheduled_reminders")
private val FIRED_KEY = stringSetPreferencesKey("fired_reminders")
private val EXACT_KEY = booleanPreferencesKey("reminders_armed_exact")
fun encode(set: Set<ScheduledReminder>): Set<String> =
set.mapTo(HashSet()) { "${it.taskId}|${it.triggerAt}|${it.occurrenceStart}" }
fun decode(entry: String): ScheduledReminder? {
val parts = entry.split('|')
val id = parts.getOrNull(0)?.toLongOrNull() ?: return null
val at = parts.getOrNull(1)?.toLongOrNull() ?: return null
val occ = parts.getOrNull(2)?.toLongOrNull() ?: ScheduledReminder.NO_OCCURRENCE
return ScheduledReminder(id, at, occ)
}
}
}
@@ -3,36 +3,59 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.text.format.DateFormat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.calendarDate
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.Instant as JInstant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.temporal.ChronoUnit
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/**
* Posts one notification per due task on a dedicated channel. The tag is the
* task id, so a re-fired alarm replaces rather than duplicates. Tapping opens
* the app (later: the task's detail screen — see [MainActivity]).
* Posts one notification per due task on its list's channel, so each list can
* have its own sound and importance. The tag is the task id, so a re-fired alarm
* replaces rather than duplicates. Tapping opens the task's detail screen (see
* [MainActivity.taskIntent]).
*/
@Singleton
class TaskNotifier @Inject constructor(
@ApplicationContext private val context: Context,
private val resolver: ProviderResolver,
private val dataSource: TasksDataSource,
private val startupGate: StartupGate,
@ApplicationScope private val scope: CoroutineScope,
) {
private val manager get() = context.getSystemService(NotificationManager::class.java)
private val channelPrefs get() = context.getSharedPreferences(CHANNEL_PREFS, Context.MODE_PRIVATE)
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
@@ -42,20 +65,32 @@ class TaskNotifier @Inject constructor(
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
@SuppressLint("MissingPermission")
fun postDue(task: Task) {
fun postDue(task: Task, settings: Settings) {
if (!canPost()) return
ensureChannel()
val channelId = ensureListChannel(task.listId, task.listName)
val title = task.title.ifBlank { context.getString(R.string.task_untitled) }
val text = task.due?.let { due ->
context.getString(R.string.reminder_due_at, formatDue(due.toEpochMilliseconds(), task.isAllDay))
context.getString(
R.string.reminder_due_at,
formatReminderDue(
due.toEpochMilliseconds(),
task.isAllDay,
is24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context)),
relative = RelativeDays(
today = context.getString(R.string.reminder_day_today),
tomorrow = context.getString(R.string.reminder_day_tomorrow),
yesterday = context.getString(R.string.reminder_day_yesterday),
),
),
)
}
val tapIntent = PendingIntent.getActivity(
context,
task.taskId.toInt(),
MainActivity.taskIntent(context, task.taskId),
MainActivity.taskIntent(context, task.taskId, task.occurrenceStart?.toEpochMilliseconds()),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.apply { if (text != null) setContentText(text) }
@@ -63,36 +98,145 @@ class TaskNotifier @Inject constructor(
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(tapIntent)
.addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE))
.addAction(
0,
context.getString(R.string.reminder_action_snooze, settings.snoozeMinutes),
actionIntent(task, ReminderActionReceiver.ACTION_SNOOZE),
)
.build()
NotificationManagerCompat.from(context).notify(task.taskId.toString(), NOTIFICATION_ID, notification)
}
private fun formatDue(millis: Long, allDay: Boolean): String {
val zone = ZoneId.systemDefault()
val style = if (allDay) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
} else {
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
}
return JInstant.ofEpochMilli(millis).atZone(zone)
.format(style.withLocale(Locale.getDefault()))
fun cancel(taskId: Long) {
NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID)
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast(
context,
task.taskId.toInt(),
ReminderActionReceiver.intent(
context,
action,
task.taskId,
task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/**
* Creates [listId]'s channel, or renames it after the list, and returns its id.
* A new channel starts at the importance the old shared channel had, so
* a user who silenced reminders before per-list channels stays silenced.
*/
fun ensureListChannel(listId: Long, listName: String?): String {
ensureGroup()
val id = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_$listId"
val importance = manager.getNotificationChannel(id)?.importance ?: legacyImportance()
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reminder_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply { description = context.getString(R.string.reminder_channel_desc) },
id,
listName?.takeIf { it.isNotBlank() } ?: context.getString(R.string.reminder_channel_name),
importance,
).apply { group = GROUP_ID },
)
return id
}
/** The old shared channel's importance, remembered past its deletion for lists made later. */
private fun legacyImportance(): Int =
manager.getNotificationChannel(LEGACY_CHANNEL_ID)?.importance
?: channelPrefs.getInt(KEY_LEGACY_IMPORTANCE, NotificationManager.IMPORTANCE_HIGH)
private fun ensureGroup() {
manager.createNotificationChannelGroup(
NotificationChannelGroup(GROUP_ID, context.getString(R.string.reminder_channel_name))
.apply { description = context.getString(R.string.reminder_channel_desc) },
)
}
private companion object {
const val CHANNEL_ID = "task_reminders"
const val NOTIFICATION_ID = 1
/**
* Brings the channels in line with the current store's lists: one per list,
* named after it and the group in the current language, none for a list that
* is gone. Retires the old shared channel once its lists have theirs.
*/
fun refreshChannel() {
scope.launch {
runCatching {
startupGate.awaitReady()
val lists = dataSource.taskLists()
lists.forEach { ensureListChannel(it.id, it.name) }
val prefix = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_"
val live = lists.map { "$prefix${it.id}" }.toSet()
manager.notificationChannels
.filter { it.id.startsWith(prefix) && it.id !in live }
.forEach { manager.deleteNotificationChannel(it.id) }
manager.getNotificationChannel(LEGACY_CHANNEL_ID)?.let { legacy ->
channelPrefs.edit { putInt(KEY_LEGACY_IMPORTANCE, legacy.importance) }
manager.deleteNotificationChannel(LEGACY_CHANNEL_ID)
}
}
}
}
companion object {
const val GROUP_ID = "task_reminders"
private const val LEGACY_CHANNEL_ID = "task_reminders"
private const val LIST_CHANNEL_PREFIX = "reminders_"
private const val NOTIFICATION_ID = 1
private const val CHANNEL_PREFS = "reminder_channels"
private const val KEY_LEGACY_IMPORTANCE = "legacy_importance"
/**
* Whether a reminder can sound anywhere: the group is not blocked and at
* least one reminder channel is on. No channel yet counts as on.
*/
fun remindersAudible(manager: NotificationManager): Boolean {
if (manager.getNotificationChannelGroup(GROUP_ID)?.isBlocked == true) return false
val channels = manager.notificationChannels.filter { it.group == GROUP_ID || it.id == LEGACY_CHANNEL_ID }
return channels.isEmpty() || channels.any { it.importance != NotificationManager.IMPORTANCE_NONE }
}
}
}
/** The words a reminder uses for the days either side of today. */
internal class RelativeDays(val today: String, val tomorrow: String, val yesterday: String)
/**
* The due line of a reminder. An all-day due is UTC midnight of its date, so it
* is read as that date rather than in the device zone, where it lands on the
* previous day west of Greenwich. With [relative], yesterday to tomorrow read as
* words and the rest of the coming week as its weekday.
*/
internal fun formatReminderDue(
millis: Long,
allDay: Boolean,
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault(),
is24Hour: Boolean? = null,
relative: RelativeDays? = null,
today: LocalDate = LocalDate.now(zone),
): String {
val at = JInstant.ofEpochMilli(millis).atZone(zone)
val date = if (allDay) Instant.fromEpochMilliseconds(millis).calendarDate(allDay = true) else at.toLocalDate()
val day = relative?.let { relativeDay(date, today, it, locale) }
?: date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale))
if (allDay) return day
val time = when (is24Hour) {
null -> at.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale))
else -> at.format(DateTimeFormatter.ofPattern(if (is24Hour) "HH:mm" else "h:mm a", locale))
}
return "$day, $time"
}
private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays, locale: Locale): String? {
val days = ChronoUnit.DAYS.between(today, date)
return when {
days == 0L -> words.today
days == 1L -> words.tomorrow
days == -1L -> words.yesterday
days in 2..6 -> date.format(DateTimeFormatter.ofPattern("EEEE", locale))
else -> null
}
}
@@ -0,0 +1,604 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.TaskCollection
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* The one thing the sign-in flow needs from [AccountRepository].
*
* A seam, so the flow's state machine can be tested without a database, a
* Keystore or an `AccountManager` — the three things that make the rest of this
* class Android-only.
*/
interface AccountCreator {
suspend fun create(
displayName: String,
username: String,
appPassword: String,
found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>,
/** An existing account to sign in again, instead of creating one. */
reauthenticating: Long? = null,
): AccountRepository.Outcome
}
/**
* Turns a finished sign-in into an account that exists in all three places it
* has to: the Room `accounts` row, the encrypted credential, and the
* system-visible `AccountManager` entry.
*
* The order matters. The Room row comes first because its id keys the
* credential, and the system account comes last because it is the one thing a
* user can see — an entry in Settings for an account whose credential failed to
* store would be a sync that silently never works.
*/
@Singleton
class AccountRepository @Inject constructor(
private val database: TasksDatabase,
private val credentials: CredentialStore,
private val accounts: CalDavAccounts,
private val syncTrigger: SyncTrigger,
private val cadence: SyncCadenceStore,
private val accountState: AccountStateStore,
private val quarantine: QuarantineStore,
private val notices: SyncNoticeStore,
private val collectionSupport: CollectionSupportStore,
private val gateway: CalDavGateway,
private val availability: SyncAvailability,
private val settings: SettingsPrefs,
private val push: PushRegistrar,
@IoDispatcher private val io: CoroutineDispatcher,
) : AccountCreator {
private suspend fun syncInterval(): Int = settings.settings.first().syncIntervalMinutes
/** What went wrong, in words a user can act on. */
sealed interface Outcome {
data class Created(val accountId: Long) : Outcome
data object AlreadyExists : Outcome
/** External storage mode is on, and nothing would ever show what this account syncs. */
data object ExternalStorage : Outcome
data class CredentialFailed(val cause: Cause, val detail: String = "") : Outcome
/**
* Why an account could not be saved, in a form the UI can translate.
*
* ⚠️ The UI renders *this*, never [CredentialFailed.detail] — which is
* a `Throwable.message` and so an untranslated, often unreadable string.
*/
enum class Cause {
/** The Keystore refused to hold the password. */
KEYSTORE_REFUSED,
/** Anything else that stopped the write. */
NOT_SAVED,
}
}
suspend fun all(): List<AccountEntity> = withContext(io) { database.accounts().all() }
/** The accounts, observed, so a sync landing updates a screen that is open. */
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
/** Every account's synced lists. */
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
/**
* Creates an account and the task lists the user chose.
*
* [selected] is a subset of what discovery found; a collection the user did
* not tick is simply not created, and can be added later without touching
* anything else — `task_lists.account_id` is a nullable FK, so attaching is
* an `UPDATE`.
*/
override suspend fun create(
displayName: String,
username: String,
appPassword: String,
found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>,
reauthenticating: Long?,
): Outcome = withContext(io) {
if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage
// "Sign in again" names its account, so a login name the server spells
// differently from last time still lands on it rather than beside it.
// Only on the same server: anything else is a different account.
val target = reauthenticating?.let { database.accounts().account(it) }
if (target != null && sameServer(target, found)) {
return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected)
}
// Both stores, not just one. There is no unique index on
// accounts.display_name and nothing prunes Room when the system account
// disappears, so "removed from system Settings, re-added here" would
// otherwise leave a second Room row and a duplicate of every list.
val systemAccount = accounts.find(displayName)
val existing = database.accounts().all().firstOrNull { it.displayName == displayName }
// ⚠️ Re-authentication, not a duplicate. An account stopped by a 401 has
// no other way back: `create` is the only path that writes a credential,
// and refusing it here left the user with "that account is already set
// up" and no option but to remove the account — discarding the choice to
// keep its lists attached. Narrow on purpose: a *healthy* account of the
// same name is still a duplicate, so this can never silently overwrite a
// working credential.
if (existing != null && accountState.needsSignIn(existing.id)) {
return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected)
}
if (existing != null) return@withContext Outcome.AlreadyExists
// ⚠️ In the system, not in Room: an orphan, not a duplicate. `remove()`
// ignores whether the AccountManager entry actually went (and `find`
// returns null while the device is locked), so this state is reachable —
// and refusing here left the user with an account that cannot be removed
// from inside the app at all, since the accounts screen is driven off
// Room. Clearing it up is kinder than refusing for ever.
systemAccount?.let { accounts.remove(it) }
// ⚠️ Uncancellable as a whole. The row, its lists, the credential and
// the system entry are four stores that cannot share a transaction, and
// the caller is a viewModelScope tied to the Settings destination — a
// back gesture during "Adding the account" would otherwise leave the row
// and its lists with no credential and no system account: the rollback
// never runs, `needsSignIn` is false so re-auth will not fire, and every
// retry answers AlreadyExists. `remove()` documents the same hazard.
withContext(NonCancellable) {
val inserted = mutableListOf<Long>()
val accountId = database.runInTransaction<Long> {
val id = database.accounts().insert(
AccountEntity(
displayName = displayName,
// Persist where a 301/308 actually put us — dav4jvm#209 exists
// precisely so this is knowable, and re-following the redirect
// on every sync is what not persisting it costs.
principalUrl = (found.movedTo ?: found.principal).toString(),
// The principal's own home set. `resolve("./")` on a collection
// URL is a no-op — CalDAV hrefs already end in "/" — so the
// old version stored the first collection's own URL, and that
// collection may not even be from the account's own home set.
homeSetUrl = found.homeSets.firstOrNull()?.toString(),
username = username,
),
)
inserted += attach(id, selected)
id
}
if (!credentials.put(accountId, appPassword)) {
// Never leave a half-made account behind: without a credential it
// would sit in Settings failing to sync with nothing to explain it.
rollback(accountId, inserted)
return@withContext Outcome.CredentialFailed(
Outcome.Cause.KEYSTORE_REFUSED,
"the device keystore would not store the password",
)
}
if (!accounts.add(displayName, accountId)) {
credentials.clear(accountId)
rollback(accountId, inserted)
return@withContext Outcome.AlreadyExists
}
// On the schedule from the moment it exists, and syncing immediately —
// an account that shows up empty until the first periodic window looks
// broken.
syncTrigger.schedule(displayName, syncInterval())
syncTrigger.enqueue(displayName)
Outcome.Created(accountId)
}
}
/**
* Points [selected] at [accountId], re-attaching what a previous removal
* left behind rather than inserting a second copy.
*
* ⚠️ `remove()` leaves the lists as device-only ones on purpose, so a plain
* insert gives the user their old "Personal" full of tasks *and* a freshly
* synced "Personal" holding the same tasks from the server. The row keeps
* its name, colour, ordering and tasks — the user's, not the server's — and
* loses only its cursor, because the account it was reconciled against is
* gone.
*
* ⚠️ On a re-authentication this also has to see the account's *own* lists,
* not just detached ones — the re-auth path walks the same picker, so a
* lookup that missed them would insert a second copy of every list the
* account already syncs. On a create the id was minted a statement earlier,
* so nothing is attached to it yet and only detached rows can match.
*
* @return the ids of the lists this call *created*, which are the only ones
* a rollback may delete.
*/
private fun attach(accountId: Long, selected: Set<TaskCollection>): List<Long> {
val known = database.taskLists().attachable(accountId).associateBy { it.href }
val inserted = mutableListOf<Long>()
selected.forEach { collection ->
val href = collection.url.toString()
val current = known[href]
if (current == null) {
inserted += database.taskLists().insert(
TaskListEntity(
name = collection.displayName ?: collection.url.pathSegments
.lastOrNull { it.isNotEmpty() }
.orEmpty(),
color = collection.color ?: DEFAULT_LIST_COLOR,
accountId = accountId,
isReadOnly = collection.readOnly,
href = href,
),
)
} else {
// Changing hands, as opposed to re-authenticating the account
// that already owns it: a cursor from the previous owner says
// nothing about this one, while the current owner's is still
// good and throwing it away costs a full reconciliation.
val changingHands = current.accountId != accountId
database.taskLists().update(
current.copy(
accountId = accountId,
isReadOnly = collection.readOnly,
syncToken = current.syncToken.takeUnless { changingHands },
ctag = current.ctag.takeUnless { changingHands },
),
)
}
}
return inserted
}
/** What the server offers an existing account, beside what it already syncs. */
sealed interface Collections {
data class Found(
val collections: List<TaskCollection>,
/** Hrefs of the collections this account already syncs. */
val attached: Set<String>,
) : Collections
/** The account is stopped, or has no credential we can use. */
data object NeedsSignIn : Collections
data class Failed(val cause: CalDavDiscovery.Outcome.Cause?) : Collections
}
/**
* Re-runs discovery for [accountId], so lists added on the server after
* setup can be picked up and ones that are synced can be dropped.
*/
suspend fun collections(accountId: Long): Collections = withContext(io) {
if (accountState.needsSignIn(accountId)) return@withContext Collections.NeedsSignIn
val account = database.accounts().account(accountId)
?: return@withContext Collections.Failed(null)
val username = account.username ?: return@withContext Collections.NeedsSignIn
val principal = account.principalUrl?.toHttpUrlOrNull()
?: return@withContext Collections.Failed(null)
val password = (credentials.get(accountId) as? CredentialStore.Secret.Present)?.value
?: return@withContext Collections.NeedsSignIn
val attached = database.taskLists().syncedForAccount(accountId)
.mapNotNull { it.href }
.toSet()
when (
val outcome = gateway.discover(
principal.toString(),
CalDavGateway.Credentials(username, password, principal),
)
) {
is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached)
is CalDavDiscovery.Outcome.NeedsAuthentication,
CalDavDiscovery.Outcome.Unauthenticated,
-> Collections.NeedsSignIn
is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause)
}
}
/**
* Makes [selected] the synced subset of [offered] for [accountId].
*
* Newly ticked collections are attached and synced. Unticked ones that were
* synced go the way an account removal takes its lists: detached as
* device-only lists when [keepUnticked], deleted from this device otherwise.
* Nothing is deleted on the server either way.
*/
suspend fun setSyncedCollections(
accountId: Long,
offered: List<TaskCollection>,
selected: Set<HttpUrl>,
keepUnticked: Boolean,
) = withContext(io) {
val account = database.accounts().account(accountId) ?: return@withContext
withContext(NonCancellable) {
val change = collectionChange(
offered,
selected,
database.taskLists().syncedForAccount(accountId),
)
database.runInTransaction {
attach(accountId, change.attach)
change.drop.forEach { list ->
if (keepUnticked) {
database.taskLists().setAccount(list.id, null)
} else {
database.taskLists().delete(list.id)
}
}
}
val dropped = change.drop.map { it.id }.toSet()
runCatching { push.forgetLists(accountId, dropped) }
cadence.forget(dropped)
quarantine.forget(dropped)
change.drop.forEach { notices.forgetList(accountId, it.name) }
if (change.attach.isNotEmpty()) syncTrigger.enqueue(account.displayName, expedited = true)
}
}
/**
* Gives a quarantined resource another go: its failure count is cleared and
* the account synced. If it fails again it is counted afresh, and the notice
* comes back once it reaches the threshold again.
*/
suspend fun retryQuarantined(accountId: Long, key: String) = withContext(io) {
val account = database.accounts().account(accountId) ?: return@withContext
val listIds = database.taskLists().syncedForAccount(accountId).map { it.id }.toSet()
quarantine.release(listIds, key)
notices.forgetQuarantined(accountId, key)
syncTrigger.enqueue(account.displayName, expedited = true)
}
/**
* Replaces the credential of an account the server had stopped accepting.
*
* The tasks stay exactly as they are — the password was the only thing that
* went stale. Everything the user was asked for on the way here is applied
* all the same:
*
* ⚠️ This branch used to take [appPassword] and drop [found] and [selected]
* on the floor. The user walked the whole add flow, ticked collections, and
* was shown Done — while no newly-ticked list was created, no unticked one
* was detached, and a moved principal or a corrected username was discarded,
* so a relocated account could never be repaired. It also never called
* `accounts.add`, so an account the user had deleted in Android Settings —
* nothing listens for `LOGIN_ACCOUNTS_CHANGED` — stayed absent from Settings
* for ever while syncing happily via WorkManager, and every later add
* answered AlreadyExists.
*/
private suspend fun reauthenticate(
existing: AccountEntity,
displayName: String,
username: String,
appPassword: String,
found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>,
): Outcome {
val accountId = existing.id
if (!credentials.put(accountId, appPassword)) {
return Outcome.CredentialFailed(
Outcome.Cause.KEYSTORE_REFUSED,
"the device keystore would not store the password",
)
}
withContext(NonCancellable) {
database.runInTransaction {
database.accounts().update(
existing.copy(
// Where discovery just found it, which is the only way a
// principal that has moved can ever be corrected.
principalUrl = (found.movedTo ?: found.principal).toString(),
homeSetUrl = found.homeSets.firstOrNull()?.toString()
?: existing.homeSetUrl,
username = username,
),
)
attach(accountId, selected)
}
// ⚠️ Ticked lists are attached; unticked ones are left alone. The
// picker pre-ticks everything *writable*, not everything already
// attached, so detaching what is unticked would silently stop
// syncing a read-only share the account has synced for months —
// over a default the user never chose. Detaching belongs here the
// day the picker knows what this account already holds.
// The Room row is the account as far as this app is concerned, so a
// missing system entry is re-registered rather than left behind.
if (accounts.find(displayName) == null) accounts.add(displayName, accountId)
accountState.setNeedsSignIn(accountId, false)
database.accounts().recordSync(accountId, at = null, error = null)
syncTrigger.schedule(displayName, syncInterval())
syncTrigger.enqueue(displayName)
}
return Outcome.Created(accountId)
}
/**
* Undoes a half-made account.
*
* ⚠️ The lists this attempt *created* have to go explicitly.
* `task_lists.account_id` is `ON DELETE SET NULL` — deliberately, so
* removing a working account never destroys tasks — which means deleting the
* account row alone would leave a set of empty device-only lists behind.
*
* ⚠️ And only those. A list [attach] re-attached was already on the device
* and holds the user's tasks; `SET NULL` returns it to being device-only,
* which is exactly where it came from.
*/
private fun rollback(accountId: Long, inserted: List<Long>) = database.runInTransaction {
inserted.forEach { database.taskLists().delete(it) }
database.accounts().delete(accountId)
}
/**
* Puts every existing account back on the periodic schedule.
*
* Cheap and idempotent — `KEEP` means an already-scheduled account is left
* exactly as it is — so calling it on app open costs nothing and repairs the
* one case WorkManager cannot: a schedule lost to "clear app data" or to a
* restore onto a device that never ran the account-add flow.
*/
suspend fun rescheduleAll(intervalChanged: Boolean = false) = withContext(io) {
val stopped = accountState.needingSignIn()
database.accounts().all().forEach { account ->
// ⚠️ A stopped account must not come back on the timer. `KEEP` only
// keeps work that is unfinished, and CANCELLED counts as finished —
// so rescheduling would re-enqueue the very request a 401 removed,
// and the next app open would put a dead app password back on a
// four-hour loop against a server that throttles by IP.
//
// This is also where the cancellation happens at all: the engine
// cannot cancel from inside the worker it is running in.
if (account.id in stopped) {
syncTrigger.cancel(account.displayName)
} else {
syncTrigger.schedule(account.displayName, syncInterval(), intervalChanged)
}
}
}
/** The accounts a caller may sync right now — stopped ones excluded. */
suspend fun syncable(): List<AccountEntity> = withContext(io) {
val stopped = accountState.needingSignIn()
database.accounts().all().filterNot { it.id in stopped }
}
/**
* Removes an account and everything that keys off it.
*
* The lists are **not** deleted: `task_lists.account_id` is `ON DELETE SET
* NULL`, so they become device-only lists. Removing an account is not an
* instruction to destroy the tasks it held.
*/
suspend fun remove(accountId: Long, displayName: String, deleteLocalData: Boolean = false) =
withContext(io) {
syncTrigger.cancel(displayName)
// Before the revocation: the subscriptions can only be removed with
// the credential that is about to stop working.
withContext(NonCancellable) { runCatching { push.forgetAccount(accountId) } }
revokeAppPassword(accountId)
// ⚠️ Uncancellable from here. Everything below is destructive and
// spread over four stores that cannot share a transaction, and the
// caller is a viewModelScope tied to the Settings destination — the
// user taps Remove, the screen slides away, and a couple of back
// gestures kill the scope mid-sequence. Only the DataStore writes can
// observe cancellation (every Room DAO here is blocking), so the
// realistic landing point is `cadence.forget`: the app password is
// already revoked server-side while the row survives holding it, and
// the account reads "sign in again" for a credential we ourselves
// invalidated. Land further in and the tasks are gone with the row
// still there. The tail is three DataStore writes, two deletes and an
// AccountManager call — bounded and sub-second, so finishing it is
// strictly better than stopping anywhere inside it.
withContext(NonCancellable) {
// The lists survive as device-only lists, so their cursors must
// not: a re-added account would otherwise inherit a "reconciled
// recently" that was true of a different account's data.
val listIds = database.taskLists().syncedForAccount(accountId)
.map { it.id }
.toSet()
cadence.forget(listIds)
// ⚠️ And the quarantine counts, which are keyed the same way and
// are just as global. A list re-attached to a new account would
// otherwise inherit them, and a resource already at THRESHOLD is
// skipped for ever — it never succeeds, so it never clears.
quarantine.forget(listIds)
// Play's Account Deletion policy does not apply to us — there is
// no Agendula account to delete — but "I want it gone from this
// device too" is a reasonable thing to want, and it is the only
// way to get the tasks off the device without also uninstalling.
if (deleteLocalData) database.taskLists().deleteForAccount(accountId)
accountState.setNeedsSignIn(accountId, false)
// Keyed by account id, exactly like the flag above, and just as
// orphaned once the row goes: ids are AUTOINCREMENT so they are
// never reused, but nothing would ever read or clear these again.
notices.dismiss(accountId)
// The same reasoning, for what the server said it would let us
// create: keyed by an id nothing will ever mention again.
collectionSupport.forget(accountId)
credentials.clear(accountId)
database.accounts().delete(accountId)
accounts.find(displayName)?.let { accounts.remove(it) }
}
}
/**
* Best effort, and before the credential is cleared — it is the credential.
*
* A failure here is never allowed to stop the removal: the user asked for the
* account to go, and a server that is unreachable, or was never a Nextcloud,
* is not a reason to keep it.
*/
private suspend fun revokeAppPassword(accountId: Long) {
val account = database.accounts().account(accountId) ?: return
val username = account.username ?: return
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return
val password = (credentials.get(accountId) as? CredentialStore.Secret.Present)?.value
?: return
// ⚠️ The budget lives on the request itself, in AppPassword.revoke.
// Wrapping this in withTimeoutOrNull only *looked* bounded: the call
// parks on a socket read that no cancellation can break, and withContext
// returns when its block does, so the deadline passed and we waited
// anyway — minutes, on a multi-homed host that stalls.
try {
gateway.revokeAppPassword(
CalDavGateway.Credentials(username, password, origin),
)
} catch (_: CancellationException) {
// Deliberately swallowed. If the caller went away mid-revoke we still
// want the removal to finish rather than stop half-done; the tail
// below runs uncancellable for the same reason.
}
}
/** What [setSyncedCollections] has to do: collections to attach, lists to let go. */
internal data class CollectionChange(
val attach: Set<TaskCollection>,
val drop: List<TaskListEntity>,
)
private fun sameServer(account: AccountEntity, found: CalDavDiscovery.Outcome.Found): Boolean {
val stored = account.principalUrl?.toHttpUrlOrNull() ?: return true
return stored.host.equals((found.movedTo ?: found.principal).host, ignoreCase = true)
}
internal companion object {
/** M3 primary-ish blue; the user recolours a list from its own screen. */
const val DEFAULT_LIST_COLOR = 0xFF4C6FFF.toInt()
/**
* Only collections the server still offers are judged. A synced list
* missing from [offered] — a share revoked a minute ago, a flaky listing —
* is not the user unticking it, and is left for sync to sort out.
*/
fun collectionChange(
offered: List<TaskCollection>,
selected: Set<HttpUrl>,
synced: List<TaskListEntity>,
): CollectionChange {
val syncedHrefs = synced.mapNotNull { it.href }.toSet()
val offeredHrefs = offered.map { it.url.toString() }.toSet()
val selectedHrefs = selected.map { it.toString() }.toSet()
return CollectionChange(
attach = offered.filter {
it.url in selected && it.url.toString() !in syncedHrefs
}.toSet(),
drop = synced.filter { it.href in offeredHrefs && it.href !in selectedHrefs },
)
}
}
}
@@ -0,0 +1,72 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which accounts the server has stopped accepting.
*
* Separate from `accounts.last_sync_error` because the two mean different
* things to the user and to the engine: an error is "this did not work, we will
* try again", while this is "**we have stopped trying** and only you can change
* that". Conflating them is how a client ends up retrying a revoked app password
* on a timer.
*
* Lives with the other per-device sync state, and is therefore excluded from
* backup — see [SyncStateDataStore]. That is also correct on its own terms: the
* credential does not survive a restore either, so a restored "needs sign-in" is
* at best redundant and at worst stale.
*/
@Singleton
class AccountStateStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
suspend fun needingSignIn(): Set<Long> = observeNeedingSignIn().first()
/** Observed, so a 401 during a background sync reaches an open screen. */
fun observeNeedingSignIn(): Flow<Set<Long>> = dataStore.data.map { prefs ->
prefs[KEY].orEmpty().mapNotNull { it.toLongOrNull() }.toSet()
}
suspend fun needsSignIn(accountId: Long): Boolean = accountId in needingSignIn()
suspend fun setNeedsSignIn(accountId: Long, needed: Boolean) {
dataStore.edit { prefs ->
val current = prefs[KEY].orEmpty().toMutableSet()
if (needed) current += accountId.toString() else current -= accountId.toString()
prefs[KEY] = current
// A later stop is news again.
if (!needed) prefs[NOTIFIED] = prefs[NOTIFIED].orEmpty() - accountId.toString()
}
}
/**
* Records that the user has been told [accountId] needs signing in to.
*
* @return true the first time per stop, so the notification is posted once
* rather than on every run the stopped account refuses.
*/
suspend fun markSignInNotified(accountId: Long): Boolean {
var fresh = false
dataStore.edit { prefs ->
val notified = prefs[NOTIFIED].orEmpty()
fresh = accountId.toString() !in notified
if (fresh) prefs[NOTIFIED] = notified + accountId.toString()
}
return fresh
}
private companion object {
val KEY = stringSetPreferencesKey("accounts_needing_sign_in")
val NOTIFIED = stringSetPreferencesKey("accounts_sign_in_notified")
}
}
@@ -0,0 +1,103 @@
package de.jeanlucmakiola.agendula.data.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.os.Bundle
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
/**
* Identifiers shared between Kotlin and the two XML descriptors.
*
* Derived from `applicationId`, so the debug and `releaseTest` builds get their
* own account type and authority and can be installed alongside the real app
* without their accounts colliding. ⚠️ `res/xml/authenticator.xml` and
* `res/xml/sync_adapter.xml` cannot read `BuildConfig`, so they use string
* resources generated by `resValue` in `app/build.gradle.kts` — the two must be
* changed together.
*/
object SyncContract {
val ACCOUNT_TYPE: String = BuildConfig.APPLICATION_ID + ".caldav"
val AUTHORITY: String = BuildConfig.APPLICATION_ID + ".sync"
}
/**
* Agendula's CalDAV accounts, as the system sees them.
*
* The Room `accounts` table is the source of truth for everything about an
* account; this is only the system-visible half — the entry in Settings, and the
* handle the sync framework needs to trigger us.
*/
@Singleton
class CalDavAccounts @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val accountManager get() = AccountManager.get(context)
fun all(): List<Account> =
accountManager.getAccountsByType(SyncContract.ACCOUNT_TYPE).toList()
fun find(name: String): Account? = all().firstOrNull { it.name == name }
/**
* Registers [name] with the system and turns sync on for it.
*
* No password is handed to `AccountManager`: it stores them as plain `TEXT`.
* The app password goes to [CredentialStore], keyed by the Room account id.
*
* @return false if an account with this name already exists
*/
fun add(name: String, roomAccountId: Long): Boolean {
val account = Account(name, SyncContract.ACCOUNT_TYPE)
val userData = Bundle().apply { putString(KEY_ROOM_ACCOUNT_ID, roomAccountId.toString()) }
if (!accountManager.addAccountExplicitly(account, null, userData)) return false
// All three are among the calls that return silently with no registered
// sync adapter — see SyncAdapterService. They work because we register one.
ContentResolver.setIsSyncable(account, SyncContract.AUTHORITY, 1)
ContentResolver.setSyncAutomatically(account, SyncContract.AUTHORITY, true)
return true
}
/**
* The Room account id for [account], or null.
*
* ⚠️ `getUserData` returns null while the device is locked, so a
* boot-triggered sync has to wait for unlock rather than treat this as
* "account gone".
*/
fun roomAccountId(account: Account): Long? =
accountManager.getUserData(account, KEY_ROOM_ACCOUNT_ID)?.toLongOrNull()
/**
* Removes the system-visible account.
*
* `removeAccountExplicitly` works because we own the account type. The Room
* row and the credential are removed by [AccountRepository]; pruning must be
* driven by this call and by `AccountManager`'s account-removed broadcast,
* never by "absent from the visible set" — `getAccountsByType` returns
* nothing while the device is locked, and treating that as removal is how a
* restore silently deletes the user's lists.
*/
fun remove(account: Account): Boolean = accountManager.removeAccountExplicitly(account)
fun requestSync(account: Account) {
ContentResolver.requestSync(
account,
SyncContract.AUTHORITY,
Bundle().apply {
putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
},
)
}
private companion object {
const val KEY_ROOM_ACCOUNT_ID = "roomAccountId"
}
}
@@ -0,0 +1,127 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.caldav.AppPassword
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.DnsJavaResolver
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* The network side of adding an account, behind one interface.
*
* It exists so the sign-in state machine can be tested. That machine decides
* which of five outcomes leads where, when a one-shot app password is spent, and
* which host a credential is scoped to — all of which are exactly the sort of
* thing that goes wrong quietly, and none of which should require a server to
* exercise.
*/
interface CalDavGateway {
/** Discovery against [target], optionally carrying credentials. */
suspend fun discover(target: String, credentials: Credentials? = null): CalDavDiscovery.Outcome
/** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */
suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow?
suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult
/**
* Hands an app password back to the server, best effort.
*
* Without it, uninstalling never revokes anything — the credential we minted
* outlives the app in the user's device list.
*/
suspend fun revokeAppPassword(credentials: Credentials): Boolean
/**
* The same, for a password the login flow just minted.
*
* ⚠️ Separate because [Credentials.origin] means something different here:
* the *server root* the flow reported, not a principal URL. Sending it
* through [revokeAppPassword] would derive the OCS root as though it were a
* principal, and a subpath install's `https://host/nextcloud/` would collapse
* to `https://host/` — a DELETE that 404s on every one of them.
*/
suspend fun revokeIssuedAppPassword(credentials: Credentials): Boolean
/** Credentials, and the origin whose registrable domain they are scoped to. */
data class Credentials(val username: String, val password: String, val origin: HttpUrl)
}
@Singleton
class OkHttpCalDavGateway @Inject constructor(
@IoDispatcher private val io: CoroutineDispatcher,
) : CalDavGateway {
/**
* Becomes the app password's **name** in Nextcloud's Settings → Security →
* Devices & sessions. OkHttp's default would show `okhttp/4.12.0`, leaving
* the user unable to tell what to revoke — which defeats the whole point of
* using an app password.
*/
private val userAgent = "Agendula (Android)"
override suspend fun discover(
target: String,
credentials: CalDavGateway.Credentials?,
): CalDavDiscovery.Outcome = withContext(io) {
val client = credentials?.let {
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
} ?: CalDavHttp.anonymous(userAgent)
CalDavDiscovery(client, DnsJavaResolver()).discover(target)
}
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
withContext(io) {
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent)
.start(server, now())
.getOrNull()
}
/**
* ⚠️ The budget lives on the request, as it does for the revocation.
* `execute()` parks on a socket read that no cancellation can break, so the
* four places that cancel the poll job only stop the *next* request — and
* the shared client's ceiling is sized for a multiget, not for a two-second
* poll loop against a server that answered a moment ago.
*/
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult =
withContext(io) {
val client = CalDavHttp.anonymous(userAgent).newBuilder()
.callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
NextcloudLoginFlow(client, userAgent).poll(flow, now())
}
override suspend fun revokeAppPassword(
credentials: CalDavGateway.Credentials,
): Boolean = withContext(io) {
val client = CalDavHttp.authenticated(
userAgent, credentials.username, credentials.password, credentials.origin,
)
AppPassword.revoke(client, credentials.origin)
}
override suspend fun revokeIssuedAppPassword(
credentials: CalDavGateway.Credentials,
): Boolean = withContext(io) {
val client = CalDavHttp.authenticated(
userAgent, credentials.username, credentials.password, credentials.origin,
)
AppPassword.revokeAt(client, credentials.origin)
}
private fun now() = System.currentTimeMillis() / 1000
private companion object {
/** One poll of a 2s loop. Long enough for a homelab, short enough to cancel. */
const val POLL_TIMEOUT_SECONDS = 15L
}
}
@@ -0,0 +1,72 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.CollectionSupport
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* What each account's home set answered to OPTIONS, last time we asked.
*
* ⚠️ A cache, never the answer. The "new task list" affordance is *hidden*
* where neither MKCALENDAR nor extended
* MKCOL exists, and a picker that has to make a network round trip before it can
* draw a row is a picker that stutters — so the last answer is what it draws
* with, and [RemoteListRepository] re-asks before it actually writes. A server
* that gained the capability in an upgrade, or lost it in a config change, is
* then wrong for exactly one glance rather than for ever.
*/
@Singleton
class CollectionSupportStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
suspend fun get(accountId: Long): CollectionSupport =
decode(dataStore.data.first()[KEY].orEmpty())[accountId] ?: CollectionSupport.NONE
/** Asks [ask], records what it said, and hands it back. */
suspend fun refresh(accountId: Long, ask: () -> CollectionSupport): CollectionSupport {
val answer = ask()
dataStore.edit { prefs ->
// Re-read inside `edit`, which DataStore serialises: two accounts
// can be asked at once and a snapshot taken outside would drop one.
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
current[accountId] = answer
prefs[KEY] = current.map { (id, support) -> encode(id, support) }.toSet()
}
return answer
}
suspend fun forget(accountId: Long) {
dataStore.edit { prefs ->
prefs[KEY] = decode(prefs[KEY].orEmpty())
.filterKeys { it != accountId }
.map { (id, support) -> encode(id, support) }
.toSet()
}
}
private fun encode(accountId: Long, support: CollectionSupport): String =
"$accountId|${support.mkCalendar}|${support.extendedMkCol}"
private fun decode(entries: Set<String>): Map<Long, CollectionSupport> =
entries.mapNotNull { entry ->
val parts = entry.split('|')
if (parts.size != FIELDS) return@mapNotNull null
val accountId = parts[0].toLongOrNull() ?: return@mapNotNull null
accountId to CollectionSupport(
mkCalendar = parts[1].toBooleanStrictOrNull() ?: return@mapNotNull null,
extendedMkCol = parts[2].toBooleanStrictOrNull() ?: return@mapNotNull null,
)
}.toMap()
private companion object {
val KEY = stringSetPreferencesKey("collection_support")
const val FIELDS = 3
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
package de.jeanlucmakiola.agendula.data.sync
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.agendula.data.di.CredentialsDataStore
import kotlinx.coroutines.flow.first
import java.io.IOException
import java.security.GeneralSecurityException
import java.security.ProviderException
import java.security.KeyStore
import java.util.Base64
import javax.crypto.AEADBadTagException
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* App passwords, encrypted with a hardware-backed Keystore key.
*
* `androidx.security:security-crypto` is **formally deprecated and terminal** —
* deprecated at 1.1.0-alpha07, shipped deprecated in stable 1.1.0, with release
* notes saying there will be no further releases — and its successor
* `datastore-tink` is alpha. So: Keystore `AES/GCM/NoPadding` directly, blob in
* DataStore.
*
* Be honest about what this buys. `AccountManager` stores passwords as plain
* `TEXT` — there is no encryption or hashing anywhere in AOSP — so file-based
* encryption plus a same-signature check is the whole boundary there. That is
* DAVx5's posture and it is defensible, but it is not secure storage. This is
* better, and the difference is worth the ~80 lines.
*
* Three deliberate non-choices:
* - `setUserAuthenticationRequired` is left at its default of `false`. Requiring
* a device unlock per decryption makes background sync impossible.
* - `setUnlockedDeviceRequired` is **not** set, for the same reason.
* - A failure to decrypt is *never* a crash. It means re-authenticate.
*/
@Singleton
class CredentialStore @Inject constructor(
@CredentialsDataStore private val dataStore: DataStore<Preferences>,
) {
/** What came back for an account. */
sealed interface Secret {
data class Present(val value: String) : Secret
data object Absent : Secret
/**
* The ciphertext exists but can no longer be decrypted, so the only
* recovery is to sign in again.
*
* Reached by a restored backup (Keystore keys are non-exportable, so a
* restored blob is permanently undecryptable — which is why the blob is
* excluded from backup), by the key being invalidated when the user
* changes their lock screen, or by corruption.
*/
data class Unrecoverable(val reason: String) : Secret
}
/**
* Stores [appPassword] for [accountId].
*
* @return false when the Keystore could not be used at all. A wedged or
* degraded keystore throws [ProviderException], which is a `RuntimeException`
* and would otherwise take down the account-add flow — the same "never
* crash over this" rule [get] follows.
*/
suspend fun put(accountId: Long, appPassword: String): Boolean = try {
val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key()) }
// The IV travels with the ciphertext. GCM requires a unique IV per
// encryption under the same key; letting the provider generate it is the
// only way to be sure of that.
val payload = cipher.iv + cipher.doFinal(appPassword.toByteArray(Charsets.UTF_8))
dataStore.edit { it[keyFor(accountId)] = Base64.getEncoder().encodeToString(payload) }
true
} catch (e: GeneralSecurityException) {
false
} catch (e: ProviderException) {
false
} catch (e: IOException) {
false
}
suspend fun get(accountId: Long): Secret {
val stored = dataStore.data.first()[keyFor(accountId)] ?: return Secret.Absent
return try {
val payload = Base64.getDecoder().decode(stored)
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
init(
Cipher.DECRYPT_MODE,
key(),
GCMParameterSpec(TAG_BITS, payload, 0, IV_BYTES),
)
}
Secret.Present(
String(
cipher.doFinal(payload, IV_BYTES, payload.size - IV_BYTES),
Charsets.UTF_8,
),
)
} catch (e: KeyPermanentlyInvalidatedException) {
// The lock screen changed, or the key was otherwise invalidated.
Secret.Unrecoverable(e.message ?: "the encryption key was invalidated")
} catch (e: AEADBadTagException) {
// Wrong key or tampered ciphertext — the restored-backup case.
Secret.Unrecoverable(e.message ?: "the stored credential could not be decrypted")
} catch (e: GeneralSecurityException) {
Secret.Unrecoverable(e.message ?: "the stored credential could not be read")
} catch (e: IllegalArgumentException) {
// Not valid Base64 at all — a truncated or hand-edited blob.
Secret.Unrecoverable(e.message ?: "the stored credential is malformed")
} catch (e: ProviderException) {
// ⚠️ AndroidKeyStore signals keystore-level failure ("Keystore
// operation failed", "Failed to load key") with this — a
// RuntimeException, so none of the catches above match it. On a
// device with a degraded keystore it would crash the sync worker
// instead of prompting a re-authentication.
Secret.Unrecoverable(e.message ?: "the device keystore is unavailable")
} catch (e: IOException) {
// KeyStore.load declares it.
Secret.Unrecoverable(e.message ?: "the device keystore could not be opened")
}
}
suspend fun clear(accountId: Long) {
dataStore.edit { it.remove(keyFor(accountId)) }
}
/** Every stored credential. Used when the last account goes away. */
suspend fun clearAll() {
dataStore.edit { it.clear() }
}
private fun keyFor(accountId: Long) = stringPreferencesKey("caldav_app_password_$accountId")
private fun key(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply {
init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
// Not calling setUserAuthenticationRequired /
// setUnlockedDeviceRequired is the point — see the class doc.
.build(),
)
}.generateKey()
}
private companion object {
const val KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "agendula.caldav.credentials"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_BYTES = 12
const val TAG_BITS = 128
}
}
@@ -0,0 +1,145 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* Where a started login flow is written down, so it can outlive this process.
*
* A seam for the same reason [AccountCreator] and [CalDavGateway] are: the flow
* that decides *when* a one-shot password stops being ours has to be testable
* without a DataStore.
*/
interface LoginFlowRecord {
/** Called **before** the browser is handed the URL. */
suspend fun remember(flow: NextcloudLoginFlow.Flow)
/** The flow is over, however it ended. */
suspend fun forget()
}
/**
* The Nextcloud login flow that is currently out at a browser.
*
* ⚠️ [NextcloudLoginFlow.Flow]'s own doc says to persist it **before** launching
* the browser, because the flow outlives our process — and it did not. The
* browser is a separate task, so process death while the user is approving is
* ordinary rather than exotic, and it stranded a one-shot app password that
* nothing could then collect *or* revoke: the flow's poll token was the only way
* back to it, and it lived in a ViewModel field.
*
* The token is not a credential. It authorises exactly one poll of one flow the
* user is in the middle of approving, and it is useless past the twenty-minute
* window — so it belongs in the sync-state store rather than the Keystore.
*
* ⚠️ What this does **not** cover is the window *after* approval, where the
* password itself lives only in memory. That needs the wizard's own state to
* survive, which is a different piece of work.
*/
@Singleton
class PendingLoginFlowStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
private val gateway: CalDavGateway,
) : LoginFlowRecord {
private val lock = Mutex()
private var reclaimed = false
/** Records [flow] so a process that dies mid-approval can still finish with it. */
override suspend fun remember(flow: NextcloudLoginFlow.Flow) {
dataStore.edit { prefs ->
prefs[LOGIN_URL] = flow.loginUrl.toString()
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
prefs[POLL_TOKEN] = flow.pollToken
prefs[DEADLINE] = flow.deadlineEpochSeconds
}
}
/** The flow is finished, one way or another. */
override suspend fun forget() {
dataStore.edit { prefs ->
prefs.remove(LOGIN_URL)
prefs.remove(POLL_ENDPOINT)
prefs.remove(POLL_TOKEN)
prefs.remove(DEADLINE)
}
}
/**
* Collects and hands back a password nobody is left to own.
*
* Revoked rather than used: the address the user typed, the collections they
* ticked and the account name are all gone with the process, so there is
* nothing to finish. What is left is a live app password in the user's
* device list, under the same name as every other attempt — which is exactly
* what they cannot tell apart, and so dare not prune.
*
* ⚠️ **Once per process.** This activity is recreated on every rotation,
* theme switch and locale change, and a second run against a flow the *live*
* wizard is still polling would consume its one-shot 200 and revoke the
* password it was about to be handed. A flow remembered after this has run
* belongs to a wizard that is alive to finish it.
*/
suspend fun reclaim() {
lock.withLock {
if (reclaimed) return
reclaimed = true
}
val flow = pending() ?: return
when (val result = gateway.pollLoginFlow(flow)) {
is NextcloudLoginFlow.PollResult.Approved -> {
// Cleared first: a revocation that fails must not leave a token
// that would be polled again, and the 200 is already spent.
forget()
gateway.revokeIssuedAppPassword(
CalDavGateway.Credentials(
username = result.credentials.loginName,
password = result.credentials.appPassword,
origin = result.credentials.server,
),
)
}
is NextcloudLoginFlow.PollResult.Expired -> forget()
// Still inside the window, or the server had a moment. Either way
// the token is still worth something, so it is left for the next
// open; a poll past the deadline answers Expired and clears it.
NextcloudLoginFlow.PollResult.Pending,
is NextcloudLoginFlow.PollResult.Failed,
-> Unit
}
}
private suspend fun pending(): NextcloudLoginFlow.Flow? {
val prefs = dataStore.data.first()
val endpoint = prefs[POLL_ENDPOINT]?.toHttpUrlOrNull() ?: return null
val token = prefs[POLL_TOKEN] ?: return null
val deadline = prefs[DEADLINE] ?: return null
return NextcloudLoginFlow.Flow(
loginUrl = prefs[LOGIN_URL]?.toHttpUrlOrNull() ?: endpoint,
pollEndpoint = endpoint,
pollToken = token,
deadlineEpochSeconds = deadline,
)
}
private companion object {
val LOGIN_URL = stringPreferencesKey("login_flow_url")
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
val DEADLINE = longPreferencesKey("login_flow_deadline")
}
}
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* How many times each resource has failed, and therefore which ones to skip.
*
* ⚠️ Deliberately **not** a backoff. A backoff assumes the failure is transient
* and asks "how long until I try again"; the failures that matter here are
* permanent — a body sabre answers 415 for, a contradictory `RRULE`/`EXDATE`
* pair Nextcloud answers 500 for forever, a 507 the spec forbids retrying at
* all. The question worth asking is "how many times before I leave this one
* alone and finish the collection", and the answer is [THRESHOLD].
*
* Counts are cleared the moment a resource succeeds, so a genuinely transient
* failure costs nothing beyond the runs it actually failed in.
*/
@Singleton
class QuarantineStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
/** Current failure counts, keyed by [key]. */
suspend fun counts(): Map<String, Int> = decode(dataStore.data.first()[KEY].orEmpty())
private fun decode(entries: Set<String>): Map<String, Int> = entries.mapNotNull { entry ->
val separator = entry.lastIndexOf(COUNT_SEPARATOR)
if (separator <= 0) return@mapNotNull null
val count = entry.substring(separator + 1).toIntOrNull() ?: return@mapNotNull null
entry.substring(0, separator) to count
}.toMap()
/**
* Applies one account's changes without disturbing anyone else's.
*
* ⚠️ Not a whole-map replace. The counts are global — keyed by list, not by
* account — while `SyncWorker`'s uniqueness is only *per account*, so two
* accounts can sync at once. Each would snapshot the same global map and the
* later writer would discard the other's increments and resurrect the
* counters it had cleared. Re-reading inside `edit`, which DataStore
* serialises, keeps the read-modify-write atomic.
*
* @param updates counts to set, replacing any current value for those keys.
* @param cleared keys to remove outright, whatever they currently hold.
*/
suspend fun merge(updates: Map<String, Int>, cleared: Set<String>) {
dataStore.edit { prefs ->
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
current -= cleared
current += updates.filterValues { it > 0 }
prefs[KEY] = current
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
.toSet()
}
}
/**
* Forgets every count belonging to [listIds].
*
* ⚠️ The keys are global, exactly like the cadence cursors cleared beside
* them. A list detached from a removed account and re-attached to a new one
* would otherwise inherit its old counters — and a resource already at
* [THRESHOLD] is skipped for ever, since a quarantined resource never
* succeeds and so never clears.
*/
suspend fun forget(listIds: Set<Long>) {
if (listIds.isEmpty()) return
val prefixes = listIds.map { "$it|" }
dataStore.edit { prefs ->
val current = decode(prefs[KEY].orEmpty())
.filterKeys { key -> prefixes.none(key::startsWith) }
prefs[KEY] = current
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
.toSet()
}
}
/**
* Clears the count for one resource in any of [listIds], so the next sync
* tries it again — the user's "retry" on a quarantined task.
*/
suspend fun release(listIds: Set<Long>, href: String) {
val keys = listIds.map { key(it, href) }.toSet()
dataStore.edit { prefs ->
val current = decode(prefs[KEY].orEmpty())
if (current.keys.none { it in keys }) return@edit
prefs[KEY] = (current - keys)
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
.toSet()
}
}
companion object {
/**
* Attempts before a resource is left alone.
*
* Three rather than one: a 502 from a reverse proxy mid-restart and a
* permanently malformed body arrive as the same outcome, and burning two
* extra runs is cheaper than quarantining a resource that would have
* worked.
*/
const val THRESHOLD = 3
fun key(listId: Long, href: String) = "$listId|$href"
private const val COUNT_SEPARATOR = '#'
private val KEY = stringSetPreferencesKey("sync_quarantine")
}
}
@@ -0,0 +1,312 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.CollectionAdmin
import de.jeanlucmakiola.caldav.CollectionOutcome
import de.jeanlucmakiola.caldav.CollectionSupport
import de.jeanlucmakiola.caldav.DavCollectionAdmin
import de.jeanlucmakiola.caldav.ResourceNames
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Singleton
/**
* Task lists that live on a server: making them, renaming them, recolouring
* them and deleting them.
*
* ⚠️ Every write here is **server first, Room second**, and that ordering is the
* whole design. The other way round gives the user a list that exists on their
* phone and nowhere else, and nothing to tell them so — `task_lists.is_dirty`
* was already set by a rename and read by nobody, which is precisely that
* failure with the evidence discarded. A refused write leaves the local row
* exactly as it was, so what is on screen is what is on the server.
*
* Device-only lists are not this class's business: they have no href, no
* account and nothing to ask permission of. [de.jeanlucmakiola.agendula.data.tasks.TasksRepository]
* keeps them.
*/
@Singleton
class RemoteListRepository @Inject constructor(
private val database: TasksDatabase,
private val credentials: CredentialStore,
private val support: CollectionSupportStore,
private val syncTrigger: SyncTrigger,
private val cadence: SyncCadenceStore,
private val notices: SyncNoticeStore,
private val quarantine: QuarantineStore,
private val accountState: AccountStateStore,
private val resolver: ProviderResolver,
private val push: PushStore,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/** Why a collection write did not happen, in a form the UI can translate. */
sealed interface Outcome {
data object Done : Outcome
/** The server said no, and will say no again. */
data class Refused(val code: Int) : Outcome
/** The server could not be reached. Worth another try. */
data object Unreachable : Outcome
/** This account cannot make collections at all — iCloud, Posteo, Google. */
data object Unsupported : Outcome
/** Ours is a read-only share; the write belongs to whoever owns it. */
data object ReadOnly : Outcome
/** The account is gone, stopped, or has no credential we can decrypt. */
data object NoAccount : Outcome
/**
* The server answered something this call cannot make sense of.
*
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
* update and delete, so each of them has branches the other's method
* can return and its own cannot — and mapping those to [Unreachable]
* told someone sitting on wifi that they were offline. Unreachable is a
* claim about the network, and this is not one.
*/
data object Unexpected : Outcome
}
/**
* The accounts a new list may be created on, freshest answer first.
*
* ⚠️ Re-asked rather than cached for ever. `CollectionSupportStore` holds
* the last answer so a picker can draw immediately, but a server that gained
* the capability in an upgrade — or lost it in a config change — must be
* able to say so, and the only moment that costs nothing is while the user
* is looking at the picker.
*/
suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
// ⚠️ Empty in External mode, whatever the accounts table holds. The
// lists on screen then come from a third-party provider, so a row
// inserted into ours would exist, sync, and be visible to nobody.
if (resolver.mode() != StorageMode.OWN) return@withContext emptyList()
val stopped = accountState.needingSignIn()
val candidates = database.accounts().all().filter {
it.homeSetUrl?.toHttpUrlOrNull() != null && it.id !in stopped
}
// ⚠️ Together, not one after another. Each probe is a blocking OPTIONS,
// so three accounts with one server on a slow link held the "Where" row
// off the sheet for the sum of all three — with the sheet already drawn.
coroutineScope {
candidates.map { account -> async { account to supportFor(account) } }
.awaitAll()
.filter { (_, support) -> support.canCreate }
.map { (account, _) -> account }
}
}
/**
* Makes a collection on [accountId]'s home set and a row pointing at it.
*
* @return the new list's local id, or why there is none.
*/
suspend fun create(
accountId: Long,
name: String,
color: Int,
): Outcome = withContext(io) {
// ⚠️ Re-checked here, not only in `creatableAccounts`. The picker's list
// is a StateFlow that outlives one opening of the sheet, so a mode that
// flips between the list being built and Save being tapped would
// otherwise create the collection on the server and file the row in a
// store External mode never reads.
if (resolver.mode() != StorageMode.OWN) return@withContext Outcome.NoAccount
val account = database.accounts().account(accountId) ?: return@withContext Outcome.NoAccount
val homeSet = account.homeSetUrl?.toHttpUrlOrNull()
?: return@withContext Outcome.NoAccount
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
val capabilities = support.refresh(accountId) { admin.support(homeSet) }
if (!capabilities.canCreate) return@withContext Outcome.Unsupported
// ⚠️ A second attempt, but only for the one refusal a different name
// can fix. Nextcloud's trashbin *renames* a deleted collection rather
// than removing it, so re-creating under a segment used before answers
// 403 for ever — and "Shopping" is exactly the name someone deletes and
// remakes. Retrying anything else spends a second authenticated write
// that will fail the same way, and worse: a 401 is a second hit on the
// brute-force counter, and a 507 retried reports the wrong code back,
// since the caller only ever sees the *last* attempt's.
val first = ResourceNames.forCollection(name)
var created = admin.create(homeSet, first, name, color, capabilities)
if (created is CollectionOutcome.Refused && created.code in NAME_REFUSALS) {
created = admin.create(homeSet, ResourceNames.randomCollection(), name, color, capabilities)
}
when (created) {
is CollectionOutcome.Created -> {
// ⚠️ Uncancellable. The collection exists on the server from
// here on, and a cancellation between that and the row would
// leave one the app has no record of and no way to reach —
// visible only on the next full account re-add.
withContext(NonCancellable) {
database.taskLists().insert(
TaskListEntity(
name = name,
color = color,
accountId = accountId,
href = created.url.toString(),
),
)
}
// The server has it and we do not; a sync is how the two agree
// on a ctag and a token rather than reconciling in full later.
syncTrigger.enqueue(account.displayName, expedited = true)
Outcome.Done
}
is CollectionOutcome.Refused -> Outcome.Refused(created.code)
is CollectionOutcome.Failed -> Outcome.Unreachable
CollectionOutcome.Unsupported -> Outcome.Unsupported
CollectionOutcome.Updated -> Outcome.Unexpected
}
}
/**
* Renames and recolours [listId] on the server, then locally.
*
* ⚠️ Refuses a read-only collection rather than discovering it at write
* time. A share the owner has made read-only answers 403 to a PROPPATCH, and
* a row that has already been renamed locally by then reads as a rename that
* worked and then quietly reverted on the next sync.
*/
suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
val account = list.accountId?.let { database.accounts().account(it) }
?: return@withContext Outcome.NoAccount
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
when (val outcome = admin.updateProperties(url, displayName = name, color = color)) {
CollectionOutcome.Updated -> {
withContext(NonCancellable) {
// Read again inside the write: a sync running alongside this
// may have refreshed the ACL flag or the cursor, and writing
// back the entity we read before the network call would
// revert it.
val current = database.taskLists().entity(listId) ?: return@withContext
database.taskLists().update(
// isDirty stays false: the server already has this. The
// flag existed for a PROPPATCH that never happened.
current.copy(name = name, color = color, isDirty = false),
)
}
Outcome.Done
}
is CollectionOutcome.Refused -> Outcome.Refused(outcome.code)
is CollectionOutcome.Failed -> Outcome.Unreachable
is CollectionOutcome.Created, CollectionOutcome.Unsupported -> Outcome.Unexpected
}
}
/**
* Deletes [listId] on the server, then on the device.
*
* ⚠️ The one write where "already gone" is success — [CollectionAdmin.delete]
* grades 404 and 410 that way — because otherwise a collection someone
* removed from another client leaves a row here that nothing can get rid of.
*/
suspend fun delete(listId: Long): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
val account = list.accountId?.let { database.accounts().account(it) }
?: return@withContext Outcome.NoAccount
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
when (val outcome = admin.delete(url)) {
CollectionOutcome.Updated -> {
withContext(NonCancellable) {
// `tasks.list_id` is ON DELETE CASCADE, so the tasks go with
// it — which is what was just done on the server.
database.taskLists().delete(listId)
// And the per-list state keyed off it, exactly as removing an
// account clears its lists': the ids are AUTOINCREMENT so
// nothing would ever read these again. The notices go by
// *name*, which is how they are keyed — a discarded-edit
// notice would otherwise name a list that no longer exists
// until the user tapped "Got it".
forgetPerListState(listId)
list.accountId?.let { notices.forgetList(it, list.name) }
}
Outcome.Done
}
is CollectionOutcome.Refused -> Outcome.Refused(outcome.code)
is CollectionOutcome.Failed -> Outcome.Unreachable
is CollectionOutcome.Created, CollectionOutcome.Unsupported -> Outcome.Unexpected
}
}
private suspend fun supportFor(account: AccountEntity): CollectionSupport {
val homeSet = account.homeSetUrl?.toHttpUrlOrNull() ?: return CollectionSupport.NONE
val admin = adminFor(account) ?: return CollectionSupport.NONE
return support.refresh(account.id) { admin.support(homeSet) }
}
/**
* Null when the account has no credential we can use — a stopped account, or
* a restore.
*
* ⚠️ The stopped check is the same one `SyncEngine.sync` makes before it
* touches the network, and for the same reason: Nextcloud's brute-force
* protection throttles and then **429s per source IP**, so spending a
* request on a credential we already know the server rejects lands on the
* user's *other* clients. Opening the "new list" sheet must not do that any
* more than a timer may.
*/
private suspend fun adminFor(account: AccountEntity): CollectionAdmin? {
if (accountState.needsSignIn(account.id)) return null
val username = account.username ?: return null
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
?: return null
return DavCollectionAdmin(client(username, password, origin))
}
private fun client(username: String, password: String, origin: HttpUrl): OkHttpClient =
CalDavHttp.authenticated(USER_AGENT, username, password, origin)
private suspend fun forgetPerListState(listId: Long) {
val ids = setOf(listId)
cadence.forget(ids)
quarantine.forget(ids)
// The subscription went with the collection on the server.
push.forget(ids)
}
private companion object {
/** The same agent the sync and the add flow use, so the server names us once. */
const val USER_AGENT = "Agendula (Android)"
/**
* Refusals a different path segment can get past, and only those.
*
* 403 is Nextcloud's trashbin still holding the name; 405 is a server
* answering "already a collection there". Everything else — 401, 409,
* 423, 507 — means the same thing under any name.
*/
val NAME_REFUSALS = setOf(403, 405)
}
}
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.agendula.data.sync
import android.accounts.Account
import android.app.Service
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.os.Bundle
import android.os.IBinder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.minutes
/**
* The sync adapter whose entire job is to start a WorkManager job and wait.
*
* DAVx5's own comment describes the same design: *"We use the sync adapter
* framework only for the trigger, actual syncing is implemented with
* WorkManager."*
*
* ⚠️ Registering this is **not optional decoration**.
* `ContentService.hasAuthorityAccess()` gates `requestSync`,
* `setSyncAutomatically`, `addPeriodicSync`, `setIsSyncable`, `getSyncStatus` and
* seven more behind a compat change that is on for targetSdk ≥ 34 — which we
* are. With no sync adapter registered for our authority, every one of those
* calls **returns silently**: no exception, no log, and it passes on a
* Robolectric shadow. The visible result is an account permanently reading "Sync
* off for all items" with a greyed-out "Sync now", and it is documented on no
* Android behaviour-changes page.
*
* The greying-out is why the app ships its own sync button regardless:
* `enabledSyncNowMenu()` needs at least one checked authority switch, and ours
* is `userVisible="false"`.
*/
class SyncAdapterService : Service() {
private val adapter by lazy { CalDavSyncAdapter(applicationContext) }
override fun onBind(intent: Intent?): IBinder = adapter.syncAdapterBinder
}
private class CalDavSyncAdapter(context: Context) :
AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true) {
override fun onPerformSync(
account: Account,
extras: Bundle,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult,
) {
val workManager = WorkManager.getInstance(context)
val uniqueName = SyncTrigger(context).enqueue(account.name)
// Block this thread until the work reaches a terminal state. The framework
// treats onPerformSync returning as "the sync is done", so returning early
// would make every sync look instantaneous and defeat the back-off it
// applies on failure. runBlocking is fine here: onPerformSync is already
// called on a background thread the framework owns.
//
// ⚠️ Watch the **unique work name**, not the request id. enqueueUniqueWork
// is asynchronous — the WorkSpec row is not written by the time the next
// line runs — so a flow keyed on the id emits null for an unknown id and
// the wait returns immediately, having waited for nothing. And under
// KEEP, when a run is already in flight, our request is never enqueued at
// all and its id stays unknown forever. Keying on the name handles both:
// it waits for whichever run is actually happening.
val infos = runCatching {
runBlocking {
withTimeoutOrNull(WORKER_TIMEOUT_MINUTES.minutes) {
workManager.getWorkInfosForUniqueWorkFlow(uniqueName)
.first { infos -> infos.isNotEmpty() && infos.all { it.state.isFinished } }
}
}
}.getOrNull()
// Counted as a soft error: the engine's own per-collection and
// per-resource isolation decides what is actually fatal, and telling the
// framework otherwise would have it back off the whole account. Being
// deduplicated by KEEP is *not* a failure — the sync is happening, this
// trigger simply joined the one already running.
val timedOut = infos == null
val failed = infos?.any { it.state == WorkInfo.State.FAILED } == true
if (timedOut || failed) syncResult.stats.numIoExceptions++
}
private companion object {
/** DAVx5 uses the same ceiling; an ordinary worker is documented for < 10 min. */
const val WORKER_TIMEOUT_MINUTES = 10L
}
}
@@ -0,0 +1,109 @@
package de.jeanlucmakiola.agendula.data.sync
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
/**
* The account authenticator.
*
* Agendula holds no auth tokens — a CalDAV account is a username and an app
* password, and the password lives in [CredentialStore], not here.
* `AccountManager` stores passwords as plain `TEXT`; there is no encryption or
* hashing anywhere in AOSP, so nothing secret is handed to it.
*
* It is **not** required by any
* provider — that argument was circular. The real reasons: a stable account
* identity a third-party engine could address, presence in system Settings, and
* the sync framework as a change trigger.
*/
class SyncAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
/**
* ⚠️ Refuses until the account-add UI exists.
*
* The authenticator service is exported and registered, so Settings →
* Accounts → Add account lists Agendula **today**. Handing back an intent to
* a screen that does not yet handle [ACTION_ADD_ACCOUNT] would open the
* ordinary home screen while Settings waits forever on a response nothing
* answers. A refusal the user can read is strictly better than a hang; 2d
* replaces this with the real intent and answers [response].
*/
override fun addAccount(
response: AccountAuthenticatorResponse?,
accountType: String?,
authTokenType: String?,
requiredFeatures: Array<out String>?,
options: Bundle?,
): Bundle = unsupported("Add a CalDAV account from inside Agendula, under Settings")
override fun editProperties(
response: AccountAuthenticatorResponse?,
accountType: String?,
): Bundle = Bundle()
/**
* ⚠️ Never `null`. `AbstractAccountAuthenticator.Transport` reads a null
* return as "I will answer asynchronously via the response", and nothing here
* ever does — the caller's `AccountManagerFuture` would never complete.
*/
override fun confirmCredentials(
response: AccountAuthenticatorResponse?,
account: Account?,
options: Bundle?,
): Bundle = unsupported("Agendula does not confirm credentials from the system UI")
/** No token type: this is Basic/Digest against a CalDAV server. */
override fun getAuthToken(
response: AccountAuthenticatorResponse?,
account: Account?,
authTokenType: String?,
options: Bundle?,
): Bundle = unsupported("Agendula accounts do not use auth tokens")
override fun getAuthTokenLabel(authTokenType: String?): String? = null
/** Never `null`, for the reason given on [confirmCredentials]. */
override fun updateCredentials(
response: AccountAuthenticatorResponse?,
account: Account?,
authTokenType: String?,
options: Bundle?,
): Bundle = unsupported("Re-authenticate from inside Agendula, under Settings")
override fun hasFeatures(
response: AccountAuthenticatorResponse?,
account: Account?,
features: Array<out String>?,
): Bundle = Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) }
private fun unsupported(message: String) = Bundle().apply {
putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
putString(AccountManager.KEY_ERROR_MESSAGE, message)
}
companion object {
/** Sent to `MainActivity` when the system asks us to add an account (chunk 2d). */
const val ACTION_ADD_ACCOUNT = "de.jeanlucmakiola.agendula.ADD_ACCOUNT"
}
}
/**
* Binds [SyncAuthenticator] for the system.
*
* Exported and guarded by `android.permission.ACCOUNT_MANAGER` — note that
* `android.permission.ACCOUNT_AUTHENTICATOR`, which the obvious guess would
* reach for, **does not exist**.
*/
class AuthenticatorService : Service() {
private val authenticator by lazy { SyncAuthenticator(this) }
override fun onBind(intent: Intent?): IBinder? = authenticator.iBinder
}
@@ -0,0 +1,32 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Whether CalDAV accounts can do anything right now.
*
* Sync writes into Agendula's own store. In External mode the screens read a
* third-party provider instead, so an account would sync into rows nobody sees.
* Read from the stored preference rather than [ProviderResolver.mode], which is
* only current once `StorageModeHolder` has mirrored it — and a worker can start
* before that.
*/
@Singleton
class SyncAvailability @Inject constructor(
private val prefs: SettingsPrefs,
private val resolver: ProviderResolver,
) {
suspend fun accountsUsable(): Boolean = observe().first()
fun observe(): Flow<Boolean> = prefs.storageMode.map { stored ->
(stored ?: resolver.autoMode()) == StorageMode.OWN
}
}
@@ -0,0 +1,92 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
/**
* When each collection was last reconciled against a full listing.
*
* ⚠️ This is the mitigation for the one RFC 6578 failure that has no signal at
* all: a token the server still accepts, over a change log it has already
* pruned, answers `207` with zero changes and no error. Nothing in the protocol
* distinguishes that from "nothing happened". The only defence is to stop
* trusting the token periodically and diff a real listing — so the full path is
* a permanent safety net, not a fallback, and this is its clock.
*
* Kept out of Room deliberately: it is scheduling bookkeeping, not user data,
* and it must never be part of a backup that could restore a stale "we checked
* recently" into a fresh install.
*/
@Singleton
class SyncCadenceStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
/**
* Last *scheduled* full reconciliation per list id.
*
* ⚠️ Not "the last time a full listing was read". A collection whose server
* has no `sync-collection` support reads one on every run, and recording
* each would keep this permanently fresh — so nothing hung off the periodic
* mark would ever come due again.
*/
suspend fun lastFullSync(): Map<Long, Instant> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
val separator = entry.lastIndexOf(SEPARATOR)
if (separator <= 0) return@mapNotNull null
val id = entry.substring(0, separator).toLongOrNull() ?: return@mapNotNull null
val at = entry.substring(separator + 1).toLongOrNull() ?: return@mapNotNull null
id to Instant.fromEpochSeconds(at)
}.toMap()
/** Merges, rather than replacing, so concurrent accounts do not erase each other. */
suspend fun record(reconciled: Map<Long, Instant>) {
if (reconciled.isEmpty()) return
dataStore.edit { prefs ->
val current = prefs[KEY].orEmpty()
.mapNotNull { entry ->
val separator = entry.lastIndexOf(SEPARATOR)
if (separator <= 0) null else entry.substring(0, separator) to entry
}
.toMap()
.toMutableMap()
reconciled.forEach { (id, at) ->
current["$id"] = "$id$SEPARATOR${at.epochSeconds}"
}
prefs[KEY] = current.values.toSet()
}
}
/** Forgets a list, so a re-added account starts from a full reconciliation. */
suspend fun forget(listIds: Set<Long>) {
if (listIds.isEmpty()) return
dataStore.edit { prefs ->
prefs[KEY] = prefs[KEY].orEmpty().filterNot { entry ->
entry.substringBefore(SEPARATOR).toLongOrNull() in listIds
}.toSet()
}
}
companion object {
/**
* How long a sync token is trusted before a full listing is diffed anyway.
*
* Long enough that the incremental path still carries almost every sync,
* short enough that a silently pruned change log is a day's divergence
* rather than an indefinite one.
*/
val FULL_RECONCILIATION_INTERVAL: Duration = 24.hours
private const val SEPARATOR = '@'
private val KEY = stringSetPreferencesKey("sync_last_full")
}
}
@@ -0,0 +1,257 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.CalendarCollection
import de.jeanlucmakiola.caldav.RemoteCalendar
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* Syncs one account: every list it owns, against the collection each points at.
*
* ⚠️ **A failed collection must not fail the account.** One revoked share, one
* calendar the server 500s on, must not stop the other four from syncing — so
* every collection's outcome is a [SyncReport] rather than an exception, and the
* account's own result is the list of them.
*/
@Singleton
class SyncEngine @Inject constructor(
private val database: TasksDatabase,
private val store: RoomSyncStore,
private val credentials: CredentialStore,
private val quarantine: QuarantineStore,
private val cadence: SyncCadenceStore,
private val accountState: AccountStateStore,
private val notices: SyncNoticeStore,
private val availability: SyncAvailability,
private val push: PushRegistrar,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/** Why an account could not be synced at all, as opposed to one of its lists. */
sealed interface Result {
data class Synced(
val reports: List<SyncReport>,
/**
* What this run destroyed or gave up on that was not already on
* record — the caller's cue to say so out loud.
*/
val notices: List<SyncNotice> = emptyList(),
) : Result
/** The credential is gone or undecryptable: only re-authentication helps. */
data class NeedsSignIn(val accountId: Long, val reason: String) : Result
data class Misconfigured(val reason: String) : Result
/** External storage mode: nothing reads what a sync would write, so none runs. */
data object Paused : Result
}
suspend fun sync(accountName: String): Result = withContext(io) {
if (!availability.accountsUsable()) return@withContext Result.Paused
val account = database.accounts().all().firstOrNull { it.displayName == accountName }
?: return@withContext Result.Misconfigured("no such account: $accountName")
// ⚠️ Before anything reaches the network. A periodic request that outlives
// the stop — or a manual trigger on a stopped account — must not spend a
// request on a credential we already know the server rejects: Nextcloud
// throttles then 429s per source IP, and that lands on the user's other
// clients rather than on us.
if (accountState.needsSignIn(account.id)) {
return@withContext Result.NeedsSignIn(account.id, "waiting for you to sign in again")
}
val username = account.username
?: return@withContext fatal(account.id, Result.Misconfigured("account has no username"))
val origin = account.principalUrl?.toHttpUrlOrNull()
?: return@withContext fatal(
account.id,
Result.Misconfigured("account has no principal URL"),
)
val password = when (val secret = credentials.get(account.id)) {
is CredentialStore.Secret.Present -> secret.value
CredentialStore.Secret.Absent -> {
stopForSignIn(account.id, "no stored password")
return@withContext Result.NeedsSignIn(account.id, "no stored password")
}
is CredentialStore.Secret.Unrecoverable -> {
stopForSignIn(account.id, secret.reason)
return@withContext Result.NeedsSignIn(account.id, secret.reason)
}
}
val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin)
val subscriptions = push.subscriptionsByHref(account.id)
val reports = syncCollections(account) { url ->
CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()])
}
// ⚠️ Before the auth check, not after it. A 401 on one collection does
// not un-discard an edit another collection already destroyed, and
// returning NeedsSignIn past this point would drop the record of it.
// Outside `syncCollections` because that is driven without a network by
// the reconciliation tests, which have nothing to say about notices.
val fresh = notices.record(
accountId = account.id,
at = kotlin.time.Clock.System.now(),
reports = reports,
titles = quarantinedTitles(reports),
)
if (reports.any { it.authFailure }) {
// ⚠️ Stop the account rather than let the schedule keep trying.
// Nextcloud throttles and then 429s **per source IP**, so a timer on a
// dead app password degrades every other Nextcloud client on the
// user's network — and there is nothing here to retry: the fix is a
// sign-in only the user can perform.
stopForSignIn(account.id, "the server rejected the credentials")
return@withContext Result.NeedsSignIn(account.id, "the server rejected the credentials")
}
accountState.setNeedsSignIn(account.id, false)
// Never fails the sync: push is an optimisation on top of the schedule.
runCatching { push.onSynced(account, reports) }
Result.Synced(reports, fresh)
}
/**
* The local title of each quarantined resource, by href.
*
* ⚠️ Resolved here rather than left to the store, which has no database.
* Without it the user is told "a task has stopped syncing" over a row
* reading `a1f9c3e2-….ics` — the opaque blob `SyncNoticeStore` refuses to
* show for a discarded edit, and unactionable for exactly the same reason.
* A resource we have never stored has no title to find, and its filename is
* then genuinely all there is.
*/
private fun quarantinedTitles(reports: List<SyncReport>): Map<String, String> =
reports.filter { it.quarantined.isNotEmpty() }
.flatMap { report ->
val wanted = report.quarantined.mapTo(mutableSetOf()) { it.href }
store.rowsIn(report.listId)
.filter { it.href in wanted && !it.title.isNullOrBlank() }
.map { it.href!! to it.title!! }
}
.toMap()
/**
* Records why the account could not be synced at all.
*
* ⚠️ Without this the row keeps its old `lastSyncAt`, and the accounts screen
* goes on reporting "synced 5 minutes ago" for an account whose credential
* can no longer be decrypted — the silent failure the account layer exists to
* avoid.
*/
/**
* Marks an account as stopped until the user signs in again.
*
* ⚠️ It does **not** cancel the work, even though stopping the timer is the
* whole point — because this runs *inside* `SyncWorker`, and one of the two
* unique names it would cancel is the WorkSpec currently executing us.
* WorkManager would interrupt the coroutine, so `Result.NeedsSignIn` would
* never be returned and the adapter would see CANCELLED rather than FAILED.
*
* The flag does the work instead: [sync] refuses before touching the network,
* so a firing that survives costs nothing, and [AccountRepository.rescheduleAll]
* cancels the schedule from outside any worker.
*/
private suspend fun stopForSignIn(accountId: Long, reason: String) {
accountState.setNeedsSignIn(accountId, true)
database.accounts().recordSync(accountId, at = null, error = reason)
}
private fun fatal(accountId: Long, result: Result): Result {
val reason = when (result) {
is Result.NeedsSignIn -> result.reason
is Result.Misconfigured -> result.reason
is Result.Synced, Result.Paused -> return result
}
database.accounts().recordSync(accountId, at = null, error = reason)
return result
}
/** Split out from [sync] so the reconciliation can be driven without a network. */
internal suspend fun syncCollections(
account: AccountEntity,
remoteFor: (HttpUrl) -> RemoteCalendar,
): List<SyncReport> {
// Lists owing a DELETE first: a task moved between two of this account's
// collections then leaves the old one before it arrives in the new one,
// which a server that keeps UIDs unique per account needs.
val owing = database.tasks().listsWithTombstones().toSet()
val lists = database.taskLists().syncedForAccount(account.id)
.sortedBy { it.id !in owing }
val listIds = lists.map { it.id }.toSet()
// ⚠️ Only this account's keys are written back. The counts are global
// while the worker's uniqueness is only per account, so replacing the
// whole map would discard a concurrently syncing account's increments and
// resurrect the counters it had cleared.
val before = quarantine.counts()
val counts = before.toMutableMap()
val syncer = CollectionSyncer(store)
val now = kotlin.time.Clock.System.now()
val lastFull = cadence.lastFullSync()
// Never reconciled, or the token has been trusted long enough.
val due = lists.associate { list ->
val since = lastFull[list.id]
list.id to (since == null || now - since >= SyncCadenceStore.FULL_RECONCILIATION_INTERVAL)
}
val reports = lists.map { list ->
val url = list.href?.toHttpUrlOrNull()
?: return@map SyncReport(list.id, list.name, failure = "list has no collection URL")
syncer.sync(
list = list,
remote = remoteFor(url),
quarantine = counts,
fullReconciliationDue = due[list.id] == true,
)
}
// ⚠️ Only the runs that were *due*. A server without `sync-collection`
// reconciles in full every single time, so recording each one kept the
// clock permanently fresh and `fullReconciliationDue` permanently false
// — which costs nothing on that path, since the cursor is null anyway,
// but silently disables everything else hung off the periodic mark. The
// download-side quarantine probe is the one that matters: for exactly
// those servers it would never have fired.
cadence.record(
reports.filter { it.reconciledInFull && it.failure == null && due[it.listId] == true }
.associate { it.listId to now },
)
fun mine(key: String) = key.substringBefore('|').toLongOrNull() in listIds
quarantine.merge(
updates = counts.filterKeys(::mine),
cleared = before.keys.filter(::mine).filterNot { it in counts }.toSet(),
)
database.accounts().recordSync(
accountId = account.id,
at = now,
error = reports.mapNotNull { it.failure }.firstOrNull(),
)
return reports
}
private companion object {
/**
* Matches what the account-add flow signed in with, so Nextcloud's
* Settings → Security → Devices & sessions keeps naming the app password
* after the app rather than after OkHttp.
*/
const val USER_AGENT = "Agendula (Android)"
}
}
@@ -0,0 +1,81 @@
package de.jeanlucmakiola.agendula.data.sync
/**
* Why a sync failed, in classes a user can act on.
*
* `accounts.last_sync_error` holds the engine's own words — a collection
* failure wrapping an exception's `toString()` — which are for logs and never
* for the screen. This reads the class back out of them.
*/
data class SyncFailure(val kind: Kind, val httpCode: Int? = null) {
enum class Kind {
/** The server refused the credentials. */
SIGN_IN,
/** DNS, a refused connection, a timeout: nothing answered. */
UNREACHABLE,
/** The TLS handshake failed — an untrusted or mismatched certificate. */
CERTIFICATE,
/** The server answered, with an error of its own. */
SERVER,
/** The account or a list is missing something the sync needs. */
MISCONFIGURED,
/** Anything else: the collection did not finish, for a reason we do not name. */
OTHER,
}
companion object {
fun of(error: String): SyncFailure {
val code = HTTP_CODE.find(error)?.groupValues?.get(1)?.toIntOrNull()
return when {
TLS.any { it in error } -> SyncFailure(Kind.CERTIFICATE)
AUTH.any { it in error } || code == 401 -> SyncFailure(Kind.SIGN_IN)
NETWORK.any { it in error } -> SyncFailure(Kind.UNREACHABLE)
CONFIG.any { it in error } -> SyncFailure(Kind.MISCONFIGURED)
"ServiceUnavailableException" in error -> SyncFailure(Kind.SERVER, code ?: 503)
code != null -> SyncFailure(Kind.SERVER, code)
else -> SyncFailure(Kind.OTHER)
}
}
private val HTTP_CODE = Regex("""\bHTTP (\d{3})\b""")
private val TLS = listOf(
"SSLHandshakeException",
"SSLPeerUnverifiedException",
"CertPathValidatorException",
"CertificateException",
"SSLException",
)
private val AUTH = listOf(
"UnauthorizedException",
"rejected the credentials",
"no stored password",
)
private val NETWORK = listOf(
"UnknownHostException",
"ConnectException",
"NoRouteToHostException",
"SocketTimeoutException",
"InterruptedIOException",
"SocketException",
"EOFException",
"timeout",
)
private val CONFIG = listOf(
"no such account",
"has no username",
"has no principal URL",
"has no collection URL",
)
}
}
@@ -0,0 +1,191 @@
package de.jeanlucmakiola.agendula.data.sync
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import javax.inject.Inject
import javax.inject.Singleton
/**
* Tells the user what a background sync destroyed or gave up on.
*
* ⚠️ A notification, and not only a row on the accounts screen. Sync runs on a
* four-hour timer while the app is closed, so a surface the user has to go and
* look at means the discarded edit is discovered — if ever — days later, next to
* a task that quietly says something else than what they typed. The account
* screen keeps the detail; this is what makes them go there.
*
* Its own channel, at `IMPORTANCE_LOW`: it is a report rather than an alarm, and
* it must be silenceable without taking due-task reminders with it.
*/
@Singleton
class SyncNoticeNotifier @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
@SuppressLint("MissingPermission")
fun post(accountName: String, notices: List<SyncNotice>) {
if (notices.isEmpty() || !canPost()) return
ensureChannel()
val discarded = notices.count { it.kind == SyncNotice.Kind.DISCARDED_EDIT }
val quarantined = notices.size - discarded
// ⚠️ A discarded edit outranks a quarantine even when there are more
// quarantines, and the collapsed line says so. They are not equivalent:
// an edit that lost is work already destroyed and unrecoverable, while a
// quarantined task is a condition that persists and clears itself. The
// big text below lists both, in full, whichever headline was chosen.
val title = if (discarded > 0) {
context.resources.getQuantityString(
R.plurals.sync_notice_discarded_title, discarded, discarded,
)
} else {
context.resources.getQuantityString(
R.plurals.sync_notice_quarantined_title, quarantined, quarantined,
)
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(context.getString(R.string.sync_notice_body, accountName))
.setStyle(NotificationCompat.BigTextStyle().bigText(summaryOf(notices)))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setAutoCancel(true)
.setContentIntent(
PendingIntent.getActivity(
context,
accountName.hashCode(),
MainActivity.openIntent(context),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
.build()
// Tagged by account, so a second account's news replaces nothing.
NotificationManagerCompat.from(context).notify(accountName, NOTIFICATION_ID, notification)
}
/** "Sign in to <account> again", for a background sync the server refused. */
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
@SuppressLint("MissingPermission")
fun postSignIn(accountName: String, accountId: Long) {
if (!canPost()) return
ensureSignInChannel()
val body = context.getString(R.string.sync_sign_in_body)
val notification = NotificationCompat.Builder(context, SIGN_IN_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.sync_sign_in_title, accountName))
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setAutoCancel(true)
.setContentIntent(
PendingIntent.getActivity(
context,
accountId.toInt(),
signInIntent(accountId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
.build()
NotificationManagerCompat.from(context).notify(accountName, SIGN_IN_NOTIFICATION_ID, notification)
}
/** The account syncs again, so the prompt has done its job. */
fun cancelSignIn(accountName: String) {
NotificationManagerCompat.from(context).cancel(accountName, SIGN_IN_NOTIFICATION_ID)
}
/**
* Where tapping the sign-in prompt lands. Only opens the app for now; the
* extra names the account for routing to Settings → Accounts → it.
*/
private fun signInIntent(accountId: Long): Intent =
MainActivity.openIntent(context).putExtra(EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
private fun ensureSignInChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(SIGN_IN_CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
SIGN_IN_CHANNEL_ID,
context.getString(R.string.sync_sign_in_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = context.getString(R.string.sync_sign_in_channel_desc) },
)
}
/**
* The first few, by name.
*
* ⚠️ A count on its own is unactionable — "3 edits were replaced" leaves the
* user to guess which three, across every list they own. The names are the
* only part that makes the account screen worth opening.
*/
private fun summaryOf(notices: List<SyncNotice>): String {
val named = notices.take(SUMMARY_LIMIT).joinToString("\n") { notice ->
val subject = notice.subject.ifBlank { context.getString(R.string.task_untitled) }
context.getString(R.string.sync_notice_line, subject, notice.listName)
}
val rest = notices.size - SUMMARY_LIMIT
return if (rest > 0) {
named + "\n" + context.resources.getQuantityString(R.plurals.sync_notice_more, rest, rest)
} else {
named
}
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.sync_notice_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply { description = context.getString(R.string.sync_notice_channel_desc) },
)
}
/** Re-create the channel, if it exists, in the current language. */
fun refreshChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
}
companion object {
/** The account a sign-in notification is about, on the intent it opens. */
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
private const val CHANNEL_ID = "sync_notices"
private const val NOTIFICATION_ID = 2
private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
private const val SIGN_IN_NOTIFICATION_ID = 3
/** Enough to recognise the work; the screen has the rest. */
private const val SUMMARY_LIMIT = 5
}
}
@@ -0,0 +1,279 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/**
* One thing a sync did that the user would not otherwise find out about.
*
* ⚠️ Conflict policy is **server wins, local edit discarded**, and
* [SyncReport]'s own doc says the report is "the other half of the decision, not
* a nice-to-have". Until this existed the other half was a `Log.i` — the edit was
* gone, nothing in `ui/` read `discardedEdits`, and from where the user sits that
* is indistinguishable from the app losing their work.
*/
data class SyncNotice(
val accountId: Long,
val listName: String,
val kind: Kind,
/** The task's title for a discarded edit, the resource's name for a quarantine. */
val subject: String,
/**
* What makes this notice distinct from another about a different task.
*
* ⚠️ Carried but never shown. These are stored as a `Set<String>`, and two
* discarded edits from one run share an account, a list, a cause and a
* timestamp — so two *untitled* tasks, or two both called "Milk", encoded
* identically and one of them silently vanished. The notification counted
* two and the screen listed one. The UID is the only thing that tells them
* apart, and it is exactly what must not reach the user: opaque text chosen
* by whoever created the task.
*/
val key: String,
/** Why the edit lost. Null for a quarantine, which has no such choice behind it. */
val cause: DiscardedEdit.Cause?,
val at: Instant,
) {
enum class Kind { DISCARDED_EDIT, QUARANTINED }
}
/**
* What the last syncs destroyed or gave up on, per account, until it is read.
*
* The two kinds keep different company, which is why they are written
* differently:
*
* - A **discarded edit** is news. It happened once, it cannot be undone, and a
* later clean sync does not make it untrue — so it accumulates and is cleared
* only by the user acknowledging it. Replacing the set every run would let a
* quiet sync an hour later erase the one thing worth saying.
* - A **quarantined resource** is a standing condition: one task has stopped
* syncing while the rest of its list is fine. It is re-reported on every run
* for as long as it holds, so the account's set of them is *replaced* each
* time — which is also how it clears itself the moment the resource starts
* working again.
*
* Lives with the other per-device sync state, and is therefore excluded from
* backup — see [SyncStateDataStore]. Correct on its own terms too: a restored
* device has not discarded anything.
*/
@Singleton
class SyncNoticeStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
/** Observed, so a background sync's news reaches a screen that is already open. */
fun observeAll(): Flow<List<SyncNotice>> = dataStore.data.map { prefs ->
prefs[KEY].orEmpty().mapNotNull(::decode).sortedByDescending { it.at }
}
/**
* Folds one account's run into the store.
*
* @return only what is **new**, which is what a notification may be posted
* for. A quarantine already on record is a condition the user has already
* been told about, and re-announcing it on every four-hour run would train
* them to ignore the one that matters.
*/
/**
* @param titles the local title of each quarantined resource, by href.
* ⚠️ Not optional decoration. Without it the row read
* `a1f9c3e2-….ics`, which is the opaque blob this file refuses to show
* for a discarded edit — and "a task has stopped syncing" that does not
* say which task is the very failure the feature exists to fix. Absent
* only for a resource we never stored, where the filename is genuinely
* all there is.
*/
suspend fun record(
accountId: Long,
at: Instant,
reports: List<SyncReport>,
titles: Map<String, String> = emptyMap(),
): List<SyncNotice> {
val discarded = reports.flatMap { report ->
report.discardedEdits.map { edit ->
SyncNotice(
accountId = accountId,
listName = report.listName,
kind = SyncNotice.Kind.DISCARDED_EDIT,
// The UID is not shown to anyone: it is opaque text chosen by
// whoever created the task, routinely a bare hex blob.
subject = edit.title.orEmpty(),
key = edit.uid,
cause = edit.cause,
at = at,
)
}
}
// ⚠️ Only the ones that have actually stopped. Below the threshold the
// resource is still being retried, and "one of your tasks has stopped
// syncing" would be untrue of a single 502 from a proxy mid-restart.
val quarantined = reports.flatMap { report ->
report.quarantined
.filter { it.failures >= QuarantineStore.THRESHOLD }
.map { resource ->
SyncNotice(
accountId = accountId,
listName = report.listName,
kind = SyncNotice.Kind.QUARANTINED,
subject = titles[resource.href] ?: resource.href.substringAfterLast('/'),
key = resource.href,
cause = null,
at = at,
)
}
}
// ⚠️ Nothing to say is the overwhelmingly common case — most syncs
// discard nothing and quarantine nothing — and a DataStore edit rewrites
// and fsyncs the whole file. Skipped only when there is also nothing on
// record to clear, or a recovered resource would keep its notice for ever.
if (discarded.isEmpty() && quarantined.isEmpty() && !hasRecord(accountId)) {
return emptyList()
}
var added = emptyList<SyncNotice>()
dataStore.edit { prefs ->
// ⚠️ Re-read inside `edit`, which DataStore serialises. The set is
// global while `SyncWorker`'s uniqueness is only per account, so two
// accounts can be folding in at once and a snapshot taken outside
// would discard the other's.
val current = prefs[KEY].orEmpty().mapNotNull(::decode)
val others = current.filter { it.accountId != accountId }
val keptDiscards = current.filter {
it.accountId == accountId && it.kind == SyncNotice.Kind.DISCARDED_EDIT
}
val standing = current.filter {
it.accountId == accountId && it.kind == SyncNotice.Kind.QUARANTINED
}
added = discarded + quarantined.filterNot { fresh ->
standing.any { it.key == fresh.key }
}
// Newest first, then capped: an account that has been failing for a
// week must not grow this without bound, and the oldest news is the
// least actionable.
val kept = (discarded + keptDiscards).sortedByDescending { it.at }.take(MAX_PER_ACCOUNT)
// ⚠️ Capped as well, and the class doc used to claim it did not need
// to be. "Bounded by the collection" is only true of a healthy one:
// a server answering 415 to four hundred resources puts four hundred
// entries in one preference key, rewritten on every run — and the
// account screen renders them into a plain scrolling column.
val standingNow = quarantined.take(MAX_PER_ACCOUNT)
val updated = (others + kept + standingNow).map(::encode).toSet()
// ⚠️ Only when it differs. A DataStore edit rewrites and fsyncs the
// whole file, and an account holding one un-dismissed notice would
// otherwise pay that on every four-hour sync until the user tapped
// "Got it" — which is the cost the fast path above claims to avoid.
if (updated != prefs[KEY]) prefs[KEY] = updated
}
return added
}
private suspend fun hasRecord(accountId: Long): Boolean =
dataStore.data.first().let { prefs ->
prefs[KEY].orEmpty().mapNotNull(::decode).any { it.accountId == accountId }
}
/**
* Forgets one list's notices, for a list that has just been deleted.
*
* By name, because that is how they are keyed — there is no list id in a
* notice, and by the time this is called the row it would have named is
* already gone.
*/
suspend fun forgetList(accountId: Long, listName: String) {
dataStore.edit { prefs ->
val kept = prefs[KEY].orEmpty()
.mapNotNull(::decode)
.filterNot { it.accountId == accountId && it.listName == listName }
.map(::encode)
.toSet()
if (kept != prefs[KEY]) prefs[KEY] = kept
}
}
/** Drops one quarantine notice, for a resource the user has asked to retry. */
suspend fun forgetQuarantined(accountId: Long, key: String) {
dataStore.edit { prefs ->
val kept = prefs[KEY].orEmpty()
.mapNotNull(::decode)
.filterNot {
it.accountId == accountId && it.kind == SyncNotice.Kind.QUARANTINED && it.key == key
}
.map(::encode)
.toSet()
if (kept != prefs[KEY]) prefs[KEY] = kept
}
}
/** The user has read them. */
suspend fun dismiss(accountId: Long) {
dataStore.edit { prefs ->
prefs[KEY] = prefs[KEY].orEmpty()
.mapNotNull(::decode)
.filterNot { it.accountId == accountId }
.map(::encode)
.toSet()
}
}
/**
* ⚠️ Base64 around the free text, not a delimiter and a hope. A list is
* named by its owner and a task is titled by its author, so both can hold
* any character at all — including whatever separator looked safe.
*/
private fun encode(notice: SyncNotice): String = listOf(
notice.accountId.toString(),
notice.kind.name,
notice.cause?.name.orEmpty(),
notice.at.toEpochMilliseconds().toString(),
base64(notice.listName),
base64(notice.subject),
base64(notice.key),
).joinToString(SEPARATOR)
private fun decode(entry: String): SyncNotice? {
val parts = entry.split(SEPARATOR)
if (parts.size != FIELDS) return null
val accountId = parts[0].toLongOrNull() ?: return null
val kind = SyncNotice.Kind.entries.firstOrNull { it.name == parts[1] } ?: return null
val at = parts[3].toLongOrNull() ?: return null
return SyncNotice(
accountId = accountId,
listName = unBase64(parts[4]) ?: return null,
kind = kind,
subject = unBase64(parts[5]) ?: return null,
key = unBase64(parts[6]) ?: return null,
cause = DiscardedEdit.Cause.entries.firstOrNull { it.name == parts[2] },
at = Instant.fromEpochMilliseconds(at),
)
}
private fun base64(value: String): String =
Base64.getUrlEncoder().encodeToString(value.toByteArray(Charsets.UTF_8))
private fun unBase64(value: String): String? = runCatching {
String(Base64.getUrlDecoder().decode(value), Charsets.UTF_8)
}.getOrNull()
private companion object {
val KEY = stringSetPreferencesKey("sync_notices")
/** Not present in URL-safe Base64, nor in a decimal or an enum name. */
const val SEPARATOR = "|"
const val FIELDS = 7
/** Discarded edits, per account. Quarantines are bounded by the collection. */
const val MAX_PER_ACCOUNT = 50
}
}
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.agendula.data.sync
import android.util.Log
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import javax.inject.Inject
import javax.inject.Singleton
/**
* Pushes a local edit to its account soon after it is made.
*
* Hooked into the store's own write paths rather than Room's invalidation
* tracker: sync writes the same tables, and an observer cannot tell its own
* downloads from the user's edits.
*/
@Singleton
class SyncOnEdit @Inject constructor(
private val database: TasksDatabase,
private val trigger: SyncTrigger,
) : LocalWriteListener {
override fun onWritten(listIds: Set<Long>) {
try {
listIds.mapNotNull { database.taskLists().entity(it)?.accountId }
.toSet()
.mapNotNull { database.accounts().account(it)?.displayName }
.forEach(trigger::pushSoon)
} catch (e: Exception) {
// The edit is saved either way; the schedule picks it up later.
Log.w(TAG, "could not schedule a push", e)
}
}
private companion object {
const val TAG = "SyncOnEdit"
}
}
@@ -0,0 +1,27 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
/**
* The end of [SyncTrigger.pushSoon]'s debounce: hands the account to a real
* sync. Instant, so the REPLACE that restarts the debounce only ever cancels a
* timer.
*/
@HiltWorker
class SyncPushWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted parameters: WorkerParameters,
private val trigger: SyncTrigger,
) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
val accountName = inputData.getString(SyncWorker.KEY_ACCOUNT_NAME) ?: return Result.failure()
trigger.enqueueAfterRunning(accountName)
return Result.success()
}
}
@@ -0,0 +1,105 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.caldav.PushSupport
/**
* What a sync did, and — the part that matters — what it destroyed.
*
* ⚠️ Conflict policy is **server wins, local edit discarded**.
* That policy terminates, which is why it was chosen over forking under a new
* UID, but on its own it is indistinguishable from data loss: the user's edit is
* gone and nothing said so. The report is the other half of the decision, not a
* nice-to-have — [discardedEdits] is why this type exists.
*/
data class SyncReport(
val listId: Long,
val listName: String,
val downloaded: Int = 0,
val uploaded: Int = 0,
val deletedRemotely: Int = 0,
val deletedLocally: Int = 0,
/** Local edits thrown away because the server's copy was newer. */
val discardedEdits: List<DiscardedEdit> = emptyList(),
/** Resources the collection gave up on, so the rest of it could finish. */
val quarantined: List<QuarantinedResource> = emptyList(),
/**
* Writes sent without `If-Match` because the server offers no usable
* validator. Not an error, but the one case where a concurrent edit can be
* overwritten without us noticing, so it is said out loud.
*/
val unconditionalWrites: Int = 0,
/**
* Whether this run reconciled against a full listing rather than a change log.
*
* ⚠️ Tracked because a token the server accepts over a change log it has
* already pruned returns 207, zero changes and no error — RFC 6578 gives no
* signal for it at all. The only mitigation is to reconcile in full on a slow
* cadence regardless of the token, which means knowing when we last did.
*/
val reconciledInFull: Boolean = false,
/**
* Why the change-log path was abandoned, on a run the full path then
* completed.
*
* Not a [failure]: the collection is reconciled and the user has nothing to
* act on. Kept because a server that rejects `sync-collection` every time
* will do it again, and that is worth seeing in a log without it becoming an
* error in the UI.
*/
val incrementalNote: String? = null,
/**
* The server refused our credentials.
*
* ⚠️ Escalates to the whole account and stops it, unlike every other failure
* here. Nextcloud's brute-force protection throttles and then **429s per
* source IP**, so a client that keeps retrying a dead app password on a timer
* takes the user's *other* Nextcloud clients down with it, on that network,
* and looks from the outside like we broke their server. There is nothing to
* retry anyway: only the user can fix it.
*/
val authFailure: Boolean = false,
/** Set when the collection failed as a whole. The account keeps going. */
val failure: String? = null,
/**
* The collection's own properties were read this run, so [pushSupport] is
* the server's answer rather than the absence of one.
*/
val collectionRead: Boolean = false,
/** WebDAV-Push, as the collection offered it this run. */
val pushSupport: PushSupport? = null,
) {
val hadWork: Boolean
get() = downloaded > 0 || uploaded > 0 || deletedRemotely > 0 || deletedLocally > 0
}
/** One local edit that lost to the server. */
data class DiscardedEdit(
val uid: String,
val title: String?,
val cause: Cause,
) {
enum class Cause {
/** The server's copy changed after we last read it. */
SERVER_NEWER,
/** The task was deleted on the server while it was edited here. */
DELETED_ON_SERVER,
/** Deleted here, but changed on the server after that. The delete lost. */
DELETE_LOST,
}
}
/**
* A resource the collection stopped trying.
*
* ⚠️ Quarantine is a **counter, not a backoff**. A single HTTP 400 on one
* resource has halted all of a user's calendar sync in DAVx5 for weeks; the
* failure has to be contained to the resource that caused it, and the rest of
* the collection has to complete.
*/
data class QuarantinedResource(
val href: String,
val reason: String,
val failures: Int,
)
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import javax.inject.Inject
/**
* The database, as [CollectionSyncer] needs it.
*
* A seam, and the reason is the same one that put [CalDavGateway] in front of
* discovery: the reconciliation above this interface is where local edits are
* discarded, tombstones swept and conflicts resolved, and every one of those is
* a decision that should be provable without a device. Room's test double is
* Robolectric plus an in-memory database; this is eight methods.
*/
interface SyncStore {
/** Every row in a list, **tombstones included**. */
fun rowsIn(listId: Long): List<TaskEntity>
fun insert(row: TaskEntity): Long
fun update(row: TaskEntity)
fun deleteAll(taskIds: List<Long>)
/**
* Records href and ETag on a resource's rows, clearing `is_dirty`.
*
* The caller excludes any row it left out of the body — that row is deleted,
* not marked synced.
*/
fun markSynced(taskIds: List<Long>, href: String?, eTag: String?)
fun setParent(taskId: Long, parentId: Long?)
/** The master row for a UID — the one with no `RECURRENCE-ID`. */
fun masterByUid(listId: Long, uid: String): TaskEntity?
fun row(taskId: Long): TaskEntity?
/**
* One column, deliberately. A whole-entity update would carry the row as it
* looked when the sync started and revert anything the user changed while it
* ran.
*/
fun setListReadOnly(listId: Long, readOnly: Boolean)
/** The RFC 6578 cursor. Null resets the collection to a full reconciliation. */
fun setSyncToken(listId: Long, token: String?)
}
class RoomSyncStore @Inject constructor(
private val database: TasksDatabase,
) : SyncStore {
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
override fun insert(row: TaskEntity) = database.tasks().insert(row)
override fun update(row: TaskEntity) {
database.tasks().update(row)
}
override fun deleteAll(taskIds: List<Long>) {
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
}
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
if (taskIds.isNotEmpty()) database.tasks().markSynced(taskIds, href, eTag)
}
override fun setParent(taskId: Long, parentId: Long?) {
database.tasks().setParent(taskId, parentId)
}
override fun masterByUid(listId: Long, uid: String) = database.tasks().byUid(listId, uid)
override fun row(taskId: Long) = database.tasks().entity(taskId)
override fun setListReadOnly(listId: Long, readOnly: Boolean) {
database.taskLists().setReadOnly(listId, readOnly)
}
override fun setSyncToken(listId: Long, token: String?) {
database.taskLists().setSyncToken(listId, token)
}
}
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
/**
* A `ContentProvider` that stores nothing.
*
* It exists because **a sync adapter is registered against a content
* authority**, and Agendula publishes no provider — `:provider` was deleted when
* we took our own Room store. Without an authority there
* is nothing for `<sync-adapter android:contentAuthority>` to name, nothing for
* `ContentResolver.requestSync` to address, and nothing for system Settings to
* render a sync switch against.
*
* The sync-adapter registration is not optional:
* `ContentService.hasAuthorityAccess()` gates `requestSync`,
* `setSyncAutomatically`, `addPeriodicSync`, `setIsSyncable` and seven more
* behind a compat change that is **on for targetSdk ≥ 34**, and with nothing
* registered every one of those calls returns silently — no exception, no log,
* and it passes on a Robolectric shadow. This provider is the cheapest way to
* hold up the other end of that requirement.
*
* Not exported, and every method is a no-op. Real data lives in Room.
*/
class SyncStubProvider : ContentProvider() {
override fun onCreate() = true
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
) = 0
}
@@ -0,0 +1,201 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* Starts a sync for one account.
*
* Shared by the sync adapter and by the app's own "sync now", because the app
* cannot rely on the system trigger: ⚠️ `ContentService.hasAuthorityAccess()`
* gates `requestSync` behind a compat change that is on at targetSdk ≥ 34, and
* our authority is `userVisible="false"`, so Settings greys "Sync now" out. The
* in-app button enqueues the work directly and is unaffected.
*/
@Singleton
class SyncTrigger @Inject constructor(
@ApplicationContext private val context: Context,
) {
/**
* Starts a sync now.
*
* @param expedited for a trigger the user is looking at. ⚠️ Paired with
* `RUN_AS_NON_EXPEDITED_WORK_REQUEST`, which is not optional: the expedited
* quota is per-app and exhaustible, and the alternative policy
* (`DROP_WORK_REQUEST`) silently discards the sync the user just asked for.
* Never set from a background trigger — a boot receiver spending the quota
* leaves none for the button.
* @return the unique work name, which the caller may wait on.
*/
fun enqueue(accountName: String, expedited: Boolean = false): String {
val uniqueName = SyncWorker.uniqueNameFor(accountName)
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(inputFor(accountName))
.setConstraints(NETWORK)
.apply {
if (expedited) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
}
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
uniqueName,
// KEEP, not REPLACE: a periodic trigger arriving while a manual sync
// is mid-flight must not cancel it and lose the cursor.
ExistingWorkPolicy.KEEP,
request,
)
return uniqueName
}
/**
* Pushes a local edit soon, rather than on the next periodic window.
*
* Debounced: each call restarts [PUSH_DELAY], so a burst of edits costs one
* sync. What waits out the delay is a [SyncPushWorker], which hands over to
* [enqueueAfterRunning] — so the replace can only ever cancel a timer, never
* a sync that is mid-flight.
*/
fun pushSoon(accountName: String) {
val request = OneTimeWorkRequestBuilder<SyncPushWorker>()
.setInputData(inputFor(accountName))
.setInitialDelay(PUSH_DELAY.inWholeSeconds, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
pushNameFor(accountName),
ExistingWorkPolicy.REPLACE,
request,
)
}
/**
* A sync that runs after any one already running for [accountName].
*
* Not [enqueue]'s KEEP: a sync already past its upload phase would swallow
* this request and leave the edit that prompted it for the next window.
*/
fun enqueueAfterRunning(accountName: String) {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(inputFor(accountName))
.setConstraints(NETWORK)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
SyncWorker.uniqueNameFor(accountName),
ExistingWorkPolicy.APPEND_OR_REPLACE,
request,
)
}
/**
* A sync for a push message: the server says something changed.
*
* Appended like [enqueueAfterRunning], since a sync already past its
* download would miss the change — but only once. A burst of pushes during
* one sync must cost one more sync, not one each.
*/
suspend fun enqueueFromPush(accountName: String) = pushEnqueue.withLock {
val waiting = WorkManager.getInstance(context)
.getWorkInfosForUniqueWorkFlow(SyncWorker.uniqueNameFor(accountName))
.first()
.any { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.BLOCKED }
if (!waiting) enqueueAfterRunning(accountName)
}
/** Serialises [enqueueFromPush]'s check and its enqueue across concurrent messages. */
private val pushEnqueue = Mutex()
/**
* Puts the account on the periodic schedule.
*
* A plain `PeriodicWorkRequest` and no foreground service, deliberately —
* see [SyncWorker]. WorkManager restores its own schedule after a reboot, so
* nothing has to re-arm this from `BOOT_COMPLETED`; that matters because
* Android 15 forbids starting a `dataSync` foreground service from boot, and
* a design that needed one would have no way to run at all.
*
* ⚠️ Be honest about the cadence in the UI. The interval setting is a
* floor, not a promise: in the `rare` and `restricted` App Standby buckets
* network access is off entirely, and the genuine worst case is once overnight.
*
* @param intervalMinutes the sync-interval setting; 0 (manual only) takes
* the account off the schedule.
* @param intervalChanged the user just picked a new interval, so a schedule
* already in place is replaced rather than kept.
*/
fun schedule(accountName: String, intervalMinutes: Int, intervalChanged: Boolean = false) {
val minutes = intervalMinutes
if (minutes <= 0) {
WorkManager.getInstance(context).cancelUniqueWork(periodicNameFor(accountName))
return
}
val request = PeriodicWorkRequestBuilder<SyncWorker>(
minutes.toLong(), TimeUnit.MINUTES,
flexFor(minutes).inWholeMinutes, TimeUnit.MINUTES,
)
.setInputData(inputFor(accountName))
.setConstraints(NETWORK)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
periodicNameFor(accountName),
// UPDATE on every call would restart the interval on every app
// launch, so a device that is opened often would never reach the
// end of one. Only a changed interval replaces it.
if (intervalChanged) ExistingPeriodicWorkPolicy.UPDATE else ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
/** Takes a removed account off the schedule. */
fun cancel(accountName: String) {
WorkManager.getInstance(context).apply {
cancelUniqueWork(periodicNameFor(accountName))
cancelUniqueWork(pushNameFor(accountName))
cancelUniqueWork(SyncWorker.uniqueNameFor(accountName))
}
}
private fun inputFor(accountName: String) =
Data.Builder().putString(SyncWorker.KEY_ACCOUNT_NAME, accountName).build()
private companion object {
/** How long a local edit waits for more before it is pushed. */
val PUSH_DELAY: Duration = 30.seconds
/** The tail of each interval the system may run us in: a quarter of it, at most an hour. */
fun flexFor(intervalMinutes: Int): Duration =
(intervalMinutes / 4).minutes.coerceIn(5.minutes, 1.hours)
/**
* Sync needs a network, and saying so lets WorkManager run us the moment
* connectivity returns rather than on the next interval.
*/
val NETWORK: Constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
fun periodicNameFor(accountName: String) = "caldav-sync-periodic:$accountName"
fun pushNameFor(accountName: String) = "caldav-sync-push:$accountName"
}
}
@@ -0,0 +1,133 @@
package de.jeanlucmakiola.agendula.data.sync
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
/**
* Where sync actually happens.
*
* Two things about this worker are decided already. It is a
* **`CoroutineWorker` with no foreground service**: an ordinary
* worker is documented for under 10 minutes, and escalating to `setForeground`
* pulls in `FOREGROUND_SERVICE_DATA_SYNC`, the Android 15 six-hours-per-24
* `dataSync` budget whose failure mode is a fatal `RemoteServiceException`, and
* a Play requirement for a video demo per declared FGS type. And it must be
* **chunked and resumable** — the sync cursor is persisted per collection so a
* killed worker resumes rather than restarts, because under WorkManager process
* death mid-sync is routine rather than exotic.
*/
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted parameters: WorkerParameters,
private val engine: SyncEngine,
private val noticeNotifier: SyncNoticeNotifier,
private val reminderScheduler: ReminderScheduler,
private val accountState: AccountStateStore,
) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
val accountName = inputData.getString(KEY_ACCOUNT_NAME) ?: return Result.failure()
return when (val outcome = engine.sync(accountName)) {
is SyncEngine.Result.Synced -> {
// ⚠️ Success even when collections failed. A retry re-runs the
// whole account, and WorkManager's backoff would then punish the
// four healthy collections for the one that 500s — while the
// failing one is already contained by its own quarantine counter.
outcome.reports.forEach { report ->
if (report.failure != null || report.hadWork) Log.i(TAG, report.toString())
}
// ⚠️ Said out loud, not only logged. `SyncReport`'s own doc
// calls the report "the other half" of server-wins, and this
// worker runs on a four-hour timer with the app closed — so a
// log line is the same as saying nothing. Only what is new: the
// store has already dropped whatever the user has been told.
noticeNotifier.post(accountName, outcome.notices)
// Nothing else re-arms reminders for what a sync pulled in: in our
// own store no provider broadcast fires.
if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) {
runCatching { reminderScheduler.sync() }
}
noticeNotifier.cancelSignIn(accountName)
Result.success()
}
// Only the user can fix this, and retrying costs them Nextcloud's
// per-IP brute-force throttle — which takes their *other* clients
// down with it. Said once per stop, since this runs with the app closed.
is SyncEngine.Result.NeedsSignIn -> {
if (accountState.markSignInNotified(outcome.accountId)) {
noticeNotifier.postSignIn(accountName, outcome.accountId)
}
Result.failure()
}
is SyncEngine.Result.Misconfigured -> Result.failure()
// Not a failure: the account is kept, and syncs again once the user
// switches back to Agendula's own storage.
SyncEngine.Result.Paused -> Result.success()
}
}
/**
* ⚠️ Implemented **unconditionally**, even though this worker never asks to
* run in the foreground.
*
* `setExpedited` falls back to a foreground service below API 31, and
* WorkManager calls this to build it. The default implementation throws
* `IllegalStateException`, so a worker that only ever runs expedited on
* modern devices crashes on every device running API 29 or 30 — which we
* support. It is never actually shown above API 30.
*/
override suspend fun getForegroundInfo(): ForegroundInfo {
val manager = applicationContext.getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
applicationContext.getString(R.string.sync_notification_channel),
NotificationManager.IMPORTANCE_LOW,
),
)
val notification: Notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(applicationContext.getString(R.string.sync_notification_title))
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
// ⚠️ **No `foregroundServiceType`.** Declaring `dataSync` is what drags in
// `FOREGROUND_SERVICE_DATA_SYNC`, the Android 15 six-hours-per-24 budget
// whose failure mode is a fatal `RemoteServiceException`, and a Play
// requirement for a video demo per declared type — the whole tail this
// worker exists to avoid. It is not needed either: above API 30
// `setExpedited` uses an expedited job and never calls this at all, and
// types only became mandatory at API 34.
return ForegroundInfo(NOTIFICATION_ID, notification)
}
companion object {
/** One in-flight sync per account, so a manual trigger cannot pile up. */
fun uniqueNameFor(accountName: String) = "caldav-sync:$accountName"
const val KEY_ACCOUNT_NAME = "accountName"
private const val TAG = "SyncWorker"
private const val CHANNEL_ID = "sync"
private const val NOTIFICATION_ID = 4001
}
}
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.agendula.data.sync.push
import android.util.Log
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
import javax.inject.Inject
/**
* Where the UnifiedPush distributor reaches us; the instance is the account id.
* Work runs in the application scope, since the connector unbinds after a second.
*/
@AndroidEntryPoint
class AgendulaPushService : PushService() {
@Inject @ApplicationScope lateinit var scope: CoroutineScope
@Inject lateinit var registrar: PushRegistrar
@Inject lateinit var messages: PushMessageHandler
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
val accountId = instance.toLongOrNull() ?: return
scope.launch { runCatching { registrar.onNewEndpoint(accountId, endpoint) }.onFailure(::log) }
}
override fun onMessage(message: PushMessage, instance: String) {
// Encryption is mandatory in the draft.
if (!message.decrypted) {
Log.w(TAG, "dropped a push message that did not decrypt")
return
}
val content = message.content.toString(Charsets.UTF_8)
scope.launch { runCatching { messages.handle(content, instance) }.onFailure(::log) }
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "distributor refused registration for $instance: $reason")
// A transient failure leaves the last endpoint valid; the next renewal retries.
if (reason == FailedReason.NETWORK || reason == FailedReason.INTERNAL_ERROR) return
val accountId = instance.toLongOrNull() ?: return
scope.launch { runCatching { registrar.onUnregistered(accountId) }.onFailure(::log) }
}
override fun onUnregistered(instance: String) {
val accountId = instance.toLongOrNull() ?: return
scope.launch { runCatching { registrar.onUnregistered(accountId) }.onFailure(::log) }
}
private fun log(error: Throwable) = Log.w(TAG, "push handling failed", error)
private companion object {
const val TAG = "AgendulaPushService"
}
}
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.agendula.data.sync.push
import android.content.Context
import android.content.pm.PackageManager
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.unifiedpush.android.connector.UnifiedPush
import org.unifiedpush.android.connector.data.ResolvedDistributor
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which UnifiedPush distributor delivers our pushes, and whether push is on. The
* connector keeps the choice; the on/off switch lives in [SettingsPrefs].
*/
@Singleton
class PushDistributors @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: SettingsPrefs,
) {
data class Distributor(val packageName: String, val label: String)
/** The distributor apps installed right now. */
fun installed(): List<Distributor> =
UnifiedPush.getDistributors(context)
.filter { it != context.packageName }
.map { Distributor(it, labelOf(it)) }
.sortedBy { it.label.lowercase() }
/**
* The distributor to register with, or null when push is off or none is
* usable. With no choice saved, a default or sole distributor is adopted.
*/
suspend fun toUse(): String? {
if (!settings.settings.first().pushEnabled) return null
UnifiedPush.getSavedDistributor(context)?.let { return it }
return when (val resolved = UnifiedPush.resolveDefaultDistributor(context)) {
is ResolvedDistributor.Found -> resolved.packageName
.takeIf { it != context.packageName }
?.also { UnifiedPush.saveDistributor(context, it) }
ResolvedDistributor.ToSelect, ResolvedDistributor.NoneAvailable -> null
}
}
/** The saved choice, without resolving a default. For display. */
fun saved(): String? = UnifiedPush.getSavedDistributor(context)
suspend fun select(packageName: String) {
UnifiedPush.saveDistributor(context, packageName)
settings.setPushEnabled(true)
}
/** Turns push off. Every registration with the distributor goes with it. */
suspend fun disable() {
settings.setPushEnabled(false)
UnifiedPush.removeDistributor(context)
}
/**
* Unregisters one instance without losing the user's choice, which the
* connector drops along with its last instance.
*/
fun unregister(instance: String) {
val chosen = UnifiedPush.getSavedDistributor(context)
UnifiedPush.unregister(context, instance)
if (chosen != null && UnifiedPush.getSavedDistributor(context) == null) {
UnifiedPush.saveDistributor(context, chosen)
}
}
fun labelOf(packageName: String): String = try {
val info = context.packageManager.getApplicationInfo(packageName, 0)
context.packageManager.getApplicationLabel(info).toString()
} catch (_: PackageManager.NameNotFoundException) {
packageName
}
}
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.agendula.data.sync.push
import android.util.Log
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.WebDavPush
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/** Turns a WebDAV-Push message into a sync of the account it is about. */
@Singleton
class PushMessageHandler @Inject constructor(
private val database: TasksDatabase,
private val store: PushStore,
private val trigger: SyncTrigger,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* @param content the decrypted message body.
* @param instance the UnifiedPush instance, which is the account id.
*/
suspend fun handle(content: String, instance: String) = withContext(io) {
val accountId = instance.toLongOrNull() ?: return@withContext
val account = database.accounts().account(accountId) ?: return@withContext
val message = WebDavPush.parse(content)
val topic = message?.topic
if (topic != null) {
// Within this account: a shared calendar has one topic across accounts.
val list = store.all().values
.filter { it.support?.topic == topic }
.firstNotNullOfOrNull { push ->
database.taskLists().entity(push.listId)?.takeIf { it.accountId == accountId }
}
if (list == null) {
Log.i(TAG, "push for a topic no synced list has")
return@withContext
}
// Already at that state, e.g. our own write echoed back.
if (message.syncToken != null && message.syncToken == list.syncToken) return@withContext
}
// Without a topic (key rotation, unreadable): a sync re-reads the VAPID key.
trigger.enqueueFromPush(account.displayName)
}
private companion object {
const val TAG = "PushMessageHandler"
}
}
@@ -0,0 +1,271 @@
package de.jeanlucmakiola.agendula.data.sync.push
import android.content.Context
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
import de.jeanlucmakiola.agendula.data.sync.CredentialStore
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
import de.jeanlucmakiola.agendula.data.sync.SyncReport
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.WebDavPush
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.unifiedpush.android.connector.UnifiedPush
import org.unifiedpush.android.connector.data.PushEndpoint
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
/**
* Keeps WebDAV-Push subscriptions in step with the synced lists, modelled on
* DAVx5's `PushRegistrationManager`. [update] registers each account with the
* distributor; [onNewEndpoint] subscribes the endpoint it answers with. The
* daily [PushRenewalWorker] re-registers, which is what renews subscriptions.
*/
@Singleton
class PushRegistrar @Inject constructor(
@ApplicationContext private val context: Context,
private val database: TasksDatabase,
private val store: PushStore,
private val distributors: PushDistributors,
private val credentials: CredentialStore,
private val accountState: AccountStateStore,
private val availability: SyncAvailability,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/** One subscribe/unsubscribe pass at a time, across every entry point. */
private val mutex = Mutex()
/** Records what a sync read about push support, and registers if that changed. */
suspend fun onSynced(account: AccountEntity, reports: List<SyncReport>) {
val read = reports.filter { it.collectionRead }.associate { it.listId to it.pushSupport }
if (store.recordSupport(read)) update(account.id)
}
suspend fun updateAll() = mutex.withLock {
withContext(io) {
database.accounts().all().forEach { updateAccount(it) }
scheduleRenewal()
}
}
suspend fun update(accountId: Long) = mutex.withLock {
withContext(io) {
database.accounts().account(accountId)?.let { updateAccount(it) }
scheduleRenewal()
}
}
/** Our subscription per collection URL, for `Push-Dont-Notify` on the account's writes. */
suspend fun subscriptionsByHref(accountId: Long): Map<String, HttpUrl> = withContext(io) {
val pushes = store.all()
database.taskLists().syncedForAccount(accountId).mapNotNull { list ->
// Normalised the way SyncEngine spells the URL it looks up.
val href = list.href?.toHttpUrlOrNull()?.toString() ?: return@mapNotNull null
val subscription = pushes[list.id]?.subscription?.toHttpUrlOrNull() ?: return@mapNotNull null
href to subscription
}.toMap()
}
/** The distributor's endpoint for [accountId] is ready: subscribe the account's lists to it. */
suspend fun onNewEndpoint(accountId: Long, endpoint: PushEndpoint) = mutex.withLock {
withContext(io) {
val account = database.accounts().account(accountId) ?: return@withContext
if (!pushAllowed(account)) return@withContext
val client = clientFor(account) ?: return@withContext
val pushes = store.all()
val lists = database.taskLists().syncedForAccount(account.id)
val wanted = lists.filter { it.href != null && pushes[it.id]?.support != null }
val renewBefore = Clock.System.now() + RENEW_MARGIN
for (list in wanted) {
val push = pushes.getValue(list.id)
val current = push.subscription != null &&
push.endpoint == endpoint.url &&
push.expires?.let { it > renewBefore } == true
if (current) continue
// A changed endpoint means a new subscription, not an update.
if (push.endpoint != null && push.endpoint != endpoint.url) {
push.subscription?.toHttpUrlOrNull()?.let { WebDavPush.unregister(client, it) }
}
val collection = list.href!!.toHttpUrlOrNull() ?: continue
when (
val outcome = WebDavPush.register(
client = client,
collection = collection,
endpoint = endpoint.url,
publicKey = endpoint.pubKeySet?.pubKey,
authSecret = endpoint.pubKeySet?.auth,
expires = Clock.System.now() + REQUESTED_LIFETIME,
)
) {
is WebDavPush.Registration.Registered -> store.recordSubscription(
listId = list.id,
subscription = outcome.url?.toString(),
endpoint = endpoint.url,
expires = outcome.expires,
)
is WebDavPush.Registration.Refused -> {
Log.w(TAG, "push refused for ${list.id}: HTTP ${outcome.code}")
store.clearSubscription(list.id)
// Stop the account as a sync would; Nextcloud throttles per IP.
if (outcome.code == UNAUTHORIZED) {
accountState.setNeedsSignIn(account.id, true)
return@withContext
}
}
// Retried by the next renewal.
is WebDavPush.Registration.Failed -> Log.w(TAG, "push registration failed for ${list.id}: ${outcome.reason}")
}
}
// A list that lost push support still holds a subscription nobody wants.
val wantedIds = wanted.map { it.id }.toSet()
val stale = lists.filter { it.id !in wantedIds && pushes[it.id]?.subscription != null }
unsubscribe(client, stale.map { it.id }, pushes)
}
}
/** The distributor dropped [accountId]'s registration: its subscriptions lead nowhere. */
suspend fun onUnregistered(accountId: Long) = mutex.withLock {
withContext(io) {
database.accounts().account(accountId)?.let { unsubscribeAll(it) }
}
}
/** Before an account is removed, while its credential still works. */
suspend fun forgetAccount(accountId: Long) = mutex.withLock {
withContext(io) {
val account = database.accounts().account(accountId) ?: return@withContext
unsubscribeAll(account)
distributors.unregister(accountId.toString())
store.forget(database.taskLists().syncedForAccount(accountId).map { it.id }.toSet())
}
}
/** Before lists stop syncing with [accountId]. */
suspend fun forgetLists(accountId: Long, listIds: Set<Long>) = mutex.withLock {
withContext(io) {
if (listIds.isEmpty()) return@withContext
val account = database.accounts().account(accountId)
val client = account?.let { clientFor(it) }
val pushes = store.all()
if (client != null) unsubscribe(client, listIds.toList(), pushes)
store.forget(listIds)
}
}
private suspend fun updateAccount(account: AccountEntity) {
val instance = account.id.toString()
val distributor = if (pushAllowed(account)) distributors.toUse() else null
val pushes = store.all()
val capable = database.taskLists().syncedForAccount(account.id)
.mapNotNull { pushes[it.id]?.support }
if (distributor == null || capable.isEmpty()) {
// Not unregistered: the connector would forget the user's distributor.
unsubscribeAll(account)
return
}
val vapid = capable.firstNotNullOfOrNull { it.vapidPublicKey }
try {
UnifiedPush.register(context, instance, account.displayName, vapid)
} catch (_: UnifiedPush.VapidNotValidException) {
Log.w(TAG, "server VAPID key for ${account.id} is not usable")
UnifiedPush.register(context, instance, account.displayName, null)
}
}
private suspend fun unsubscribeAll(account: AccountEntity) {
val pushes = store.all()
val held = database.taskLists().syncedForAccount(account.id)
.filter { pushes[it.id]?.subscription != null }
.map { it.id }
if (held.isEmpty()) return
val client = clientFor(account)
if (client != null) {
unsubscribe(client, held, pushes)
} else {
// Without a credential they are left to expire.
held.forEach { store.clearSubscription(it) }
}
}
/** Tells the server, then forgets locally; after the first failure only forgets. */
private suspend fun unsubscribe(client: OkHttpClient, listIds: List<Long>, pushes: Map<Long, PushStore.ListPush>) {
var reachable = true
listIds.forEach { listId ->
val subscription = pushes[listId]?.subscription?.toHttpUrlOrNull()
if (reachable && subscription != null) reachable = WebDavPush.unregister(client, subscription)
store.clearSubscription(listId)
}
}
private suspend fun pushAllowed(account: AccountEntity): Boolean =
availability.accountsUsable() && !accountState.needsSignIn(account.id)
/** Null for an account waiting on sign-in, as in `SyncEngine.sync`. */
private suspend fun clientFor(account: AccountEntity): OkHttpClient? {
if (accountState.needsSignIn(account.id)) return null
val username = account.username ?: return null
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null
return CalDavHttp.authenticated(USER_AGENT, username, password, origin)
.newBuilder()
.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
/** Only while some list could be pushed; nothing to renew otherwise. */
private suspend fun scheduleRenewal() {
val work = WorkManager.getInstance(context)
val needed = distributors.toUse() != null && store.all().values.any { it.support != null }
if (!needed) {
work.cancelUniqueWork(PushRenewalWorker.NAME)
return
}
val request = PeriodicWorkRequestBuilder<PushRenewalWorker>(RENEWAL_INTERVAL_DAYS, TimeUnit.DAYS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
work.enqueueUniquePeriodicWork(PushRenewalWorker.NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
private companion object {
const val TAG = "PushRegistrar"
const val USER_AGENT = "Agendula (Android)"
const val UNAUTHORIZED = 401
/** A registration is one small request; removal waits on it. */
const val CALL_TIMEOUT_SECONDS = 15L
/** What we ask for; the draft recommends at least three days. */
val REQUESTED_LIFETIME = 3.days
const val RENEWAL_INTERVAL_DAYS = 1L
/** Two renewal intervals, since periodic work is not punctual. */
val RENEW_MARGIN = (2 * RENEWAL_INTERVAL_DAYS).days
}
}
@@ -0,0 +1,26 @@
package de.jeanlucmakiola.agendula.data.sync.push
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
/** Re-registers every account daily, which renews the subscriptions; see [PushRegistrar]. */
@HiltWorker
class PushRenewalWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted parameters: WorkerParameters,
private val registrar: PushRegistrar,
) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
registrar.updateAll()
return Result.success()
}
companion object {
const val NAME = "push-renewal"
}
}
@@ -0,0 +1,120 @@
package de.jeanlucmakiola.agendula.data.sync.push
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.PushSupport
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.net.URLDecoder
import java.net.URLEncoder
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/**
* Per synced list: what its server offers for push, and our subscription there.
* Kept out of Room and backups, since a subscription names this device's endpoint.
*/
@Singleton
class PushStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
data class ListPush(
val listId: Long,
/** Null when the server offers no push for the list. */
val support: PushSupport? = null,
/** Where our subscription lives on the server; null when there is none. */
val subscription: String? = null,
/** The push endpoint [subscription] was registered for. */
val endpoint: String? = null,
val expires: Instant? = null,
)
suspend fun all(): Map<Long, ListPush> = decode(dataStore.data.first()[KEY].orEmpty())
fun observe(): Flow<Map<Long, ListPush>> = dataStore.data.map { decode(it[KEY].orEmpty()) }
/**
* Records what the last sync read about each list's push support.
*
* @return whether anything changed, which is what makes a registration due.
*/
suspend fun recordSupport(support: Map<Long, PushSupport?>): Boolean {
if (support.isEmpty()) return false
var changed = false
update { current ->
support.forEach { (listId, found) ->
val before = current[listId] ?: ListPush(listId)
if (before.support != found) {
changed = true
current[listId] = before.copy(support = found)
}
}
}
return changed
}
suspend fun recordSubscription(listId: Long, subscription: String?, endpoint: String?, expires: Instant?) =
update { current ->
val before = current[listId] ?: ListPush(listId)
current[listId] = before.copy(subscription = subscription, endpoint = endpoint, expires = expires)
}
suspend fun clearSubscription(listId: Long) = recordSubscription(listId, null, null, null)
suspend fun forget(listIds: Set<Long>) {
if (listIds.isEmpty()) return
update { current -> listIds.forEach(current::remove) }
}
private suspend fun update(change: (MutableMap<Long, ListPush>) -> Unit) {
dataStore.edit { prefs ->
// Re-read inside `edit`, which DataStore serialises.
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
change(current)
prefs[KEY] = current.values
.filter { it.support != null || it.subscription != null }
.map(::encode)
.toSet()
}
}
private fun encode(push: ListPush): String = listOf(
push.listId.toString(),
push.support?.topic,
push.support?.vapidPublicKey,
push.subscription,
push.endpoint,
push.expires?.epochSeconds?.toString(),
).joinToString(SEPARATOR) { it?.let(::escape).orEmpty() }
private fun decode(entries: Set<String>): Map<Long, ListPush> =
entries.mapNotNull { entry ->
val parts = entry.split(SEPARATOR).map { part -> part.takeIf { it.isNotEmpty() }?.let(::unescape) }
if (parts.size != FIELDS) return@mapNotNull null
val listId = parts[0]?.toLongOrNull() ?: return@mapNotNull null
listId to ListPush(
listId = listId,
support = parts[1]?.let { PushSupport(topic = it, vapidPublicKey = parts[2]) },
subscription = parts[3],
endpoint = parts[4],
expires = parts[5]?.toLongOrNull()?.let(Instant::fromEpochSeconds),
)
}.toMap()
// Server-chosen strings, escaped so none contains the separator.
private fun escape(value: String): String = URLEncoder.encode(value, "UTF-8")
private fun unescape(value: String): String = URLDecoder.decode(value, "UTF-8")
private companion object {
val KEY = stringSetPreferencesKey("push_lists")
const val SEPARATOR = "|"
const val FIELDS = 6
}
}
@@ -11,13 +11,21 @@ import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
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.room.SPLIT_COUNT_CEILING
import de.jeanlucmakiola.agendula.data.tasks.room.TaskFormWriter
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 de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/**
* The only class that knows about the ContentResolver, [TasksContract] and the
@@ -66,6 +74,12 @@ class AndroidTasksDataSource @Inject constructor(
return rows.firstOrNull { it.distanceFromCurrent == 0 } ?: rows.firstOrNull()
}
override fun task(taskId: Long, occurrenceStart: Instant?): Task? {
if (occurrenceStart == null) return task(taskId)
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
return rows.firstOrNull { it.occurrenceStart == occurrenceStart } ?: task(taskId)
}
override fun subtasks(parentTaskId: Long): List<Task> =
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
@@ -83,27 +97,296 @@ class AndroidTasksDataSource @Inject constructor(
} ?: 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 ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id)
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
?: throw TaskWriteFailedException("insert task")
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
}
override fun updateTask(taskId: Long, form: TaskForm) {
val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id)
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
val occurrence = task(seriesId, occurrenceStart)
val anchors = resolver.query(
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
)?.use { c ->
if (!c.moveToFirst()) return@use null
val r = CursorColumnReader(c)
r.getLong(Tasks.DTSTART) to r.getLong(Tasks.DUE)
}
fun shifted(anchor: Long?, was: Instant?, now: Instant?): Long? = when {
now == null -> null
anchor == null || was == null -> now.toEpochMilliseconds()
else -> anchor + (now - was).inWholeMilliseconds
}
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) +
(Tasks.DTSTART to shifted(anchors?.first, occurrence?.start, form.start)) +
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
}
/**
* The provider has no series split of its own, so it is done by hand: the
* master's rule is ended with an `UNTIL` and the rest continues as a new
* task. Per-occurrence edits past the split point are dropped rather than
* carried over — the provider offers no way to re-parent an exception.
*/
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long {
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
updateSeries(seriesId, occurrenceStart, form)
return seriesId
}
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
val spent = RecurrenceExpander.expand(
spec.copy(exdate = null),
ExpansionWindow(from = spec.anchor, until = occurrenceStart, maxOccurrences = SPLIT_COUNT_CEILING),
).size
endSeriesBefore(seriesId, spec, occurrenceStart)
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
return insertTask(form.copy(rrule = rule, percentComplete = null))
}
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
val spec = seriesSpec(seriesId) ?: return
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId)
endSeriesBefore(seriesId, spec, occurrenceStart)
}
/** The series' rule set as stored on its master row, or null for a missing row. */
private fun seriesSpec(seriesId: Long): RecurrenceSpec? = resolver.query(
taskUri(authority(), seriesId),
arrayOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ),
null, null, null,
)?.use { c ->
if (!c.moveToFirst()) return@use null
val r = CursorColumnReader(c)
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return@use null
RecurrenceSpec(
rrule = r.getString(Tasks.RRULE)?.ifBlank { null },
rdate = r.getString(Tasks.RDATE),
exdate = r.getString(Tasks.EXDATE),
anchor = Instant.fromEpochMilliseconds(anchor),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
timeZone = r.getString(Tasks.TZ),
)
}
/** End the master's rule before [occurrenceStart], and drop the exceptions past it. */
private fun endSeriesBefore(seriesId: Long, spec: RecurrenceSpec, occurrenceStart: Instant) {
val rule = TaskFormWriter.ruleEndedBefore(
spec.rrule ?: return,
occurrenceStart,
spec.isAllDay,
spec.timeZone,
ZoneId.systemDefault().id,
)
val values = mapOf<String, Any?>(Tasks.RRULE to rule)
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("end series $seriesId")
exceptionsFrom(seriesId, occurrenceStart).forEach(::deleteTask)
}
/** Override rows of [seriesId] standing for [from] or a later occurrence. */
private fun exceptionsFrom(seriesId: Long, from: Instant): List<Long> = resolver.query(
TasksContract.tasksUri(authority()),
arrayOf(Tasks.ID, Tasks.ORIGINAL_INSTANCE_TIME),
"${Tasks.ORIGINAL_INSTANCE_ID} = ?",
arrayOf(seriesId.toString()),
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
val original = r.getLong(Tasks.ORIGINAL_INSTANCE_TIME) ?: continue
if (original >= from.toEpochMilliseconds()) r.getLong(Tasks.ID)?.let(::add)
}
}
} ?: emptyList()
/** Through the instances URI, which the provider turns into an exception on the series. */
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) {
val instanceId = instanceIdFor(seriesId, occurrenceStart) ?: return
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
}
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. Only the editable
// (last before-due) row goes — a start-relative or additional alarm
// another client wrote is not ours to drop.
alarmRows(taskId).lastOrNull { !it.second.fromStart }?.let { (propertyId, _) ->
resolver.delete(uri, "${Properties.PROPERTY_ID} = ?", arrayOf(propertyId.toString()))
}
if (minutesBeforeDue != null) {
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
?: throw TaskWriteFailedException("set alarm for task $taskId")
}
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
val uri = TasksContract.propertiesUri(authority())
resolver.delete(
uri,
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE),
)
reminders.forEach {
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, it.minutesBefore, it.fromStart).toContentValues())
?: throw TaskWriteFailedException("set alarms for task $taskId")
}
}
override fun reminders(): Map<Long, List<TaskReminder>> =
readAlarms(null).groupBy({ it.first }) { it.third }
override fun alarms(): Map<Long, TaskReminder> =
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
/** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */
private fun alarmRows(taskId: Long): List<Pair<Long, TaskReminder>> =
readAlarms(taskId).map { it.second to it.third }
/** `(task_id, property_id, reminder)` rows, for one task or (with `null`) all. */
private fun readAlarms(taskId: Long?): List<Triple<Long, Long, TaskReminder>> {
val uri = TasksContract.propertiesUri(authority())
val projection = arrayOf(
Properties.PROPERTY_ID,
Properties.TASK_ID,
TasksContract.Alarm.MINUTES_BEFORE,
TasksContract.Alarm.REFERENCE,
)
val selection = buildString {
append("${Properties.MIMETYPE} = ?")
if (taskId != null) append(" AND ${Properties.TASK_ID} = ?")
}
val args = listOfNotNull(TasksContract.Alarm.MIMETYPE, taskId?.toString()).toTypedArray()
return resolver.query(uri, projection, selection, args, Properties.PROPERTY_ID)?.use { c ->
val reader = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
val propertyId = reader.getLong(Properties.PROPERTY_ID)
val id = reader.getLong(Properties.TASK_ID)
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
val reference = reader.getInt(TasksContract.Alarm.REFERENCE)
if (propertyId != null && id != null && minutes != null) {
add(
Triple(
id,
propertyId,
TaskReminder(
minutesBefore = minutes,
fromStart = reference == TasksContract.Alarm.REFERENCE_START,
),
),
)
}
}
}
} ?: emptyList()
}
override fun setCancelled(taskId: Long, cancelled: Boolean) {
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
}
/** Through the instances URI, like [setCompletedInstance]. */
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: return setCancelled(taskId, cancelled)
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("cancel instance $instanceId")
}
private fun cancelValues(cancelled: Boolean) = mapOf(
Tasks.STATUS to if (cancelled) TasksContract.STATUS_CANCELLED else TasksContract.STATUS_NEEDS_ACTION,
Tasks.COMPLETED to null,
)
override fun setCompleted(taskId: Long, completed: Boolean) {
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
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) {
resolver.delete(taskUri(authority(), taskId), null, null)
}
@@ -120,6 +403,31 @@ class AndroidTasksDataSource @Inject constructor(
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 ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
@@ -127,9 +435,16 @@ class AndroidTasksDataSource @Inject constructor(
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) = onChange()
}
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
return AutoCloseable { resolver.unregisterContentObserver(observer) }
// 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.listsUri(provider.authority), true, observer)
} catch (e: RuntimeException) {
runCatching { resolver.unregisterContentObserver(observer) }
throw e
}
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
}
private fun Map<String, Any?>.toContentValues(): ContentValues {
@@ -0,0 +1,105 @@
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 task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) =
active().updateSeries(seriesId, occurrenceStart, form)
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long =
active().splitSeries(seriesId, occurrenceStart, form)
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
active().deleteOccurrence(seriesId, occurrenceStart)
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
active().deleteFollowing(seriesId, occurrenceStart)
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 reminders(): Map<Long, List<TaskReminder>> = active().reminders()
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
active().setReminders(taskId, reminders)
override fun exportTasks(listId: Long): List<ExportTask> = active().exportTasks(listId)
override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed)
override fun setCancelled(taskId: Long, cancelled: Boolean) = active().setCancelled(taskId, cancelled)
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) =
active().setCompletedInstance(taskId, occurrenceStart, completed)
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) =
active().setCancelledInstance(taskId, occurrenceStart, cancelled)
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)
override fun reorderLists(listIds: List<Long>) = active().reorderLists(listIds)
/**
* 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
}
}
}
}
@@ -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()
}
@@ -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
}
@@ -1,15 +1,12 @@
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 java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
import javax.inject.Singleton
/**
* A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every
* candidate, so the [TasksContract] columns apply regardless of which is present.
* An external tasks provider Agendula can talk to. Every candidate runs the same
* dmfs `TaskProvider`, so the [TasksContract] columns apply to either.
*/
data class TaskProvider(
val authority: String,
@@ -19,41 +16,122 @@ data class TaskProvider(
)
/**
* The A/B seam. Detects which tasks provider is installed at runtime and which
* permission set it needs, so nothing above the data layer hardcodes an
* authority. Under Posture B (bundled provider) this simply finds our own
* `org.dmfs.tasks` first. See docs/PLAN.md.
* Discovers the *external* tasks providers (OpenTasks, tasks.org) that
* [StorageMode.EXTERNAL] can be pointed at.
*
* 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.
*
* Which store is active comes from [storageMode]; how that gets decided when the
* user has not chosen is [autoMode].
*/
@Singleton
class ProviderResolver @Inject constructor(
@ApplicationContext private val context: Context,
private val environment: ProviderEnvironment,
) {
/** The active provider, or `null` when no tasks provider is installed. */
fun resolve(): TaskProvider? {
for (candidate in CANDIDATES) {
val info = context.packageManager.resolveContentProvider(candidate.authority, 0)
?: continue
return candidate.copy(packageName = info.packageName)
private val modeListeners = CopyOnWriteArrayList<() -> Unit>()
/**
* The user's explicit choice, or `null` while they have not made one (which is
* the normal state — most people never open Settings). Kept as a plain field
* 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
}
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 {
/**
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
* `org.dmfs.provider.tasks.TaskProvider`, guarded by
* `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks`
* + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical
* authority; on a device with only one installed, order is moot.
* `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*`
* (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`.
* OpenTasks is listed first as the canonical authority; on a device with
* only one installed, order is moot.
*/
val CANDIDATES: List<TaskProvider> = listOf(
val EXTERNAL_CANDIDATES: List<TaskProvider> = listOf(
TaskProvider(
authority = "org.dmfs.tasks",
readPermission = "org.dmfs.permission.READ_TASKS",
@@ -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()
}
@@ -0,0 +1,26 @@
package de.jeanlucmakiola.agendula.data.tasks
/**
* Which task store backs the app — the user's choice.
*
* Only two values.
* **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,
}
@@ -0,0 +1,64 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CancellationException
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 {
try {
prefs.storageMode.collect { mode ->
resolver.storageMode = mode
firstValue.complete(Unit)
}
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
// ⚠️ Two failures in one, and the second is the worse. This is a
// root coroutine in a scope with no exception handler, so a read
// that throws — a corrupt file the handler could not replace, a
// filesystem that will not answer — takes the app down at every
// launch. And releasing the gate is not optional either:
// `awaitReady` is awaited before anything touches the provider,
// so failing quietly without it parks every observing flow for
// ever, which is a blank app rather than a crashing one.
// `autoMode` answers from here on, which is the steady state for
// a user who never chose.
firstValue.complete(Unit)
}
}
}
/** Suspends until the stored mode has been applied at least once. */
suspend fun awaitReady() = firstValue.await()
}
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
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.statusFromInt
import kotlin.time.Instant
@@ -16,10 +17,17 @@ object TaskMapper {
fun instant(name: String): Instant? =
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(
id = instanceId,
taskId = r.getLong(Instances.TASK_ID) ?: instanceId,
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
title = r.getString(Tasks.TITLE).orEmpty(),
description = r.getString(Tasks.DESCRIPTION),
@@ -38,10 +46,65 @@ object TaskMapper {
listName = r.getString(Tasks.LIST_NAME),
accountName = r.getString(Tasks.ACCOUNT_NAME),
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),
created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED),
seriesId = if (recurring) r.getLong(Instances.TASK_ID) ?: rowId else null,
recurrenceRule = r.getString(Tasks.RRULE),
)
}
/**
* 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 },
)
}
@@ -1,8 +1,6 @@
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.Tasks
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
object TaskProjections {
@@ -18,31 +16,9 @@ object TaskProjections {
Lists.ACCOUNT_TYPE,
)
/** Read from the `instances` view (inherits all task columns). */
val INSTANCES: Array<String> = arrayOf(
Tasks.ID,
Instances.TASK_ID,
Tasks.LIST_ID,
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,
)
// No `instances` projection on purpose: that read passes `projection = null`
// (all columns), because the view's shape differs across provider versions —
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
// here would drift out of sync with the by-name mapper and quietly drop
// columns it depends on. See AndroidTasksDataSource.queryInstances.
}
@@ -1,9 +1,21 @@
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.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskForm
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),
@@ -16,6 +28,8 @@ object TaskWriteMapper {
put(Tasks.TITLE, form.title.trim())
put(Tasks.LIST_ID, form.listId)
put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
put(Tasks.LOCATION, form.location?.trim()?.ifBlank { null })
put(Tasks.URL, form.url?.trim()?.ifBlank { null })
put(Tasks.PRIORITY, form.priority.toICal())
// Keep completion in sync with progress whenever the form carries a
// percent (the Progress field was used). The provider already auto-
@@ -39,8 +53,17 @@ object TaskWriteMapper {
}
}
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.toEpochMilliseconds())
// All-day tasks are date-only in iCalendar. The provider reads them back
// 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)
// The provider treats a null tz as local time; set it explicitly for
// timed tasks so the stored instant is unambiguous across zones.
@@ -48,6 +71,20 @@ object TaskWriteMapper {
put(Tasks.TZ, if (timed) tzId else null)
}
/** [taskValues] plus the rule — for a plain task or a series master, never an occurrence. */
fun seriesValues(form: TaskForm, tzId: String): Map<String, Any?> =
taskValues(form, tzId) + (Tasks.RRULE to form.rrule?.removePrefix("RRULE:")?.ifBlank { 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?> =
if (completed) {
mapOf(
@@ -63,9 +100,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, fromStart: Boolean = false): Map<String, Any?> = mapOf(
Properties.TASK_ID to taskId,
Properties.MIMETYPE to Alarm.MIMETYPE,
Alarm.MINUTES_BEFORE to minutesBeforeDue,
Alarm.REFERENCE to if (fromStart) Alarm.REFERENCE_START else 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.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_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
Lists.VISIBLE to 1,
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.net.Uri
import androidx.core.net.toUri
/**
* The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as
@@ -66,6 +67,12 @@ object TasksContract {
const val IS_ALLDAY = "is_allday"
const val TZ = "tz"
const val RRULE = "rrule"
const val RDATE = "rdate"
const val EXDATE = "exdate"
/** Set on an override row — the master occurrence this one replaces. */
const val ORIGINAL_INSTANCE_ID = "original_instance_id"
/** On an override row: the start of the occurrence it replaces. */
const val ORIGINAL_INSTANCE_TIME = "original_instance_time"
const val PARENT_ID = "parent_id"
const val SORTING = "sorting"
const val CREATED = "created"
@@ -96,8 +103,59 @@ object TasksContract {
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
const val DISTANCE_FROM_CURRENT = "distance_from_current"
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 = "content://$authority/${Properties.PATH}".toUri()
// --- status values (TaskColumns.STATUS_*) --------------------------------
const val STATUS_NEEDS_ACTION = 0
const val STATUS_IN_PROCESS = 1
@@ -107,10 +165,21 @@ object TasksContract {
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (iCalendar). */
const val PRIORITY_NONE = 0
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
fun authorityUri(authority: String): Uri = "content://$authority".toUri()
fun listsUri(authority: String): Uri = "content://$authority/${Lists.PATH}".toUri()
fun listUri(authority: String, listId: Long): Uri =
"content://$authority/${Lists.PATH}/$listId".toUri()
fun tasksUri(authority: String): Uri = "content://$authority/${Tasks.PATH}".toUri()
fun instancesUri(authority: String): Uri = "content://$authority/${Instances.PATH}".toUri()
/**
* 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 =
"content://$authority/${Instances.PATH}/$instanceId".toUri()
/** Append the sync-adapter params required to write local-account rows. */
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
@@ -3,6 +3,25 @@ package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
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)
/**
* The one reminder Agendula's single-reminder editor shows and writes: the last
* before-due one, which is where a write lands, so it stays the same one across
* saves. Anything else a task carries — a start-relative alarm, or the extra ones
* another client or the dmfs import brought — is left to ride along.
*/
fun List<TaskReminder>.editable(): TaskReminder? = lastOrNull { !it.fromStart }
/** What to fetch from the provider. Smart-list date logic is applied above this. */
data class TaskQuery(
@@ -19,14 +38,112 @@ interface TasksDataSource {
fun taskLists(): List<TaskList>
fun tasks(query: TaskQuery): List<Task>
fun task(taskId: Long): Task?
/**
* The occurrence of [taskId] that starts at [occurrenceStart], or [task] when
* that is null or names no occurrence — so a stale anchor still lands on the task.
*/
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
fun subtasks(parentTaskId: Long): List<Task>
fun insertTask(form: TaskForm): Long
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 task's [editable] reminder lead, stored as an
* Alarm property row. Every other reminder on the task is left untouched. 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?)
/**
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
* content from [form], and the series moved by however far that occurrence moved.
*/
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm)
/**
* "This and following": end [seriesId] before [occurrenceStart] and continue
* from there as a new series built from [form]. Returns the new series' id.
*/
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
/** Delete [occurrenceStart] and every later occurrence of [seriesId]. */
fun deleteFollowing(seriesId: Long, occurrenceStart: Instant)
/** Replace every reminder on [taskId] with [reminders], in order. */
fun setReminders(taskId: Long, reminders: List<TaskReminder>)
/** Every task's reminders, by task id, in stored order. One query, for the scheduler. */
fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) }
/** Every task's [editable] reminder, by task id. */
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)
/** Cancel [taskId] (`STATUS:CANCELLED`) or put it back to needs-action. */
fun setCancelled(taskId: Long, cancelled: 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)
/** Cancel (or restore) one occurrence of a series; the counterpart of [setCompletedInstance]. */
fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean)
fun deleteTask(taskId: 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)
/** Store [listIds] as the lists' display order. A store without one ignores it. */
fun reorderLists(listIds: List<Long>) = Unit
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
fun registerObserver(onChange: () -> Unit): AutoCloseable
}
@@ -5,7 +5,9 @@ import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlin.time.Instant
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
@@ -18,6 +20,13 @@ enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
*/
interface TasksRepository {
fun taskLists(): Flow<List<TaskList>>
/**
* The lists a task may be saved into — [taskLists] without read-only shares.
* What a list picker for creating or moving a task should offer.
*/
fun writableTaskLists(): Flow<List<TaskList>> =
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
fun tasks(filter: TaskFilter): Flow<List<Task>>
/**
@@ -26,20 +35,66 @@ interface TasksRepository {
* children are usually filtered out of the list's own (date-based) query.
*/
fun subtasks(parentId: Long): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?>
/** [occurrenceStart] picks one occurrence of a series; `null` means the current one. */
fun taskDetail(taskId: Long, occurrenceStart: Instant? = null): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long
/**
* Copy a task into its own list as a fresh, open, non-repeating task — this
* occurrence's dates, its reminders and its open subtasks. Returns the copy's id.
*/
suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant? = null): Long
/**
* Overwrite [taskId] with [form]. When [expectedLastModified] is non-null, the
* task's current `last_modified` is re-checked first and a
* [TaskConflictException] is thrown if it differs — i.e. something changed it
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
*/
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun setCompleted(taskId: Long, completed: Boolean)
suspend fun deleteTask(taskId: Long)
suspend fun updateTask(
taskId: Long,
form: TaskForm,
expectedLastModified: Instant? = null,
occurrenceStart: Instant? = null,
scope: RecurringScope = RecurringScope.ThisOccurrence,
)
/**
* 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)
/** Cancel or restore a task; with [occurrenceStart], only that occurrence of a series. */
suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean)
/** Delete a task, or — for an occurrence of a series — as much of the series as [scope] says. */
suspend fun deleteTask(
taskId: Long,
occurrenceStart: Instant? = null,
scope: RecurringScope = RecurringScope.AllOccurrences,
)
/**
* 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?
/** Every reminder stored on the task, in order — including start-relative ones. */
suspend fun remindersFor(taskId: Long): List<TaskReminder>
/** Replace every reminder on the task; an empty list leaves the list's / global setting in charge. */
suspend fun setReminders(taskId: Long, reminders: List<TaskReminder>)
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)
suspend fun reorderLists(listIds: List<Long>)
/** Synchronous snapshot for the permission/onboarding gate. */
fun providerStatus(): ProviderStatus
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter
@@ -9,6 +10,8 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSorting
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
@@ -28,6 +31,7 @@ import kotlin.time.Instant
class TasksRepositoryImpl @Inject constructor(
private val dataSource: TasksDataSource,
private val providerResolver: ProviderResolver,
private val startupGate: StartupGate,
@IoDispatcher private val io: CoroutineDispatcher,
) : TasksRepository {
@@ -36,16 +40,16 @@ class TasksRepositoryImpl @Inject constructor(
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
dataSource.subtasks(parentId)
SeriesCollapse.visible(dataSource.subtasks(parentId))
.filter { it.taskId != parentId }
.sortedWith(TaskSorting.DEFAULT)
}
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
dataSource.task(taskId)?.let { task ->
override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow<TaskDetail?> = observing {
dataSource.task(taskId, occurrenceStart)?.let { task ->
TaskDetail(
task = task,
subtasks = dataSource.subtasks(taskId).filter { it.taskId != taskId },
subtasks = SeriesCollapse.visible(dataSource.subtasks(taskId)).filter { it.taskId != taskId },
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
)
}
@@ -61,7 +65,7 @@ class TasksRepositoryImpl @Inject constructor(
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
}
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = dataSource.tasks(query)
val all = SeriesCollapse.visible(dataSource.tasks(query))
// (done, total) of direct children per parent, over the unfiltered set.
val progress: Map<Long, Pair<Int, Int>> = all
.filter { (it.parentId ?: 0L) > 0L }
@@ -80,41 +84,197 @@ class TasksRepositoryImpl @Inject constructor(
}
override suspend fun createTask(form: TaskForm): Long =
withContext(io) { dataSource.insertTask(form) }
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
withContext(io) {
// Conflict-safe overwrite: re-read just before writing and 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) {
val current = dataSource.task(taskId)?.lastModified
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
}
dataSource.updateTask(taskId, form)
val id = dataSource.insertTask(form)
writeReminders(id, form)
id
}
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
withContext(io) { dataSource.setCompleted(taskId, completed) }
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
withContext(io) {
val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" }
copyOf(task, task.parentId, depth = 0)
}
override suspend fun deleteTask(taskId: Long) =
withContext(io) { dataSource.deleteTask(taskId) }
private fun copyOf(task: Task, parentId: Long?, depth: Int): Long {
val id = dataSource.insertTask(
TaskForm(
title = task.title,
listId = task.listId,
description = task.description,
start = task.start,
due = task.due,
isAllDay = task.isAllDay,
priority = task.priority,
parentId = parentId,
location = task.location,
url = task.url,
),
)
val reminders = dataSource.reminders()[task.taskId].orEmpty()
if (reminders.isNotEmpty()) dataSource.setReminders(id, reminders)
if (depth < MAX_COPY_DEPTH) {
SeriesCollapse.visible(dataSource.subtasks(task.taskId))
.filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED }
.forEach { copyOf(it, id, depth + 1) }
}
return id
}
override suspend fun reminderFor(taskId: Long): Int? =
withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() }
override suspend fun remindersFor(taskId: Long): List<TaskReminder> =
withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) }
override suspend fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
withContext(io) { dataSource.setReminders(taskId, reminders) }
override suspend fun updateTask(
taskId: Long,
form: TaskForm,
expectedLastModified: Instant?,
occurrenceStart: Instant?,
scope: RecurringScope,
) = withContext(io) {
// Re-read just before writing: it settles the conflict check *and* tells
// us which URI to write through.
val current = dataSource.task(taskId, occurrenceStart)
// 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) {
val seen = current?.lastModified
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
}
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId != null && occurrence != null) {
when (scope) {
RecurringScope.AllOccurrences -> {
writeReminders(seriesId, form)
dataSource.updateSeries(seriesId, occurrence, form)
return@withContext
}
RecurringScope.ThisAndFollowing -> {
val created = dataSource.splitSeries(seriesId, occurrence, form)
writeReminders(created, form)
return@withContext
}
RecurringScope.ThisOccurrence -> Unit
}
}
// 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 instance = current?.takeIf { it.isRecurring }?.occurrenceStart
if (instance == null) {
writeReminders(taskId, form)
dataSource.updateTask(taskId, form)
return@withContext
}
// ⚠️ Written to the series, and then put back. Forking an occurrence
// copies the task's *current* properties onto the new override row,
// so setting the alarm beforehand is the only way to carry it
// across — and leaving it there would silently give every other
// occurrence in the series the reminder the user set on one of them.
val seriesReminders = dataSource.reminders()[taskId].orEmpty()
writeReminders(taskId, form)
try {
dataSource.updateInstance(taskId, instance, form)
} finally {
dataSource.setReminders(taskId, seriesReminders)
}
}
/** The form's before-due reminders; a start-relative one from elsewhere is kept as it was. */
private fun writeReminders(taskId: Long, form: TaskForm) {
val kept = dataSource.reminders()[taskId].orEmpty().filter { it.fromStart }
dataSource.setReminders(taskId, kept + form.reminders.distinct().sorted().map { TaskReminder(it) })
}
override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) =
withContext(io) {
if (occurrenceStart != null) dataSource.setCancelledInstance(taskId, occurrenceStart, cancelled)
else dataSource.setCancelled(taskId, cancelled)
}
override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) =
withContext(io) {
if (occurrenceStart != null) {
dataSource.setCompletedInstance(taskId, occurrenceStart, completed)
} else {
dataSource.setCompleted(taskId, completed)
// Done means done with the parts too; reopening leaves them as they are.
if (completed) {
SeriesCollapse.visible(dataSource.subtasks(taskId))
.filter { it.taskId != taskId && !it.isClosed }
.forEach { sub ->
val occurrence = sub.occurrenceStart
if (occurrence != null) dataSource.setCompletedInstance(sub.taskId, occurrence, true)
else dataSource.setCompleted(sub.taskId, true)
}
}
}
}
override suspend fun deleteTask(taskId: Long, occurrenceStart: Instant?, scope: RecurringScope) =
withContext(io) {
val current = dataSource.task(taskId, occurrenceStart)
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId)
when (scope) {
RecurringScope.ThisOccurrence ->
// An override row deletes itself into an EXDATE; a generated
// occurrence has no row to delete.
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
else dataSource.deleteOccurrence(seriesId, occurrence)
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId)
}
}
/** A parent goes with its subtasks; left behind they would hang off a tombstone. */
private fun deleteWithSubtasks(taskId: Long) {
dataSource.subtasks(taskId).map { it.taskId }.distinct()
.filter { it != taskId }
.forEach { dataSource.deleteTask(it) }
dataSource.deleteTask(taskId)
}
override suspend fun createLocalList(name: String, color: Int): Long =
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 suspend fun reorderLists(listIds: List<Long>) =
withContext(io) { dataSource.reorderLists(listIds) }
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
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
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
* query runs on [io].
*/
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 handle = dataSource.registerObserver { ticks.trySend(Unit) }
ticks.trySend(Unit) // prime the initial emission
@@ -128,3 +288,6 @@ class TasksRepositoryImpl @Inject constructor(
}
}.flowOn(io)
}
/** Subtask levels a duplicate follows; deeper than any real list, short of a cycle. */
private const val MAX_COPY_DEPTH = 8
@@ -0,0 +1,56 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.VTimeZones
/**
* One CalDAV resource: the `VCALENDAR` wrapper around the `VTODO`s that share a
* `UID`.
*
* ⚠️ A resource is **not** a task. RFC 4791 §4.1 requires every component in one
* resource to share a UID, which makes a recurring task and all of its
* `RECURRENCE-ID` overrides exactly one resource — and makes two unrelated tasks
* in one resource unuploadable. The engine works in resources; the mapper works
* in components.
*/
object CalendarResource {
const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN"
const val VERSION = "2.0"
/** Wraps [vtodos] with the `VTIMEZONE`s their `TZID` parameters reference. */
fun build(vtodos: List<ICalComponent>): ICalComponent {
val zones = vtodos
.flatMap { VTimeZones.forComponent(it) }
.distinctBy { it.property("TZID")?.value }
return ICalComponent(
name = "VCALENDAR",
properties = listOf(
ICalProperty("VERSION", emptyList(), VERSION),
ICalProperty("PRODID", emptyList(), PRODUCT_ID),
),
// Zones first: a server that streams the object as it parses has the
// definition before the reference.
components = zones + vtodos,
)
}
fun serialize(vtodos: List<ICalComponent>): String =
ICalSerializer.serialize(build(vtodos))
/**
* The `VCALENDAR`s in a downloaded body.
*
* More than one is malformed but does happen; the caller decides what to do
* about it rather than having the decision made here by a parser.
*/
fun parse(text: String): List<ICalComponent> =
ICalParser.parseAll(text).filter { it.name.equals("VCALENDAR", ignoreCase = true) }
/** Every `VTODO` across [calendars], in document order. */
fun todosIn(calendars: List<ICalComponent>): List<ICalComponent> =
calendars.flatMap { it.components("VTODO") }
}
@@ -0,0 +1,107 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
/**
* Refuses a resource before it is uploaded.
*
* ⚠️ This exists because **sabre answers 415 for things ordinary UI actions
* produce**, and a 415 is not recoverable by retrying: the row stays dirty, the
* next sync sends the same bytes, and the user sees a task that never leaves the
* device with no explanation. Catching it here turns a permanent silent failure
* into one message naming the field.
*
* Every rule below is a real sabre rejection, not defensive tidiness.
*/
object ResourceValidator {
/** Why a resource cannot be uploaded, in words that name the offending field. */
@JvmInline
value class Rejection(val reason: String)
/** Null when [calendar] may be uploaded. */
fun validate(calendar: ICalComponent): Rejection? {
// An object carrying METHOD is an iTIP *message*, not calendar data.
// RFC 4791 §4.1 forbids it outright in a calendar object resource.
calendar.property("METHOD")?.let {
return Rejection("carries METHOD:${it.value}, which makes it a scheduling message")
}
val todos = calendar.components("VTODO")
if (todos.isEmpty()) return Rejection("contains no VTODO")
// ⚠️ Mixed component types in one resource. A VEVENT that arrived in the
// same body and was never modelled must not be re-emitted next to a
// VTODO — RFC 4791 §4.1 allows only one component type per resource.
val foreign = calendar.components
.map { it.name.uppercase() }
.filterNot { it == "VTODO" || it == "VTIMEZONE" }
.distinct()
if (foreign.isNotEmpty()) {
return Rejection("mixes VTODO with ${foreign.joinToString(", ")}")
}
// ⚠️ Overrides with no master. A `RECURRENCE-ID` names an instance *of*
// a series, so a body holding only overrides describes instances of
// something that is not in the resource — RFC 4791 §4.1 asks for the
// recurring component and its overridden instances, not the instances
// alone. This is the shape a partly tombstoned series used to serialise
// to, and no server rejects it in a way that names the cause.
if (todos.all { it.property("RECURRENCE-ID") != null }) {
return Rejection("holds overridden instances but not the task they override")
}
// One resource, one UID — the constraint that makes "fork the conflicting
// edit into the same resource" impossible, and the one servers enforce.
val uids = todos.map { it.property("UID")?.value.orEmpty() }.distinct()
if (uids.size > 1) return Rejection("holds ${uids.size} different UIDs")
if (uids.singleOrNull().isNullOrBlank()) return Rejection("has no UID")
// ⚠️ A TZID without a leading solidus must reference a VTIMEZONE in the
// same object (RFC 5545 §3.2.19). We regenerate definitions from
// `java.time`, which cannot resolve a non-IANA id — a Windows zone name
// that arrived from another client and survives in the residue produces a
// reference with nothing behind it. `Prefer: handling=strict` turns that
// from a server-side repair into a rejection, so it is caught by name
// here instead of as an unexplained 415.
val defined = calendar.components("VTIMEZONE")
.mapNotNull { it.property("TZID")?.value }
.toSet()
val undefined = todos.flatMap(::tzidsIn)
// ⚠️ The solidus form is exempt, and the rule above says so: §3.2.19
// requires a local VTIMEZONE only for a TZID *without* a leading
// solidus, because the prefix marks a globally defined identifier.
// Thunderbird writes `/mozilla.org/20050126_1/Europe/Berlin` on every
// zoned task, `java.time` cannot resolve it, and rejecting it would
// quarantine every Lightning-authored task permanently.
.filterNot { it.startsWith('/') }
.filterNot { it in defined }
.distinct()
if (undefined.isNotEmpty()) {
return Rejection("references the unknown time zone ${undefined.first()}")
}
return todos.firstNotNullOfOrNull(::validateTodo)
}
/**
* ⚠️ The per-component rules are [VTodoMapper.validate]'s, not a second copy.
*
* Two value-type tests that disagree is worse than one: `ResourceValidator`
* originally tested only `VALUE=DATE`, while the mapper also treats a bare
* eight-digit value as a DATE, so a residue `DTSTART:20260101` beside an
* authored `DUE;VALUE=DATE:20260102` was rejected as a mismatch and never
* left the device.
*/
private fun validateTodo(todo: ICalComponent): Rejection? {
if (todo.property("DUE") != null && todo.property("DURATION") != null) {
// RFC 5545 §3.6.2: DUE and DURATION are mutually exclusive.
return Rejection("has both DUE and DURATION")
}
return VTodoMapper.validate(todo).firstOrNull()?.let(::Rejection)
}
private fun tzidsIn(component: ICalComponent): List<String> =
component.properties.mapNotNull { it.param("TZID")?.takeIf(String::isNotBlank) } +
component.components.flatMap(::tzidsIn)
}
@@ -0,0 +1,546 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParam
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import java.time.ZoneId
import kotlin.time.Clock
import kotlin.time.Instant
/**
* VTODO ↔ [TaskEntity].
*
* ## The residue
*
* Everything the mapper does not claim — unknown properties, unknown parameters,
* `VALARM`s, whole unknown sub-components — is serialised verbatim into
* [TaskEntity.unknownProperties] and re-emitted on write. RFC 5545 §3.1 requires
* it ("Applications MUST preserve the value data for x-name and iana-token
* values that they don't recognize"), and failing it destroys other people's
* data invisibly — invisible in our own UI precisely because we are the client
* that does not understand the property.
*
* ## What "claimed" means, and why it is narrow
*
* The mapper claims a property only when it can **reproduce it exactly** from
* its columns. Everything else stays in the residue and round-trips untouched:
*
* - a value it cannot parse or that is out of range (`PRIORITY:11`,
* `PERCENT-COMPLETE:abc`, `STATUS:X-DEFERRED`, `SEQUENCE:x`) — clamping or
* defaulting these would be a silent rewrite of somebody's data;
* - a time it can read but not reproduce — a floating stamp, or a `TZID` this
* device's tzdb has never heard of. The column still gets a best-effort
* instant so the UI has something to show;
* - a `DTSTART`/`DUE` pair that disagrees on value type or timezone, where
* authoring both from one `is_all_day` flag and one `timezone` column would
* destroy the odd one out.
*
* ## Residue eviction
*
* A suppressed property is only suppressed while it still *agrees* with its
* column. [contradictsResidue] compares the two on write: if the user has since
* edited that field, the stale residue copy is evicted and the column is
* authored. Without this, editing the due date of a task imported with a
* floating `DUE` would silently do nothing on the server.
*
* ## Alarms
*
* `VALARM`s round-trip in the residue and are never authored here. Local
* reminders live in `task_alarms` and are never serialised. The two stores are
* disjoint, so neither can destroy the other. Merging them is a later
* decision, not a silent one.
*/
object VTodoMapper {
/**
* DAVx5's limit, and ours for the same two reasons: Android's `CursorWindow`
* row cap, and `CALDAV:max-resource-size`, whose violation is a failed PUT.
*/
const val MAX_RESIDUE_BYTES = 25 * 1024
/**
* Cardinality-one properties the mapper authors from a column, and therefore
* must not author while the residue still holds the original.
*/
private val SUPPRESSED_BY_RESIDUE = setOf(
"DTSTART", "DUE", "COMPLETED", "RECURRENCE-ID", "CREATED", "LAST-MODIFIED",
"STATUS", "RELATED-TO",
// ⚠️ The scalars belong here too, and their absence was invisible: the
// round-trip corpus filters SEQUENCE out of comparison entirely, so a
// task read from `SEQUENCE:x` was re-emitted carrying *both* `SEQUENCE:0`
// and `SEQUENCE:x` with nothing to catch it. `write` always authors
// SEQUENCE, so the duplicate is unconditional — and under
// `Prefer: handling=strict` sabre will not quietly repair it.
"SEQUENCE", "PRIORITY", "PERCENT-COMPLETE", "CLASS",
// Parameterised copies stay in the residue verbatim; authoring the
// column beside them would emit the same exclusions twice.
"RDATE", "EXDATE",
)
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
data class Mapped(
val entity: TaskEntity,
/** `RELATED-TO;RELTYPE=PARENT`, for the caller to resolve to a row id. */
val parentUid: String?,
/** Set when the source carried no `UID` and the caller must mint one. */
val uidWasMissing: Boolean,
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
val droppedResidue: Boolean,
)
// ---------------------------------------------------------------- read
fun read(vtodo: ICalComponent, listId: Long = 0L): Mapped {
// Claims are tracked by index, not by value: two identical content lines
// are two pieces of data, and claiming one must not swallow the other on
// the way into the residue.
val claimed = mutableSetOf<Int>()
fun claim(property: ICalProperty?) {
property?.let { p ->
vtodo.properties.indexOfFirst { it === p }.takeIf { it >= 0 }?.let(claimed::add)
}
}
/** Claims [property] only if [value] could be read from it. */
fun <T> take(property: ICalProperty?, value: T?): T? =
value?.also { claim(property) }
/**
* Every `RDATE` / `EXDATE` copy's values, merged.
*
* ⚠️ Both are cardinality-**many**, and Apple Calendar writes one line
* per excluded occurrence. Reading only the first left the rest out of
* the column the expander works from, so a deleted occurrence
* reappeared in the list and in its reminders — while round-tripping
* back to the server perfectly out of the residue, which is what made it
* invisible.
*
* ⚠️ Claimed only when no copy carries a parameter. The column is the
* bare value, so claiming a `TZID=`- or `VALUE=DATE`-qualified property
* would drop that qualifier for good: the property never reaches the
* residue, and `write` re-emits it naked. A floating EXDATE stops
* matching the instance it excluded, and a bare eight-digit RDATE is
* read as a malformed DATE-TIME (§3.3.5) — a permanent 415 under the
* `Prefer: handling=strict` this client sends.
*/
fun dateList(vtodo: ICalComponent, name: String, claim: (ICalProperty?) -> Unit): String? {
val copies = vtodo.properties(name)
if (copies.isEmpty()) return null
if (copies.all { it.params.isEmpty() }) copies.forEach(claim)
return copies.joinToString(",") { it.value.trim() }.takeIf { it.isNotEmpty() }
}
val uidProperty = vtodo.property("UID")
// Claimed even when empty: an empty UID must not reach the residue, or
// write would emit the caller's minted UID alongside the empty one.
val uid = take(uidProperty, uidProperty?.value?.trim()).orEmpty()
val dtstart = readTime(vtodo, "DTSTART")
val due = readTime(vtodo, "DUE")
// A task's primary stamp is DUE; DTSTART only decides when there is no
// DUE. Deriving one all-day flag from "either is a DATE" turns a
// date/date-time pair into two DATEs and destroys the time half.
val allDay = if (due.present) due.isDate else dtstart.isDate
val timezone = dtstart.tzid ?: due.tzid
// Claim a stamp only when the single is_all_day flag and the single
// timezone column can reproduce it. Otherwise it stays in the residue and
// is re-emitted exactly as it arrived.
if (dtstart.reproducible(allDay, timezone)) claim(dtstart.property)
if (due.reproducible(allDay, timezone)) claim(due.property)
val recurrenceId = readTime(vtodo, "RECURRENCE-ID")
if (recurrenceId.reproducible(allDay, timezone)) claim(recurrenceId.property)
// These three are UTC-only on the way out, so a zoned or date-valued one
// is not reproducible.
val completed = readTime(vtodo, "COMPLETED")
if (completed.isUtcDateTime) claim(completed.property)
val created = readTime(vtodo, "CREATED")
if (created.isUtcDateTime) claim(created.property)
val lastModified = readTime(vtodo, "LAST-MODIFIED")
if (lastModified.isUtcDateTime) claim(lastModified.property)
val statusProperty = vtodo.property("STATUS")
val status = take(
statusProperty,
when (statusProperty?.value?.trim()?.uppercase()) {
"NEEDS-ACTION" -> TaskStatus.NEEDS_ACTION
"IN-PROCESS" -> TaskStatus.IN_PROCESS
"COMPLETED" -> TaskStatus.COMPLETED
"CANCELLED" -> TaskStatus.CANCELLED
else -> null
},
) ?: TaskStatus.NEEDS_ACTION
val percentProperty = vtodo.property("PERCENT-COMPLETE")
val percent = take(
percentProperty,
percentProperty?.value?.trim()?.toIntOrNull()?.takeIf { it in 0..100 },
)
// 0 = undefined, 1 = highest, 9 = lowest. Stored raw: bucketing on the way
// in would rewrite a server's PRIORITY:3 as 1 and lose it on write-back.
val priorityProperty = vtodo.property("PRIORITY")
val priority = take(
priorityProperty,
priorityProperty?.value?.trim()?.toIntOrNull()?.takeIf { it in 0..9 },
) ?: PRIORITY_NONE
val classProperty = vtodo.property("CLASS")
val classification = take(
classProperty,
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
)
val sequenceProperty = vtodo.property("SEQUENCE")
val sequence = take(
sequenceProperty,
sequenceProperty?.value?.trim()?.toIntOrNull()?.takeIf { it >= 0 },
) ?: 0
// RELTYPE defaults to PARENT (§3.2.15), and §3.8.4.5 reads backwards to
// most implementers: the *referencing* component is the subordinate one,
// so this points at our parent.
//
// Deliberately **not** claimed. TaskEntity holds only a local `parent_id`,
// so a parent that is not in this store — not fetched yet, in another
// collection, deleted locally — would leave nothing to write back and the
// relationship would be destroyed. Keeping it in the residue means the
// link survives even when the parent row does not.
val parentUid = vtodo.properties("RELATED-TO")
.firstOrNull { (it.param("RELTYPE") ?: "PARENT").equals("PARENT", ignoreCase = true) }
?.value?.trim()?.takeIf { it.isNotEmpty() }
val entity = TaskEntity(
listId = listId,
uid = uid,
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
// URI, not TEXT — escaping it would corrupt a query string.
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
status = status,
percentComplete = percent,
completedAt = completed.instant,
priority = priority,
classification = classification,
dtstart = dtstart.instant,
due = due.instant,
duration = take(vtodo.property("DURATION"), vtodo.property("DURATION")?.value?.trim()),
isAllDay = allDay,
timezone = timezone,
rrule = take(vtodo.property("RRULE"), vtodo.property("RRULE")?.value?.trim()),
rdate = dateList(vtodo, "RDATE", ::claim),
exdate = dateList(vtodo, "EXDATE", ::claim),
recurrenceId = recurrenceId.instant,
createdAt = created.instant,
lastModified = lastModified.instant,
// The Organizer's revision counter (§3.8.7.4) — preserved verbatim,
// never ours to bump.
sequence = sequence,
)
// DTSTAMP is regenerated on every serialisation and carries no state, so
// it is dropped rather than stored. LAST-MODIFIED, which does carry
// state, is a column above — conflating the two makes every sync look
// like an edit.
val residueProperties = vtodo.properties
.filterIndexed { index, _ -> index !in claimed }
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
val residueText = ICalSerializer.serializeProperties(residueProperties) +
ICalSerializer.serializeAll(vtodo.components)
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
return Mapped(
entity = entity.copy(
unknownProperties = residueText.takeIf { it.isNotEmpty() && !tooLarge },
),
parentUid = parentUid,
uidWasMissing = uid.isEmpty(),
droppedResidue = tooLarge,
)
}
// --------------------------------------------------------------- write
/**
* Serialises [entity] back to a VTODO.
*
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
* [now] is the `DTSTAMP`.
*/
fun write(
entity: TaskEntity,
parentUid: String? = null,
now: Instant = Clock.System.now(),
): ICalComponent {
val residue = parseResidue(entity.unknownProperties)
val keptResidue =
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) }
val suppressed = keptResidue
.map { it.name.uppercase() }
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
val properties = mutableListOf<ICalProperty>()
fun add(name: String, value: String?, vararg params: ICalParam) {
if (value == null || name.uppercase() in suppressed) return
properties += ICalProperty(name, params.toList(), value)
}
fun addTime(name: String, instant: Instant?) {
if (instant == null || name in suppressed) return
properties += timeProperty(name, instant, entity)
}
add("UID", entity.uid)
add("DTSTAMP", ICalValues.formatDateTime(now, null))
add("SEQUENCE", entity.sequence.toString())
add("SUMMARY", entity.title?.let(ICalValues::escapeText))
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
add("LOCATION", entity.location?.let(ICalValues::escapeText))
add("URL", entity.url)
add("STATUS", entity.status.toICalName())
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
// §3.8.2.1: COMPLETED MUST be UTC — no TZID, no floating, no DATE.
add("COMPLETED", entity.completedAt?.let { ICalValues.formatDateTime(it, null) })
add("PRIORITY", entity.priority.takeIf { it != PRIORITY_NONE }?.toString())
add("CLASS", entity.classification?.let { CLASS_NAMES.getOrNull(it) })
addTime("DTSTART", entity.dtstart)
addTime("DUE", entity.due)
add("DURATION", entity.duration)
add("RRULE", entity.rrule)
// ⚠️ Authored only when the residue's copies contradicted the column —
// i.e. we changed the exclusions ourselves. The parameters come from the
// copy the residue is giving up, so an authored value keeps the TZID the
// list was written in; with no copy to take them from, a date-shaped
// value still needs its VALUE=DATE, since §3.3.5's default is DATE-TIME
// and a bare eight-digit value is malformed.
dateList("RDATE", entity.rdate, residue, suppressed, properties)
dateList("EXDATE", entity.exdate, residue, suppressed, properties)
addTime("RECURRENCE-ID", entity.recurrenceId)
add("CREATED", entity.createdAt?.let { ICalValues.formatDateTime(it, null) })
add("LAST-MODIFIED", entity.lastModified?.let { ICalValues.formatDateTime(it, null) })
if (parentUid != null && "RELATED-TO" !in suppressed) {
properties += ICalProperty("RELATED-TO", listOf(ICalParam("RELTYPE", "PARENT")), parentUid)
}
return ICalComponent(
name = "VTODO",
properties = properties + keptResidue,
components = residue.components,
)
}
private fun dateList(
name: String,
value: String?,
residue: ICalComponent,
suppressed: Set<String>,
into: MutableList<ICalProperty>,
) {
if (value == null || name in suppressed) return
val inherited = residue.properties(name).firstOrNull { it.params.isNotEmpty() }?.params
into += ICalProperty(name, paramsFor(value, inherited.orEmpty()), value)
}
/**
* The parameters an authored list may carry, given what is in it.
*
* ⚠️ Checked against the value rather than copied across. §3.3.5 forbids
* `TZID` on a `…Z` value and sabre answers 415 for the pair, and a
* date-shaped value needs `VALUE=DATE` or it is read as a malformed
* DATE-TIME. A `TZID` inherited from a residue copy therefore only survives
* where the value is still a local wall time.
*
* What this cannot repair is an `EXDATE` whose zone differs from `DTSTART`'s:
* one `timezone` column describes the task, so a value appended here is
* written in *that* zone. Legal, and vanishingly rare — every client that
* writes both writes them alike.
*/
private fun paramsFor(value: String, inherited: List<ICalParam>): List<ICalParam> {
val values = value.split(',').map(String::trim).filter(String::isNotEmpty)
if (values.isEmpty()) return emptyList()
if (values.none { it.contains('T') }) return listOf(ICalParam("VALUE", "DATE"))
if (values.any { it.endsWith("Z") }) return emptyList()
return inherited.filter { it.name.equals("TZID", ignoreCase = true) }
}
/**
* True when a suppressing residue property no longer describes what its
* column holds — i.e. the user has edited that field since it was imported,
* so the stale copy must go and the column must be authored instead.
*/
private fun contradictsResidue(
property: ICalProperty,
residue: ICalComponent,
entity: TaskEntity,
parentUid: String?,
): Boolean {
// Cardinality-many and list-valued: the column carries every copy's
// values merged, so it contradicts only when that whole set has changed
// — which is what deleting an occurrence does, and nothing else here
// does. All the copies are then dropped together and one authored line
// carries the lot, parameters included.
fun listChanged(column: String?): Boolean {
fun split(text: String?) =
text.orEmpty().split(',').map(String::trim).filter(String::isNotEmpty)
return split(column) != residue.properties(property.name).flatMap { split(it.value) }
}
fun sameInstant(column: Instant?) =
ICalValues.readInstant(ICalValues.parseTime(property)) == column
return when (property.name.uppercase()) {
"DTSTART" -> !sameInstant(entity.dtstart)
"DUE" -> !sameInstant(entity.due)
"COMPLETED" -> !sameInstant(entity.completedAt)
"RECURRENCE-ID" -> !sameInstant(entity.recurrenceId)
"RDATE" -> listChanged(entity.rdate)
"EXDATE" -> listChanged(entity.exdate)
"CREATED" -> !sameInstant(entity.createdAt)
"LAST-MODIFIED" -> !sameInstant(entity.lastModified)
// The residue only ever holds a STATUS we could not read, which left
// the column at its NEEDS-ACTION fallback. Anything else means the
// user has since set a real status.
"STATUS" -> entity.status != TaskStatus.NEEDS_ACTION
// A null parentUid is "the parent is not in this store", not "there is
// no parent" — dropping the link there would destroy a relationship
// over a row we simply have not fetched.
//
// ⚠️ Scoped to RELTYPE=PARENT, exactly as the read side is. Nothing
// here models CHILD or SIBLING, and no RELATED-TO is ever claimed,
// so every one of them lives in the residue — where an unscoped
// comparison read a CHILD link as "a PARENT link that disagrees with
// the column" and dropped it on the next PUT.
"RELATED-TO" ->
(property.param("RELTYPE") ?: "PARENT").equals("PARENT", ignoreCase = true) &&
parentUid != null && property.value.trim() != parentUid
// Each of these reaches the residue only when it could not be parsed,
// which left the column at its fallback. A column that has since moved
// off that fallback is the user's edit, and it wins.
"PRIORITY" -> entity.priority != PRIORITY_NONE
"PERCENT-COMPLETE" -> entity.percentComplete != null
"CLASS" -> entity.classification != null
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
// so the column stays at its fallback and the residue always wins.
else -> false
}
}
/**
* A `VALARM` with `TRIGGER;RELATED=END` needs `DUE`, or `DTSTART` plus
* `DURATION` (§3.8.6.3). Clearing the due date on a task that has an
* end-relative reminder produces a resource the server rejects permanently —
* and it is reachable from ordinary UI actions, so it is checked before PUT
* rather than discovered as a 415.
*/
fun validate(vtodo: ICalComponent): List<String> {
val problems = mutableListOf<String>()
val due = vtodo.property("DUE")
val dtstart = vtodo.property("DTSTART")
val duration = vtodo.property("DURATION")
if (due != null && dtstart != null) {
val start = ICalValues.readInstant(ICalValues.parseTime(dtstart))
val end = ICalValues.readInstant(ICalValues.parseTime(due))
// sabre answers 415 for both of these, not a 4xx that names them.
if (start != null && end != null && end < start) problems += "DUE precedes DTSTART"
if (isDateValue(dtstart) != isDateValue(due)) {
problems += "DTSTART and DUE disagree on value type"
}
}
if (due == null && (dtstart == null || duration == null)) {
val endRelative = vtodo.components("VALARM").any { alarm ->
alarm.property("TRIGGER")?.param("RELATED").equals("END", ignoreCase = true)
}
if (endRelative) problems += "TRIGGER;RELATED=END with neither DUE nor DTSTART+DURATION"
}
if (vtodo.property("METHOD") != null) problems += "METHOD is not allowed on a stored resource"
return problems
}
// -------------------------------------------------------------- helpers
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
private class TimeRead(
val property: ICalProperty?,
val instant: Instant?,
val tzid: String?,
val isDate: Boolean,
val representable: Boolean,
) {
val present get() = property != null
/** True when one `is_all_day` flag and one `timezone` column reproduce it. */
fun reproducible(allDay: Boolean, timezone: String?) =
present && representable && isDate == allDay && tzid == timezone
/** True when it is a UTC date-time — the only form we author for these. */
val isUtcDateTime get() = present && representable && !isDate && tzid == null
}
private fun readTime(vtodo: ICalComponent, name: String): TimeRead {
val property = vtodo.property(name) ?: return TimeRead(null, null, null, false, false)
return when (val value = ICalValues.parseTime(property)) {
is ICalValues.TimeValue.Date -> TimeRead(property, value.instant, null, true, true)
is ICalValues.TimeValue.Timed -> TimeRead(property, value.instant, value.tzid, false, true)
// Readable but not reproducible: the column gets the best-effort
// instant, the property round-trips from the residue verbatim.
is ICalValues.TimeValue.Unrepresentable ->
TimeRead(property, value.instant, null, false, false)
}
}
private fun timeProperty(name: String, instant: Instant, entity: TaskEntity): ICalProperty {
if (entity.isAllDay) {
return ICalProperty(
name,
listOf(ICalParam("VALUE", "DATE")),
ICalValues.formatDate(instant),
)
}
// Resolve the zone once. Letting the parameter and the value each decide
// separately produces TZID on a `…Z` value, which §3.3.5 forbids and
// sabre answers 415 for.
val tzid = entity.timezone?.takeIf { runCatching { ZoneId.of(it) }.isSuccess }
val params = if (tzid == null) emptyList() else listOf(ICalParam("TZID", tzid))
return ICalProperty(name, params, ICalValues.formatDateTime(instant, tzid))
}
/** The same DATE test [ICalValues.parseTime] applies, so the two cannot disagree. */
private fun isDateValue(property: ICalProperty): Boolean =
ICalValues.parseTime(property) is ICalValues.TimeValue.Date
private fun ICalProperty.text(): String = ICalValues.unescapeText(value)
private fun parseResidue(text: String?): ICalComponent {
if (text.isNullOrEmpty()) return ICalComponent("VTODO")
// The residue is stored as bare properties followed by whole sub-component
// blocks, so it parses as the body of a VTODO with the wrapper restored.
return runCatching {
ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n")
}.getOrElse { ICalComponent("VTODO") }
}
private fun TaskStatus.toICalName(): String = when (this) {
TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION"
TaskStatus.IN_PROCESS -> "IN-PROCESS"
TaskStatus.COMPLETED -> "COMPLETED"
TaskStatus.CANCELLED -> "CANCELLED"
}
}

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