Compare commits

..
Author SHA1 Message Date
makiolaj faa3e3817f Point floret-kit at main after #17 merged (#66) 2026-10-16 15:06:28 +02:00
makiolaj 71591f617a Bump floret-kit to latest main (#66) 2026-10-16 14:56:23 +02:00
makiolaj eea3a8b917 Address review on the Calendula alignment (#66)
Bumps floret-kit so a closing screen stops taking back, returns from
Storage to Import, and renames the per-list notifications row.
2026-10-16 14:42:47 +02:00
makiolaj a454c3685e Align motion and settings with Calendula (#66)
Screens slide in and leave through the predictive-back exit, settings
sub-screens get Calendula's back preview, Reminders becomes Notifications,
and export/import move out of Storage into Backup & restore. Bumps
floret-kit for the language picker header.
2026-10-16 14:27:15 +02:00
Jean-Luc Makiola 9b7f7f60d2 Pin the clock in the delete-following subtree test (#65)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/65
2026-10-15 16:23:52 +02:00
Jean-Luc Makiolaandmakiolaj cff6308018 Let other apps write to the own store through the provider (#64)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Builds on the read-only provider (#62) and makes it writable for other apps, as long as Agendula uses its own store.

- `tasks`: insert, update and delete. Writes follow the same rules as the app's own edits. Status, progress and completion time stay consistent, a subtree moves and deletes as one, and a write that changes nothing doesn't mark the task for sync.
- `instances`: completion columns complete, cancel or reopen that one occurrence. Other content columns edit the whole series. Changing a time column is rejected, so a series is moved through `tasks`.
- `properties`: alarms and categories can be inserted, updated and deleted.
- `tasklists` stays read-only. Batches run in one transaction.
- A client can write back a row it read. Columns it carries unchanged are ignored, and so are rdate/exdate values in the shape they were synced in.
- A task inserted with a time but no `tz` is floating, as in OpenTasks.
- Deleting a task now takes its whole subtree in the app as well, including "this and following" from the first occurrence and in External mode.

Deviation: tag property ids stay stable, with one rare exception. If two tags on one task have hash-colliding spellings, removing one can shift the other's id. Avoiding that would mean storing the ids, which didn't seem worth it at about a 1 in 2³¹ chance.

Closes #63

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/64
2026-10-15 16:16:51 +02:00
Jean-Luc Makiolaandmakiolaj e7bf23e276 Publish the own store through a read-only OpenTasks provider (#43) (#62)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
In Own storage mode, Agendula now publishes its Room store as a ContentProvider that follows the OpenTasks contract. It serves `tasklists`, `tasks`, `instances` and `properties` (reminders and tags) at `<package>.tasks`, behind a dangerous-level `<package>.permission.READ_TASKS` that the user grants per app. An app that already reads OpenTasks or tasks.org only has to change the authority and the permission.

On a query, the provider lays the Room store out in OpenTasks' columns and loads that into an in-memory SQLite database, so a client's selection, arguments and sort order run as real SQL. The snapshot is rebuilt after a write, after a storage mode switch, or once it is an hour old, because instance ids and `distance_from_current` move with the clock. Observers on the authority get one notification per burst of changes. In External mode every table is empty.

Recurrence expansion moved out of `RoomTasksDataSource` into shared functions, so the app and the provider expand a series the same way.

`docs/CONTENT_PROVIDER.md` describes the contract for client developers, with the authority and permission for each build. README, CHANGELOG and the privacy policy mention the new permission.

Where this differs from the issue: the issue asked for read and write permissions, and this PR is read-only. Insert, update and delete throw. Accepting writes from other apps means deciding what happens to the fields Agendula keeps verbatim for CalDAV round-tripping, which was the issue's second open question, and that needs its own design. That is tracked in #63.

Closes #43

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/62
2026-10-15 13:25:48 +02:00
Jean-Luc Makiola 35110c96ce docs: Google Play is live (#61)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 11s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m6s
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-15 13:00:26 +02:00
Jean-Luc Makiola dcc55a158e docs: F-Droid install, Play note, Weblate widget (#60)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m47s
2026-10-14 17:41:10 +02:00
Jean-Luc Makiolaandmakiolaj dd08c035df Release 1.1.0 (#51)
Renovate / renovate (push) Successful in 54s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 21m44s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m32s
Agendula 1.1.0. Merging this triggers the release pipeline (F-Droid, Codeberg, Play) and mints the v1.1.0 tag.

- Tags, synced as CATEGORIES (#35)
- Saved filters, also usable in the Tasks widget (#47)
- Offline flavor without sync or network, published to Codeberg only (#39)
- New Today, Up next, Lists and Week widgets; widgets can show several lists
- Synced lists can be renamed and deleted
- All-day tasks no longer land a day off west of UTC in smart lists and filters

versionName 1.1.0 / versionCode 1010099. CHANGELOG has the 1.1.0 section, and What's New is written for en-US, en-GB, de-DE, pt-BR, plus new fr-FR and ar.

After the tag lands, fdroiddata!49998 needs a 1.1.0 build entry with gradle: full.

Closes #35, closes #39, closes #47

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/51
2026-10-10 15:06:07 +02:00
Jean-Luc Makiola b6f0391cb3 Translations update from Weblate (#34)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/34
2026-10-10 14:28:03 +02:00
Jean-Luc Makiolaandmakiolaj 22f3d8c2c5 ci: route jobs to the gradle and light runners (#48)
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m47s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 9s
Moves Gradle jobs to the dedicated `gradle` runner (capacity 1, 8g per job) and the cheap gate/lint jobs to `light`. Two runners used to share the `docker` label, so two Gradle builds could run at once and the VPS ran out of memory. Only `runs-on` lines change.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/48
2026-10-10 11:59:22 +02:00
Weblate 4c2a6dc87b Translated using Weblate (Arabic)
Currently translated at 19.4% (112 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-08 13:09:37 +00:00
Sup99999 33639c0a7c Translated using Weblate (Arabic)
Currently translated at 19.4% (112 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-08 13:09:37 +00:00
Robin Schanbacher a15e696059 Translated using Weblate (German)
Currently translated at 66.0% (380 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-07 11:37:09 +00:00
Robin Schanbacher b8e797f419 Translated using Weblate (German)
Currently translated at 64.1% (369 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-07 11:37:09 +00:00
Jean-Luc Makiolaandmakiolaj 72b2932197 Agendula: repin floret-kit to shared main, Gradle 9.8 toolchain (#44)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 52s
Moves the floret-kit submodule from v0.4.0 (db4be68) to floret-kit main (8b33635). The kit's per-app branches were folded back into main in jlmakiola/floret-kit#11, so agendula now tracks the same line as calendula, clockula and vouch. The kit adds core-prefs, core-di, CollapsingScaffold FAB/no-back slots, the glance module, LocaleManager on API 33+ and a dependency bump; nothing existing is removed.

floret-kit main requires Gradle >= 9.6 in the consuming build, so this also bumps Gradle 9.5.1 → 9.8.0, AGP 9.2.1 → 9.4.1 and Kotlin 2.3.21 → 2.4.20. Hilt 2.60.1 / KSP 2.3.11 were already new enough. No source changes.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/44
2026-10-07 10:16:36 +02:00
Jean-Luc Makiolaandmakiolaj c4a69d30f7 ci(release): exact tag checks and a retryable beta publish (#40)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m26s
### What this changes

- New `scripts/release_gate.sh`, used by the `detect` jobs of both `release.yaml` and `beta.yaml`, decides whether the committed version still needs publishing. Tags are read by exact name via `git ls-remote` instead of Codeberg's `git/refs/tags/<name>` API.
- A beta counts as done only once its Codeberg pre-release carries the APK, so a publish that failed part-way is redone by the next push of the branch.
- A beta is refused unless it is newer than the latest stable tag (previously only an exact `vX.Y.Z` tag was checked).
- `publish_codeberg_release.sh` fails a pre-release when `CODEBERG_RELEASE_TOKEN` is missing (stable still skips), fails on any asset upload that doesn't return 201, and no longer aborts when the asset listing returns an error.
- `version_info.sh` rejects leading zeros (`1.1.0-beta.01` would have shared `beta.1`'s versionCode under a different tag).
- `beta.yaml` header and `docs/RELEASING.md` updated to match.

### Why

Codeberg's `git/refs/tags/<name>` matches by prefix: asking for `v1.1.0` returns 200 because `v1.1.0-beta.1` exists. Merging 1.1.0 into `main` would have logged "tag already exists" and shipped nothing while the run stayed green, and `1.1.0-beta.2` would have been refused as "already shipped stable".

`release/v1.1.0` needs `main` merged in after this lands, since `beta.yaml` runs from the pushed branch's files.

### Also in here

`gradle/gradle-daemon-jvm.properties` now points at JetBrains' own download URLs for JBR 21.0.11 instead of foojay. foojay dropped JetBrains 21 from its index, so the pinned package ids return 400 and CI couldn't provision the daemon JVM (unrelated to the release change, but it blocks every PR until fixed).

### Checklist

- [x] No `values-*/strings.xml` touched
- [x] No `versionName` / `versionCode` bump
- [x] `CHANGELOG.md` not updated: release infrastructure, not a user-visible change

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/40
2026-10-06 18:32:42 +02:00
Jean-Luc Makiolaandmakiolaj fe0c7d83a9 ci(release): beta releases as Codeberg-only pre-releases (#38)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 52s
### What this changes

Adds beta releases. Pushing a `release/*` branch whose committed `versionName` is `X.Y.Z-beta.N` makes the new `.gitea/workflows/beta.yaml` run the unit tests, build and sign the APK with the app key, and publish it as a **Codeberg pre-release** (APK + `.sha256`), plus a Gitea pre-release with the R8 mapping. F-Droid (self-hosted and official) and Play never get a beta; Obtainium only offers it with *Include prereleases* on.

- **`scripts/version_info.sh`** is the single source for `versionName` → `versionCode`, used by `release.yaml`, `beta.yaml`, the changelog sync and the store-listing check. From 1.1.0: `X*1000000 + Y*10000 + Z*100 + N` for betas (N = 1–98), `+ 99` for stable, so `1.1.0-beta.1` → `1010001`, `1.1.0` → `1010099`. All 1.0.x versions keep the legacy formula, so `release/v1.0.1` (code `10001`) stays valid.
- **Shared publish scripts:** `scripts/publish_codeberg_release.sh`, `scripts/publish_gitea_release.sh` and `scripts/release_notes.sh`, moved out of `release.yaml`. The stable path behaves as before.
- **Guards:**
  - CI fails a PR whose committed `versionCode` doesn't match its `versionName`.
  - CI fails a PR into `main` that carries a beta version.
  - `release.yaml`'s `detect` refuses a beta on `main` as a backstop.
  - `beta.yaml` refuses a beta of a version that has already shipped as stable.
  - Betas get no store What's New file.
- **Docs:** "Cutting a beta" and the versionCode table in `docs/RELEASING.md`; a note on beta tags in `docs/fdroid-official/README.md`; how to opt in to betas in the README.

### Why

To ship a test build of an upcoming version (e.g. 1.1.0) to opted-in testers before the stable release, without it reaching F-Droid or Play users.

### How it was tested

- `scripts/version_info.sh` against stable, beta, legacy and invalid version names.
- Both publish scripts against a mock forge API: create, re-run (PATCH plus asset replacement), Codeberg's 500-then-retry path, and the skip when no token is set.
- A scratch copy with `1.1.0-beta.1` committed: the version check passes, the changelog sync and `check_store_listing.py --complete` pass without a What's New, the PR-into-main guard trips, and a wrong `versionCode` is rejected.
- `sync_changelog_to_fastlane.sh` and `check_store_listing.py` (with and without `--complete`) still pass on the current `1.0.0`.
- All three workflow files parse as YAML.

Not run on the real runners yet. The first beta push is the live test of `beta.yaml`, which assumes a mirrored branch push starts a workflow on Gitea, the same way pushes to `main` already do.

### Checklist

- [x] No `versionName` / `versionCode` bump
- [x] No `values-*/strings.xml` touched
- [x] `CHANGELOG.md` not updated: this is release infrastructure, not a user-visible change

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/38
2026-10-05 18:47:57 +02:00
Jean-Luc Makiolaandmakiolaj f92f9bcdad chore(renovate): rebase conflicted PRs on every merge to main (#36)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 10s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 53s
Renovate only rebases while it runs, and it ran once a week, so merging one dependency PR left the others conflicted until the next Monday.

- `renovate.yml`: also run on push to `main` (mirror syncs fire push events, like `release.yaml`).
- `renovate.json5`: new PRs only in a Monday window (`* 4-6 * * 1`) around the weekly cron; on-merge runs only rebase conflicted PRs (`rebaseWhen: "conflicted"`).

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/36
2026-10-05 13:30:11 +02:00
Jean-Luc Makiola 53d5e2c5aa Translations update from Weblate (#26)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/26
2026-10-02 18:13:09 +02:00
Robin Schanbacher 6888965f5a Translated using Weblate (German)
Currently translated at 62.4% (359 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-01 21:35:13 +00:00
Robin Schanbacher f1ba21e7fa Translated using Weblate (German)
Currently translated at 57.3% (330 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-01 19:19:02 +00:00
Robin Schanbacher 8abc44bbdf Translated using Weblate (German)
Currently translated at 57.0% (328 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-09-30 20:57:48 +00:00
Robin Schanbacher a6e3a35f04 Translated using Weblate (German)
Currently translated at 45.0% (259 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-09-29 20:37:10 +00:00
Jean-Luc Makiolaandmakiolaj a3de9f3d93 docs(readme): show the store screenshots (#24)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Adds the five phone screenshots from `fastlane/metadata/android/en-US/images/phoneScreenshots/` to the README header, as a centered row beneath the badges.

The README references the fastlane files directly rather than copies, so the README and the store listings stay in sync. Each image has descriptive alt text.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/24
2026-09-28 14:11:27 +02:00
Jean-Luc Makiola c823243e53 Merge pull request 'docs: sync the F-Droid recipe with the submitted one' (#22) from docs/fdroid-recipe into main
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
2026-09-24 22:35:14 +02:00
makiolaj d5a74213d6 docs: sync the F-Droid recipe with the submitted one
Pinned to the v1.0.0 commit hash as fdroiddata requires; the README now
tracks the submission (fdroiddata!49998) instead of the steps to make it.
2026-09-24 22:32:01 +02:00
263 changed files with 12621 additions and 1742 deletions
+2 -2
View File
@@ -26,14 +26,14 @@ 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
./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug
python3 scripts/check_translations.py
-->
### Checklist
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
- [ ] `./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :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)
+27 -7
View File
@@ -37,11 +37,29 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# The committed versionName must parse (X.Y.Z or X.Y.Z-beta.N) and the
# committed versionCode must be the one it derives: the official F-Droid
# repo builds the tag as committed. And a beta must never reach main,
# where the release pipeline would ship it to F-Droid and Play; betas are
# cut from release/* branches (docs/RELEASING.md).
- name: Committed version is well-formed
env:
BASE: ${{ github.base_ref }}
run: |
set -e
bash scripts/version_info.sh --check
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
echo "Set the stable version (and its versionCode) before merging into main." >&2
exit 1
fi
# 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.
# of a hand-written summary. A beta version passes both: it ships no
# What's New, so the script writes nothing for it.
- name: Changelog fits the stores, and is committed
run: |
set -e
@@ -109,10 +127,12 @@ jobs:
- name: Setup Java
if: steps.scope.outputs.code == 'true'
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
distribution: 'jetbrains'
java-version: '21'
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
@@ -160,18 +180,18 @@ jobs:
# No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only)
- name: Lint (debug variants only)
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug
run: ./gradlew lintFullDebug lintOfflineDebug
# :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 :dav:test :caldav:test
run: ./gradlew testFullDebugUnitTest testOfflineDebugUnitTest :dav:test :caldav:test
- name: Assemble debug APK
- name: Assemble debug APKs
if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug
+1 -1
View File
@@ -2,7 +2,7 @@ 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).
# build. The deeper checks still run in CI via lintFullDebug (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
+181
View File
@@ -0,0 +1,181 @@
name: Beta — Codeberg pre-release
# A beta is cut by pushing a release branch whose committed versionName is
# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the
# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
# its Codeberg pre-release doesn't carry the APK yet, this runs the unit tests, builds and
# signs the APK with the app key, records a Gitea pre-release (with the R8
# mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
#
# Deliberately nothing else. A beta never reaches the F-Droid repos or Play:
# Obtainium hides pre-releases unless a user opts in, the official F-Droid
# recipe only picks up `^v[0-9.]+$` tags, and Codeberg's "latest release"
# skips pre-releases. Pushes of a release branch carrying a stable version
# (the usual state) fall through `detect` and do nothing.
#
# Lives in .gitea/workflows next to release.yaml for the same reason: it needs
# the app signing key, which only the self-hosted Gitea instance holds.
on:
push:
branches: ['release/**']
concurrency:
group: beta
cancel-in-progress: false
jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: light
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
version_code: ${{ steps.v.outputs.version_code }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and whether it still needs publishing
id: v
run: |
set -e
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
echo "Not a beta — nothing to do."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Done only once its Codeberg pre-release carries the APK, so a failed
# publish is redone by the next push; refused unless newer than the
# latest stable release.
GATE=$(bash scripts/release_gate.sh)
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
beta:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: gradle
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4
with:
distribution: 'jetbrains'
java-version: '21'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install jq
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Pin versionCode to versionName
run: |
set -e
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
- name: Unit tests
run: ./gradlew testFullDebugUnitTest
# The real app key, same as a stable release: a beta has to update in
# place to the next beta and to the stable version.
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
mkdir -p app
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
cat > key.properties <<EOF
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=upload-keystore.jks
EOF
# Both flavors; the offline one goes to Codeberg only (issue #39).
- name: Build release APKs
run: ./gradlew assembleRelease
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
# `## [Unreleased]`. Gitea creates the tag at this commit.
- name: Create tag + Gitea pre-release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
bash scripts/publish_gitea_release.sh
# The point of the whole workflow, so NOT continue-on-error.
- name: Publish pre-release to Codeberg
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/full/release/app-full-release.apk \
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
bash scripts/publish_codeberg_release.sh
+55 -222
View File
@@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
# 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.
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
# one that reaches main.
#
# 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
@@ -46,7 +48,7 @@ jobs:
# 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
runs-on: light
outputs:
is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }}
@@ -60,60 +62,32 @@ jobs:
- name: Resolve version and whether it is a new release
id: v
env:
# 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)
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
# versionName -> versionCode, channel and the pre-release flag (set
# while MAJOR was 0) all come from the one script beta.yaml uses too.
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
#
# 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
# Betas ship from release/* branches via beta.yaml and must never be
# cut here: this path publishes to F-Droid and Play. CI blocks such a
# PR into main; this is the backstop if one gets through anyway.
if [ "$CHANNEL" != "stable" ]; then
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
exit 1
fi
# Tags are read from Codeberg, the canonical forge, by exact name: its
# git/refs/tags API matches by prefix, so v1.1.0-beta.1 would read as
# v1.1.0. A lookup error is fatal rather than read as "no tag".
GATE=$(bash scripts/release_gate.sh)
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
# 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.
@@ -131,7 +105,7 @@ jobs:
release:
needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: docker
runs-on: gradle
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
@@ -146,10 +120,12 @@ jobs:
submodules: recursive
- name: Setup Java
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
distribution: 'jetbrains'
java-version: '21'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
@@ -196,8 +172,8 @@ jobs:
run: chmod +x ./gradlew
# The committed versionName is the source of truth. Pin versionCode to the
# value derived from it so the published APK's code is always
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
# value scripts/version_info.sh derives from it, so the published APK's
# code follows the scheme even if the committed code was forgotten.
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
run: |
@@ -208,7 +184,7 @@ jobs:
# Test the exact commit being shipped (only on a real release).
- name: Unit tests
if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest
run: ./gradlew testFullDebugUnitTest
- name: Setup Android Keystore
if: env.IS_RELEASE == 'true'
@@ -226,7 +202,9 @@ jobs:
storeFile=upload-keystore.jks
EOF
- name: Build release APK
# Both flavors. Only full goes to F-Droid and Play; the offline one
# (issue #39) is a Codeberg release asset and nothing else.
- name: Build release APKs
if: env.IS_RELEASE == 'true'
run: ./gradlew assembleRelease
@@ -282,7 +260,7 @@ jobs:
run: |
set -e
mkdir -p fdroid/repo
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
cp app/build/outputs/apk/full/release/app-full-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
# Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this
@@ -332,6 +310,8 @@ jobs:
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# this commit, so the tag only ever marks a fully-shipped release (and a
# failure before here leaves no tag, so re-running the workflow retries).
# Also attaches the R8 mapping (best-effort) so user crash stacktraces
# stay deobfuscatable. Notes = this version's CHANGELOG section.
- name: Create tag + Gitea release
if: env.IS_RELEASE == 'true'
env:
@@ -340,85 +320,18 @@ jobs:
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION"
# Notes = this version's CHANGELOG section.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": sys.argv[3] == "true",
}))
PY
# Upsert (re-run safe): PATCH if a release for the tag already exists,
# else POST a new one (which also creates the tag at target_commitish).
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases")
OK=201
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the release (it's not an APK, so it fits the no-binaries
# rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
bash scripts/publish_gitea_release.sh
# 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.
# releases (see docs/RELEASING.md): push the tag there and attach 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'
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
@@ -431,91 +344,11 @@ jobs:
SHA: ${{ github.sha }}
run: |
set -e
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="agendula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# 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, pre = sys.argv[1:3]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": open("release-notes.md").read(),
"draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": pre == "true",
}))
PY
# 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")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
[ -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
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/full/release/app-full-release.apk \
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
bash scripts/publish_codeberg_release.sh
# 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
@@ -529,7 +362,7 @@ jobs:
- name: Build release AAB
if: env.IS_RELEASE == 'true'
continue-on-error: true
run: ./gradlew bundleRelease
run: ./gradlew bundleFullRelease
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
@@ -541,7 +374,7 @@ jobs:
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
path: app/build/outputs/bundle/fullRelease/app-full-release.aab
if-no-files-found: error
retention-days: 14
@@ -635,7 +468,7 @@ jobs:
run: |
set -euo pipefail
# Absolute: a lane body runs from fastlane/, not the workspace root.
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
AAB="$GITHUB_WORKSPACE/dist/app-full-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" \
+11 -2
View File
@@ -1,8 +1,17 @@
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).
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
# same trigger release.yaml relies on), so a merged Renovate PR is followed
# within minutes by a run that rebases the sibling PRs it just conflicted —
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
# `schedule` window such a run only maintains existing branches
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
# to renovate/* branches, not main, so this cannot loop.
push:
branches: [main]
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
# in renovate.json5 — keep the two in step.
schedule:
- cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab.
+53
View File
@@ -7,6 +7,59 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Added
- Other apps can read the tasks kept in Agendula's own database through an
OpenTasks-compatible provider, once you grant them permission. Apps that
already support OpenTasks or tasks.org need only a small change to support
Agendula too (#43).
- With a second permission, other apps can also add, edit, complete and delete
those tasks, including their reminders and tags. They can tick off a single
occurrence of a recurring task. Task lists stay read-only for them (#63).
### Changed
- Screens now open and close the way they do in Calendula: they slide in, and
a back swipe previews the screen behind before closing (#66).
- Settings now use Calendula's names and layout. Reminders is now
Notifications, Task form is now New task form, and export and import moved
out of Storage into a new Backup & restore section (#66).
## [1.1.0] - 2026-10-10
### Added
- Four new home-screen widgets: Today, Up next, Lists and Week.
- A widget can show tasks from several lists at once, optionally grouped by
list.
- A setting under Settings → Task form pre-fills a new task's start with today.
- An offline version of Agendula with no sync and no network access at all,
published as a separate APK on Codeberg (#39).
- Tags. Add them while editing a task, see them on the task and in lists,
browse them from the home screen and filter any list by them. They are saved
as `CATEGORIES`, so they sync with Nextcloud Tasks, tasks.org and DAVx⁵ (#35).
- Filters. Save your own view of the open tasks by due date (overdue, today,
upcoming, no date, in any combination), lists, tags and minimum priority.
They live on the home screen and can be shown in the Tasks widget, so today
and overdue tasks can now share one widget (#47).
### Changed
- The Tasks widget has agenda-style rows, an optional row colour and an "All
caught up" state. Ticking a task in any widget shows it as done for a moment
before it disappears.
- The "At a glance" widget is gone; the Tasks and Today widgets cover what it
showed.
- Synced lists can be renamed and deleted, not only device-only ones. A
read-only share shows the edit button greyed out and explains why on tap.
- The sort button in task lists now opens "Sort and filter", which can also
filter by tag.
- While editing a task, its title moves into the top bar once you scroll past
it.
### Fixed
- The list editor's Where and smart-lists rows no longer have double padding.
- A field you tap now scrolls fully above the keyboard, and the "add subtask"
row in task lists no longer ends up hidden behind it.
- All-day tasks west of UTC no longer show up a day early in Overdue, Today
and Upcoming.
## [1.0.0] - 2026-09-21
### Added
+3 -3
View File
@@ -28,9 +28,9 @@ layer.
## Build, test, lint
```sh
./gradlew :app:assembleDebug # build the debug APK
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintDebug # Android lint (CI runs this on every PR)
./gradlew :app:assembleDebug # build the debug APKs (full + offline)
./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR)
```
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
+65 -5
View File
@@ -15,11 +15,23 @@ required.</p>
</p>
<p>
<a href="https://f-droid.org/packages/de.jeanlucmakiola.agendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
&nbsp;
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
&nbsp;
<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>
<p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" alt="Home screen: today's progress, overdue and upcoming tasks, and your lists" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" alt="Onboarding: sync with your own CalDAV server" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" alt="Upcoming list with a task expanded into its subtasks" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" alt="Task detail: a monthly recurring task with a reminder and priority" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" alt="Reminder notification with Done and Snooze actions" width="18%">
</p>
</div>
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
@@ -68,6 +80,16 @@ 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.
### Tested With
The following CalDAV Services have been Tested
| Service | Version | Date | Status | User |
|---|---|---|---|---|
| **Nextcloud** | 25.0.0 | Oct 9th 2026 | ✅ | [@jlmakiola](https://codeberg.org/jlmakiola) [@Schanbro](https://codeberg.org/Schanbro)
## Where your tasks live
| | Where | Sync | Needs |
@@ -79,6 +101,10 @@ 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.
Other apps can read and write the tasks in Agendula's own store through an
OpenTasks-compatible provider, once you grant them permission. See
[docs/CONTENT_PROVIDER.md](docs/CONTENT_PROVIDER.md).
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
@@ -89,10 +115,20 @@ proprietary, and open standards — CalDAV and iCalendar — are the lane.
## Install
### F-Droid repository
### F-Droid (recommended)
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:
Agendula is in the official [F-Droid](https://f-droid.org) repository. Search
for Agendula in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.agendula/).
F-Droid builds from source on its own schedule, so a new version usually shows
up there a few days after release.
### Self-hosted F-Droid repo (fastest updates)
The release pipeline builds, signs and publishes every release to a
self-hosted F-Droid repository, so new versions land there first. 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):
@@ -111,6 +147,29 @@ 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)**.
Each release carries two APKs (see [Offline version](#offline-version)), so set
*Filter APKs by regular expression* to `^agendula_v` to always get the regular
app.
Betas of upcoming versions are published there too, as pre-releases; to test
them, switch on *Include prereleases* for Agendula in Obtainium.
### Google Play
Agendula is on
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula).
Play builds are signed with Google's key rather than mine, so switching between
Play and any other channel means uninstalling first. Tasks kept only in
Agendula's own database go with it, so sync them to a CalDAV account first.
### Offline version
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
without CalDAV sync and **without the network permission**, so it cannot
connect to anything. It installs alongside the regular app rather than replacing
it; move tasks between the two with Settings → Backup & restore → Export / Import. In
Obtainium, pick it with *Filter APKs by regular expression* set to
`^agendula-offline_v`.
### Build from source
@@ -126,8 +185,9 @@ JDK 17 and the Android SDK are all it needs; see
## 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.
fine — an untranslated string simply falls back to English.
<a href="https://weblate.dev.jeanlucmakiola.de/engage/agendula/"><img src="https://weblate.dev.jeanlucmakiola.de/widget/agendula/multi-auto.svg" alt="Translation status" width="600"></a>
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
+60 -11
View File
@@ -1,3 +1,4 @@
import com.android.build.api.artifact.SingleArtifact
import java.util.Properties
import java.io.FileInputStream
@@ -26,12 +27,14 @@ android {
// These committed values ARE the source of truth for a release: merging
// 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. 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"
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
// versionCode is derived from versionName by scripts/version_info.sh
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 1010099
versionName = "1.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -44,6 +47,20 @@ android {
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
}
// `offline` (#39): no sync code, network libraries or network permission.
flavorDimensions += "network"
productFlavors {
create("full") {
dimension = "network"
buildConfigField("boolean", "SYNC_ENABLED", "true")
}
create("offline") {
dimension = "network"
applicationIdSuffix = ".offline"
buildConfigField("boolean", "SYNC_ENABLED", "false")
}
}
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
@@ -206,26 +223,26 @@ dependencies {
// @HiltWorker plumbing.
implementation(libs.androidx.work.runtime.ktx)
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
implementation(libs.androidx.browser)
"fullImplementation"(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)
"fullImplementation"(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"))
"fullImplementation"(project(":dav"))
// Discovery, auth and Nextcloud Login Flow v2.
implementation(project(":caldav"))
"fullImplementation"(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)
"testFullImplementation"(libs.xpp3)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
@@ -244,6 +261,7 @@ dependencies {
implementation(libs.floret.core.crash)
implementation(libs.floret.identity)
implementation(libs.floret.components)
implementation(libs.floret.glance)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
@@ -263,3 +281,34 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
}
/** Fails the build if the offline flavor's merged manifest asks for the network. */
abstract class VerifyNoNetworkPermissions : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val manifest: RegularFileProperty
@TaskAction
fun verify() {
val text = manifest.get().asFile.readText()
val found = listOf("INTERNET", "ACCESS_NETWORK_STATE", "ACCESS_WIFI_STATE", "CHANGE_NETWORK_STATE")
.filter { "\"android.permission.$it\"" in text }
if (found.isNotEmpty()) {
throw GradleException(
"The offline flavor's merged manifest declares ${found.joinToString()}. " +
"Remove it in app/src/offline/AndroidManifest.xml with tools:node=\"remove\".",
)
}
}
}
androidComponents {
onVariants(selector().withFlavor("network" to "offline")) { variant ->
val name = variant.name.replaceFirstChar { it.uppercase() }
val verify = tasks.register<VerifyNoNetworkPermissions>("verify${name}Manifest") {
manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
}
tasks.matching { it.name in setOf("package$name", "package${name}Bundle", "check") }
.configureEach { dependsOn(verify) }
}
}
@@ -0,0 +1,529 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "14b159c51416ba27653f6320ac7f6f03",
"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, `categories` TEXT NOT NULL DEFAULT '', `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": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"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, '14b159c51416ba27653f6320ac7f6f03')"
]
}
}
Binary file not shown.
@@ -212,6 +212,15 @@ class OneShotImportTest {
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
}
@Test
fun importsCategoryPropertiesAsTags() {
importer.importFrom(fixture())
val tasks = taskRows()
assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands")
assertThat(tasks.getValue("Ship the release").categories).isEmpty()
}
// --- running it -----------------------------------------------------------
@Test
@@ -0,0 +1,157 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.content.Context
import android.content.pm.PermissionInfo
import android.os.Build
import android.os.ParcelFileDescriptor
import androidx.core.net.toUri
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 org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/** The provider as another app meets it: manifest, permission, URIs. */
@RunWith(AndroidJUnit4::class)
class AgendulaTaskProviderTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val authority = "${context.packageName}.tasks"
private val readPermission = "${context.packageName}.permission.READ_TASKS"
private val writePermission = "${context.packageName}.permission.WRITE_TASKS"
private val resolver = context.contentResolver
private fun uri(path: String) = "content://$authority/$path".toUri()
@Test
fun isExportedUnderItsPermissions() {
val info = requireNotNull(context.packageManager.resolveContentProvider(authority, 0))
assertThat(info.name).isEqualTo(AgendulaTaskProvider::class.java.name)
assertThat(info.exported).isTrue()
assertThat(info.readPermission).isEqualTo(readPermission)
assertThat(info.writePermission).isEqualTo(writePermission)
}
@Test
fun bothPermissionsAreDangerous() {
listOf(readPermission, writePermission).forEach { permission ->
val info = context.packageManager.getPermissionInfo(permission, 0)
assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS)
assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty()
assertThat(info.loadDescription(context.packageManager)?.toString()).isNotEmpty()
}
}
@Test
fun anAppWithoutThePermissionIsDenied() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
// The shell is another uid that was never granted it.
val output = shell("content query --uri content://$authority/tasks")
assertThat(output).contains("Permission Denial")
}
@Test
fun anAppWithoutTheWritePermissionCannotWrite() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
val output = shell("content insert --uri content://$authority/tasks --bind title:s:x --bind list_id:i:1")
assertThat(output).contains("Permission Denial")
assertThat(output).contains(writePermission)
}
@Test
fun reportsOpenTasksMimeTypes() {
ProviderTable.entries.forEach { table ->
assertThat(resolver.getType(uri(table.path))).isEqualTo(table.mimeType(single = false))
assertThat(resolver.getType(uri("${table.path}/3"))).isEqualTo(table.mimeType(single = true))
}
}
@Test
fun everyTableAnswersWithItsColumns() {
ProviderTable.entries.forEach { table ->
resolver.query(uri(table.path), null, null, null, null)!!.use {
assertThat(it.columnNames.toList()).containsExactlyElementsIn(table.columns.map { c -> c.first }).inOrder()
}
}
}
@Test
fun aCursorWatchesTheUriItWasQueriedFor() {
val tasks = uri("tasks")
resolver.query(tasks, null, null, null, null)!!.use { assertThat(it.notificationUri).isEqualTo(tasks) }
}
@Test
fun aRowUriReadsOnlyThatRow() {
// Ids start at 1, so 0 names nothing whatever the store holds.
resolver.query(uri("tasks/0"), null, null, null, null)!!.use { assertThat(it.count).isEqualTo(0) }
resolver.query(uri("tasks/0"), null, "1 = 1 OR 1 = 1", null, null)!!.use { assertThat(it.count).isEqualTo(0) }
}
@Test
fun anUnknownUriIsRejected() {
assertThrows(IllegalArgumentException::class.java) { resolver.query(uri("events"), null, null, null, null) }
// ContentResolver.getType swallows the provider's exception and answers null.
assertThat(runCatching { resolver.getType(uri("tasks/abc")) }.getOrNull()).isNull()
}
@Test
fun listsAreReadOnly() {
val values = ContentValues().apply { put("list_name", "x") }
assertThrows(UnsupportedOperationException::class.java) { resolver.insert(uri("tasklists"), values) }
assertThrows(UnsupportedOperationException::class.java) { resolver.update(uri("tasklists/1"), values, null, null) }
assertThrows(UnsupportedOperationException::class.java) { resolver.delete(uri("tasklists/1"), null, null) }
}
@Test
fun aRowUriTakesNoInsert() {
assertThrows(IllegalArgumentException::class.java) {
resolver.insert(uri("tasks/1"), ContentValues().apply { put("title", "x") })
}
}
@Test
fun aBadWriteReachesTheClientAsIllegalArgument() {
// No list has this id, so nothing in the app's real store is touched.
val values = ContentValues().apply {
put("list_id", Long.MAX_VALUE)
put("title", "x")
}
assertThrows(IllegalArgumentException::class.java) { resolver.insert(uri("tasks"), values) }
}
@Test
fun aFailingBatchWritesNothing() {
val missingList = Long.MAX_VALUE
val operations = arrayListOf(
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
)
assertThrows(IllegalArgumentException::class.java) { resolver.applyBatch(authority, operations) }
resolver.query(uri("tasks"), arrayOf("_id"), "list_id = ?", arrayOf(missingList.toString()), null)!!
.use { assertThat(it.count).isEqualTo(0) }
}
/** stdout and stderr of [command]; the denial lands on stderr. */
private fun shell(command: String): String {
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommandRwe(command)
stdin.close()
return listOf(stdout, stderr).joinToString("\n") { pfd ->
ParcelFileDescriptor.AutoCloseInputStream(pfd).bufferedReader().use { it.readText() }
}
}
}
@@ -0,0 +1,214 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
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.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** The snapshot follows the Room store, the storage mode and the clock, and says when it moved. */
@RunWith(AndroidJUnit4::class)
class ProviderSnapshotSourceTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var db: TasksDatabase
private lateinit var resolver: ProviderResolver
private lateinit var source: ProviderSnapshotSource
private val clock = MutableClock(Instant.parse("2026-10-10T08:00:00Z"))
private val changes = LinkedBlockingQueue<Uri>()
private val observer = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
changes += uri ?: Uri.EMPTY
}
}
private var listId = 0L
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
resolver = ProviderResolver(NoProviderInstalled)
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}, clock = clock)
listId = db.taskLists().insert(TaskListEntity(name = "Home", color = 0xFF112233.toInt()))
context.contentResolver.registerContentObserver(TasksContract.authorityUri(source.authority), true, observer)
}
@After
fun tearDown() {
context.contentResolver.unregisterContentObserver(observer)
db.close()
}
private fun query(table: ProviderTable, selection: String? = null, args: Array<String>? = null): Cursor =
source.query(table, null, selection, args, null)
private fun titles(): List<String?> = query(ProviderTable.TASKS).use { cursor ->
val index = cursor.getColumnIndexOrThrow(Tasks.TITLE)
buildList { while (cursor.moveToNext()) add(cursor.getString(index)) }
}
private fun insert(title: String, due: Instant? = null, rrule: String? = null): Long =
db.tasks().insert(TaskEntity(listId = listId, uid = title, title = title, due = due, rrule = rrule, timezone = "UTC"))
/** Waits for the coalesced notification, then drains any stragglers. */
private fun awaitChange() {
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
Thread.sleep(700)
changes.clear()
}
private fun assertNoChange() {
assertThat(changes.poll(1500, TimeUnit.MILLISECONDS)).isNull()
}
@Test
fun servesTheOwnStore() {
val id = insert("Milk")
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 10))
assertThat(titles()).containsExactly("Milk")
query(ProviderTable.LISTS).use {
it.moveToFirst()
assertThat(it.getString(it.getColumnIndexOrThrow(Lists.NAME))).isEqualTo("Home")
}
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
query(ProviderTable.PROPERTIES).use { assertThat(it.count).isEqualTo(1) }
}
@Test
fun aDeletedTaskIsNotPublished() {
val id = insert("Gone")
insert("Kept")
db.tasks().markDeleted(id, clock.now())
assertThat(titles()).containsExactly("Kept")
}
@Test
fun aWriteNotifiesObserversAndIsServedNext() {
source.start()
assertThat(titles()).isEmpty()
insert("Later")
awaitChange()
assertThat(titles()).containsExactly("Later")
}
@Test
fun aBurstOfWritesNotifiesOnce() {
source.start()
titles()
repeat(5) { insert("t$it") }
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
assertNoChange()
}
@Test
fun externalModeServesNothing() {
insert("Mine")
resolver.storageMode = StorageMode.EXTERNAL
ProviderTable.entries.forEach { table -> query(table).use { assertThat(it.count).isEqualTo(0) } }
}
@Test
fun switchingModeNotifiesAndRebuilds() {
source.start()
insert("Mine")
assertThat(titles()).containsExactly("Mine")
changes.clear()
resolver.storageMode = StorageMode.EXTERNAL
awaitChange()
assertThat(titles()).isEmpty()
resolver.storageMode = StorageMode.OWN
awaitChange()
assertThat(titles()).containsExactly("Mine")
}
@Test
fun writesInExternalModeDoNotNotify() {
resolver.storageMode = StorageMode.EXTERNAL
source.start()
titles()
insert("Hidden")
assertNoChange()
}
@Test
fun aClientSelectionNarrowsTheRows() {
insert("a")
insert("b")
query(ProviderTable.TASKS, "${Tasks.TITLE} = ?", arrayOf("b")).use { assertThat(it.count).isEqualTo(1) }
}
@Test
fun anHourOldSnapshotIsRebuiltAgainstTheClock() {
source.start()
insert("Daily", due = Instant.parse("2026-10-10T09:00:00Z"), rrule = "FREQ=DAILY;COUNT=3")
awaitChange()
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-10T09:00:00Z").toEpochMilliseconds())
clock.current += 1.days
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-11T09:00:00Z").toEpochMilliseconds())
awaitChange()
}
@Test
fun aFreshSnapshotIsReused() {
insert("a")
titles()
clock.current += 30.minutes
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
assertNoChange()
}
private fun currentOriginalTime(): Long =
query(ProviderTable.INSTANCES, "${Instances.DISTANCE_FROM_CURRENT} = 0").use {
assertThat(it.moveToFirst()).isTrue()
it.getLong(it.getColumnIndexOrThrow(Instances.INSTANCE_ORIGINAL_TIME))
}
}
private class MutableClock(var current: Instant) : Clock {
override fun now(): Instant = current
}
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
}
@@ -0,0 +1,811 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
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.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
import org.junit.Assert.assertThrows
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.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.awaitCancellation
import kotlin.time.Instant
/** Writes from another app, applied to the own store through the published tables. */
@RunWith(AndroidJUnit4::class)
class ProviderWriterTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var db: TasksDatabase
private lateinit var resolver: ProviderResolver
private lateinit var source: ProviderSnapshotSource
private lateinit var writer: ProviderWriter
private var afterWrites = 0
private var home = 0L
/** Truncated to the store's granularity, and on the hour so occurrences land on round times. */
private val today = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() / 3_600_000 * 3_600_000)
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
resolver = ProviderResolver(NoProvider)
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {})
writer = ProviderWriter(db, RoomTasksDataSource(db), source, resolver, afterWrite = { afterWrites++ })
home = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
}
@After
fun tearDown() = db.close()
private fun task(id: Long) = db.tasks().entity(id)
private fun insert(vararg values: Pair<String, Any?>): Long =
writer.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home, *values))
private fun byId(table: ProviderTable, id: Long) = "${table.idColumn} = $id"
private fun synced(readOnly: Boolean = false): Long {
val account = db.accounts().insert(AccountEntity(displayName = "me", username = "me"))
return db.taskLists().insert(
TaskListEntity(name = "Work", color = 0, accountId = account, href = "https://s/w/", isReadOnly = readOnly),
)
}
private fun dailySeries(title: String = "Daily"): Long = db.tasks().insert(
TaskEntity(listId = home, uid = title, title = title, dtstart = today - 1.days, due = today - 1.days + 1.hours,
rrule = "FREQ=DAILY;COUNT=5", timezone = "UTC"),
)
/** Each instance of [taskId]'s series by its original time, with its task row and status. */
private fun instances(): List<Triple<Long, Long, Int>> =
source.rows(
ProviderTable.INSTANCES,
listOf(Instances.INSTANCE_ORIGINAL_TIME, Instances.TASK_ID, Tasks.STATUS),
null,
null,
).map { (original, taskId, status) -> Triple(original as Long, taskId as Long, (status as Long).toInt()) }
private fun instanceId(original: Instant): Long =
source.rows(
ProviderTable.INSTANCES,
listOf(Tasks.ID),
"${Instances.INSTANCE_ORIGINAL_TIME} = ?",
arrayOf(original.toEpochMilliseconds().toString()),
).single().single() as Long
// --- inserts ----------------------------------------------------------------
@Test
fun anInsertedTaskIsPublishedAndOwedASync() {
val id = insert(Tasks.TITLE to "Milk", Tasks.PRIORITY to 1)
val stored = task(id)!!
assertThat(stored.title).isEqualTo("Milk")
assertThat(stored.priority).isEqualTo(1)
assertThat(stored.isDirty).isTrue()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.TITLE), byId(ProviderTable.TASKS, id), null))
.containsExactly(listOf("Milk"))
assertThat(afterWrites).isEqualTo(1)
}
@Test
fun anInsertNeedsAListThatExistsAndIsWritable() {
assertThrows(IllegalArgumentException::class.java) { writer.insert(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x")) }
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.LIST_ID to 999L) }
val readOnly = synced(readOnly = true)
assertThrows(UnsupportedOperationException::class.java) { insert(Tasks.LIST_ID to readOnly) }
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun anOccurrenceCannotBeInsertedAsATask() {
val series = dailySeries()
assertThrows(IllegalArgumentException::class.java) {
insert(Tasks.ORIGINAL_INSTANCE_ID to series, Tasks.ORIGINAL_INSTANCE_TIME to today.toEpochMilliseconds())
}
}
@Test
fun aDuplicateUidIsRejected() {
insert(Tasks.UID to "same")
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.UID to "same") }
}
@Test
fun listsAndInstancesTakeNoInserts() {
assertThrows(UnsupportedOperationException::class.java) {
writer.insert(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "New"))
}
assertThrows(UnsupportedOperationException::class.java) { writer.insert(ProviderTable.INSTANCES, emptyMap()) }
assertThrows(UnsupportedOperationException::class.java) {
writer.update(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "x"), null, null)
}
assertThrows(UnsupportedOperationException::class.java) { writer.delete(ProviderTable.LISTS, null, null) }
}
@Test
fun anUnknownColumnIsRejected() {
assertThrows(IllegalArgumentException::class.java) { insert("etag" to "x") }
}
@Test
fun nothingIsWrittenInExternalMode() {
val id = insert(Tasks.TITLE to "Mine")
resolver.storageMode = StorageMode.EXTERNAL
assertThrows(IllegalStateException::class.java) { insert() }
assertThrows(IllegalStateException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), null, null)
}
assertThat(task(id)!!.title).isEqualTo("Mine")
}
// --- updates ----------------------------------------------------------------
@Test
fun anUpdateChangesTheRowsItsSelectionMatches() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("b"))
assertThat(count).isEqualTo(1)
assertThat(task(a)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(task(b)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(b)!!.completedAt).isNotNull()
}
@Test
fun anUpdateMatchingNothingChangesNothing() {
insert(Tasks.TITLE to "a")
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.TITLE} = 'zzz'", null)).isEqualTo(0)
}
@Test
fun aTaskInAReadOnlyListCannotChange() {
val readOnly = synced(readOnly = true)
val id = db.tasks().insert(TaskEntity(listId = readOnly, uid = "r", title = "theirs"))
assertThrows(UnsupportedOperationException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "mine"), byId(ProviderTable.TASKS, id), null)
}
assertThrows(UnsupportedOperationException::class.java) {
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
}
assertThat(task(id)!!.title).isEqualTo("theirs")
}
@Test
fun aTaskMovesBetweenLists() {
val work = synced()
val id = insert(Tasks.TITLE to "t")
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
assertThat(db.tasks().live().single { it.title == "t" }.listId).isEqualTo(work)
}
@Test
fun aParentMustBeInTheSameListAndNotADescendant() {
val work = synced()
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val elsewhere = insert(Tasks.LIST_ID to work, Tasks.TITLE to "elsewhere")
assertThat(task(child)!!.parentId).isEqualTo(parent)
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to elsewhere), byId(ProviderTable.TASKS, child), null)
}
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to child), byId(ProviderTable.TASKS, parent), null)
}
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.PARENT_ID to 999L) }
}
@Test
fun movingASeriesDropsTheOverridesItNoLongerGenerates() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).hasSize(1)
writer.update(
ProviderTable.TASKS,
mapOf(Tasks.DTSTART to (today + 2.hours).toEpochMilliseconds(), Tasks.DUE to (today + 3.hours).toEpochMilliseconds()),
byId(ProviderTable.TASKS, series),
null,
)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aClientCannotTouchSyncState() {
val work = synced()
val id = db.tasks().insert(
TaskEntity(listId = work, uid = "s", title = "t", href = "/s.ics", etag = "e", sequence = 2, unknownProperties = "X-A:1"),
)
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "new", "_sync_id" to "x", "sync1" to "y"), byId(ProviderTable.TASKS, id), null)
val stored = task(id)!!
assertThat(stored.title).isEqualTo("new")
assertThat(stored.href).isEqualTo("/s.ics")
assertThat(stored.etag).isEqualTo("e")
assertThat(stored.sequence).isEqualTo(2)
assertThat(stored.unknownProperties).isEqualTo("X-A:1")
assertThat(stored.isDirty).isTrue()
}
// --- instances --------------------------------------------------------------
@Test
fun completingAnOccurrenceTicksOffOnlyThatOne() {
val series = dailySeries()
val count = writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(count).isEqualTo(1)
assertThat(task(series)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
val override = db.tasks().overrides(series).single()
assertThat(override.recurrenceId).isEqualTo(today)
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
val statuses = instances().associate { it.first to it.third }
assertThat(statuses[today.toEpochMilliseconds()]).isEqualTo(TasksContract.STATUS_COMPLETED)
assertThat(statuses.filterKeys { it != today.toEpochMilliseconds() }.values.toSet())
.containsExactly(TasksContract.STATUS_NEEDS_ACTION)
}
@Test
fun anOccurrenceCanBeCancelledAndReopened() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 3), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.CANCELLED)
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun contentWrittenToAnOccurrenceEditsTheSeries() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun anOccurrenceCannotBeMoved() {
dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.DUE to 0L), byId(ProviderTable.INSTANCES, instanceId(today)), null)
}
}
@Test
fun anOccurrenceHasNoPartialProgress() {
dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50), byId(ProviderTable.INSTANCES, instanceId(today)), null)
}
}
@Test
fun aPlainTaskInstanceWritesTheTask() {
val id = insert(Tasks.TITLE to "t", Tasks.DUE to today.toEpochMilliseconds())
val instance = source.rows(ProviderTable.INSTANCES, listOf(Tasks.ID), "${Instances.TASK_ID} = $id", null).single().single() as Long
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50, Tasks.DUE to (today + 1.days).toEpochMilliseconds()), byId(ProviderTable.INSTANCES, instance), null)
assertThat(task(id)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(task(id)!!.due).isEqualTo(today + 1.days)
}
@Test
fun deletingAnOccurrenceExceptsIt() {
val series = dailySeries()
assertThat(writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)).isEqualTo(1)
assertThat(task(series)!!.exdate).isNotNull()
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
assertThat(instances()).hasSize(4)
}
@Test
fun deletingAModifiedOccurrenceExceptsItToo() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).isEmpty()
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
}
// --- deletes ----------------------------------------------------------------
@Test
fun aParentGoesWithItsSubtasks() {
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
val other = insert(Tasks.TITLE to "other")
assertThat(writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)).isEqualTo(1)
assertThat(task(parent)).isNull()
assertThat(task(child)).isNull()
assertThat(task(grandchild)).isNull()
assertThat(task(other)).isNotNull()
}
@Test
fun aSyncedTaskLeavesATombstone() {
val work = synced()
val id = insert(Tasks.LIST_ID to work, Tasks.TITLE to "t")
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
assertThat(task(id)!!.isDeleted).isTrue()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(0)
}
@Test
fun deletingAnOverrideThroughTasksExceptsTheOccurrence() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, override), null)
assertThat(task(series)).isNotNull()
assertThat(task(series)!!.exdate).isNotNull()
assertThat(instances()).hasSize(4)
}
// --- properties -------------------------------------------------------------
@Test
fun anAlarmCanBeAddedChangedAndRemoved() {
val id = insert(Tasks.TITLE to "t", Tasks.DUE to (today + 1.days).toEpochMilliseconds())
db.tasks().update(task(id)!!.copy(isDirty = false))
val propertyId = writer.insert(
ProviderTable.PROPERTIES,
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE, Alarm.MINUTES_BEFORE to "15", Alarm.REFERENCE to "1"),
)
val alarm = db.alarms().forTask(id).single()
assertThat(alarm.minutesBefore).isEqualTo(15)
assertThat(alarm.reference).isEqualTo(AlarmReference.DUE)
assertThat(task(id)!!.isDirty).isTrue()
assertThat(propertyId).isEqualTo(ProviderIds.alarm(alarm.id))
writer.update(ProviderTable.PROPERTIES, mapOf(Alarm.MINUTES_BEFORE to 60, Alarm.REFERENCE to 2), byId(ProviderTable.PROPERTIES, propertyId), null)
assertThat(db.alarms().forTask(id).single().minutesBefore).isEqualTo(60)
assertThat(db.alarms().forTask(id).single().reference).isEqualTo(AlarmReference.START)
writer.delete(ProviderTable.PROPERTIES, "${Properties.MIMETYPE} = ?", arrayOf(Alarm.MIMETYPE))
assertThat(db.alarms().forTask(id)).isEmpty()
}
@Test
fun tagsCanBeAddedRenamedAndRemoved() {
val id = insert(Tasks.TITLE to "t")
val propertyId = writer.insert(
ProviderTable.PROPERTIES,
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to " errand "),
)
assertThat(task(id)!!.categories).containsExactly("errand")
writer.update(ProviderTable.PROPERTIES, mapOf(Category.NAME to "shopping"), byId(ProviderTable.PROPERTIES, propertyId), null)
assertThat(task(id)!!.categories).containsExactly("shopping")
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to "shopping"))
assertThat(task(id)!!.categories).containsExactly("shopping")
writer.delete(ProviderTable.PROPERTIES, "${Category.NAME} = ?", arrayOf("shopping"))
assertThat(task(id)!!.categories).isEmpty()
}
@Test
fun aPropertyCannotChangeItsKindOrTask() {
val id = insert(Tasks.TITLE to "t")
val other = insert(Tasks.TITLE to "o")
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 5))
val propertyId = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), null, null).single().single() as Long
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.MIMETYPE to Category.MIMETYPE), byId(ProviderTable.PROPERTIES, propertyId), null)
}
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to other), byId(ProviderTable.PROPERTIES, propertyId), null)
}
}
@Test
fun onlyAlarmsAndTagsCanBeWritten() {
val id = insert(Tasks.TITLE to "t")
assertThrows(IllegalArgumentException::class.java) {
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to "vnd.android.cursor.item/comment"))
}
assertThrows(IllegalArgumentException::class.java) {
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE))
}
}
// --- batches ----------------------------------------------------------------
@Test
fun aBatchIsAllOrNothing() {
assertThrows(IllegalArgumentException::class.java) {
writer.batch {
insert(Tasks.TITLE to "first")
insert(Tasks.TITLE to "second")
insert(Tasks.LIST_ID to 999L)
}
}
assertThat(db.tasks().live()).isEmpty()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
}
@Test
fun aBatchSeesItsOwnWritesAndReArmsRemindersOnce() {
writer.batch {
insert(Tasks.TITLE to "first")
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("first"))
}
assertThat(db.tasks().live().single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(afterWrites).isEqualTo(1)
}
// --- review regressions -----------------------------------------------------
@Test
fun aBatchDeletingTwoPropertiesDeletesThoseTwo() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
listOf(5, 10).forEach { db.alarms().insert(TaskAlarmEntity(taskId = a, minutesBefore = it)) }
db.alarms().insert(TaskAlarmEntity(taskId = b, minutesBefore = 15))
val ids = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), "${Properties.TASK_ID} = $a", null)
.map { it.single() as Long }
writer.batch { ids.forEach { writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, it), null) } }
assertThat(db.alarms().forTask(a)).isEmpty()
assertThat(db.alarms().forTask(b).map { it.minutesBefore }).containsExactly(15)
}
@Test
fun aBatchDeletingTwoOccurrencesDeletesThoseTwo() {
dailySeries()
val first = instanceId(today)
val second = instanceId(today + 1.days)
writer.batch {
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, first), null)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, second), null)
}
assertThat(instances().map { it.first }).containsExactly(
(today - 1.days).toEpochMilliseconds(),
(today + 2.days).toEpochMilliseconds(),
(today + 3.days).toEpochMilliseconds(),
)
}
@Test
fun anInstanceIdOutlivesAnotherOccurrencesDeletion() {
dailySeries()
val later = instanceId(today + 2.days)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(instanceId(today + 2.days)).isEqualTo(later)
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, later), null))
.isEqualTo(1)
assertThat(instances().single { it.third == TasksContract.STATUS_COMPLETED }.first)
.isEqualTo((today + 2.days).toEpochMilliseconds())
}
@Test
fun aRecurringSubtaskOutsideTheWindowGoesWithItsParent() {
val parent = insert(Tasks.TITLE to "parent")
val longAgo = today - 1000.days
val subtask = db.tasks().insert(
TaskEntity(listId = home, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2",
timezone = "UTC", parentId = parent),
)
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
assertThat(task(subtask)).isNull()
}
@Test
fun anOverriddenRecurringSubtaskGoesWholeWithItsParent() {
val parent = insert(Tasks.TITLE to "parent")
val subtask = dailySeries("sub")
db.tasks().update(task(subtask)!!.copy(parentId = parent))
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
assertThat(task(subtask)).isNull()
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun aModifiedOccurrenceCanBeWrittenBackAsRead() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
val row = readRow(ProviderTable.TASKS, override)
writer.update(ProviderTable.TASKS, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.TASKS, override), null)
assertThat(task(override)!!.title).isEqualTo("Renamed")
}
@Test
fun anInstanceCanBeWrittenBackAsReadToCompleteIt() {
val series = dailySeries()
val instance = instanceId(today)
val row = readRow(ProviderTable.INSTANCES, instance)
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instance), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(series)!!.dtstart).isEqualTo(today - 1.days)
}
@Test
fun movingASubtreeMovesItsSubtasks() {
val work = synced()
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, parent), null)
assertThat(listOf(parent, child, grandchild).map { task(it)!!.listId }).containsExactly(work, work, work)
assertThat(task(child)!!.parentId).isEqualTo(parent)
assertThat(task(grandchild)!!.parentId).isEqualTo(child)
}
@Test
fun aMoveOntoATakenUidIsRejected() {
val work = synced()
insert(Tasks.LIST_ID to work, Tasks.UID to "same")
val id = insert(Tasks.UID to "same")
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
}
assertThat(task(id)!!.listId).isEqualTo(home)
}
@Test
fun contentOnAModifiedOccurrenceStillEditsTheSeries() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(task(override)!!.title).isEqualTo("Renamed")
assertThat(task(override)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aWriteThatChangesNothingOwesNoSync() {
val id = insert(Tasks.TITLE to "t")
db.tasks().update(task(id)!!.copy(isDirty = false))
val modified = task(id)!!.lastModified
writer.update(ProviderTable.TASKS, readRow(ProviderTable.TASKS, id), byId(ProviderTable.TASKS, id), null)
writer.update(ProviderTable.TASKS, emptyMap(), byId(ProviderTable.TASKS, id), null)
assertThat(task(id)!!.isDirty).isFalse()
assertThat(task(id)!!.lastModified).isEqualTo(modified)
}
@Test
fun reopeningAnOpenOccurrenceForksNothing() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aWriteBeforeStartupSettlesIsRefused() {
val stuck = ProviderSnapshotSource(context, db, resolver, awaitStartup = { awaitCancellation() }, startupWait = 50.milliseconds)
val early = ProviderWriter(db, RoomTasksDataSource(db), stuck, resolver, afterWrite = {})
assertThrows(IllegalStateException::class.java) { early.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home)) }
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun aSingleRowWriteNeedsNoSnapshot() {
val id = insert(Tasks.TITLE to "t")
val unknown = 987_654L
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, unknown), null)).isEqualTo(0)
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(1)
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.INSTANCES, unknown), null)).isEqualTo(0)
assertThat(writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, unknown), null)).isEqualTo(0)
}
// --- second review regressions -----------------------------------------------
@Test
fun tickingOffAModifiedOccurrenceAsReadLeavesTheSeriesAlone() {
val series = dailySeries("Standup")
// Reopening forks an override, which then gets its own title as the app's "this occurrence" edit would.
store().setCompletedInstance(series, today, false)
val override = db.tasks().overrides(series).single()
db.tasks().update(override.copy(title = "Standup (moved room)"))
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Standup")
assertThat(task(override.id)!!.title).isEqualTo("Standup (moved room)")
assertThat(task(override.id)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun anInstanceWrittenBackWithOnlyProgressChangedIsCompleted() {
val series = dailySeries()
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.PERCENT_COMPLETE to 100), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aBadZoneOnAnInstanceReportsTheZone() {
dailySeries()
val instance = instanceId(today)
val error = assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TZ to "Europe/Berln"), byId(ProviderTable.INSTANCES, instance), null)
}
assertThat(error).hasMessageThat().contains("Unknown time zone")
}
@Test
fun anInProcessSeriesCanBeWrittenBackAsRead() {
val series = dailySeries()
db.tasks().update(task(series)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 50))
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(task(series)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aFloatingSeriesCanBeWrittenBackAsRead() {
val series = db.tasks().insert(
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
)
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(series)!!.timezone).isNull()
}
@Test
fun aParentLoopDeletesInsteadOfRecursingForever() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b", Tasks.PARENT_ID to a)
db.tasks().update(task(a)!!.copy(parentId = b))
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, a), null)
assertThat(task(a)).isNull()
assertThat(task(b)).isNull()
}
@Test
fun aBulkMoveWorksWhicheverOrderTheRowsComeIn() {
val work = synced()
// The subtask gets the lower id, so it comes first unless the writer reorders.
val child = insert(Tasks.TITLE to "child")
val parent = insert(Tasks.TITLE to "parent")
db.tasks().update(task(child)!!.copy(parentId = parent))
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), "${Tasks.LIST_ID} = ?", arrayOf(home.toString()))
assertThat(count).isEqualTo(2)
assertThat(task(child)!!.listId).isEqualTo(work)
assertThat(task(parent)!!.listId).isEqualTo(work)
assertThat(task(child)!!.parentId).isEqualTo(parent)
}
@Test
fun aRowSelectedWithAnArgumentIsWritten() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID}=?", arrayOf(b.toString()))).isEqualTo(1)
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID} = ?", arrayOf("987654"))).isEqualTo(0)
assertThat(task(a)!!.title).isEqualTo("a")
assertThat(task(b)!!.title).isEqualTo("x")
}
@Test
fun garbageExceptionDatesAreRejected() {
val series = dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.EXDATE to "tomorrow"), byId(ProviderTable.TASKS, series), null)
}
assertThat(task(series)!!.exdate).isNull()
}
private fun store() = RoomTasksDataSource(db)
/** Every column of one published row, as a client that read it would write it back. */
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
val columns = table.columns.map { it.first }
val row = source.rows(table, columns, byId(table, id), null).single()
return columns.zip(row).toMap() - table.idColumn
}
}
private object NoProvider : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = null
override fun isGranted(permission: String): Boolean = false
override fun appLabel(packageName: String): String? = null
}
@@ -0,0 +1,141 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.Cursor
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/** The SQL a client sends runs against the snapshot as it would against OpenTasks. */
@RunWith(AndroidJUnit4::class)
class SnapshotDatabaseTest {
private val db = SnapshotDatabase()
private fun task(id: Long, title: String?, due: Long? = null): ProviderRow =
mapOf(Tasks.ID to id, Tasks.LIST_ID to 1L, Tasks.TITLE to title, Tasks.DUE to due, Tasks.PRIORITY to 0)
private fun load(vararg tasks: ProviderRow, lists: List<ProviderRow> = emptyList()) = db.load(
ProviderSnapshot(
ProviderTable.entries.associateWith {
when (it) {
ProviderTable.TASKS -> tasks.toList()
ProviderTable.LISTS -> lists
else -> emptyList()
}
},
),
)
private fun Cursor.strings(column: String): List<String?> = use {
val index = getColumnIndexOrThrow(column)
buildList { while (moveToNext()) add(if (isNull(index)) null else getString(index)) }
}
@Test
fun aNullProjectionReturnsEveryColumnInTableOrder() {
load(task(1, "a"))
db.query(ProviderTable.TASKS, null, null, null, null).use {
assertThat(it.columnNames.toList()).containsExactlyElementsIn(ProviderTable.TASKS.columns.map { c -> c.first })
.inOrder()
assertThat(it.count).isEqualTo(1)
}
}
@Test
fun selectionArgumentsAndSortOrderApply() {
load(task(1, "b", due = 30), task(2, "a", due = 10), task(3, "c", due = 20), task(4, "skip", due = 5))
val titles = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), "${Tasks.TITLE} != ?", arrayOf("skip"), "${Tasks.DUE} ASC")
.strings(Tasks.TITLE)
assertThat(titles).containsExactly("a", "c", "b").inOrder()
}
@Test
fun typesSurviveTheCopy() {
load(task(1, null, due = 1_700_000_000_000))
db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE, Tasks.DUE, Tasks.PRIORITY), null, null, null).use {
it.moveToFirst()
assertThat(it.isNull(0)).isTrue()
assertThat(it.getType(1)).isEqualTo(Cursor.FIELD_TYPE_INTEGER)
assertThat(it.getLong(1)).isEqualTo(1_700_000_000_000)
assertThat(it.getInt(2)).isEqualTo(0)
}
}
@Test
fun anUnsupportedColumnReadsAsNull() {
load(task(1, "a"))
assertThat(db.query(ProviderTable.TASKS, arrayOf("_sync_id"), null, null, null).strings("_sync_id"))
.containsExactly(null)
}
@Test
fun anUnknownProjectionColumnIsRejected() {
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, arrayOf("no_such_column"), null, null, null)
}
}
@Test
fun anUnknownSelectionColumnIsRejected() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, null, "sqlite_master.name IS NOT NULL", null, null)
}
}
@Test
fun aSelectionCannotBreakOutOfItsWhereClause() {
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, null, "1) UNION SELECT name FROM sqlite_master --", null, null)
}
}
@Test
fun eachTableAnswersOnlyForItsOwnRows() {
load(task(1, "a"), lists = listOf(mapOf(Lists.ID to 7L, Lists.NAME to "Home")))
assertThat(db.query(ProviderTable.LISTS, arrayOf(Lists.NAME), null, null, null).strings(Lists.NAME))
.containsExactly("Home")
db.query(ProviderTable.INSTANCES, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
}
@Test
fun aReloadReplacesTheRowsButNotAnOpenCursor() {
load(task(1, "old"))
val before = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null)
load(task(2, "new"))
assertThat(before.strings(Tasks.TITLE)).containsExactly("old")
assertThat(db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null).strings(Tasks.TITLE))
.containsExactly("new")
}
@Test
fun anEmptySnapshotEmptiesEveryTable() {
load(task(1, "a"))
db.load(ProviderSnapshot.EMPTY)
ProviderTable.entries.forEach { table ->
db.query(table, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
}
}
}
@@ -539,4 +539,47 @@ class RoomTasksDataSourceTest {
assertThat(thrown).isNotNull()
}
@Test
fun subtaskIdsNameRowsNotOccurrences() {
val parent = source.insertTask(form("parent"))
val longAgo = now - 1000.days
val outOfWindow = db.tasks().insert(
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
)
val series = db.tasks().insert(
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
)
source.setCompletedInstance(series, now, true)
val plain = source.insertTask(form("plain").copy(parentId = parent))
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
}
@Test
fun deletingASubtreeEndsOnAParentLoop() {
val a = source.insertTask(form("a"))
val b = source.insertTask(form("b").copy(parentId = a))
val c = source.insertTask(form("c").copy(parentId = b))
db.tasks().update(db.tasks().entity(a)!!.copy(parentId = c))
source.deleteWithSubtasks(a)
assertThat(listOf(a, b, c).mapNotNull { db.tasks().entity(it) }).isEmpty()
}
@Test
fun deletingFollowingFromTheFirstOccurrenceTakesTheWholeSubtree() {
val start = now
val series = db.tasks().insert(
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3"),
)
val child = source.insertTask(form("child").copy(parentId = series))
val grandchild = source.insertTask(form("grandchild").copy(parentId = child))
source.deleteFollowing(series, start)
assertThat(listOf(series, child, grandchild).mapNotNull { db.tasks().entity(it) }).isEmpty()
}
}
@@ -8,27 +8,7 @@ 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
* }
* ```
*/
/** Migrations, proven against the committed schemas in `app/schemas/`. */
@RunWith(AndroidJUnit4::class)
class TasksDatabaseMigrationTest {
@@ -62,6 +42,39 @@ class TasksDatabaseMigrationTest {
}
}
@Test
fun liftsCategoriesOutOfTheResidueWithoutDirtyingTheTask() {
helper.createDatabase(TEST_DB, 1).use { db ->
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Work', 0)")
db.execSQL(
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
arrayOf<Any?>(1, "uid-1", "Claimed", "X-FOO:bar\r\nCATEGORIES:Work,Home\r\n"),
)
db.execSQL(
"INSERT INTO tasks (id, list_id, uid, title, unknown_properties) VALUES (?, 1, ?, ?, ?)",
arrayOf<Any?>(2, "uid-2", "Split", "CATEGORIES:a\r\nCATEGORIES:b\r\n"),
)
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (3, 1, 'uid-3', 'Untagged')")
}
helper.runMigrationsAndValidate(TEST_DB, 2, true, TasksDatabase.MIGRATION_1_2).use { db ->
db.query("SELECT categories, unknown_properties, is_dirty FROM tasks ORDER BY id").use { c ->
c.moveToNext()
assertThat(c.getString(0)).isEqualTo("Work,Home")
assertThat(c.getString(1)).doesNotContain("CATEGORIES")
assertThat(c.getString(1)).contains("X-FOO:bar")
assertThat(c.getInt(2)).isEqualTo(0)
c.moveToNext()
assertThat(c.getString(0)).isEqualTo("a,b")
assertThat(c.getString(1)).isEqualTo("CATEGORIES:a\r\nCATEGORIES:b\r\n")
c.moveToNext()
assertThat(c.getString(0)).isEmpty()
}
}
}
private companion object {
const val TEST_DB = "migration-test.db"
}
@@ -107,6 +107,17 @@ class ExternalImportTest {
assertThat(importer.hasRun.first()).isTrue()
}
@Test
fun copiesTagsCleanedUp() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home"))))
importer.run()
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories)
.containsExactly("Shop", "Home").inOrder()
}
/** Every list arrives device-only: the account belongs to the sync app. */
@Test
fun importedListsAreDeviceOnly() = runBlocking {
@@ -241,6 +252,7 @@ class ExternalImportTest {
title: String,
uid: String? = "uid-$id",
parentId: Long? = null,
tags: List<String> = emptyList(),
) = ExportTask(
taskId = id,
uid = uid,
@@ -260,6 +272,7 @@ class ExternalImportTest {
rrule = null,
rdate = null,
parentId = parentId,
tags = tags,
)
private companion object {
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- CalDAV sync and UnifiedPush; everything here needs the network. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 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" />
<queries>
<!-- 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 android:networkSecurityConfig="@xml/network_security_config">
<!-- 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>
</application>
</manifest>
@@ -0,0 +1,131 @@
package de.jeanlucmakiola.agendula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
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.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import javax.inject.Provider
import javax.inject.Singleton
/** 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"
/** CalDAV sync: the `full` flavor's side of the seams `main` declares. */
@Module
@InstallIn(SingletonComponent::class)
abstract class SyncBindModule {
@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
@Binds
@Singleton
abstract fun bindRemoteLists(impl: RemoteListRepository): RemoteLists
@Binds
@Singleton
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
@Binds
@IntoSet
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher
}
@Module
@InstallIn(SingletonComponent::class)
object SyncProvideModule {
@Provides
@Singleton
@CredentialsDataStore
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.credentialsDataStore
@Provides
@Singleton
@SyncStateDataStore
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.syncStateDataStore
/**
* 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.
*/
@Provides
@IntoSet
fun syncOnOpenHook(
accounts: Provider<AccountRepository>,
syncTrigger: Provider<SyncTrigger>,
pendingLoginFlows: Provider<PendingLoginFlowStore>,
push: Provider<PushRegistrar>,
): LaunchHook = LaunchHook {
runCatching {
accounts.get().rescheduleAll()
accounts.get().syncable().forEach { syncTrigger.get().enqueue(it.displayName) }
// A login flow the previous process died in the middle of.
// Its password, if the user approved, exists nowhere else.
pendingLoginFlows.get().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.get().updateAll() }
}
}
@@ -0,0 +1,34 @@
package de.jeanlucmakiola.agendula.data.di
import javax.inject.Qualifier
/**
* 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
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
@@ -53,38 +54,7 @@ class RemoteListRepository @Inject constructor(
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
}
) : RemoteLists {
/**
* The accounts a new list may be created on, freshest answer first.
@@ -95,7 +65,7 @@ class RemoteListRepository @Inject constructor(
* 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) {
override 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.
@@ -120,7 +90,7 @@ class RemoteListRepository @Inject constructor(
*
* @return the new list's local id, or why there is none.
*/
suspend fun create(
override suspend fun create(
accountId: Long,
name: String,
color: Int,
@@ -189,7 +159,7 @@ class RemoteListRepository @Inject constructor(
* 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) {
override 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
@@ -227,7 +197,7 @@ class RemoteListRepository @Inject constructor(
* 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) {
override 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
@@ -14,6 +14,7 @@ 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.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.R
import javax.inject.Inject
import javax.inject.Singleton
@@ -33,7 +34,7 @@ import javax.inject.Singleton
@Singleton
class SyncNoticeNotifier @Inject constructor(
@ApplicationContext private val context: Context,
) {
) : ChannelRefresher {
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
@@ -123,7 +124,7 @@ class SyncNoticeNotifier @Inject constructor(
* 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)
MainActivity.openIntent(context).putExtra(MainActivity.EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
private fun ensureSignInChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
@@ -170,16 +171,13 @@ class SyncNoticeNotifier @Inject constructor(
}
/** Re-create the channel, if it exists, in the current language. */
fun refreshChannel() {
override 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"
@@ -35,7 +35,11 @@ import kotlin.time.Duration.Companion.seconds
@Singleton
class SyncTrigger @Inject constructor(
@ApplicationContext private val context: Context,
) {
) : SyncRequests {
override fun syncNow(accountName: String) {
enqueue(accountName)
}
/**
* Starts a sync now.
@@ -70,6 +70,7 @@ internal fun AccountDetailScreen(
onSignInAgain: (AccountEntity) -> Unit,
onOpenStorage: () -> Unit,
viewModel: AccountsViewModel,
visible: Boolean,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
@@ -90,7 +91,7 @@ internal fun AccountDetailScreen(
// shown on the accounts list rather than here.
var confirming by remember { mutableStateOf(false) }
CollapsingScaffold(title = identity.title, onBack = onBack) {
CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) {
AccountHero(identity)
Spacer(Modifier.height(24.dp))
@@ -48,6 +48,7 @@ internal fun AccountsScreen(
onOpenStorage: () -> Unit,
onBack: () -> Unit,
viewModel: AccountsViewModel,
visible: Boolean,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
@@ -60,6 +61,8 @@ internal fun AccountsScreen(
CollapsingScaffold(
title = stringResource(R.string.settings_section_accounts),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
val loaded = accounts ?: return@CollapsingScaffold
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.ui.onboarding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
/** The inline wizard's shortest length, before a provider is picked. */
internal const val ACCOUNT_STEP_SLOTS = ADD_ACCOUNT_STEPS
/** [OnboardingStep.Account]: the add-account wizard, given the whole screen. */
@Composable
internal fun AccountStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
// The same instance the wizard resolves for itself, so its length can be
// read here without keeping a second copy of its state.
val account: AddAccountViewModel = hiltViewModel()
val accountState by account.state.collectAsStateWithLifecycle()
// ⚠️ The wizard grows a step for a provider that needs an app password
// minted first, and the outer bar has to grow with it — otherwise first
// run counts one flow while the screen inside it counts another.
LaunchedEffect(accountState.totalSteps) {
viewModel.onAccountStepsChanged(accountState.totalSteps)
}
AddAccountScreen(
onDone = { viewModel.onAccountFinished(added = true) },
onBack = { viewModel.onAccountFinished(added = false) },
stepOffset = state.position - 1,
totalSteps = state.total,
viewModel = account,
)
}
@@ -0,0 +1,81 @@
package de.jeanlucmakiola.agendula.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
/** Settings → Accounts and the screens under it, layered over the hub. */
@Composable
internal fun AccountSections(
section: SettingsSection?,
initialAccountId: Long?,
onSection: (SettingsSection?) -> Unit,
) {
// Hoisted so the add flow can refresh the list it returns to.
val accountsViewModel: AccountsViewModel = hiltViewModel()
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
val signInAgain: (AccountEntity) -> Unit = { account ->
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
onSection(SettingsSection.AddAccount)
}
// Which account the detail screen is showing; the section alone cannot say.
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
// Accounts stays composed under Add account, for the same reason Backup
// stays composed under export and import: the deeper screen slides over it.
val accountsOpen = section == SettingsSection.Accounts ||
section?.parent == SettingsSection.Accounts
SlideInSection(visible = accountsOpen) {
AccountsScreen(
onAddAccount = {
// A sign-in abandoned half-way must not turn this into one.
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
onSection(SettingsSection.AddAccount)
},
onSignInAgain = signInAgain,
onOpenAccount = {
openAccount = it
onSection(SettingsSection.Account)
},
onOpenStorage = { onSection(SettingsSection.Storage) },
onBack = { onSection(null) },
viewModel = accountsViewModel,
visible = accountsOpen,
)
}
val accountOpen = section == SettingsSection.Account
SlideInSection(visible = accountOpen) {
openAccount?.let { id ->
AccountDetailScreen(
accountId = id,
onBack = { onSection(SettingsSection.Accounts) },
onRemoved = { onSection(SettingsSection.Accounts) },
onSignInAgain = signInAgain,
onOpenStorage = { onSection(SettingsSection.Storage) },
viewModel = accountsViewModel,
visible = accountOpen,
)
}
}
SlideInSection(visible = section == SettingsSection.AddAccount) {
AddAccountScreen(
// The list stays composed underneath, so nothing re-runs its
// init and OnResume never fires on a section change. It no longer
// needs to: the accounts come from an observed query, so a new
// account — and every later sync — arrives on its own.
onDone = { onSection(SettingsSection.Accounts) },
onBack = { onSection(SettingsSection.Accounts) },
viewModel = addAccountViewModel,
)
}
}
+74 -76
View File
@@ -9,35 +9,25 @@
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. -->
directory. -->
<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" />
<!-- What another app requests to read or write the own store through
AgendulaTaskProvider. Dangerous, like OpenTasks' own: the user grants
them per app. Suffixed per build, so debug and release can coexist. -->
<permission
android:name="${applicationId}.permission.READ_TASKS"
android:description="@string/permission_read_tasks_description"
android:label="@string/permission_read_tasks_label"
android:protectionLevel="dangerous" />
<permission
android:name="${applicationId}.permission.WRITE_TASKS"
android:description="@string/permission_write_tasks_description"
android:label="@string/permission_write_tasks_label"
android:protectionLevel="dangerous" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -55,23 +45,17 @@
<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
android:name=".AgendulaApp"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
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"
@@ -185,6 +169,58 @@
android:resource="@xml/appwidget_info_tasks" />
</receiver>
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
<receiver
android:name=".widget.TodayWidgetReceiver"
android:label="@string/widget_today_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_today" />
</receiver>
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
<receiver
android:name=".widget.UpNextWidgetReceiver"
android:label="@string/widget_up_next_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_up_next" />
</receiver>
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
<receiver
android:name=".widget.ListsWidgetReceiver"
android:label="@string/widget_lists_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_lists" />
</receiver>
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
<receiver
android:name=".widget.WeekWidgetReceiver"
android:label="@string/widget_week_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_week" />
</receiver>
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
<activity
android:name=".widget.TaskWidgetConfigActivity"
@@ -211,53 +247,15 @@
</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. -->
<!-- The own store under the OpenTasks contract. Empty, and refusing
writes, while External mode is active. -->
<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:name=".data.tasks.provider.AgendulaTaskProvider"
android:authorities="${applicationId}.tasks"
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>
android:readPermission="${applicationId}.permission.READ_TASKS"
android:writePermission="${applicationId}.permission.WRITE_TASKS"
tools:ignore="SystemPermissionTypo" />
<!-- WorkManager's on-demand initialisation. Removing the default
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
@@ -10,12 +10,13 @@ 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.di.ChannelRefresher
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.provider.ProviderSnapshotSource
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
import de.jeanlucmakiola.floret.crash.CrashConfig
@@ -87,6 +88,7 @@ class AgendulaApp : Application(), Configuration.Provider {
scheduler.sync()
}
runCatching { entryPoint.taskWidgetUpdater().start() }
runCatching { entryPoint.providerSnapshotSource().start() }
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
}
}
@@ -105,7 +107,7 @@ class AgendulaApp : Application(), Configuration.Provider {
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
runCatching {
entryPoint.taskNotifier().refreshChannel()
entryPoint.syncNoticeNotifier().refreshChannel()
entryPoint.channelRefreshers().forEach { it.refreshChannel() }
}
}
@@ -120,7 +122,8 @@ class AgendulaApp : Application(), Configuration.Provider {
fun applicationScope(): CoroutineScope
fun databaseCheckpoint(): DatabaseCheckpoint
fun taskNotifier(): TaskNotifier
fun syncNoticeNotifier(): SyncNoticeNotifier
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun taskWidgetUpdater(): TaskWidgetUpdater
fun providerSnapshotSource(): ProviderSnapshotSource
}
}
@@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
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
@@ -33,10 +32,6 @@ 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
@@ -55,14 +50,6 @@ class MainActivity : ComponentActivity() {
@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.
@@ -94,34 +81,9 @@ class MainActivity : ComponentActivity() {
// Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
// 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.
// Each hook on its own, so one waiting on the network holds up no other.
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) } } }
launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -180,12 +142,18 @@ class MainActivity : ComponentActivity() {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
/** 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"
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 ACTION_OPEN_FILTER = "de.jeanlucmakiola.agendula.action.OPEN_FILTER"
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
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 EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L
@@ -206,9 +174,14 @@ class MainActivity : ComponentActivity() {
.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)
/**
* The launcher shortcut's, Quick Settings tile's and a widget's "New
* task". [listId] preset the list when it names exactly one; the data
* URI keeps each widget's PendingIntent apart from the others.
*/
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
fun todayIntent(context: Context): Intent =
@@ -227,19 +200,31 @@ class MainActivity : ComponentActivity() {
.putExtra(EXTRA_LIST_ID, listId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** Opens one saved filter. */
fun filterIntent(context: Context, filterId: Long): Intent =
Intent(ACTION_OPEN_FILTER, "agendula://filter/$filterId".toUri(), context, MainActivity::class.java)
.putExtra(EXTRA_FILTER_ID, filterId)
.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
if (BuildConfig.SYNC_ENABLED) {
intent.getLongExtra(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_NEW_TASK -> return NavRequest.NewTask(
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
)
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) }
ACTION_OPEN_FILTER -> intent.getLongExtra(EXTRA_FILTER_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenFilter(it) }
}
importIntentUri(intent)?.let { return NavRequest.Import(it) }
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.agendula.data.di
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
/**
* A notification channel a build variant adds, re-created in the current
* language on start and on a configuration change. The `full` flavor
* contributes the sync notices; `offline` has none.
*/
fun interface ChannelRefresher {
fun refreshChannel()
}
@Module
@InstallIn(SingletonComponent::class)
abstract class ChannelRefresherModule {
@Multibinds
abstract fun channelRefreshers(): Set<ChannelRefresher>
}
@@ -12,13 +12,6 @@ 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
@@ -27,7 +20,6 @@ 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
@@ -51,37 +43,13 @@ import javax.inject.Singleton
* 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() }
internal 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 {
@@ -94,22 +62,6 @@ abstract class DataBindModule {
@Singleton
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.
@@ -128,18 +80,6 @@ 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 =
@@ -149,6 +89,7 @@ object DataProvideModule {
// 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)
.addMigrations(TasksDatabase.MIGRATION_1_2)
.build()
/**
@@ -7,9 +7,8 @@ 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.
* Something a build variant runs on a genuine app open (not on a configuration
* change): sync in the `full` flavor, the demo seeder in debug builds.
*/
fun interface LaunchHook {
suspend fun onLaunch(intent: Intent)
@@ -17,37 +17,6 @@ annotation class IoDispatcher
@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
@@ -0,0 +1,83 @@
package de.jeanlucmakiola.agendula.data.prefs
import de.jeanlucmakiola.agendula.domain.DueWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.TagMatch
import java.net.URLDecoder
import java.net.URLEncoder
/**
* Stored form of the saved filters: one line per filter,
* `id|name|due|lists|tags|match|priority`, with name and tags URL-encoded and
* list ids qualified by the store they belong to (`OWN:3`), since each store
* numbers its lists on its own. Fixed once released — don't change without a
* data migration.
*/
internal object SavedFilterCodec {
/** The filters in [raw], with only the list ids of [mode]'s store. */
fun decode(raw: String?, mode: String): List<SavedFilter> =
lines(raw).mapNotNull { runCatching { decodeLine(it, mode) }.getOrNull() }
/** [raw] with [filter] replacing the line of the same id, or appended. */
fun upsert(raw: String?, filter: SavedFilter, mode: String): String {
val existing = lines(raw)
val index = existing.indexOfFirst { idOf(it) == filter.id }
// Keep the lists another store contributed, so switching stores and back
// doesn't widen a filter that was edited in between, unless "any list"
// was chosen here, which drops them on purpose.
val foreign = if (filter.anyList) {
emptyList()
} else {
existing.getOrNull(index)?.let(::fields)?.getOrNull(3)
?.let(::splitList)?.filterNot { it.startsWith("$mode:") }.orEmpty()
}
val line = encodeLine(filter, mode, foreign)
val updated = if (index >= 0) existing.toMutableList().also { it[index] = line } else existing + line
return updated.joinToString("\n")
}
fun remove(raw: String?, id: Long): String = lines(raw).filterNot { idOf(it) == id }.joinToString("\n")
private fun lines(raw: String?): List<String> = raw.orEmpty().split('\n').filter { it.isNotBlank() }
private fun fields(line: String): List<String> = line.split('|')
private fun idOf(line: String): Long? = fields(line).firstOrNull()?.toLongOrNull()
private fun splitList(value: String): List<String> = value.split(',').filter { it.isNotEmpty() }
private fun encodeLine(filter: SavedFilter, mode: String, foreignLists: List<String>): String =
listOf(
filter.id.toString(),
encode(filter.name),
filter.due.sorted().joinToString(",") { it.name },
(filter.listIds.sorted().map { "$mode:$it" } + foreignLists).joinToString(","),
filter.tags.sorted().joinToString(",") { encode(it) },
filter.tagMatch.name,
filter.minPriority.name,
).joinToString("|")
private fun decodeLine(line: String, mode: String): SavedFilter? {
val f = fields(line)
if (f.size < 7) return null
val id = f[0].toLongOrNull() ?: return null
val lists = splitList(f[3])
val here = lists.filter { it.startsWith("$mode:") }.mapNotNull { it.removePrefix("$mode:").toLongOrNull() }.toSet()
return SavedFilter(
id = id,
name = decode(f[1]),
due = splitList(f[2]).mapNotNull { runCatching { DueWindow.valueOf(it) }.getOrNull() }.toSet(),
listIds = here,
listsElsewhere = lists.any { !it.startsWith("$mode:") },
tags = splitList(f[4]).map(::decode).toSet(),
tagMatch = runCatching { TagMatch.valueOf(f[5]) }.getOrDefault(TagMatch.ANY),
minPriority = runCatching { Priority.valueOf(f[6]) }.getOrDefault(Priority.NONE),
)
}
private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
private fun decode(value: String): String = URLDecoder.decode(value, Charsets.UTF_8.name())
}
@@ -10,6 +10,7 @@ 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.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
@@ -20,6 +21,7 @@ import de.jeanlucmakiola.floret.reminders.applyReminderOverride
import de.jeanlucmakiola.floret.reminders.normalizeReminders
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import java.time.DayOfWeek
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@@ -95,6 +97,8 @@ data class Settings(
val pushEnabled: Boolean = true,
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
val hiddenFromSmartLists: Set<Long> = emptySet(),
/** Pre-fill a new task's start with today's date. */
val defaultStartToday: Boolean = false,
) {
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
@@ -153,6 +157,7 @@ class SettingsPrefs @Inject constructor(
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
.toSet()
},
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
)
}
@@ -166,6 +171,8 @@ class SettingsPrefs @Inject constructor(
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
suspend fun setDefaultStartToday(enabled: Boolean) = dataStore.edit { it[DEFAULT_START_TODAY] = enabled }
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
@@ -254,6 +261,34 @@ class SettingsPrefs @Inject constructor(
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
}
/** The saved filters, oldest first, with the list ids of the current store. */
val savedFilters: Flow<List<SavedFilter>> =
dataStore.data.map { p -> p[SAVED_FILTERS] to p[STORAGE_MODE] }
.distinctUntilChanged()
.map { (raw, mode) -> SavedFilterCodec.decode(raw, (storedMode(mode) ?: resolver.autoMode()).name) }
.distinctUntilChanged()
/** One saved filter; `null` while it doesn't exist. */
fun savedFilter(id: Long): Flow<SavedFilter?> =
savedFilters.map { all -> all.firstOrNull { it.id == id } }.distinctUntilChanged()
/** Store [filter]; an id of 0 makes a new one. Returns the filter's id. */
suspend fun saveFilter(filter: SavedFilter): Long {
var id = filter.id
dataStore.edit { p ->
if (id <= 0) {
id = p[SAVED_FILTER_NEXT_ID] ?: 1L
p[SAVED_FILTER_NEXT_ID] = id + 1
}
p[SAVED_FILTERS] = SavedFilterCodec.upsert(p[SAVED_FILTERS], filter.copy(id = id), modeOf(p).name)
}
return id
}
suspend fun deleteFilter(id: Long) = dataStore.edit { p ->
p[SAVED_FILTERS] = SavedFilterCodec.remove(p[SAVED_FILTERS], id)
}
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
@@ -282,6 +317,9 @@ class SettingsPrefs @Inject constructor(
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
}
}
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
/**
* Task lists that live on a server: making, renaming, recolouring and deleting
* them. The `full` flavor talks CalDAV; the `offline` one has no accounts, so
* every list is device-only and none of this is ever reached with a real one.
*/
interface RemoteLists {
/** 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. */
suspend fun creatableAccounts(): List<AccountEntity>
/** Makes a collection on [accountId]'s home set and a row pointing at it. */
suspend fun create(accountId: Long, name: String, color: Int): Outcome
suspend fun rename(listId: Long, name: String, color: Int): Outcome
suspend fun delete(listId: Long): Outcome
}
@@ -0,0 +1,6 @@
package de.jeanlucmakiola.agendula.data.sync
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
fun interface SyncRequests {
fun syncNow(accountName: String)
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
@@ -19,6 +20,7 @@ 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.normalizeTags
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
@@ -83,6 +85,27 @@ class AndroidTasksDataSource @Inject constructor(
override fun subtasks(parentTaskId: Long): List<Task> =
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
/**
* Read from the `tasks` table, so a recurring subtask with no occurrence in
* the window still counts. All columns and a lenient `_deleted`, as in
* [exportTasks], since the table's shape varies across providers.
*/
override fun subtaskIds(parentTaskId: Long): List<Long> = resolver.query(
TasksContract.tasksUri(authority()),
null,
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(parentTaskId.toString()),
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null) continue
r.getLong(Tasks.ID)?.takeIf { it != parentTaskId }?.let(::add)
}
}
} ?: emptyList()
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
val uri = TasksContract.instancesUri(authority())
// projection = null (all columns): the instances view differs across
@@ -90,11 +113,14 @@ class AndroidTasksDataSource @Inject constructor(
// `is_recurring` / `distance_from_current`). The by-name mapper reads
// whatever is present and nulls the rest; the repository re-sorts, so we
// don't depend on a provider sort column either.
return resolver.query(uri, null, selection, args, null)
val tasks = resolver.query(uri, null, selection, args, null)
?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
} ?: emptyList()
if (tasks.isEmpty()) return tasks
val tags = categories(tasks.map { it.taskId })
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
}
override fun exportTasks(listId: Long): List<ExportTask> {
@@ -102,7 +128,7 @@ class AndroidTasksDataSource @Inject constructor(
// table's shape varies across provider versions, and the by-name mapper
// reads what's there.
val uri = TasksContract.tasksUri(authority())
return resolver.query(
val tasks = resolver.query(
uri,
null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
@@ -115,21 +141,39 @@ class AndroidTasksDataSource @Inject constructor(
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList()
if (tasks.isEmpty()) return tasks
val tags = categories(tasks.map { it.taskId })
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
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")
// One batch, so a refused tag leaves no task behind for a retry to duplicate.
val authority = authority()
val ops = arrayListOf(
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
.withValues(values.toContentValues())
.build(),
)
normalizeTags(form.tags).forEach { tag ->
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
.withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) })
.withValueBackReference(Properties.TASK_ID, 0)
.build()
}
val results = runCatching { resolver.applyBatch(authority, ops) }
.getOrElse { throw TaskWriteFailedException("insert task", it) }
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
?: throw TaskWriteFailedException("insert task: no id")
}
override fun updateTask(taskId: Long, form: TaskForm) {
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")
setCategories(taskId, form.tags)
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
@@ -151,6 +195,7 @@ class AndroidTasksDataSource @Inject constructor(
(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")
setCategories(seriesId, form.tags)
}
/**
@@ -177,7 +222,7 @@ class AndroidTasksDataSource @Inject constructor(
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
val spec = seriesSpec(seriesId) ?: return
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId)
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteWithSubtasks(seriesId)
endSeriesBefore(seriesId, spec, occurrenceStart)
}
@@ -243,8 +288,28 @@ class AndroidTasksDataSource @Inject constructor(
?: 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 fork copies the series' properties onto the override, so the tags
// go onto the series first and are put back once the override exists.
// Unchanged tags are inherited as they are, and the series is left alone.
val seriesTags = categories(listOf(taskId))[taskId].orEmpty()
val retag = normalizeTags(form.tags) != seriesTags
if (retag) setCategories(taskId, form.tags)
var failure: Throwable? = null
try {
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
} catch (e: Throwable) {
failure = e
throw e
} finally {
if (retag) {
try {
setCategories(taskId, seriesTags)
} catch (e: Throwable) {
failure?.addSuppressed(e) ?: throw e
}
}
}
}
/**
@@ -348,6 +413,58 @@ class AndroidTasksDataSource @Inject constructor(
} ?: emptyList()
}
/** Tags by task id for [taskIds], in stored order. */
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
resolver.query(
TasksContract.propertiesUri(authority()),
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
selection,
args,
Properties.PROPERTY_ID,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID) ?: continue
val name = reader.getString(TasksContract.Category.NAME) ?: continue
add(id to name)
}
}
} ?: emptyList()
}
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
}
/**
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
* leave half the tags, and leaves them alone when nothing changed so the task
* isn't dirtied.
*/
private fun setCategories(taskId: Long, tags: List<String>) {
val wanted = normalizeTags(tags)
if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return
val authority = authority()
val uri = TasksContract.propertiesUri(authority)
val ops = arrayListOf(
ContentProviderOperation.newDelete(uri)
.withSelection(
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE),
)
.build(),
)
wanted.forEach {
ops += ContentProviderOperation.newInsert(uri)
.withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues())
.build()
}
runCatching { resolver.applyBatch(authority, ops) }
.getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) }
}
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")
@@ -460,3 +577,6 @@ class AndroidTasksDataSource @Inject constructor(
return cv
}
}
/** SQLite's default bound-argument limit on older Android releases. */
private const val MAX_SQL_ARGS = 999
@@ -5,8 +5,8 @@ class ProviderUnavailableException :
IllegalStateException("No tasks provider installed")
/** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) :
RuntimeException("Task write failed: $operation")
class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
RuntimeException("Task write failed: $operation", cause)
/**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
@@ -43,6 +43,8 @@ class ModeRoutingTasksDataSource(
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
active().deleteFollowing(seriesId, occurrenceStart)
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
override fun subtaskIds(parentTaskId: Long): List<Long> = active().subtaskIds(parentTaskId)
override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId)
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
@@ -113,6 +113,12 @@ object TaskWriteMapper {
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
)
fun categoryValues(taskId: Long, name: String): Map<String, Any?> = mapOf(
Properties.TASK_ID to taskId,
Properties.MIMETYPE to TasksContract.Category.MIMETYPE,
TasksContract.Category.NAME to name,
)
/** 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(),
@@ -86,6 +86,12 @@ object TasksContract {
const val UID = "_uid"
const val DELETED = "_deleted"
const val HAS_ALARMS = "has_alarms"
const val ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"
const val LIST_OWNER = "list_owner"
const val LIST_ACCESS_LEVEL = "list_access_level"
const val VISIBLE = "visible"
}
/**
@@ -103,6 +109,7 @@ object TasksContract {
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
const val DISTANCE_FROM_CURRENT = "distance_from_current"
const val IS_RECURRING = "is_recurring"
const val INSTANCE_DURATION = "instance_duration"
/**
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
@@ -118,6 +125,10 @@ object TasksContract {
const val PROPERTY_ID = "property_id"
const val TASK_ID = "task_id"
const val MIMETYPE = "mimetype"
const val DATA0 = "data0"
const val DATA1 = "data1"
const val DATA2 = "data2"
const val DATA3 = "data3"
}
/**
@@ -154,6 +165,21 @@ object TasksContract {
const val TYPE_MESSAGE = 1
}
/**
* A category property row — one tag. DAVx5 maps these to and from
* `CATEGORIES`. Inserting with only [NAME] is enough: the provider looks the
* category up in the task's account, or creates it, and fills in `data0`.
*/
object Category {
const val MIMETYPE = "vnd.android.cursor.item/category"
/** `data1` — the tag as spelled. */
const val NAME = "data1"
/** `data2` — the tag's colour, if any. */
const val COLOR = "data2"
}
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
// --- status values (TaskColumns.STATUS_*) --------------------------------
@@ -46,6 +46,29 @@ interface TasksDataSource {
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
fun subtasks(parentTaskId: Long): List<Task>
/**
* The task rows directly under [parentTaskId]: one id per subtask, its series
* master when it recurs, whether or not any occurrence falls in the window
* [subtasks] expands.
*/
fun subtaskIds(parentTaskId: Long): List<Long> =
subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId }
/**
* Delete [taskId] and its subtasks, all the way down; left behind they would
* hang off a tombstone. Each task once, so a parent loop an import brought in
* ends instead of recursing forever.
*/
fun deleteWithSubtasks(taskId: Long) {
val seen = HashSet<Long>()
fun delete(id: Long) {
if (!seen.add(id)) return
subtaskIds(id).forEach(::delete)
deleteTask(id)
}
delete(taskId)
}
fun insertTask(form: TaskForm): Long
fun updateTask(taskId: Long, form: TaskForm)
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter
@@ -13,6 +14,9 @@ import kotlin.time.Instant
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
/** Tasks for a filter; [savedFilter] is a saved filter's definition, `null` once it's deleted. */
data class FilteredTasks(val tasks: List<Task>, val savedFilter: SavedFilter? = null)
/**
* The single entry point the UI layer uses. Flows re-emit automatically when the
* provider changes (our writes *and* external sync like DAVx5), via the
@@ -27,7 +31,10 @@ interface TasksRepository {
*/
fun writableTaskLists(): Flow<List<TaskList>> =
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
fun tasks(filter: TaskFilter): Flow<List<Task>>
fun tasks(filter: TaskFilter): Flow<List<Task>> = filteredTasks(filter).map { it.tasks }
/** [tasks] together with the definition behind a [TaskFilter.Saved]. */
fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks>
/**
* The direct children of [parentId], re-emitting live like the other flows.
@@ -1,7 +1,9 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
@@ -17,7 +19,10 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -32,12 +37,26 @@ class TasksRepositoryImpl @Inject constructor(
private val dataSource: TasksDataSource,
private val providerResolver: ProviderResolver,
private val startupGate: StartupGate,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : TasksRepository {
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
@OptIn(ExperimentalCoroutinesApi::class)
override fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks> = when (filter) {
// A deleted filter shows nothing rather than failing the screen or widget on it.
is TaskFilter.Saved -> settingsPrefs.savedFilter(filter.id)
.flatMapLatest { saved ->
if (saved == null) flowOf(FilteredTasks(emptyList()))
else observing {
// A deleted list's id would otherwise leave the filter quietly empty.
val current = saved.withListsIn(dataSource.taskLists().mapTo(HashSet()) { it.id })
FilteredTasks(loadTasks(filter, current), current)
}
}
else -> observing { FilteredTasks(loadTasks(filter)) }
}
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
SeriesCollapse.visible(dataSource.subtasks(parentId))
@@ -55,7 +74,7 @@ class TasksRepositoryImpl @Inject constructor(
}
}
private fun loadTasks(filter: TaskFilter): List<Task> {
private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List<Task> {
val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
@@ -63,6 +82,11 @@ class TasksRepositoryImpl @Inject constructor(
// parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
// several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
is TaskFilter.Tagged -> TaskQuery(includeCompleted = true)
is TaskFilter.Saved -> TaskQuery(includeCompleted = true)
}
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -74,7 +98,7 @@ class TasksRepositoryImpl @Inject constructor(
(if (t.isCompleted) done + 1 else done) to (total + 1)
}
return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) }
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
@@ -109,6 +133,7 @@ class TasksRepositoryImpl @Inject constructor(
parentId = parentId,
location = task.location,
url = task.url,
tags = task.tags,
),
)
val reminders = dataSource.reminders()[task.taskId].orEmpty()
@@ -223,7 +248,7 @@ class TasksRepositoryImpl @Inject constructor(
val current = dataSource.task(taskId, occurrenceStart)
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId)
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
when (scope) {
RecurringScope.ThisOccurrence ->
// An override row deletes itself into an EXDATE; a generated
@@ -231,18 +256,10 @@ class TasksRepositoryImpl @Inject constructor(
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
else dataSource.deleteOccurrence(seriesId, occurrence)
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId)
RecurringScope.AllOccurrences -> dataSource.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) }
@@ -9,6 +9,7 @@ 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 de.jeanlucmakiola.agendula.domain.normalizeTags
import java.time.ZoneId
import kotlin.time.Clock
import kotlin.time.Instant
@@ -81,6 +82,8 @@ object VTodoMapper {
// Parameterised copies stay in the residue verbatim; authoring the
// column beside them would emit the same exclusions twice.
"RDATE", "EXDATE",
// Several lines, or one the column would not re-serialise exactly.
"CATEGORIES",
)
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
@@ -200,6 +203,9 @@ object VTodoMapper {
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
)
val (categories, categoriesProperty) = readCategories(vtodo)
claim(categoriesProperty)
val sequenceProperty = vtodo.property("SEQUENCE")
val sequence = take(
sequenceProperty,
@@ -225,6 +231,7 @@ object VTodoMapper {
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()),
categories = categories,
// URI, not TEXT — escaping it would corrupt a query string.
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
status = status,
@@ -284,8 +291,9 @@ object VTodoMapper {
now: Instant = Clock.System.now(),
): ICalComponent {
val residue = parseResidue(entity.unknownProperties)
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
val keptResidue =
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) }
residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
val suppressed = keptResidue
.map { it.name.uppercase() }
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
@@ -307,6 +315,7 @@ object VTodoMapper {
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
add("LOCATION", entity.location?.let(ICalValues::escapeText))
add("URL", entity.url)
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
add("STATUS", entity.status.toICalName())
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
@@ -386,6 +395,7 @@ object VTodoMapper {
private fun contradictsResidue(
property: ICalProperty,
residue: ICalComponent,
residueCategories: List<String>,
entity: TaskEntity,
parentUid: String?,
): Boolean {
@@ -433,6 +443,7 @@ object VTodoMapper {
"PRIORITY" -> entity.priority != PRIORITY_NONE
"PERCENT-COMPLETE" -> entity.percentComplete != null
"CLASS" -> entity.classification != null
"CATEGORIES" -> entity.categories != residueCategories
// 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
@@ -475,6 +486,23 @@ object VTodoMapper {
// -------------------------------------------------------------- helpers
/**
* Every `CATEGORIES` copy's values, merged, and the one line to claim — only
* when it is the sole, parameterless copy and the column re-serialises it
* byte for byte. Anything else is read for display and stays in the residue.
*/
private fun readCategories(vtodo: ICalComponent): Pair<List<String>, ICalProperty?> {
val copies = vtodo.properties("CATEGORIES")
val values = normalizeTags(copies.flatMap(::categoryValues))
val claimable = copies.singleOrNull()?.takeIf {
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
}
return values to claimable
}
private fun categoryValues(property: ICalProperty): List<String> =
normalizeTags(ICalValues.parseTextList(property.value))
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
private class TimeRead(
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -221,12 +222,17 @@ class OneShotImport @Inject constructor(
}
val alarms = mutableListOf<LegacyAlarm>()
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null)
val categories = mutableMapOf<Long, MutableList<String>>()
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties ORDER BY property_id", null)
.use { cursor ->
val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) {
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
val taskId = r.getLong("task_id") ?: continue
if (r.getString("mimetype") == CATEGORY_MIMETYPE) {
r.getString("data1")?.let { categories.getOrPut(taskId, ::mutableListOf) += it }
continue
}
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
alarms += LegacyAlarm(
taskId = taskId,
@@ -241,7 +247,7 @@ class OneShotImport @Inject constructor(
}
}
return LegacySnapshot(lists, rows, alarms)
return LegacySnapshot(lists, rows, alarms, categories.mapValues { normalizeTags(it.value) })
}
// --- writing into Room ----------------------------------------------------
@@ -276,7 +282,12 @@ class OneShotImport @Inject constructor(
// the unique index; a fresh uid costs the row nothing it still has.
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
?: UUID.randomUUID().toString()
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
val entity = row.entity.copy(
listId = listId,
uid = uid,
recurrenceId = recurrenceId,
categories = snapshot.categories[row.id].orEmpty(),
)
val newId = taskDao.insert(entity)
taskIds[row.id] = newId
inserted += row to entity.copy(id = newId)
@@ -379,6 +390,7 @@ class OneShotImport @Inject constructor(
const val ARCHIVE_NAME = "tasks.db.imported"
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
private const val CATEGORY_MIMETYPE = "vnd.android.cursor.item/category"
private const val REFERENCE_START = "2"
private val SIDECARS = listOf("-journal", "-wal", "-shm")
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
@@ -391,6 +403,8 @@ private class LegacySnapshot(
val lists: List<LegacyList>,
val tasks: List<LegacyTaskRow>,
val alarms: List<LegacyAlarm>,
/** Tags by legacy task `_id`. */
val categories: Map<Long, List<String>>,
)
private class LegacyList(val id: Long, val entity: TaskListEntity)
@@ -0,0 +1,99 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentProvider
import android.content.ContentProviderOperation
import android.content.ContentProviderResult
import android.content.ContentUris
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
/**
* Agendula's own store, published under the OpenTasks contract (`tasklists`,
* `tasks`, `instances`, `properties`) at `<applicationId>.tasks`, so an app that
* already works with OpenTasks or tasks.org works with Agendula by swapping the
* authority. Reads are guarded by `<applicationId>.permission.READ_TASKS`, writes
* by `<applicationId>.permission.WRITE_TASKS`; [ProviderWriter] applies them. See
* `docs/CONTENT_PROVIDER.md`.
*/
class AgendulaTaskProvider : ContentProvider() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ProviderEntryPoint {
fun snapshotSource(): ProviderSnapshotSource
fun writer(): ProviderWriter
}
private val entryPoint by lazy {
EntryPointAccessors.fromApplication(requireNotNull(context), ProviderEntryPoint::class.java)
}
private val source: ProviderSnapshotSource by lazy { entryPoint.snapshotSource() }
private val writer: ProviderWriter by lazy { entryPoint.writer() }
private val matcher by lazy {
UriMatcher(UriMatcher.NO_MATCH).apply {
ProviderTable.entries.forEach { table ->
addURI(source.authority, table.path, table.ordinal * 2)
addURI(source.authority, "${table.path}/#", table.ordinal * 2 + 1)
}
}
}
override fun onCreate(): Boolean = true
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
val (table, id) = match(uri)
return source.query(table, projection, table.selectionFor(id, selection), selectionArgs, sortOrder).apply {
setNotificationUri(requireNotNull(context).contentResolver, uri)
}
}
override fun getType(uri: Uri): String {
val (table, id) = match(uri)
return table.mimeType(single = id != null)
}
override fun insert(uri: Uri, values: ContentValues?): Uri {
val (table, id) = match(uri)
require(id == null) { "Insert into ${table.path}, not into one of its rows" }
return ContentUris.withAppendedId(uri, writer.insert(table, values.toMap()))
}
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
val (table, id) = match(uri)
return writer.update(table, values.toMap(), table.selectionFor(id, selection), selectionArgs)
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val (table, id) = match(uri)
return writer.delete(table, table.selectionFor(id, selection), selectionArgs)
}
/** All or nothing, as against OpenTasks, and observers hear of it once. */
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> =
writer.batch { super.applyBatch(operations) }
private fun match(uri: Uri): Pair<ProviderTable, Long?> {
val code = matcher.match(uri)
require(code != UriMatcher.NO_MATCH) { "Unknown URI $uri" }
val table = ProviderTable.entries[code / 2]
if (code % 2 == 0) return table to null
val id = requireNotNull(uri.lastPathSegment?.toLongOrNull()) { "Unknown URI $uri" }
return table to id
}
private fun ContentValues?.toMap(): ProviderValues =
this?.keySet()?.associateWith { get(it) }.orEmpty()
}
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
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
/** One table [AgendulaTaskProvider] serves, with its columns in cursor order. */
enum class ProviderTable(val path: String, val columns: List<Pair<String, String>>) {
LISTS(
Lists.PATH,
listOf(
Lists.ID to "INTEGER PRIMARY KEY",
Lists.NAME to "TEXT",
Lists.COLOR to "INTEGER",
Lists.ACCESS_LEVEL to "INTEGER",
Lists.VISIBLE to "INTEGER",
Lists.SYNC_ENABLED to "INTEGER",
Lists.OWNER to "TEXT",
Lists.ACCOUNT_NAME to "TEXT",
Lists.ACCOUNT_TYPE to "TEXT",
),
),
TASKS(Tasks.PATH, listOf(Tasks.ID to "INTEGER PRIMARY KEY") + TASK_COLUMNS),
INSTANCES(
Instances.PATH,
listOf(
Tasks.ID to "INTEGER PRIMARY KEY",
Instances.TASK_ID to "INTEGER",
Instances.INSTANCE_START to "INTEGER",
Instances.INSTANCE_START_SORTING to "INTEGER",
Instances.INSTANCE_DUE to "INTEGER",
Instances.INSTANCE_DUE_SORTING to "INTEGER",
Instances.INSTANCE_DURATION to "INTEGER",
Instances.INSTANCE_ORIGINAL_TIME to "INTEGER",
Instances.DISTANCE_FROM_CURRENT to "INTEGER",
Instances.IS_RECURRING to "INTEGER",
) + TASK_COLUMNS,
),
PROPERTIES(
Properties.PATH,
listOf(
Properties.PROPERTY_ID to "INTEGER PRIMARY KEY",
Properties.TASK_ID to "INTEGER",
Properties.MIMETYPE to "TEXT",
Properties.DATA0 to "TEXT",
Properties.DATA1 to "TEXT",
Properties.DATA2 to "TEXT",
Properties.DATA3 to "TEXT",
),
),
;
val idColumn: String get() = columns.first().first
/** [selection] narrowed to the row [id] names, for a `/<id>` URI. */
fun selectionFor(id: Long?, selection: String?): String? =
if (id == null) selection else listOfNotNull("$idColumn = $id", selection?.let { "($it)" }).joinToString(" AND ")
/** OpenTasks' MIME type for a whole table, or for one row of it. */
fun mimeType(single: Boolean): String =
"${if (single) "vnd.android.cursor.item" else "vnd.android.cursor.dir"}/org.dmfs.tasks.$path"
}
/** Every `tasks` column but `_id`; the instances view carries them too. */
private val TASK_COLUMNS: List<Pair<String, String>>
get() = listOf(
Tasks.LIST_ID to "INTEGER",
Tasks.LIST_NAME to "TEXT",
Tasks.LIST_COLOR to "INTEGER",
Tasks.LIST_OWNER to "TEXT",
Tasks.LIST_ACCESS_LEVEL to "INTEGER",
Tasks.VISIBLE to "INTEGER",
Tasks.ACCOUNT_NAME to "TEXT",
Tasks.ACCOUNT_TYPE to "TEXT",
Tasks.UID to "TEXT",
Tasks.DELETED to "INTEGER",
Tasks.TITLE to "TEXT",
Tasks.DESCRIPTION to "TEXT",
Tasks.LOCATION to "TEXT",
Tasks.URL to "TEXT",
Tasks.PRIORITY to "INTEGER",
Tasks.CLASSIFICATION to "INTEGER",
Tasks.STATUS to "INTEGER",
Tasks.PERCENT_COMPLETE to "INTEGER",
Tasks.COMPLETED to "INTEGER",
Tasks.IS_CLOSED to "INTEGER",
Tasks.IS_NEW to "INTEGER",
Tasks.TASK_COLOR to "INTEGER",
Tasks.DTSTART to "INTEGER",
Tasks.DUE to "INTEGER",
Tasks.DURATION to "TEXT",
Tasks.IS_ALLDAY to "INTEGER",
Tasks.TZ to "TEXT",
Tasks.RRULE to "TEXT",
Tasks.RDATE to "TEXT",
Tasks.EXDATE to "TEXT",
Tasks.ORIGINAL_INSTANCE_ID to "INTEGER",
Tasks.ORIGINAL_INSTANCE_TIME to "INTEGER",
Tasks.ORIGINAL_INSTANCE_ALLDAY to "INTEGER",
Tasks.PARENT_ID to "INTEGER",
Tasks.SORTING to "INTEGER",
Tasks.HAS_ALARMS to "INTEGER",
Tasks.CREATED to "INTEGER",
Tasks.LAST_MODIFIED to "INTEGER",
) + UNSUPPORTED_TASK_COLUMNS.map { it to "TEXT" }
/**
* OpenTasks columns Agendula has nothing for. Published as `NULL` so a client
* that projects them reads nothing rather than failing the query.
*/
private val UNSUPPORTED_TASK_COLUMNS = listOf(
"geo", "organizer", "pinned", "has_properties", "completed_is_allday", "original_instance_sync_id",
"version", "_dirty", "_sync_id", "sync_version",
"sync1", "sync2", "sync3", "sync4", "sync5", "sync6", "sync7", "sync8",
)
@@ -0,0 +1,267 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
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.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.toInt
import java.time.ZoneId
import kotlin.time.Instant
typealias ProviderRow = Map<String, Any?>
/**
* Ids that stay put for as long as what they name exists, so a client can read
* an id and write to it later, or several in one batch. The one exception is
* two tags on one task whose spellings hash alike (about one in 2³¹): removing
* the one that sorts first moves the other onto its id.
*
* An instance is its series and its original time; a plain task's one
* instance has none. An alarm property is the alarm row; a tag property is its
* task and its spelling. Alarms take even ids and tags odd ones, so the two
* never meet.
*/
object ProviderIds {
fun instance(seriesId: Long, originalTime: Instant?): Long =
(seriesId shl 32) or ((originalTime?.epochSeconds ?: 0L) and 0xFFFFFFFFL)
fun instanceSeries(instanceId: Long): Long = instanceId ushr 32
fun instanceSeconds(instanceId: Long): Long = instanceId and 0xFFFFFFFFL
fun alarm(alarmId: Long): Long = alarmId shl 1
fun isAlarm(propertyId: Long): Boolean = propertyId and 1L == 0L
fun alarmRow(propertyId: Long): Long = propertyId ushr 1
/** The property id of each of [taskId]'s [categories], by spelling. */
fun categories(taskId: Long, categories: List<String>): Map<String, Long> =
categories(taskId, categories, ::tagHash)
internal fun categories(taskId: Long, categories: List<String>, hash: (String) -> Long): Map<String, Long> {
val used = HashSet<Long>()
// Two spellings on one task with one hash: the one that sorts later takes
// the next free slot, wrapping inside the tag bits so it never reaches the
// task's. Sorting keeps that independent of the tags' order.
val ids = categories.sorted().associateWith { name ->
var bits = hash(name) and TAG_BITS
while (!used.add(bits)) bits = (bits + 1) and TAG_BITS
(((taskId shl 31) or bits) shl 1) or 1L
}
return categories.associateWith(ids::getValue)
}
fun categoryTask(propertyId: Long): Long = propertyId ushr 32
private const val TAG_BITS = 0x7FFFFFFFL
/** 64-bit FNV-1a over the spelling's UTF-8, folded to 31 bits. */
private fun tagHash(name: String): Long {
var hash = -0x340d631b7bdddcdbL
name.encodeToByteArray().forEach { byte ->
hash = (hash xor (byte.toLong() and 0xFF)) * 0x100000001b3L
}
return (hash xor (hash ushr 31) xor (hash ushr 62)) and TAG_BITS
}
}
/** The Room store laid out as OpenTasks' tables, ready to be queried. */
class ProviderSnapshot(val rows: Map<ProviderTable, List<ProviderRow>>) {
companion object {
val EMPTY = ProviderSnapshot(ProviderTable.entries.associateWith { emptyList() })
/**
* Lays out the store. [tasks] must hold no tombstones. [accountType] is
* what a list attached to an account reports; a device-only list reports
* OpenTasks' local account so clients treat it the way they already do.
*/
fun build(
lists: List<TaskListRow>,
tasks: List<TaskEntity>,
alarms: List<TaskAlarmEntity>,
accountType: String,
now: Instant,
zone: ZoneId = ZoneId.systemDefault(),
): ProviderSnapshot {
val listsById = lists.associateBy { it.list.id }
val alarmsByTask = alarms.groupBy { it.taskId }
val taskRows = tasks.mapNotNull { task ->
val list = listsById[task.listId] ?: return@mapNotNull null
task.id to taskColumns(task, list, alarmsByTask[task.id].orEmpty().isNotEmpty(), accountType)
}.toMap()
val overrides = tasks.filter { it.masterId != null }.groupBy { it.masterId }
val instances = tasks.filter { it.masterId == null && it.id in taskRows }.flatMap { task ->
occurrences(task, overrides[task.id].orEmpty(), now).mapNotNull { occurrence ->
val columns = taskRows[occurrence.row.id] ?: return@mapNotNull null
columns + instanceColumns(ProviderIds.instance(task.id, occurrence.originalTime), occurrence, zone)
}
}
return ProviderSnapshot(
mapOf(
ProviderTable.LISTS to lists.map { listColumns(it, accountType) },
ProviderTable.TASKS to taskRows.map { (id, columns) -> columns + (Tasks.ID to id) },
ProviderTable.INSTANCES to instances,
ProviderTable.PROPERTIES to properties(tasks.filter { it.id in taskRows }, alarms),
),
)
}
private fun listColumns(row: TaskListRow, accountType: String): ProviderRow = mapOf(
Lists.ID to row.list.id,
Lists.NAME to row.list.name,
Lists.COLOR to row.list.color,
Lists.ACCESS_LEVEL to 0,
Lists.VISIBLE to row.list.isVisible.bit,
Lists.SYNC_ENABLED to row.list.isSynced.bit,
Lists.OWNER to row.list.owner,
Lists.ACCOUNT_NAME to accountName(row),
Lists.ACCOUNT_TYPE to accountTypeOf(row, accountType),
)
private fun taskColumns(task: TaskEntity, list: TaskListRow, hasAlarms: Boolean, accountType: String) =
mapOf(
Tasks.LIST_ID to task.listId,
Tasks.LIST_NAME to list.list.name,
Tasks.LIST_COLOR to list.list.color,
Tasks.LIST_OWNER to list.list.owner,
Tasks.LIST_ACCESS_LEVEL to 0,
Tasks.VISIBLE to list.list.isVisible.bit,
Tasks.ACCOUNT_NAME to accountName(list),
Tasks.ACCOUNT_TYPE to accountTypeOf(list, accountType),
Tasks.UID to task.uid,
Tasks.DELETED to 0,
Tasks.TITLE to task.title,
Tasks.DESCRIPTION to task.description,
Tasks.LOCATION to task.location,
Tasks.URL to task.url,
Tasks.PRIORITY to task.priority,
Tasks.CLASSIFICATION to task.classification,
Tasks.STATUS to task.status.toInt(),
Tasks.PERCENT_COMPLETE to task.percentComplete,
Tasks.COMPLETED to task.completedAt?.toEpochMilliseconds(),
Tasks.IS_CLOSED to (task.status == TaskStatus.COMPLETED || task.status == TaskStatus.CANCELLED).bit,
Tasks.IS_NEW to (task.status == TaskStatus.NEEDS_ACTION && task.percentComplete == null).bit,
Tasks.TASK_COLOR to task.color,
Tasks.DTSTART to task.dtstart?.toEpochMilliseconds(),
Tasks.DUE to task.due?.toEpochMilliseconds(),
Tasks.DURATION to task.duration,
Tasks.IS_ALLDAY to task.isAllDay.bit,
Tasks.TZ to task.timezone,
Tasks.RRULE to task.rrule,
Tasks.RDATE to task.rdate,
Tasks.EXDATE to task.exdate,
Tasks.ORIGINAL_INSTANCE_ID to task.masterId,
Tasks.ORIGINAL_INSTANCE_TIME to task.recurrenceId?.toEpochMilliseconds(),
Tasks.ORIGINAL_INSTANCE_ALLDAY to task.masterId?.let { task.isAllDay.bit },
Tasks.PARENT_ID to task.parentId,
Tasks.SORTING to task.sortOrder,
Tasks.HAS_ALARMS to hasAlarms.bit,
Tasks.CREATED to task.createdAt?.toEpochMilliseconds(),
Tasks.LAST_MODIFIED to task.lastModified?.toEpochMilliseconds(),
)
/** One instance: the row it reads from, its times, and where it sits in its series. */
private class Occurrence(
val row: TaskEntity,
val start: Instant?,
val due: Instant?,
val originalTime: Instant?,
val distance: Int,
val recurring: Boolean,
)
private fun occurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<Occurrence> {
val series = seriesOccurrences(master, overrides, now)
?: return listOf(Occurrence(master, master.dtstart, master.due, null, 0, recurring = false))
return series.mapIndexed { index, occurrence ->
Occurrence(
row = occurrence.override ?: master,
start = occurrence.start,
due = occurrence.due,
originalTime = occurrence.anchor,
distance = occurrence.distance ?: index,
recurring = true,
)
}
}
private fun instanceColumns(id: Long, occurrence: Occurrence, zone: ZoneId): ProviderRow {
val allDay = occurrence.row.isAllDay
val start = occurrence.start?.toEpochMilliseconds()
val due = occurrence.due?.toEpochMilliseconds()
return mapOf(
Tasks.ID to id,
Instances.TASK_ID to occurrence.row.id,
Instances.INSTANCE_START to start,
Instances.INSTANCE_START_SORTING to start?.let { sorting(it, allDay, zone) },
Instances.INSTANCE_DUE to due,
Instances.INSTANCE_DUE_SORTING to due?.let { sorting(it, allDay, zone) },
Instances.INSTANCE_DURATION to if (start != null && due != null) due - start else null,
Instances.INSTANCE_ORIGINAL_TIME to occurrence.originalTime?.toEpochMilliseconds(),
Instances.DISTANCE_FROM_CURRENT to occurrence.distance,
Instances.IS_RECURRING to occurrence.recurring.bit,
)
}
/**
* An all-day date is stored as UTC midnight; OpenTasks' sorting columns
* move it to local midnight so it sorts among the timed tasks of that day.
*/
private fun sorting(millis: Long, allDay: Boolean, zone: ZoneId): Long {
if (!allDay) return millis
val offset = zone.rules.getOffset(java.time.Instant.ofEpochMilli(millis)).totalSeconds * 1000L
return millis - offset
}
private fun properties(tasks: List<TaskEntity>, alarms: List<TaskAlarmEntity>): List<ProviderRow> {
val live = tasks.mapTo(HashSet()) { it.id }
val alarmRows = alarms.filter { it.taskId in live }.map {
mapOf(
Properties.PROPERTY_ID to ProviderIds.alarm(it.id),
Properties.TASK_ID to it.taskId,
Properties.MIMETYPE to Alarm.MIMETYPE,
Alarm.MINUTES_BEFORE to it.minutesBefore.toString(),
Alarm.REFERENCE to when (it.reference) {
AlarmReference.DUE -> Alarm.REFERENCE_DUE
AlarmReference.START -> Alarm.REFERENCE_START
}.toString(),
Alarm.MESSAGE to it.message,
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE.toString(),
)
}
val categoryRows = tasks.flatMap { task ->
ProviderIds.categories(task.id, task.categories).map { (name, id) ->
mapOf(
Properties.PROPERTY_ID to id,
Properties.TASK_ID to task.id,
Properties.MIMETYPE to Category.MIMETYPE,
Category.NAME to name,
)
}
}
return alarmRows + categoryRows
}
private fun accountName(row: TaskListRow): String =
row.accountDisplayName ?: TasksContract.LOCAL_ACCOUNT_NAME
private fun accountTypeOf(row: TaskListRow, accountType: String): String =
if (row.list.accountId == null) TasksContract.LOCAL_ACCOUNT_TYPE else accountType
private val Boolean.bit: Int get() = if (this) 1 else 0
}
}

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