Compare commits

..
31 Commits
Author SHA1 Message Date
Jean-Luc Makiolaandmakiolaj 1d6c2a0d59 fix: sync task reminders as VALARMs (#52) (#81)
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 1m1s
Reminders set in Agendula never reached the server, so a Mac or any other client saw none, and alarms set there never rang here. Reminders now sync as VALARMs in both directions.

**What syncs**
- Alarms Agendula can fire exactly as written become reminders: DISPLAY/AUDIO, relative to start or due, or absolute on a non-recurring task.
- Everything else stays in the residue and round-trips untouched: Apple default alarms, location alarms, repeating alarms, RFC 9074 snoozes, and triggers after their anchor.
- An unchanged alarm is re-emitted verbatim. An edited one is authored fresh under its existing uid. All-day leads go out as whole days.
- `ACKNOWLEDGED` is only added once a reminder is past the planner's missed-reminder grace period.

**Behaviour changes**
- Default reminders are stored on the task when it first gets a due date, so other clients see them. While untouched, they swap with the other default when the task flips between timed and all-day.
- Removing a task's alarms in another app turns them off here instead of falling back to the default. Tasks from clients that never write alarms keep the default.
- "All occurrences" reminder edits now reach occurrences that still had the series' old reminders.
- `.ics` import keeps VALARMs as reminders.

**Database v3**
- Alarms gain a `uid` and `raw` VALARM. Tasks gain `reminders_off`.
- The migration lifts claimable alarms out of the residue. It queues a one-time upload for tasks with local-only reminders on writable, non-confidential lists.

**Testing**
- Unit tests: full 662, offline 489. All instrumented tests pass on the emulator, including the migration. Lint is clean for both flavours.
- Not covered: the repository has no unit-test harness, so storing defaults at creation and the timed/all-day swap have no automated tests. Nothing has been checked against a real Mac yet.

Closes #52

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/81
2026-10-19 20:09:55 +02:00
Jean-Luc Makiola 2a1230c0ca Translations update from Weblate (#67)
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 1m14s
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/67
2026-10-19 17:14:47 +02:00
Knee8811 cd3b33c959 Translated using Weblate (Czech)
Currently translated at 18.5% (121 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-19 01:54:42 +00:00
Knee8811 8175d58dcd Translated using Weblate (Czech)
Currently translated at 12.9% (85 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-18 18:45:06 +00:00
Knee8811 05696b2991 Translated using Weblate (Czech)
Currently translated at 12.0% (79 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-17 06:45:09 +00:00
Weblate 23f6156e71 Translated using Weblate (Arabic)
Currently translated at 74.3% (486 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-17 06:45:08 +00:00
Sup99999 c1b65c0c86 Translated using Weblate (Arabic)
Currently translated at 74.3% (486 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-17 06:45:08 +00:00
Weblate b5ac279b94 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/
2026-10-15 21:45:14 +00:00
Knee8811 8fba3f01a4 Translated using Weblate (Czech)
Currently translated at 9.3% (61 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-15 21:45:14 +00:00
Weblate 3b73dd4c53 Translated using Weblate (Czech)
Currently translated at 9.3% (61 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-15 21:45:14 +00:00
Sup99999 01850a7b74 Translated using Weblate (Arabic)
Currently translated at 52.1% (341 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-15 21:45:14 +00:00
Weblate 28e646fb31 Translated using Weblate (German)
Currently translated at 59.6% (390 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-15 21:45:14 +00:00
Sonja Kraus 6e3cc4bdbd Translated using Weblate (German)
Currently translated at 59.6% (390 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-15 21:45:14 +00:00
Jean-Luc Makiola b6185ed53c Translations update from Weblate (#53)
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 1m6s
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/53
2026-10-15 13:26:10 +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
Knee8811 9338c64dc7 Added translation using Weblate (Czech) 2026-10-14 21:41:45 +00:00
Weblate 9a72fb0880 Translated using Weblate (Arabic)
Currently translated at 51.8% (339 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
Sup99999 24e978efc3 Translated using Weblate (Arabic)
Currently translated at 51.8% (339 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
Robin Schanbacher 2740dd5562 Translated using Weblate (German)
Currently translated at 58.5% (383 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-14 21:41:45 +00:00
Sup99999 8dbfd07d10 Translated using Weblate (Arabic)
Currently translated at 46.0% (265 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
Weblate 3e1a84ab7d Translated using Weblate (Arabic)
Currently translated at 46.0% (265 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00: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
204 changed files with 7086 additions and 980 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 are especially useful for UI changes, and for anything touching the provider
read/write paths (OpenTasks / tasks.org installed). 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 python3 scripts/check_translations.py
--> -->
### Checklist ### 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/` - [ ] New domain logic comes with JVM unit tests under `app/src/test/`
- [ ] Provider details stay inside `data/tasks/` - [ ] Provider details stay inside `data/tasks/`
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine) - [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
+8 -6
View File
@@ -127,10 +127,12 @@ jobs:
- name: Setup Java - name: Setup Java
if: steps.scope.outputs.code == 'true' 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 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'jetbrains'
java-version: '17' java-version: '21'
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against # Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
# data.forgejo.org, Forgejo's own action mirror — actions/checkout, # data.forgejo.org, Forgejo's own action mirror — actions/checkout,
@@ -178,18 +180,18 @@ jobs:
# No --no-daemon: the daemon lives only as long as this job container # No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration. # 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' 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 # :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 # that task would compile the vendored suite and run none of it, which is
# the whole safety argument in dav/PROVENANCE.md. # the whole safety argument in dav/PROVENANCE.md.
- name: Unit tests - name: Unit tests
if: steps.scope.outputs.code == 'true' 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' if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug 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 # Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android # 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" # 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 # status is always reported — like the `ci` job. A path-filtered workflow is
+22 -47
View File
@@ -3,7 +3,7 @@ name: Beta — Codeberg pre-release
# A beta is cut by pushing a release branch whose committed versionName is # 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 # 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 # committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
# no tag exists for that version yet, this runs the unit tests, builds and # 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 # 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. # mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
# #
@@ -27,7 +27,7 @@ jobs:
detect: detect:
# Gitea only; see the same guard in release.yaml. # Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj' if: github.repository_owner == 'makiolaj'
runs-on: docker runs-on: light
outputs: outputs:
is_beta: ${{ steps.v.outputs.is_beta }} is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
@@ -36,58 +36,28 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Resolve version and whether it is a new beta - name: Resolve version and whether it still needs publishing
id: v id: v
env:
# Codeberg, not Gitea: see the same lookup in release.yaml.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
run: | run: |
set -e set -e
INFO=$(bash scripts/version_info.sh) INFO=$(bash scripts/version_info.sh)
echo "$INFO" echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT" echo "$INFO" >> "$GITHUB_OUTPUT"
VERSION=$(echo "$INFO" | sed -n 's/^version=//p') if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
BASE=$(echo "$INFO" | sed -n 's/^base_version=//p') echo "Not a beta — nothing to do."
if [ "$(echo "$INFO" | sed -n 's/^channel=//p')" != "beta" ]; then
echo "versionName $VERSION is not a beta — nothing to do."
echo "is_beta=false" >> "$GITHUB_OUTPUT" echo "is_beta=false" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
# Fatal on anything but a clean 200/404, as in release.yaml: guessing # Done only once its Codeberg pre-release carries the APK, so a failed
# "no tag" during an outage would re-cut a published beta. # publish is redone by the next push; refused unless newer than the
tag_status() { # latest stable release.
curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/$1" || echo 000 GATE=$(bash scripts/release_gate.sh)
} echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
# A beta of a version that already shipped stable would carry a lower
# versionCode than the stable one: nobody could install it over it.
case "$(tag_status "v$BASE")" in
200)
echo "v$BASE already shipped as stable; a beta of it is pointless. Bump the version." >&2
exit 1 ;;
404) ;;
*) echo "Codeberg tag lookup for v$BASE failed — refusing to guess." >&2; exit 1 ;;
esac
STATUS=$(tag_status "v$VERSION")
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the beta."
echo "is_beta=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 published beta." >&2
exit 1
;;
esac
beta: beta:
needs: detect needs: detect
if: needs.detect.outputs.is_beta == 'true' if: needs.detect.outputs.is_beta == 'true'
runs-on: docker runs-on: gradle
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
@@ -100,10 +70,12 @@ jobs:
submodules: recursive submodules: recursive
- name: Setup Java - 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 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'jetbrains'
java-version: '17' java-version: '21'
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
@@ -156,7 +128,7 @@ jobs:
grep -E 'versionName|versionCode' app/build.gradle.kts grep -E 'versionName|versionCode' app/build.gradle.kts
- name: Unit tests - name: Unit tests
run: ./gradlew testDebugUnitTest run: ./gradlew testFullDebugUnitTest
# The real app key, same as a stable release: a beta has to update in # 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. # place to the next beta and to the stable version.
@@ -175,7 +147,8 @@ jobs:
storeFile=upload-keystore.jks storeFile=upload-keystore.jks
EOF EOF
- name: Build release APK # Both flavors; the offline one goes to Codeberg only (issue #39).
- name: Build release APKs
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else # Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
@@ -190,7 +163,8 @@ jobs:
bash scripts/release_notes.sh "$VERSION" > release-notes.md bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \ TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \ MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
bash scripts/publish_gitea_release.sh bash scripts/publish_gitea_release.sh
# The point of the whole workflow, so NOT continue-on-error. # The point of the whole workflow, so NOT continue-on-error.
@@ -202,5 +176,6 @@ jobs:
run: | run: |
set -e set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \ TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \ 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 bash scripts/publish_codeberg_release.sh
+23 -47
View File
@@ -48,7 +48,7 @@ jobs:
# secrets. repository_owner differs between the two forges regardless of # secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently. # URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj' if: github.repository_owner == 'makiolaj'
runs-on: docker runs-on: light
outputs: outputs:
is_release: ${{ steps.v.outputs.is_release }} is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
@@ -62,17 +62,6 @@ jobs:
- name: Resolve version and whether it is a new release - name: Resolve version and whether it is a new release
id: v 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: | run: |
set -e set -e
# versionName -> versionCode, channel and the pre-release flag (set # versionName -> versionCode, channel and the pre-release flag (set
@@ -94,30 +83,11 @@ jobs:
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2 echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
exit 1 exit 1
fi fi
# A tag for this version already existing means the release shipped on # Tags are read from Codeberg, the canonical forge, by exact name: its
# an earlier push; do nothing. Absent => this merge cuts the release. # 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".
# Anything other than a clean 200/404 is treated as fatal rather than GATE=$(bash scripts/release_gate.sh)
# as "no tag". A Codeberg outage or a network blip would otherwise echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
# read as absent and re-cut a release that has already shipped —
# republishing to F-Droid. Failing here is recoverable; a duplicate
# release is not.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
exit 1
;;
esac
# Before a single Gradle task runs: F-Droid truncates the in-client # 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. # changelog, so an over-long one would reach users cut off mid-sentence.
@@ -135,7 +105,7 @@ jobs:
release: release:
needs: detect needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch' if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: docker runs-on: gradle
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
@@ -150,10 +120,12 @@ jobs:
submodules: recursive submodules: recursive
- name: Setup Java - 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 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'jetbrains'
java-version: '17' java-version: '21'
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
@@ -212,7 +184,7 @@ jobs:
# Test the exact commit being shipped (only on a real release). # Test the exact commit being shipped (only on a real release).
- name: Unit tests - name: Unit tests
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest run: ./gradlew testFullDebugUnitTest
- name: Setup Android Keystore - name: Setup Android Keystore
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
@@ -230,7 +202,9 @@ jobs:
storeFile=upload-keystore.jks storeFile=upload-keystore.jks
EOF 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' if: env.IS_RELEASE == 'true'
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
@@ -286,7 +260,7 @@ jobs:
run: | run: |
set -e set -e
mkdir -p fdroid/repo 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 # 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 # fastlane tree (committed at release-cut time for the official repo; this
@@ -348,7 +322,8 @@ jobs:
set -e set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \ TAG="v$VERSION" NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \ MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
bash scripts/publish_gitea_release.sh bash scripts/publish_gitea_release.sh
# Publish the release on Codeberg, which is canonical for tags and # Publish the release on Codeberg, which is canonical for tags and
@@ -371,7 +346,8 @@ jobs:
set -e set -e
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md [ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \ TAG="v$VERSION" NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \ 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 bash scripts/publish_codeberg_release.sh
# Play takes an App Bundle, not the APK: a second artifact from the same # Play takes an App Bundle, not the APK: a second artifact from the same
@@ -386,7 +362,7 @@ jobs:
- name: Build release AAB - name: Build release AAB
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
continue-on-error: true continue-on-error: true
run: ./gradlew bundleRelease run: ./gradlew bundleFullRelease
# NOT actions/upload-artifact@v4: its client refuses any non-github.com # NOT actions/upload-artifact@v4: its client refuses any non-github.com
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that # server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
@@ -398,7 +374,7 @@ jobs:
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4 uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
with: with:
name: release-aab-${{ needs.detect.outputs.version }} 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 if-no-files-found: error
retention-days: 14 retention-days: 14
@@ -492,7 +468,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
# Absolute: a lane body runs from fastlane/, not the workspace root. # 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; } test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
bundle exec fastlane deploy \ bundle exec fastlane deploy \
aab:"$AAB" \ aab:"$AAB" \
+33
View File
@@ -7,11 +7,36 @@ All notable changes to this project are documented here. The format follows
## [Unreleased] ## [Unreleased]
### Changed
- Reminders now sync. A reminder set on a task is uploaded as a standard alarm
(`VALARM`), so it also notifies in macOS and iOS Reminders, Thunderbird,
tasks.org and jtx Board, and alarms set in those apps now fire in Agendula
instead of the default reminder. Removing a task's alarm in another app
turns it off here too, rather than falling back to the default (#52).
- A new task with a due date, or one that gets its first due date, takes the
default reminder as its own, so it travels with the task to the server.
Changing the default later no longer changes those tasks; tasks that rely on
the default today are left as they are.
- On upgrade, reminders on open, synced tasks that so far stayed on this
device are uploaded once.
- Alarms imported from an `.ics` file become reminders on the task.
## [1.1.0] - 2026-10-10
### Added ### Added
- Four new home-screen widgets: Today, Up next, Lists and Week. - Four new home-screen widgets: Today, Up next, Lists and Week.
- A widget can show tasks from several lists at once, optionally grouped by - A widget can show tasks from several lists at once, optionally grouped by
list. list.
- A setting under Settings → Task form pre-fills a new task's start with today. - 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 ### Changed
- The Tasks widget has agenda-style rows, an optional row colour and an "All - The Tasks widget has agenda-style rows, an optional row colour and an "All
@@ -21,9 +46,17 @@ All notable changes to this project are documented here. The format follows
showed. showed.
- Synced lists can be renamed and deleted, not only device-only ones. A - 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. 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 ### Fixed
- The list editor's Where and smart-lists rows no longer have double padding. - 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 ## [1.0.0] - 2026-09-21
+3 -3
View File
@@ -28,9 +28,9 @@ layer.
## Build, test, lint ## Build, test, lint
```sh ```sh
./gradlew :app:assembleDebug # build the debug APK ./gradlew :app:assembleDebug # build the debug APKs (full + offline)
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine) ./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintDebug # Android lint (CI runs this on every PR) ./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR)
``` ```
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check, CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
+53 -6
View File
@@ -15,6 +15,10 @@ required.</p>
</p> </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> <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; &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> <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>
@@ -50,7 +54,8 @@ many small *florets* — so the two apps are florets of one bloom.)
this and the following ones, or the whole series. this and the following ones, or the whole series.
- **Reminders** that fire at the exact time: several per task, separate defaults - **Reminders** that fire at the exact time: several per task, separate defaults
for timed and all-day tasks (globally and per list), Done and Snooze right on for timed and all-day tasks (globally and per list), Done and Snooze right on
the notification, and they survive reboots. the notification, and they survive reboots. They sync as standard `VALARM`s,
so they also ring in other CalDAV clients, and theirs ring here.
- **iCalendar import and export** — open an `.ics` or a zip of them, or write any - **iCalendar import and export** — open an `.ics` or a zip of them, or write any
list out as standard `.ics` files. list out as standard `.ics` files.
- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and - A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
@@ -76,6 +81,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 is kept verbatim and sent back unchanged, so passing your tasks through Agendula
does not quietly lose fields another client wrote. 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 your tasks live
| | Where | Sync | Needs | | | Where | Sync | Needs |
@@ -97,10 +112,20 @@ proprietary, and open standards — CalDAV and iCalendar — are the lane.
## Install ## Install
### F-Droid repository ### F-Droid (recommended)
Every release is built, signed and published to a self-hosted F-Droid Agendula is in the official [F-Droid](https://f-droid.org) repository. Search
repository. Add it once and your F-Droid client handles updates from then on: 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 1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone): link below on your phone):
@@ -119,9 +144,30 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
serves. For automatic updates from there, use serves. For automatic updates from there, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** and **[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)**. **[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 Betas of upcoming versions are published there too, as pre-releases; to test
them, switch on *Include prereleases* for Agendula in Obtainium. 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 → Storage → Export / Import. In
Obtainium, pick it with *Filter APKs by regular expression* set to
`^agendula-offline_v`.
### Build from source ### Build from source
```sh ```sh
@@ -136,8 +182,9 @@ JDK 17 and the Android SDK are all it needs; see
## Translations ## Translations
Translations are managed on a self-hosted **Weblate**, and partial ones are Translations are managed on a self-hosted **Weblate**, and partial ones are
fine — an untranslated string simply falls back to English. Agendula ships in fine — an untranslated string simply falls back to English.
English, German and Brazilian Portuguese so far.
<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/)** **→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
+53 -7
View File
@@ -1,3 +1,4 @@
import com.android.build.api.artifact.SingleArtifact
import java.util.Properties import java.util.Properties
import java.io.FileInputStream import java.io.FileInputStream
@@ -32,8 +33,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001, // (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. // 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md. // See docs/RELEASING.md.
versionCode = 1010001 versionCode = 1010099
versionName = "1.1.0-beta.1" versionName = "1.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -46,6 +47,20 @@ android {
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync") 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 { signingConfigs {
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
create("release") { create("release") {
@@ -208,26 +223,26 @@ dependencies {
// @HiltWorker plumbing. // @HiltWorker plumbing.
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
// Custom Tabs: the Nextcloud login flow hands the browser an approval page. // 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) implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler) ksp(libs.androidx.hilt.compiler)
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages. // 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. // RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
implementation(libs.dmfs.lib.recur) implementation(libs.dmfs.lib.recur)
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md. // Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
implementation(project(":dav")) "fullImplementation"(project(":dav"))
// Discovery, auth and Nextcloud Login Flow v2. // 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 // :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 // 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 // with no framework, and android.jar's stub factory returns null under
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a // isReturnDefaultValues — so anything touching XmlUtils would NPE without a
// real implementation here. // real implementation here.
testImplementation(libs.xpp3) "testFullImplementation"(libs.xpp3)
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
@@ -266,3 +281,34 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) 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')"
]
}
}
@@ -0,0 +1,548 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "725292b38bce25ce9032c4650573ae85",
"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, `reminders_off` INTEGER NOT NULL DEFAULT 0, 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"
},
{
"fieldPath": "remindersOff",
"columnName": "reminders_off",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"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, `uid` TEXT NOT NULL DEFAULT '', `raw` 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"
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "raw",
"columnName": "raw",
"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, '725292b38bce25ce9032c4650573ae85')"
]
}
}
Binary file not shown.
@@ -212,6 +212,15 @@ class OneShotImportTest {
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands") 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 ----------------------------------------------------------- // --- running it -----------------------------------------------------------
@Test @Test
@@ -411,6 +411,134 @@ class RoomTasksDataSourceTest {
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30) assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
} }
@Test
fun aSaveKeepsTheIdentityOfAReminderItDidNotChange() {
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15)))
val original = db.alarms().forTask(id).single()
db.alarms().update(original.copy(raw = "BEGIN:VALARM\r\nX-VENDOR:keep\r\nEND:VALARM\r\n"))
source.updateTask(id, form(title = "renamed", due = now + 2.days), dueReminders = listOf(15, 60))
val rows = db.alarms().forTask(id)
val kept = rows.single { it.minutesBefore == 15 }
// Same row, same identity on the server, same original text.
assertThat(kept.id).isEqualTo(original.id)
assertThat(kept.uid).isEqualTo(original.uid)
assertThat(kept.raw).contains("X-VENDOR:keep")
assertThat(rows.single { it.minutesBefore == 60 }.uid).isNotEqualTo(original.uid)
}
@Test
fun aSaveKeepsABeforeStartReminderTheEditorNeverShows() {
val id = source.insertTask(form(due = now + 1.days))
source.setReminders(id, listOf(TaskReminder(10, fromStart = true), TaskReminder(30)))
source.updateTask(id, form(due = now + 1.days), dueReminders = listOf(45))
assertThat(source.reminders()[id]).containsExactly(TaskReminder(10, fromStart = true), TaskReminder(45))
}
@Test
fun aReminderForOneOccurrenceLeavesTheSeriesUntouched() {
val work = syncedList()
val id = source.insertTask(form(due = now + 1.days).copy(listId = work), listOf(TaskReminder(30)))
makeRecurring(id, now)
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
val target = source.tasks(TaskQuery(listId = work))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(60))
// ⚠️ The master is never written, not even briefly: a sync landing in
// between would upload the occurrence's reminder for the whole series.
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(30)
assertThat(db.tasks().entity(id)!!.isDirty).isFalse()
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(db.alarms().forTask(override.id).map { it.minutesBefore }).containsExactly(60)
}
@Test
fun splittingASeriesGivesTheNewSeriesItsOwnReminderIdentities() {
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(30)))
makeRecurring(id, now - 14.days)
val master = db.alarms().forTask(id).single()
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(30))
assertThat(created).isNotEqualTo(id)
val copy = db.alarms().forTask(created).single()
assertThat(copy.minutesBefore).isEqualTo(30)
// A different UID on the server, so the alarm must not share one either.
assertThat(copy.uid).isNotEqualTo(master.uid)
assertThat(db.alarms().forTask(id).single().uid).isEqualTo(master.uid)
}
@Test
fun changingOnlyTheRemindersOwesTheServerAPut() {
val work = syncedList()
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
source.setReminders(id, listOf(TaskReminder(15)))
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
source.setReminders(id, listOf(TaskReminder(15)))
// Nothing changed, so nothing is owed.
assertThat(db.tasks().entity(id)!!.isDirty).isFalse()
}
@Test
fun aSeriesReminderEditReachesTheOccurrencesThatStillHadTheOldOne() {
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15)))
makeRecurring(id, now)
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
val completed = occurrences.first { it.distanceFromCurrent == 1 }.occurrenceStart!!
val own = occurrences.first { it.distanceFromCurrent == 2 }.occurrenceStart!!
source.setCompletedInstance(id, completed, true)
source.updateInstance(id, own, form(title = "own"), listOf(5))
// Same times and rule, so the series is not reshaped — that drops its overrides.
val first = occurrences.first()
val unchanged = form().copy(start = first.start, due = first.due, rrule = "FREQ=WEEKLY")
source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(60))
fun leads(anchor: Instant) = db.alarms().forTask(db.tasks().override(id, anchor)!!.id).map { it.minutesBefore }
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(60)
// A snapshot of the series' reminder follows it…
assertThat(leads(completed)).containsExactly(60)
// …one set for that occurrence alone does not.
assertThat(leads(own)).containsExactly(5)
}
@Test
fun aTaskWhoseAlarmsWereTurnedOffElsewhereReportsNone() {
val id = source.insertTask(form(due = now + 1.days))
db.alarms().setRemindersOff(id, true)
assertThat(source.reminders()[id]).isEmpty()
assertThat(source.reminders()).containsKey(id)
// A reminder of its own again: the flag goes.
source.setReminders(id, listOf(TaskReminder(15)))
assertThat(db.tasks().entity(id)!!.remindersOff).isFalse()
}
@Test
fun settingTheEditableReminderOwesTheServerAPut() {
val work = syncedList()
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
source.setAlarm(id, 30)
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
}
@Test @Test
fun deletingATaskInALocalListRemovesItOutright() { fun deletingATaskInALocalListRemovesItOutright() {
val id = source.insertTask(form()) val id = source.insertTask(form())
@@ -447,7 +575,7 @@ class RoomTasksDataSourceTest {
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved")) source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
// The fork copies the master's properties, which is what carries the // The fork copies the master's properties, which is what carries the
// reminder across — the repository is what puts the series' own back. // reminder across.
val override = db.tasks().override(id, target.occurrenceStart)!! val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30) assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
} }
@@ -8,27 +8,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
/** /** Migrations, proven against the committed schemas in `app/schemas/`. */
* The migration harness, proven against the committed schema in `app/schemas/`.
*
* There is one schema version today, so all there is to assert is that the helper
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
* export, the assets wiring and the identity hash all line up. That is the point:
* the first real migration only has to add its own case.
*
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
* database, and add a test here shaped like this:
*
* ```
* helper.createDatabase(TEST_DB, 1).use { db ->
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
* }
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
* // read the seeded rows back — validation proves the shape, not the data
* }
* ```
*/
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TasksDatabaseMigrationTest { class TasksDatabaseMigrationTest {
@@ -62,6 +42,111 @@ 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()
}
}
}
@Test
fun liftsClaimableAlarmsAndPushesRemindersThatNeverLeftTheDevice() {
val future = System.currentTimeMillis() + 7L * 24 * 60 * 60 * 1000
val past = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000
val claimable = "X-FOO:bar\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nX-WR-ALARMUID:srv\r\n" +
"TRIGGER;RELATED=END:-PT15M\r\nEND:VALARM\r\n"
val appleDefault = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-APPLE-DEFAULT-ALARM:TRUE\r\n" +
"TRIGGER;RELATED=END:-PT30M\r\nEND:VALARM\r\n"
helper.createDatabase(TEST_DB, 2).use { db ->
db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me')")
db.execSQL("INSERT INTO task_lists (id, name, color, account_id) VALUES (1, 'Synced', 0, 1)")
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (2, 'Local', 0)")
db.execSQL("INSERT INTO task_lists (id, name, color, account_id, is_read_only) VALUES (3, 'Shared', 0, 1, 1)")
fun task(id: Int, list: Int, due: Long, residue: String? = null) = db.execSQL(
"INSERT INTO tasks (id, list_id, uid, title, due, unknown_properties) VALUES (?, ?, ?, ?, ?, ?)",
arrayOf<Any?>(id, list, "uid-$id", "Task $id", due, residue),
)
fun alarm(task: Int, minutes: Int) =
db.execSQL("INSERT INTO task_alarms (task_id, minutes_before) VALUES (?, ?)", arrayOf<Any?>(task, minutes))
// A server alarm, and the same lead copied locally by a fork.
task(1, 1, future, claimable)
alarm(1, 15)
// Set in Agendula, never uploaded — still to fire, already past, device-only.
task(2, 1, future)
alarm(2, 30)
task(3, 1, past)
alarm(3, 30)
task(4, 2, future)
alarm(4, 30)
// Somebody else's default alarm: not ours to claim.
task(5, 1, future, appleDefault)
// A read-only share: dirty would freeze it, never sent and never refreshed.
task(6, 3, future)
alarm(6, 30)
}
helper.runMigrationsAndValidate(TEST_DB, 3, true, TasksDatabase.MIGRATION_2_3).use { db ->
db.query("SELECT task_id, minutes_before, uid, raw FROM task_alarms ORDER BY task_id").use { c ->
val rows = buildList { while (c.moveToNext()) add(listOf(c.getLong(0), c.getInt(1), c.getString(2), c.getString(3))) }
// One for task 1: the lifted copy won and the local duplicate went.
assertThat(rows.map { it[0] }).containsExactly(1L, 2L, 3L, 4L, 6L).inOrder()
assertThat(rows[0][2]).isEqualTo("srv")
assertThat(rows[0][3] as String).contains("TRIGGER;RELATED=END:-PT15M")
// Every pre-existing row got an identity of its own, shaped as a UUID.
assertThat(rows.drop(1).map { it[2] as String }.toSet()).hasSize(4)
rows.drop(1).forEach {
assertThat(it[2] as String).matches("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
}
assertThat(rows.drop(1).all { (it[2] as String).isNotEmpty() && it[3] == null }).isTrue()
}
db.query("SELECT id, unknown_properties, is_dirty, last_modified, reminders_off FROM tasks ORDER BY id").use { c ->
c.moveToNext()
assertThat(c.getString(1)).isEqualTo("X-FOO:bar\r\n")
assertThat(c.getInt(2)).isEqualTo(0)
assertThat(c.getInt(4)).isEqualTo(0)
c.moveToNext()
assertThat(c.getInt(2)).isEqualTo(1)
// The body changes, so its modification stamp does too.
assertThat(c.isNull(3)).isFalse()
c.moveToNext()
assertThat(c.getInt(2)).isEqualTo(0)
c.moveToNext()
assertThat(c.getInt(2)).isEqualTo(0)
c.moveToNext()
assertThat(c.getString(1)).contains("X-APPLE-DEFAULT-ALARM:TRUE")
assertThat(c.getInt(2)).isEqualTo(0)
c.moveToNext()
assertThat(c.getInt(2)).isEqualTo(0)
}
}
}
private companion object { private companion object {
const val TEST_DB = "migration-test.db" const val TEST_DB = "migration-test.db"
} }
@@ -107,6 +107,17 @@ class ExternalImportTest {
assertThat(importer.hasRun.first()).isTrue() 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. */ /** Every list arrives device-only: the account belongs to the sync app. */
@Test @Test
fun importedListsAreDeviceOnly() = runBlocking { fun importedListsAreDeviceOnly() = runBlocking {
@@ -241,6 +252,7 @@ class ExternalImportTest {
title: String, title: String,
uid: String? = "uid-$id", uid: String? = "uid-$id",
parentId: Long? = null, parentId: Long? = null,
tags: List<String> = emptyList(),
) = ExportTask( ) = ExportTask(
taskId = id, taskId = id,
uid = uid, uid = uid,
@@ -260,6 +272,7 @@ class ExternalImportTest {
rrule = null, rrule = null,
rdate = null, rdate = null,
parentId = parentId, parentId = parentId,
tags = tags,
) )
private companion object { private companion object {
@@ -286,9 +299,10 @@ private class FakeExternalStore : TasksDataSource {
override fun tasks(query: TaskQuery): List<Task> = unused() override fun tasks(query: TaskQuery): List<Task> = unused()
override fun task(taskId: Long): Task? = unused() override fun task(taskId: Long): Task? = unused()
override fun subtasks(parentTaskId: Long): List<Task> = unused() override fun subtasks(parentTaskId: Long): List<Task> = unused()
override fun insertTask(form: TaskForm): Long = unused() override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = unused()
override fun updateTask(taskId: Long, form: TaskForm) = unused() override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused() override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
unused()
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused() override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused() override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
override fun setCompleted(taskId: Long, completed: Boolean) = unused() override fun setCompleted(taskId: Long, completed: Boolean) = unused()
@@ -296,8 +310,10 @@ private class FakeExternalStore : TasksDataSource {
override fun deleteTask(taskId: Long) = unused() override fun deleteTask(taskId: Long) = unused()
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused() override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused() override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = unused() override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = unused() unused()
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long =
unused()
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused() override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused() override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
override fun createLocalList(name: String, color: Int): Long = unused() override fun createLocalList(name: String, color: Int): Long = unused()
+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,8 +1,10 @@
package de.jeanlucmakiola.agendula.data.sync package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.ical.AlarmCodec
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import at.bitfire.dav4jvm.exception.UnauthorizedException import at.bitfire.dav4jvm.exception.UnauthorizedException
@@ -422,8 +424,9 @@ class CollectionSyncer(
/** Null when the resource was rejected before it left the device. */ /** Null when the resource was rejected before it left the device. */
private fun serialize(local: LocalResource): String? { private fun serialize(local: LocalResource): String? {
val alarms = store.alarmsFor(local.live.map { it.id })
val todos = local.live.map { row -> val todos = local.live.map { row ->
VTodoMapper.write(row, parentUidOf(row), now()) VTodoMapper.write(row, parentUidOf(row), now(), alarms[row.id].orEmpty())
} }
val calendar = CalendarResource.build(todos) val calendar = CalendarResource.build(todos)
@@ -778,14 +781,17 @@ class CollectionSyncer(
val losing = existing.values.firstOrNull { it.isDirty } val losing = existing.values.firstOrNull { it.isDirty }
val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first() val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first()
val masterRow = upsert(master.entity, existing[master.entity.recurrenceId], null, href, eTag) val masterRow = upsert(master, existing[master.entity.recurrenceId], null, href, eTag)
val masterId = masterRow.id val masterId = masterRow.id
parents[uid] = master.parentUid parents[uid] = master.parentUid
val written = mutableListOf(masterRow) val written = mutableListOf(masterRow)
mapped.filter { it !== master }.forEach { override -> mapped.filter { it !== master }.forEach { override ->
// ⚠️ An override's alarms are its own: RFC 5545 has an override
// replace the component, so one the server sends without a
// VALARM has none, whatever its master carries.
written += upsert( written += upsert(
override.entity, existing[override.entity.recurrenceId], masterId, href, eTag, override, existing[override.entity.recurrenceId], masterId, href, eTag,
) )
} }
val kept = written.map { it.recurrenceId }.toSet() val kept = written.map { it.recurrenceId }.toSet()
@@ -832,12 +838,13 @@ class CollectionSyncer(
/** @return the row as it now stands, so the caller can index it. */ /** @return the row as it now stands, so the caller can index it. */
private fun upsert( private fun upsert(
incoming: TaskEntity, mapped: VTodoMapper.Mapped,
existing: TaskEntity?, existing: TaskEntity?,
masterId: Long?, masterId: Long?,
href: String, href: String,
eTag: String?, eTag: String?,
): TaskEntity { ): TaskEntity {
val incoming = mapped.entity
// Local-only columns the server has no opinion about. Taking the // Local-only columns the server has no opinion about. Taking the
// mapper's defaults here would silently reset the user's ordering and // mapper's defaults here would silently reset the user's ordering and
// per-task colour on every download. // per-task colour on every download.
@@ -854,12 +861,42 @@ class CollectionSyncer(
// set uploads what was just downloaded. // set uploads what was just downloaded.
isDirty = false, isDirty = false,
isDeleted = false, isDeleted = false,
// The store's to change, from what the alarms do.
remindersOff = existing?.remindersOff ?: false,
) )
return if (existing == null) { // The server's alarms replace ours, as every other field does: a
row.copy(id = store.insert(row)) // dirty row is never downloaded except to discard its edit.
val alarms = existing?.let { keepLocalPrecision(mapped.alarms, it, row) } ?: mapped.alarms
return row.copy(id = store.upsertWithAlarms(row, alarms))
}
/**
* [incoming] with each alarm we uploaded ourselves put back to the lead
* we hold for it.
*
* ⚠️ The wire is coarser than the row: an all-day lead goes out in whole
* days, and a before-start reminder on a task without a start goes out
* before-due. Taking the round trip at its word would quietly turn a
* 15-minute all-day reminder into "on the day", or a before-start one
* into a before-due one, the first time the task is downloaded again.
* An alarm that comes back as exactly what we would have sent has not
* been changed, so the row's own lead stands.
*/
private fun keepLocalPrecision(
incoming: List<TaskAlarmEntity>,
existing: TaskEntity,
task: TaskEntity,
): List<TaskAlarmEntity> {
val local = store.alarmsFor(listOf(existing.id))[existing.id].orEmpty()
if (local.isEmpty()) return incoming
return incoming.map { alarm ->
val mine = local.firstOrNull { it.uid == alarm.uid } ?: return@map alarm
val sent = AlarmCodec.wireLead(mine, task)
if (sent == AlarmCodec.Lead(alarm.minutesBefore, alarm.reference)) {
alarm.copy(minutesBefore = mine.minutesBefore, reference = mine.reference)
} else { } else {
store.update(row) alarm
row }
} }
} }
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.data.sync package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher 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.sync.push.PushStore
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.StorageMode
@@ -53,38 +54,7 @@ class RemoteListRepository @Inject constructor(
private val resolver: ProviderResolver, private val resolver: ProviderResolver,
private val push: PushStore, private val push: PushStore,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) { ) : 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, freshest answer first. * 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 * able to say so, and the only moment that costs nothing is while the user
* is looking at the picker. * 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 // ⚠️ Empty in External mode, whatever the accounts table holds. The
// lists on screen then come from a third-party provider, so a row // lists on screen then come from a third-party provider, so a row
// inserted into ours would exist, sync, and be visible to nobody. // 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. * @return the new list's local id, or why there is none.
*/ */
suspend fun create( override suspend fun create(
accountId: Long, accountId: Long,
name: String, name: String,
color: Int, 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 * a row that has already been renamed locally by then reads as a rename that
* worked and then quietly reverted on the next sync. * 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 val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
if (list.isReadOnly) return@withContext Outcome.ReadOnly if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount 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 * 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. * 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 val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
if (list.isReadOnly) return@withContext Outcome.ReadOnly if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.MainActivity import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -33,7 +34,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class SyncNoticeNotifier @Inject constructor( class SyncNoticeNotifier @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) : ChannelRefresher {
fun canPost(): Boolean { fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || 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. * extra names the account for routing to Settings → Accounts → it.
*/ */
private fun signInIntent(accountId: Long): Intent = 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() { private fun ensureSignInChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return 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. */ /** 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 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel() if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
} }
companion object { 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 CHANNEL_ID = "sync_notices"
private const val NOTIFICATION_ID = 2 private const val NOTIFICATION_ID = 2
private const val SIGN_IN_CHANNEL_ID = "account_sign_in" private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.sync package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import javax.inject.Inject import javax.inject.Inject
@@ -11,17 +12,33 @@ import javax.inject.Inject
* discovery: the reconciliation above this interface is where local edits are * discovery: the reconciliation above this interface is where local edits are
* discarded, tombstones swept and conflicts resolved, and every one of those is * discarded, tombstones swept and conflicts resolved, and every one of those is
* a decision that should be provable without a device. Room's test double is * a decision that should be provable without a device. Room's test double is
* Robolectric plus an in-memory database; this is eight methods. * Robolectric plus an in-memory database; this is a dozen methods.
*/ */
interface SyncStore { interface SyncStore {
/** Every row in a list, **tombstones included**. */ /** Every row in a list, **tombstones included**. */
fun rowsIn(listId: Long): List<TaskEntity> fun rowsIn(listId: Long): List<TaskEntity>
fun insert(row: TaskEntity): Long
fun update(row: TaskEntity) fun update(row: TaskEntity)
/** The reminders of [taskIds] — one resource's rows — by task id, for the body that uploads them. */
fun alarmsFor(taskIds: List<Long>): Map<Long, List<TaskAlarmEntity>>
/**
* Writes a downloaded row — inserted when its id is 0 — and makes its
* reminders exactly [alarms], in one transaction.
*
* ⚠️ One transaction, not an [update] and a second call. The row lands clean
* with the server's ETag, so if the process died between the two, nothing
* would ever download it again and the reminders would stay stale for good.
*
* A row that is gone by the time it is written — deleted on the device
* while the sync ran — stays gone, reminders and all.
*
* @return the row's id.
*/
fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long
fun deleteAll(taskIds: List<Long>) fun deleteAll(taskIds: List<Long>)
/** /**
@@ -56,12 +73,26 @@ class RoomSyncStore @Inject constructor(
override fun rowsIn(listId: Long) = database.tasks().allIn(listId) override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
override fun insert(row: TaskEntity) = database.tasks().insert(row)
override fun update(row: TaskEntity) { override fun update(row: TaskEntity) {
database.tasks().update(row) database.tasks().update(row)
} }
override fun alarmsFor(taskIds: List<Long>) =
if (taskIds.isEmpty()) emptyMap() else database.alarms().forTasks(taskIds).groupBy { it.taskId }
override fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long =
database.runInTransaction<Long> {
if (row.id == 0L) {
database.tasks().insert(row).also { database.alarms().replaceFromServer(it, alarms) }
} else {
// ⚠️ Zero rows means the row was deleted under us. Its alarms
// would then name a task that does not exist, and the foreign
// key would fail the whole transaction.
if (database.tasks().update(row) > 0) database.alarms().replaceFromServer(row.id, alarms)
row.id
}
}
override fun deleteAll(taskIds: List<Long>) { override fun deleteAll(taskIds: List<Long>) {
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds) if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
} }
@@ -35,7 +35,11 @@ import kotlin.time.Duration.Companion.seconds
@Singleton @Singleton
class SyncTrigger @Inject constructor( class SyncTrigger @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) : SyncRequests {
override fun syncNow(accountName: String) {
enqueue(accountName)
}
/** /**
* Starts a sync now. * Starts a sync now.
@@ -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,78 @@
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 Storage
// stays composed under Export: 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,
)
}
SlideInSection(visible = section == SettingsSection.Account) {
openAccount?.let { id ->
AccountDetailScreen(
accountId = id,
onBack = { onSection(SettingsSection.Accounts) },
onRemoved = { onSection(SettingsSection.Accounts) },
onSignInAgain = signInAgain,
onOpenStorage = { onSection(SettingsSection.Storage) },
viewModel = accountsViewModel,
)
}
}
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,
)
}
}
-78
View File
@@ -16,29 +16,6 @@
<uses-permission android:name="org.tasks.permission.READ_TASKS" /> <uses-permission android:name="org.tasks.permission.READ_TASKS" />
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" /> <uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
but it shows in F-Droid's permission diff, so declare it deliberately
rather than letting it appear from nowhere.
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
(whose failure mode is a fatal RemoteServiceException) and a Play
requirement for a video demo.
Two more permissions appear in the merged manifest without being
declared here, and both come from work-runtime: WAKE_LOCK, and
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
API 31 WorkManager implements expedited work with a foreground service,
and minSdk is 29, so it is load-bearing for the "Sync now" button.
Removing it with tools:node="remove" would break expedited work on
exactly the older devices that need it most. Noted because it shows in
F-Droid's permission diff and would otherwise look unexplained. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for <!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
@@ -55,12 +32,6 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent> </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> </queries>
<application <application
@@ -71,7 +42,6 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:localeConfig="@xml/locales_config" android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Agendula" android:theme="@style/Theme.Agendula"
@@ -263,54 +233,6 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Sync plumbing. The stub provider exists only to give the sync
adapter an authority to register against: Agendula publishes no real
ContentProvider since :provider was deleted, and without an authority
ContentService.hasAuthorityAccess() makes every ContentResolver sync
call a silent no-op at targetSdk >= 34. -->
<provider
android:name=".data.sync.SyncStubProvider"
android:authorities="${applicationId}.sync"
android:exported="false"
android:syncable="true" />
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
<service
android:name=".data.sync.AuthenticatorService"
android:exported="true"
android:permission="android.permission.ACCOUNT_MANAGER">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".data.sync.SyncAdapterService"
android:exported="true"
android:permission="android.permission.BIND_SYNC_ADAPTER">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
</service>
<!-- UnifiedPush: the connector binds this to deliver endpoints and
WebDAV-Push messages. Not exported; the connector's own receiver is
what distributors talk to. -->
<service
android:name=".data.sync.push.AgendulaPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<!-- WorkManager's on-demand initialisation. Removing the default <!-- WorkManager's on-demand initialisation. Removing the default
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
@HiltWorker workers can take injected dependencies. --> @HiltWorker workers can take injected dependencies. -->
@@ -10,10 +10,10 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier 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.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
@@ -105,7 +105,7 @@ class AgendulaApp : Application(), Configuration.Provider {
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java) val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
runCatching { runCatching {
entryPoint.taskNotifier().refreshChannel() entryPoint.taskNotifier().refreshChannel()
entryPoint.syncNoticeNotifier().refreshChannel() entryPoint.channelRefreshers().forEach { it.refreshChannel() }
} }
} }
@@ -120,7 +120,7 @@ class AgendulaApp : Application(), Configuration.Provider {
fun applicationScope(): CoroutineScope fun applicationScope(): CoroutineScope
fun databaseCheckpoint(): DatabaseCheckpoint fun databaseCheckpoint(): DatabaseCheckpoint
fun taskNotifier(): TaskNotifier fun taskNotifier(): TaskNotifier
fun syncNoticeNotifier(): SyncNoticeNotifier fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun taskWidgetUpdater(): TaskWidgetUpdater fun taskWidgetUpdater(): TaskWidgetUpdater
} }
} }
@@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.di.LaunchHook import de.jeanlucmakiola.agendula.data.di.LaunchHook
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts 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.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest 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.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReportDialog
@@ -55,14 +50,6 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook> @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 // 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 // over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below. // 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. // Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
// Sync hard on app open: the periodic worker's interval is a floor, and // Each hook on its own, so one waiting on the network holds up no other.
// in the `rare` and `restricted` App Standby buckets it may not have run
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
// schedule lost to "clear app data" or to a restore.
//
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
// so onCreate runs again on every rotation, theme switch, locale change
// and font-scale change — each of which would otherwise start a fresh
// network sync the moment the previous one finished.
if (savedInstanceState == null) { if (savedInstanceState == null) {
lifecycleScope.launch { launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
runCatching {
accounts.rescheduleAll()
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
// A login flow the previous process died in the middle of.
// Its password, if the user approved, exists nowhere else.
pendingLoginFlows.reclaim()
}
// Last and on its own: it waits on the network, and must not
// hold up the reclaim above. Re-registering on open is what
// the connector recommends.
runCatching { push.updateAll() }
}
}
// Variant hooks: the demo seeder in debug builds, nothing in release.
if (savedInstanceState == null) {
lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
} }
setContent { setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -180,10 +142,15 @@ class MainActivity : ComponentActivity() {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID" const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START" const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS" 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_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY" 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_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST" 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_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID" 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 EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
@@ -233,11 +200,19 @@ class MainActivity : ComponentActivity() {
.putExtra(EXTRA_LIST_ID, listId) .putExtra(EXTRA_LIST_ID, listId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .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? { internal fun navRequestOf(intent: Intent?): NavRequest? {
if (intent == null) return null if (intent == null) return null
intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L } if (BuildConfig.SYNC_ENABLED) {
intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenAccount(it) } ?.let { return NavRequest.OpenAccount(it) }
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
}
when (intent.action) { 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 }, listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
@@ -248,6 +223,8 @@ class MainActivity : ComponentActivity() {
?.let { return NavRequest.OpenSmart(it) } ?.let { return NavRequest.OpenSmart(it) }
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L } ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenList(it) } ?.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) } importIntentUri(intent)?.let { return NavRequest.Import(it) }
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) { 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.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.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.AndroidProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.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.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler 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 * 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. * 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( private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "agendula_prefs", name = "agendula_prefs",
corruptionHandler = replaceCorrupted(), 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 @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class DataBindModule { abstract class DataBindModule {
@@ -94,22 +62,6 @@ abstract class DataBindModule {
@Singleton @Singleton
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment 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 // 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 // store itself, for the one caller that has to read it while another store is
// the active one. // the active one.
@@ -128,18 +80,6 @@ object DataProvideModule {
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.agendulaDataStore 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 @Provides
@Singleton @Singleton
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase = 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 // backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP. // the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3)
.build() .build()
/** /**
@@ -7,9 +7,8 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds import dagger.multibindings.Multibinds
/** /**
* Something a build variant wants to run when the app is launched with [Intent]. * Something a build variant runs on a genuine app open (not on a configuration
* Release builds contribute none; the debug source set adds the demo seeder, so * change): sync in the `full` flavor, the demo seeder in debug builds.
* that class never ships.
*/ */
fun interface LaunchHook { fun interface LaunchHook {
suspend fun onLaunch(intent: Intent) suspend fun onLaunch(intent: Intent)
@@ -17,37 +17,6 @@ annotation class IoDispatcher
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope 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 * Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than * .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 androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField 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.normalizeReminders
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import java.time.DayOfWeek import java.time.DayOfWeek
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@@ -113,6 +115,10 @@ data class Settings(
/** The lead times for an all-day task in [listId]. */ /** The lead times for an all-day task in [listId]. */
fun allDayReminderLeadsFor(listId: Long): List<Int> = fun allDayReminderLeadsFor(listId: Long): List<Int> =
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes) perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
/** The default leads a task in [listId] takes — the one rule for which default applies. */
fun defaultLeadsFor(listId: Long, allDay: Boolean): List<Int> =
if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)
} }
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */ /** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@@ -259,6 +265,34 @@ class SettingsPrefs @Inject constructor(
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry 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 { suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name } it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
} }
@@ -288,6 +322,8 @@ class SettingsPrefs @Inject constructor(
val PUSH_ENABLED = booleanPreferencesKey("push_enabled") val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden") val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today") val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
} }
} }
@@ -42,25 +42,41 @@ internal object ReminderPlanner {
// the one the editor shows; otherwise the task's list may override the // the one the editor shows; otherwise the task's list may override the
// global lead, or opt out entirely (override = null). All-day tasks // global lead, or opt out entirely (override = null). All-day tasks
// have their own day-scale defaults. // have their own day-scale defaults.
val reminders = perTask[task.taskId].orEmpty().ifEmpty { //
val leads = if (task.isAllDay) settings.allDayReminderLeadsFor(task.listId) // ⚠️ Present-but-empty is "none", not "the default": the store's
else settings.reminderLeadsFor(task.listId) // answer for a task whose alarms another client turned off. A
leads.map { TaskReminder(it) } // default here would keep ringing for what the user switched off.
} val reminders = perTask[task.taskId]
reminders.map { reminder -> ?: settings.defaultLeadsFor(task.listId, task.isAllDay).map { TaskReminder(it) }
reminders.mapNotNull { reminder ->
// A stored reminder says what it counts back from. Ours are always // A stored reminder says what it counts back from. Ours are always
// before due, but an imported dmfs alarm or another client's can be // before due, but an imported dmfs alarm or another client's can be
// before *start*. // before *start*.
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!! val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
ScheduledReminder( val trigger = triggerAt(
taskId = task.taskId,
triggerAt = triggerAt(
anchor, anchor,
task.isAllDay, task.isAllDay,
reminder.minutesBefore, reminder.minutesBefore,
settings.allDayReminderMinuteOfDay, settings.allDayReminderMinuteOfDay,
zone, zone,
), )
// Dismissed on another client already: firing it here within
// the missed-reminder grace would ring for something done with.
//
// ⚠️ An all-day reminder is compared from the start of its day,
// not from this device's time of day: other clients ring it at
// midnight, or at their own hour, so a dismissal at 07:00 must
// still silence the 09:00 this device would ring it at.
val earliest = if (task.isAllDay) {
triggerAt(anchor, true, reminder.minutesBefore, 0, zone)
} else {
trigger
}
val acknowledged = reminder.acknowledgedAt?.let { earliest <= it.toEpochMilliseconds() } == true
if (acknowledged) return@mapNotNull null
ScheduledReminder(
taskId = task.taskId,
triggerAt = trigger,
occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE, occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
) )
} }
@@ -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 package de.jeanlucmakiola.agendula.data.tasks
import android.content.ContentProviderOperation
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues 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.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask 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.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
@@ -90,11 +92,14 @@ class AndroidTasksDataSource @Inject constructor(
// `is_recurring` / `distance_from_current`). The by-name mapper reads // `is_recurring` / `distance_from_current`). The by-name mapper reads
// whatever is present and nulls the rest; the repository re-sorts, so we // whatever is present and nulls the rest; the repository re-sorts, so we
// don't depend on a provider sort column either. // 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 -> ?.use { c ->
val reader = CursorColumnReader(c) val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) } buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
} ?: emptyList() } ?: 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> { override fun exportTasks(listId: Long): List<ExportTask> {
@@ -102,7 +107,7 @@ class AndroidTasksDataSource @Inject constructor(
// table's shape varies across provider versions, and the by-name mapper // table's shape varies across provider versions, and the by-name mapper
// reads what's there. // reads what's there.
val uri = TasksContract.tasksUri(authority()) val uri = TasksContract.tasksUri(authority())
return resolver.query( val tasks = resolver.query(
uri, uri,
null, null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as // _deleted marks a row awaiting a sync round-trip. It's gone as far as
@@ -115,24 +120,60 @@ class AndroidTasksDataSource @Inject constructor(
val reader = CursorColumnReader(c) val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) } buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList() } ?: 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 --------------------------------------------------------------- // --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long { override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues()) // One batch, so a refused tag or alarm leaves no task behind for a retry
?: throw TaskWriteFailedException("insert task") // to duplicate.
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id") 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()
}
reminders.orEmpty().forEach { reminder ->
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
.withValues(
TaskWriteMapper.alarmValues(0, reminder.minutesBefore, reminder.fromStart)
.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) { override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update task $taskId") if (rows == 0) throw TaskWriteFailedException("update task $taskId")
setCategories(taskId, form.tags)
writeDueReminders(taskId, dueReminders)
} }
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) { /** [leads] as the task's before-due reminders; a start-relative one from elsewhere is kept. */
private fun writeDueReminders(taskId: Long, leads: List<Int>?) {
if (leads == null) return
val kept = alarmRows(taskId).map { it.second }.filter { it.fromStart }
setReminders(taskId, kept + leads.distinct().sorted().map { TaskReminder(it) })
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) {
val occurrence = task(seriesId, occurrenceStart) val occurrence = task(seriesId, occurrenceStart)
val anchors = resolver.query( val anchors = resolver.query(
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null, taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
@@ -151,6 +192,8 @@ class AndroidTasksDataSource @Inject constructor(
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due)) (Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update series $seriesId") if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
setCategories(seriesId, form.tags)
writeDueReminders(seriesId, dueReminders)
} }
/** /**
@@ -159,10 +202,10 @@ class AndroidTasksDataSource @Inject constructor(
* task. Per-occurrence edits past the split point are dropped rather than * task. Per-occurrence edits past the split point are dropped rather than
* carried over — the provider offers no way to re-parent an exception. * carried over — the provider offers no way to re-parent an exception.
*/ */
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long { override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long {
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId") val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
if (spec.rrule == null || occurrenceStart <= spec.anchor) { if (spec.rrule == null || occurrenceStart <= spec.anchor) {
updateSeries(seriesId, occurrenceStart, form) updateSeries(seriesId, occurrenceStart, form, dueReminders)
return seriesId return seriesId
} }
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3). // COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
@@ -172,7 +215,8 @@ class AndroidTasksDataSource @Inject constructor(
).size ).size
endSeriesBefore(seriesId, spec, occurrenceStart) endSeriesBefore(seriesId, spec, occurrenceStart)
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it } val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
return insertTask(form.copy(rrule = rule, percentComplete = null)) val reminders = dueReminders?.distinct()?.sorted()?.map { TaskReminder(it) }
return insertTask(form.copy(rrule = rule, percentComplete = null), reminders)
} }
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) { override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
@@ -238,13 +282,49 @@ class AndroidTasksDataSource @Inject constructor(
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null) resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
} }
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) { override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) {
if (dueReminders == null) return writeInstance(taskId, occurrenceStart, form)
// ⚠️ Written to the series, and then put back. Forking an occurrence
// copies the task's *current* properties onto the new override row, so
// setting the alarm beforehand is the only way to carry it across — and
// leaving it there would silently give every other occurrence in the
// series the reminder the user set on one of them.
val seriesReminders = alarmRows(taskId).map { it.second }
writeDueReminders(taskId, dueReminders)
try {
writeInstance(taskId, occurrenceStart, form)
} finally {
setReminders(taskId, seriesReminders)
}
}
private fun writeInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val instanceId = instanceIdFor(taskId, occurrenceStart) val instanceId = instanceIdFor(taskId, occurrenceStart)
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence") ?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id) val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId) val uri = TasksContract.instanceUri(authority(), 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) val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") 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 +428,58 @@ class AndroidTasksDataSource @Inject constructor(
} ?: emptyList() } ?: 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) { override fun setCancelled(taskId: Long, cancelled: Boolean) {
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId") if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
@@ -460,3 +592,6 @@ class AndroidTasksDataSource @Inject constructor(
return cv 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") IllegalStateException("No tasks provider installed")
/** A ContentResolver write returned no URI or affected no rows. */ /** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) : class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
RuntimeException("Task write failed: $operation") RuntimeException("Task write failed: $operation", cause)
/** /**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded * The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
@@ -34,20 +34,21 @@ class ModeRoutingTasksDataSource(
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query) override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
override fun task(taskId: Long): Task? = active().task(taskId) override fun task(taskId: Long): Task? = active().task(taskId)
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart) override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
active().updateSeries(seriesId, occurrenceStart, form) active().updateSeries(seriesId, occurrenceStart, form, dueReminders)
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long =
active().splitSeries(seriesId, occurrenceStart, form) active().splitSeries(seriesId, occurrenceStart, form, dueReminders)
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
active().deleteOccurrence(seriesId, occurrenceStart) active().deleteOccurrence(seriesId, occurrenceStart)
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
active().deleteFollowing(seriesId, occurrenceStart) active().deleteFollowing(seriesId, occurrenceStart)
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId) override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
override fun insertTask(form: TaskForm): Long = active().insertTask(form) override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = active().insertTask(form, reminders)
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form) override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) =
active().updateTask(taskId, form, dueReminders)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
active().updateInstance(taskId, occurrenceStart, form) active().updateInstance(taskId, occurrenceStart, form, dueReminders)
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue) override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
override fun alarms(): Map<Long, TaskReminder> = active().alarms() override fun alarms(): Map<Long, TaskReminder> = active().alarms()
@@ -113,6 +113,12 @@ object TaskWriteMapper {
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE, 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. */ /** The user-owned columns of a list — what an edit is allowed to change. */
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf( fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
Lists.NAME to name.trim(), Lists.NAME to name.trim(),
@@ -154,6 +154,18 @@ object TasksContract {
const val TYPE_MESSAGE = 1 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"
}
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri() fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
// --- status values (TaskColumns.STATUS_*) -------------------------------- // --- status values (TaskColumns.STATUS_*) --------------------------------
@@ -13,7 +13,19 @@ import kotlin.time.Instant
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it * them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
* to a number here is what silently fired those reminders off the wrong anchor. * to a number here is what silently fired those reminders off the wrong anchor.
*/ */
data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false) data class TaskReminder(
val minutesBefore: Int,
val fromStart: Boolean = false,
/**
* When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`):
* a trigger at or before it has been dealt with and is not fired here.
* Never part of what a reminder *is* — see [lead].
*/
val acknowledgedAt: Instant? = null,
) {
/** The reminder without its bookkeeping, for comparing two of them. */
val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null)
}
/** /**
* The one reminder Agendula's single-reminder editor shows and writes: the last * The one reminder Agendula's single-reminder editor shows and writes: the last
@@ -46,8 +58,17 @@ interface TasksDataSource {
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId) fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
fun subtasks(parentTaskId: Long): List<Task> fun subtasks(parentTaskId: Long): List<Task>
fun insertTask(form: TaskForm): Long /**
fun updateTask(taskId: Long, form: TaskForm) * The write paths take the task's reminders in the same write as the task
* itself, so a sync can never upload one without the other.
*
* [insertTask] takes the new task's [reminders] whole. The updates take the
* before-due leads as `dueReminders`, and `null` leaves the reminders
* alone; a start-relative reminder the task already carries is kept — the
* editor never shows one, so it never sends one.
*/
fun insertTask(form: TaskForm, reminders: List<TaskReminder>? = null): Long
fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>? = null)
/** /**
* Update a single occurrence of a recurring task, addressed by the task row and * Update a single occurrence of a recurring task, addressed by the task row and
@@ -60,7 +81,7 @@ interface TasksDataSource {
* instance row id keeps this seam independent of any one store's row * instance row id keeps this seam independent of any one store's row
* numbering; External mode maps it back to an instance row itself. * numbering; External mode maps it back to an instance row itself.
*/ */
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null)
/** /**
* Set (or clear, with `null`) the task's [editable] reminder lead, stored as an * Set (or clear, with `null`) the task's [editable] reminder lead, stored as an
* Alarm property row. Every other reminder on the task is left untouched. The * Alarm property row. Every other reminder on the task is left untouched. The
@@ -74,13 +95,13 @@ interface TasksDataSource {
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]: * Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
* content from [form], and the series moved by however far that occurrence moved. * content from [form], and the series moved by however far that occurrence moved.
*/ */
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null)
/** /**
* "This and following": end [seriesId] before [occurrenceStart] and continue * "This and following": end [seriesId] before [occurrenceStart] and continue
* from there as a new series built from [form]. Returns the new series' id. * from there as a new series built from [form]. Returns the new series' id.
*/ */
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null): Long
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */ /** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
@@ -89,10 +110,14 @@ interface TasksDataSource {
fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) fun deleteFollowing(seriesId: Long, occurrenceStart: Instant)
/** Replace every reminder on [taskId] with [reminders], in order. */ /** Make [taskId]'s reminders exactly [reminders]. A synced task is owed a PUT for it. */
fun setReminders(taskId: Long, reminders: List<TaskReminder>) fun setReminders(taskId: Long, reminders: List<TaskReminder>)
/** Every task's reminders, by task id, in stored order. One query, for the scheduler. */ /**
* Every task's reminders, by task id, in stored order. One query, for the
* scheduler. A task missing from the map takes the default; one present
* with an empty list has none (see `TaskEntity.remindersOff`).
*/
fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) } fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) }
/** Every task's [editable] reminder, by task id. */ /** Every task's [editable] reminder, by task id. */
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter 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. */ /** Whether Agendula can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER } 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 * 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 * provider changes (our writes *and* external sync like DAVx5), via the
@@ -27,7 +31,10 @@ interface TasksRepository {
*/ */
fun writableTaskLists(): Flow<List<TaskList>> = fun writableTaskLists(): Flow<List<TaskList>> =
taskLists().map { lists -> lists.filter { it.acceptsWrites } } 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. * The direct children of [parentId], re-emitting live like the other flows.
@@ -1,7 +1,9 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.time.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SeriesCollapse import de.jeanlucmakiola.agendula.domain.SeriesCollapse
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
@@ -17,7 +19,11 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -32,12 +38,26 @@ class TasksRepositoryImpl @Inject constructor(
private val dataSource: TasksDataSource, private val dataSource: TasksDataSource,
private val providerResolver: ProviderResolver, private val providerResolver: ProviderResolver,
private val startupGate: StartupGate, private val startupGate: StartupGate,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : TasksRepository { ) : TasksRepository {
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() } 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 { override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
SeriesCollapse.visible(dataSource.subtasks(parentId)) SeriesCollapse.visible(dataSource.subtasks(parentId))
@@ -55,7 +75,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) { val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true) is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
// Read completed tasks too, then let [TaskFiltering.matches] enforce each // Read completed tasks too, then let [TaskFiltering.matches] enforce each
@@ -66,6 +86,8 @@ class TasksRepositoryImpl @Inject constructor(
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so // 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. // several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true) 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 (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query)) val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -77,7 +99,7 @@ class TasksRepositoryImpl @Inject constructor(
(if (t.isCompleted) done + 1 else done) to (total + 1) (if (t.isCompleted) done + 1 else done) to (total + 1)
} }
return all return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) } .filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) }
.map { task -> .map { task ->
progress[task.taskId]?.let { (done, total) -> progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total) task.copy(subtaskDone = done, subtaskTotal = total)
@@ -88,9 +110,39 @@ class TasksRepositoryImpl @Inject constructor(
override suspend fun createTask(form: TaskForm): Long = override suspend fun createTask(form: TaskForm): Long =
withContext(io) { withContext(io) {
val id = dataSource.insertTask(form) dataSource.insertTask(form, dueRemindersFor(form, current = null).distinct().sorted().map { TaskReminder(it) })
writeReminders(id, form) }
id
/**
* The before-due leads to store for [form], given the task as it was.
*
* A task that gets its due date in this write and names no reminder of its
* own takes the list's or the app's default *as its own reminders*. The
* default is this device's setting, which nothing else can see; stored on
* the task, it travels with it to the server and fires on every client.
* The cost is tasks.org's: changing the default later does not reach the
* tasks that already have it.
*
* A task that already had a due date keeps what the form says, so an
* edit that clears every reminder means "use the default here" as before —
* with one exception. Switching between timed and all-day swaps the
* default it was given for the other kind's, as long as the reminders are
* still exactly that default: a timed 15 minutes is no lead for an all-day
* task, which the planner used to sort out by itself while the default
* stayed unstored.
*/
private suspend fun dueRemindersFor(form: TaskForm, current: Task?): List<Int> {
if (form.due == null) return form.reminders
val hadDue = current?.due != null
if (!hadDue) {
if (form.reminders.isNotEmpty()) return form.reminders
return settingsPrefs.settings.first().defaultLeadsFor(form.listId, form.isAllDay)
}
if (current.isAllDay == form.isAllDay || form.reminders.isEmpty()) return form.reminders
val settings = settingsPrefs.settings.first()
val previous = settings.defaultLeadsFor(current.listId, current.isAllDay)
return if (form.reminders.sorted() == previous.sorted()) settings.defaultLeadsFor(form.listId, form.isAllDay)
else form.reminders
} }
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long = override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
@@ -100,6 +152,9 @@ class TasksRepositoryImpl @Inject constructor(
} }
private fun copyOf(task: Task, parentId: Long?, depth: Int): Long { private fun copyOf(task: Task, parentId: Long?, depth: Int): Long {
// In the same write as the copy: inserted first and given its reminders
// after, a sync landing in between uploads the copy without them.
val reminders = dataSource.reminders()[task.taskId].orEmpty().map { it.lead }
val id = dataSource.insertTask( val id = dataSource.insertTask(
TaskForm( TaskForm(
title = task.title, title = task.title,
@@ -112,10 +167,10 @@ class TasksRepositoryImpl @Inject constructor(
parentId = parentId, parentId = parentId,
location = task.location, location = task.location,
url = task.url, url = task.url,
tags = task.tags,
), ),
reminders,
) )
val reminders = dataSource.reminders()[task.taskId].orEmpty()
if (reminders.isNotEmpty()) dataSource.setReminders(id, reminders)
if (depth < MAX_COPY_DEPTH) { if (depth < MAX_COPY_DEPTH) {
SeriesCollapse.visible(dataSource.subtasks(task.taskId)) SeriesCollapse.visible(dataSource.subtasks(task.taskId))
.filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED } .filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED }
@@ -150,18 +205,17 @@ class TasksRepositoryImpl @Inject constructor(
val seen = current?.lastModified val seen = current?.lastModified
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId) if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
} }
val reminders = dueRemindersFor(form, current)
val seriesId = current?.seriesId val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart val occurrence = current?.occurrenceStart
if (seriesId != null && occurrence != null) { if (seriesId != null && occurrence != null) {
when (scope) { when (scope) {
RecurringScope.AllOccurrences -> { RecurringScope.AllOccurrences -> {
writeReminders(seriesId, form) dataSource.updateSeries(seriesId, occurrence, form, reminders)
dataSource.updateSeries(seriesId, occurrence, form)
return@withContext return@withContext
} }
RecurringScope.ThisAndFollowing -> { RecurringScope.ThisAndFollowing -> {
val created = dataSource.splitSeries(seriesId, occurrence, form) dataSource.splitSeries(seriesId, occurrence, form, reminders)
writeReminders(created, form)
return@withContext return@withContext
} }
RecurringScope.ThisOccurrence -> Unit RecurringScope.ThisOccurrence -> Unit
@@ -169,31 +223,11 @@ class TasksRepositoryImpl @Inject constructor(
} }
// A recurring task's start/due are one occurrence's resolved times, so // A recurring task's start/due are one occurrence's resolved times, so
// writing them back to the task row would re-anchor the whole series. // writing them back to the task row would re-anchor the whole series.
// Going through the occurrence forks an override instead. // Going through the occurrence forks an override instead, which takes
// the reminders on its own row and leaves the series' alone.
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
if (instance == null) { if (instance == null) dataSource.updateTask(taskId, form, reminders)
writeReminders(taskId, form) else dataSource.updateInstance(taskId, instance, form, reminders)
dataSource.updateTask(taskId, form)
return@withContext
}
// ⚠️ Written to the series, and then put back. Forking an occurrence
// copies the task's *current* properties onto the new override row,
// so setting the alarm beforehand is the only way to carry it
// across — and leaving it there would silently give every other
// occurrence in the series the reminder the user set on one of them.
val seriesReminders = dataSource.reminders()[taskId].orEmpty()
writeReminders(taskId, form)
try {
dataSource.updateInstance(taskId, instance, form)
} finally {
dataSource.setReminders(taskId, seriesReminders)
}
}
/** The form's before-due reminders; a start-relative one from elsewhere is kept as it was. */
private fun writeReminders(taskId: Long, form: TaskForm) {
val kept = dataSource.reminders()[taskId].orEmpty().filter { it.fromStart }
dataSource.setReminders(taskId, kept + form.reminders.distinct().sorted().map { TaskReminder(it) })
} }
override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) = override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) =
@@ -0,0 +1,215 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
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.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParam
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import kotlin.math.roundToLong
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* `VALARM` ↔ [TaskAlarmEntity].
*
* ## What is claimed
*
* Only an alarm Agendula can fire exactly as written becomes a reminder row.
* Everything else stays in the task's residue and round-trips untouched — it
* still fires in the client that wrote it, just not here:
*
* - `ACTION` other than `DISPLAY`/`AUDIO`;
* - Apple's default alarms (`X-APPLE-DEFAULT-ALARM:TRUE`). They are that Mac's
* preference, not task data: claimed, they would come back to the Mac as an
* explicit alarm without the flag after the first edit here;
* - location alarms (`X-APPLE-PROXIMITY`, RFC 9074 `PROXIMITY`, a nested
* component) and Apple's `19760401T005545Z` sentinel trigger they carry;
* - repeating alarms (`REPEAT`/`DURATION`) and RFC 9074 snoozes (`RELATED-TO`);
* - a trigger *after* its anchor. The planner coerces a negative lead to 0, so
* it would fire at the wrong time;
* - an absolute trigger on a recurring task, or on one with no `DUE` to count
* back from.
*
* ## What "unchanged" means
*
* A claimed row keeps the `VALARM` it was read from in [TaskAlarmEntity.raw].
* On write, the row is "unchanged" when that original still [resolve]s to the
* row's lead against the task *as it is now* — so `-PT15M` and `-P0DT0H15M0S`
* are the same, a relative alarm survives a due-date edit, and an absolute one
* stops matching once `DUE` moves. An unchanged row is re-emitted verbatim; a
* changed one is authored fresh under its existing uid.
*
* ## Anchors
*
* `RELATED=END` counts from `DUE`. `RELATED=START` (the default) counts from
* `DTSTART` — except when `DTSTART` equals `DUE`, or when there is no
* `DTSTART` but there is a `DUE`. Apple writes the latter shape (against §3.8.6.3,
* which wants `DTSTART` present), and reading it as a before-due reminder is
* what makes it editable here: the editor only shows before-due rows.
*
* ## All-day tasks
*
* The local fire time of day is a device preference this mapper cannot see, so
* an all-day lead is whole days, written `-P<n>D` against `DUE`'s midnight. Read
* back, any trigger on an all-day task rounds to the nearest whole day.
*/
object AlarmCodec {
private const val DAY_MINUTES = 24 * 60
/** Apple's placeholder trigger for location alarms. Never a real time. */
private const val APPLE_SENTINEL = "19760401T005545Z"
/** What a `VALARM` means to the scheduler. */
data class Lead(val minutesBefore: Int, val reference: AlarmReference)
/** A `VALARM` as a reminder row — `taskId` is the caller's — or null if it is not ours to claim. */
fun fromVAlarm(alarm: ICalComponent, task: TaskEntity): TaskAlarmEntity? {
val lead = resolve(alarm, task) ?: return null
val raw = ICalSerializer.serialize(alarm)
// Too large to keep verbatim means it could not be re-emitted, and an
// alarm that is authored fresh on every write loses what made it large.
if (raw.toByteArray(Charsets.UTF_8).size > VTodoMapper.MAX_RESIDUE_BYTES) return null
val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() }
val base = TaskAlarmEntity(
taskId = 0,
minutesBefore = lead.minutesBefore,
reference = lead.reference,
raw = raw,
)
return if (uid == null) base else base.copy(uid = uid)
}
/** The lead [alarm] stands for on [task], or null when it is not claimable. */
fun resolve(alarm: ICalComponent, task: TaskEntity): Lead? {
val action = alarm.property("ACTION")?.value?.trim()?.uppercase()
if (action != "DISPLAY" && action != "AUDIO") return null
if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null
val excluded = listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO")
if (excluded.any { alarm.property(it) != null }) return null
if (alarm.components.isNotEmpty()) return null
val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null
val value = trigger.value.trim()
if (value.startsWith(APPLE_SENTINEL)) return null
val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) ||
(!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+"))
return if (absolute) absoluteLead(trigger, task) else relativeLead(trigger, task)
}
private fun relativeLead(trigger: ICalProperty, task: TaskEntity): Lead? {
val seconds = ICalValues.parseDuration(trigger.value) ?: return null
val reference = when {
trigger.param("RELATED").equals("END", ignoreCase = true) ->
if (task.due != null) AlarmReference.DUE else return null
task.dtstart != null && task.dtstart != task.due -> AlarmReference.START
task.due != null -> AlarmReference.DUE
else -> return null
}
val before = -seconds
val minutes = if (task.isAllDay) {
// Rounded before the sign check: `PT9H` on a date-only DUE is
// "09:00 on the day", which is this device's "on the day".
roundToDays(before)
} else {
// A lead in seconds is not one the planner can reproduce.
if (before % 60 != 0L) return null
before / 60
}
return minutes.toIntOrNull()?.let { Lead(it, reference) }
}
private fun absoluteLead(trigger: ICalProperty, task: TaskEntity): Lead? {
if (task.rrule != null || task.rdate != null) return null
val due = task.due ?: return null
val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed)
?.takeIf { it.tzid == null } // §3.8.6.3: an absolute trigger MUST be UTC
?.instant ?: return null
val before = (due - at).inWholeSeconds
val minutes = if (task.isAllDay) {
// An all-day DUE is stored as UTC midnight of its date, which is
// the calendar date the trigger counts back from.
roundToDays(before)
} else {
Math.floorDiv(before, 60L)
}
return minutes.toIntOrNull()?.let { Lead(it, AlarmReference.DUE) }
}
/** Nearest whole day, in minutes. */
private fun roundToDays(seconds: Long): Long =
(seconds / 86_400.0).roundToLong() * DAY_MINUTES
/** Negative is after the anchor, which is not claimed. */
private fun Long.toIntOrNull(): Int? = takeIf { it in 0..Int.MAX_VALUE }?.toInt()
/**
* [row] as a `VALARM` on [task], or null when it cannot be written: its
* anchor is missing. That row stays on the device — the planner ignores it
* too — because `RELATED=END` without `DUE` is a resource the server
* refuses ([VTodoMapper.validate]).
*
* ⚠️ A before-start row on a task that has lost its start is written
* against `DUE`, which is what the planner fires it from. Leaving it out
* would drop it from every other client, and the next download would then
* take it off this device too.
*
* [now] marks an authored alarm `ACKNOWLEDGED` once it is past even the
* planner's missed-reminder grace, so a client that honours RFC 9074 does
* not greet it as a missed alarm. Not sooner: the stamp comes back on the
* next download and would silence the late reminder this device still owes.
*/
fun toVAlarm(row: TaskAlarmEntity, task: TaskEntity, now: Instant): ICalComponent? {
val reference = if (row.reference == AlarmReference.START && task.dtstart == null) AlarmReference.DUE
else row.reference
val lead = Lead(row.minutesBefore, reference)
row.raw?.let(::parse)?.let { original ->
if (resolve(original, task) == lead) return original
}
val anchor = when (reference) {
AlarmReference.DUE -> task.due
AlarmReference.START -> task.dtstart
} ?: return null
val related = if (reference == AlarmReference.DUE) "END" else "START"
val minutes = row.minutesBefore.coerceAtLeast(0).toLong()
val seconds = if (task.isAllDay) {
-((minutes + DAY_MINUTES / 2) / DAY_MINUTES) * 86_400
} else {
-minutes * 60
}
val properties = mutableListOf(
ICalProperty("UID", value = row.uid),
ICalProperty("X-WR-ALARMUID", value = row.uid),
ICalProperty("ACTION", value = "DISPLAY"),
ICalProperty("DESCRIPTION", value = ICalValues.escapeText(row.message ?: "Reminder")),
ICalProperty("TRIGGER", listOf(ICalParam("RELATED", related)), ICalValues.formatDuration(seconds)),
)
val recurring = task.rrule != null || task.rdate != null
if (!recurring && anchor + seconds.seconds + ReminderPlanner.MISSED_GRACE_MS.milliseconds < now) {
properties += ICalProperty("ACKNOWLEDGED", value = ICalValues.formatDateTime(now, null))
}
return ICalComponent("VALARM", properties)
}
/**
* What [row] reads back as once it has been to the server and back — not
* always the row itself, because the wire is coarser: an all-day lead is
* whole days, and a before-start row on a task without a start goes out
* before-due. A download that brings back exactly this has not changed the
* reminder, and the row's own lead must be kept rather than taken from it.
*/
fun wireLead(row: TaskAlarmEntity, task: TaskEntity): Lead? =
toVAlarm(row, task, Instant.DISTANT_PAST)?.let { resolve(it, task) }
private fun parse(raw: String): ICalComponent? =
runCatching { ICalParser.parse(raw) }.getOrNull()?.takeIf { it.name.equals("VALARM", ignoreCase = true) }
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks.ical package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.TaskStatus
@@ -9,6 +10,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import java.time.ZoneId import java.time.ZoneId
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -51,10 +53,11 @@ import kotlin.time.Instant
* *
* ## Alarms * ## Alarms
* *
* `VALARM`s round-trip in the residue and are never authored here. Local * A `VALARM` [AlarmCodec] can fire exactly as written is claimed into
* reminders live in `task_alarms` and are never serialised. The two stores are * [Mapped.alarms] — rows for `task_alarms`, carrying the original verbatim —
* disjoint, so neither can destroy the other. Merging them is a later * and [write] authors the task's rows back. Any other `VALARM` (Apple's default
* decision, not a silent one. * and location alarms, repeating ones, snoozes) stays in the residue and is
* re-emitted untouched after the authored ones.
*/ */
object VTodoMapper { object VTodoMapper {
@@ -81,6 +84,8 @@ object VTodoMapper {
// Parameterised copies stay in the residue verbatim; authoring the // Parameterised copies stay in the residue verbatim; authoring the
// column beside them would emit the same exclusions twice. // column beside them would emit the same exclusions twice.
"RDATE", "EXDATE", "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. */ /** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
@@ -92,6 +97,8 @@ object VTodoMapper {
val uidWasMissing: Boolean, val uidWasMissing: Boolean,
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */ /** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
val droppedResidue: Boolean, val droppedResidue: Boolean,
/** The claimed `VALARM`s, `taskId` unset. Not counted against the residue cap. */
val alarms: List<TaskAlarmEntity> = emptyList(),
) )
// ---------------------------------------------------------------- read // ---------------------------------------------------------------- read
@@ -200,6 +207,9 @@ object VTodoMapper {
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 }, CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
) )
val (categories, categoriesProperty) = readCategories(vtodo)
claim(categoriesProperty)
val sequenceProperty = vtodo.property("SEQUENCE") val sequenceProperty = vtodo.property("SEQUENCE")
val sequence = take( val sequence = take(
sequenceProperty, sequenceProperty,
@@ -225,6 +235,7 @@ object VTodoMapper {
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()), title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()), description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()), location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
categories = categories,
// URI, not TEXT — escaping it would corrupt a query string. // URI, not TEXT — escaping it would corrupt a query string.
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()), url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
status = status, status = status,
@@ -256,8 +267,18 @@ object VTodoMapper {
.filterIndexed { index, _ -> index !in claimed } .filterIndexed { index, _ -> index !in claimed }
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) } .filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
// Claimed against the entity as read, so the anchors are the ones the
// rows will be written back against.
val alarms = mutableListOf<TaskAlarmEntity>()
val residueComponents = vtodo.components.filter { component ->
if (!component.name.equals("VALARM", ignoreCase = true)) return@filter true
val row = AlarmCodec.fromVAlarm(component, entity) ?: return@filter true
alarms += row
false
}
val residueText = ICalSerializer.serializeProperties(residueProperties) + val residueText = ICalSerializer.serializeProperties(residueProperties) +
ICalSerializer.serializeAll(vtodo.components) ICalSerializer.serializeAll(residueComponents)
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
return Mapped( return Mapped(
@@ -267,6 +288,7 @@ object VTodoMapper {
parentUid = parentUid, parentUid = parentUid,
uidWasMissing = uid.isEmpty(), uidWasMissing = uid.isEmpty(),
droppedResidue = tooLarge, droppedResidue = tooLarge,
alarms = alarms,
) )
} }
@@ -276,16 +298,19 @@ object VTodoMapper {
* Serialises [entity] back to a VTODO. * Serialises [entity] back to a VTODO.
* *
* [parentUid] is the parent's `UID`, which the entity holds only as a row id. * [parentUid] is the parent's `UID`, which the entity holds only as a row id.
* [now] is the `DTSTAMP`. * [now] is the `DTSTAMP`. [alarms] are the task's reminder rows; one whose
* anchor the task no longer has is left out (see [AlarmCodec.toVAlarm]).
*/ */
fun write( fun write(
entity: TaskEntity, entity: TaskEntity,
parentUid: String? = null, parentUid: String? = null,
now: Instant = Clock.System.now(), now: Instant = Clock.System.now(),
alarms: List<TaskAlarmEntity> = emptyList(),
): ICalComponent { ): ICalComponent {
val residue = parseResidue(entity.unknownProperties) val residue = parseResidue(entity.unknownProperties)
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
val keptResidue = val keptResidue =
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) } residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
val suppressed = keptResidue val suppressed = keptResidue
.map { it.name.uppercase() } .map { it.name.uppercase() }
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE } .filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
@@ -307,6 +332,7 @@ object VTodoMapper {
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText)) add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
add("LOCATION", entity.location?.let(ICalValues::escapeText)) add("LOCATION", entity.location?.let(ICalValues::escapeText))
add("URL", entity.url) add("URL", entity.url)
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
add("STATUS", entity.status.toICalName()) add("STATUS", entity.status.toICalName())
add("PERCENT-COMPLETE", entity.percentComplete?.toString()) add("PERCENT-COMPLETE", entity.percentComplete?.toString())
@@ -340,7 +366,7 @@ object VTodoMapper {
return ICalComponent( return ICalComponent(
name = "VTODO", name = "VTODO",
properties = properties + keptResidue, properties = properties + keptResidue,
components = residue.components, components = alarms.mapNotNull { AlarmCodec.toVAlarm(it, entity, now) } + residue.components,
) )
} }
@@ -386,6 +412,7 @@ object VTodoMapper {
private fun contradictsResidue( private fun contradictsResidue(
property: ICalProperty, property: ICalProperty,
residue: ICalComponent, residue: ICalComponent,
residueCategories: List<String>,
entity: TaskEntity, entity: TaskEntity,
parentUid: String?, parentUid: String?,
): Boolean { ): Boolean {
@@ -433,6 +460,7 @@ object VTodoMapper {
"PRIORITY" -> entity.priority != PRIORITY_NONE "PRIORITY" -> entity.priority != PRIORITY_NONE
"PERCENT-COMPLETE" -> entity.percentComplete != null "PERCENT-COMPLETE" -> entity.percentComplete != null
"CLASS" -> entity.classification != null "CLASS" -> entity.classification != null
"CATEGORIES" -> entity.categories != residueCategories
// Not SEQUENCE: it is the organiser's counter and never ours to bump, // 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. // so the column stays at its fallback and the residue always wins.
else -> false else -> false
@@ -475,6 +503,23 @@ object VTodoMapper {
// -------------------------------------------------------------- helpers // -------------------------------------------------------------- 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 val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
private class TimeRead( 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.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.statusFromInt import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -221,12 +222,17 @@ class OneShotImport @Inject constructor(
} }
val alarms = mutableListOf<LegacyAlarm>() 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 -> .use { cursor ->
val r = CursorColumnReader(cursor) val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
val taskId = r.getLong("task_id") ?: 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 val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
alarms += LegacyAlarm( alarms += LegacyAlarm(
taskId = taskId, 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 ---------------------------------------------------- // --- writing into Room ----------------------------------------------------
@@ -276,7 +282,12 @@ class OneShotImport @Inject constructor(
// the unique index; a fresh uid costs the row nothing it still has. // 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)) } val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
?: UUID.randomUUID().toString() ?: 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) val newId = taskDao.insert(entity)
taskIds[row.id] = newId taskIds[row.id] = newId
inserted += row to entity.copy(id = newId) inserted += row to entity.copy(id = newId)
@@ -379,6 +390,7 @@ class OneShotImport @Inject constructor(
const val ARCHIVE_NAME = "tasks.db.imported" const val ARCHIVE_NAME = "tasks.db.imported"
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" 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 const val REFERENCE_START = "2"
private val SIDECARS = listOf("-journal", "-wal", "-shm") private val SIDECARS = listOf("-journal", "-wal", "-shm")
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done") private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
@@ -391,6 +403,8 @@ private class LegacySnapshot(
val lists: List<LegacyList>, val lists: List<LegacyList>,
val tasks: List<LegacyTaskRow>, val tasks: List<LegacyTaskRow>,
val alarms: List<LegacyAlarm>, val alarms: List<LegacyAlarm>,
/** Tags by legacy task `_id`. */
val categories: Map<Long, List<String>>,
) )
private class LegacyList(val id: Long, val entity: TaskListEntity) private class LegacyList(val id: Long, val entity: TaskListEntity)
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import kotlin.math.roundToLong
import kotlin.time.Instant
/**
* Lifts claimable `VALARM`s out of a residue written before v3 synced them.
*
* ⚠️ Frozen with [TasksDatabase.MIGRATION_2_3]: a copy of `AlarmCodec`'s claim
* rule as it stood then, not a call into it, so a later change to what the
* codec claims cannot change what this migration produced for an earlier
* upgrader.
*/
internal object AlarmLift {
/** The task columns the claim rule reads. */
data class Task(
val dtstart: Instant?,
val due: Instant?,
val isAllDay: Boolean,
val recurring: Boolean,
)
data class Alarm(val minutesBefore: Int, val reference: AlarmReference, val uid: String?, val raw: String)
/** The lifted alarms and the residue without them, or `null` residue when nothing is left. */
data class Result(val alarms: List<Alarm>, val residue: String?)
private const val DAY_MINUTES = 24 * 60
private const val MAX_RAW_BYTES = 25 * 1024
fun lift(residue: String?, task: Task): Result {
val component = parse(residue) ?: return Result(emptyList(), residue)
val alarms = mutableListOf<Alarm>()
val kept = component.components.filter { sub ->
if (!sub.name.equals("VALARM", ignoreCase = true)) return@filter true
val alarm = claim(sub, task) ?: return@filter true
alarms += alarm
false
}
if (alarms.isEmpty()) return Result(emptyList(), residue)
val rest = ICalSerializer.serializeProperties(component.properties) + ICalSerializer.serializeAll(kept)
return Result(alarms, rest.ifEmpty { null })
}
private fun claim(alarm: ICalComponent, task: Task): Alarm? {
val action = alarm.property("ACTION")?.value?.trim()?.uppercase()
if (action != "DISPLAY" && action != "AUDIO") return null
if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null
if (listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO").any { alarm.property(it) != null }) {
return null
}
if (alarm.components.isNotEmpty()) return null
val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null
val value = trigger.value.trim()
if (value.startsWith("19760401T005545Z")) return null
val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) ||
(!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+"))
val (minutes, reference) = (if (absolute) absolute(trigger, task) else relative(trigger, task)) ?: return null
val raw = ICalSerializer.serialize(alarm)
if (raw.toByteArray(Charsets.UTF_8).size > MAX_RAW_BYTES) return null
val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() }
return Alarm(minutes, reference, uid, raw)
}
private fun relative(trigger: ICalProperty, task: Task): Pair<Int, AlarmReference>? {
val seconds = ICalValues.parseDuration(trigger.value) ?: return null
val reference = when {
trigger.param("RELATED").equals("END", ignoreCase = true) ->
if (task.due != null) AlarmReference.DUE else return null
task.dtstart != null && task.dtstart != task.due -> AlarmReference.START
task.due != null -> AlarmReference.DUE
else -> return null
}
val before = -seconds
val minutes = if (task.isAllDay) days(before) else if (before % 60 != 0L) return null else before / 60
return minutes.takeIf { it in 0..Int.MAX_VALUE }?.let { it.toInt() to reference }
}
private fun absolute(trigger: ICalProperty, task: Task): Pair<Int, AlarmReference>? {
if (task.recurring) return null
val due = task.due ?: return null
val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed)
?.takeIf { it.tzid == null }?.instant ?: return null
val before = (due - at).inWholeSeconds
val minutes = if (task.isAllDay) days(before) else Math.floorDiv(before, 60L)
return minutes.takeIf { it in 0..Int.MAX_VALUE }?.let { it.toInt() to AlarmReference.DUE }
}
private fun days(seconds: Long): Long = (seconds / 86_400.0).roundToLong() * DAY_MINUTES
private fun parse(text: String?): ICalComponent? {
if (text.isNullOrEmpty()) return null
return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull()
}
}
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
/**
* Lifts `CATEGORIES` out of a residue written before v2 claimed it.
*
* ⚠️ Frozen with [TasksDatabase.MIGRATION_1_2]. The claim rule is copied here
* rather than shared with the mapper, so a later change to what the mapper
* claims cannot change what this migration produced for an earlier upgrader.
*/
internal object CategoriesLift {
/**
* Every copy's values merged, and the residue with the one claimed line
* removed — or `null` for the residue when nothing was claimed, so the caller
* leaves the stored one alone.
*/
data class Result(val categories: List<String>, val residue: String?, val claimed: Boolean)
fun lift(residue: String?): Result {
val component = parse(residue) ?: return Result(emptyList(), residue, claimed = false)
val copies = component.properties.filter { it.name.equals("CATEGORIES", ignoreCase = true) }
val values = copies.flatMap(::values)
val claim = copies.singleOrNull()?.takeIf {
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
} ?: return Result(values, residue, claimed = false)
val rest = ICalSerializer.serializeProperties(component.properties.filterNot { it === claim }) +
ICalSerializer.serializeAll(component.components)
return Result(values, rest.ifEmpty { null }, claimed = true)
}
private fun values(property: ICalProperty): List<String> =
ICalValues.parseTextList(property.value).map(String::trim).filter(String::isNotEmpty).distinct()
private fun parse(text: String?): ICalComponent? {
if (text.isNullOrEmpty()) return null
return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull()
}
}
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.TypeConverter import androidx.room.TypeConverter
import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.statusFromInt import de.jeanlucmakiola.agendula.domain.statusFromInt
import de.jeanlucmakiola.agendula.domain.toInt import de.jeanlucmakiola.agendula.domain.toInt
import kotlin.time.Instant import kotlin.time.Instant
@@ -36,3 +38,17 @@ object Converters {
fun alarmReferenceFrom(value: String): AlarmReference = fun alarmReferenceFrom(value: String): AlarmReference =
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE) runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
} }
/**
* [TaskEntity.categories] stored as a `CATEGORIES` value, so escaping has one
* home; empty when there are none. Scoped to that field rather than the
* database, so no other `List<String>` picks up iCalendar escaping by accident.
*/
object CategoriesConverter {
@TypeConverter
fun toText(value: List<String>): String = ICalValues.formatTextList(normalizeTags(value))
@TypeConverter
fun fromText(value: String): List<String> =
if (value.isEmpty()) emptyList() else normalizeTags(ICalValues.parseTextList(value))
}
@@ -5,8 +5,10 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.TaskStatus
import java.util.UUID
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -71,7 +73,7 @@ data class TaskListEntity(
* *
* ⚠️ It was set by a rename and read by nobody. A collection's name and * ⚠️ It was set by a rename and read by nobody. A collection's name and
* colour are now written server-first by * colour are now written server-first by
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], so there is * [de.jeanlucmakiola.agendula.data.sync.RemoteLists], so there is
* no local edit left waiting to be pushed — and a flag that means "owed to * no local edit left waiting to be pushed — and a flag that means "owed to
* the server" while nothing ever pays it is worse than no flag at all. * the server" while nothing ever pays it is worse than no flag at all.
*/ */
@@ -143,6 +145,10 @@ data class TaskEntity(
@ColumnInfo(name = "url") val url: String? = null, @ColumnInfo(name = "url") val url: String? = null,
/** ARGB override for the list colour. */ /** ARGB override for the list colour. */
@ColumnInfo(name = "color") val color: Int? = null, @ColumnInfo(name = "color") val color: Int? = null,
/** RFC 5545 `CATEGORIES` — the tags — in the spelling each task carries them. */
@ColumnInfo(name = "categories", defaultValue = "")
@field:TypeConverters(CategoriesConverter::class)
val categories: List<String> = emptyList(),
// state // state
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION, @ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
@@ -194,12 +200,31 @@ data class TaskEntity(
* verbatim on write so a round-trip cannot silently lose a field. * verbatim on write so a round-trip cannot silently lose a field.
*/ */
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null, @ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
// reminders
/**
* Another client removed this task's last alarm, so it has none — not "use
* the default". A task without reminder rows normally takes the list's or
* the app's default, which is right for one that never had any (a client
* that does not write alarms), and wrong for one whose alarms were turned
* off elsewhere: it would keep ringing here. Set by a download that takes
* the last row away; cleared as soon as the task has a reminder again.
*/
@ColumnInfo(name = "reminders_off", defaultValue = "0") val remindersOff: Boolean = false,
) )
/** What [TaskAlarmEntity.minutesBefore] counts back from. */ /** What [TaskAlarmEntity.minutesBefore] counts back from. */
enum class AlarmReference { DUE, START } enum class AlarmReference { DUE, START }
/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */ /**
* A reminder lead on a task. Positive [minutesBefore] is *before* [reference].
*
* Synced as a `VALARM` (see `AlarmCodec`). [uid] is the alarm's identity on the
* wire — `X-WR-ALARMUID` / RFC 9074 `UID` — minted when the row is created, so
* every upload of the same alarm names it the same way. [raw] is the `VALARM`
* this row was read from, re-emitted verbatim while the row still means what
* it did, so another client's `ACKNOWLEDGED`, `X-*` and trigger form survive.
*/
@Entity( @Entity(
tableName = "task_alarms", tableName = "task_alarms",
foreignKeys = [ foreignKeys = [
@@ -219,4 +244,6 @@ data class TaskAlarmEntity(
@ColumnInfo(name = "minutes_before") val minutesBefore: Int, @ColumnInfo(name = "minutes_before") val minutesBefore: Int,
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE, @ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
@ColumnInfo(name = "message") val message: String? = null, @ColumnInfo(name = "message") val message: String? = null,
@ColumnInfo(name = "uid", defaultValue = "") val uid: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "raw") val raw: String? = null,
) )
@@ -49,6 +49,7 @@ object RoomTaskMapper {
description = row.task.description, description = row.task.description,
location = row.task.location, location = row.task.location,
url = row.task.url, url = row.task.url,
tags = row.task.categories,
priority = priorityFromICal(row.task.priority), priority = priorityFromICal(row.task.priority),
status = row.task.status, status = row.task.status,
percentComplete = row.task.percentComplete, percentComplete = row.task.percentComplete,
@@ -79,6 +80,7 @@ object RoomTaskMapper {
description = task.description, description = task.description,
location = task.location, location = task.location,
url = task.url, url = task.url,
tags = task.categories,
priority = priorityFromICal(task.priority), priority = priorityFromICal(task.priority),
status = task.status, status = task.status,
percentComplete = task.percentComplete, percentComplete = task.percentComplete,

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