Compare commits

..
Author SHA1 Message Date
makiolaj 7fde7ae290 fix: restore the floret glance dependency lost in the rebase
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 10m34s
2026-10-05 18:58:03 +02:00
makiolaj 5d273e05c7 chore: 1.1.0-beta.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Failing after 2m31s
2026-10-05 18:51:20 +02:00
makiolaj 59582b3641 docs(release): keep CHANGELOG entries under Unreleased while betas go out 2026-10-05 18:49:31 +02:00
makiolaj 3540d2fb7c chore: release 1.1.0 2026-10-05 18:49:15 +02:00
makiolaj 3812e950ca fix: remove double inset on the list editor's smart-lists row 2026-10-05 18:49:08 +02:00
makiolaj c6d62c36f4 fix(widget): regenerate picker previews to match the final widget designs 2026-10-05 18:49:08 +02:00
makiolaj a4fbbf43c6 feat: allow editing synced lists, not just device-only ones
The edit pencil was gated to local lists even though rename/delete already
worked end to end for synced collections. Read-only shares keep the icon
but show it disabled, with a hint on tap explaining why.

Refs #29
2026-10-05 18:49:08 +02:00
makiolaj d81e99084e fix: remove double inset on the list editor's Where row
Refs #30
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 9a87837917 feat(widget): widget redesign and new Today/Up next/Lists/Week widgets (#33)
Reworks the home-screen widgets on top of #32.

- Tasks widget: agenda-style rows, a row-colour setting, an "All caught up" empty state
- Drops "At a glance" (it duplicated the Tasks widget) for four new widgets: Today (progress ring + today's tasks), Up next (the nearest due task, optionally from one list), Lists (adaptive shortcut grid), Week (day strip with counts)
- Ticking a task in any widget shows a done frame before the row goes away
- Shared Glance primitives live in floret-kit (`feat/glance-module`): progress ring, tiles, check button, header action pill, expressive shapes, done state

Today and Up next don't offer the transparent background, since their content isn't in containers and is unreadable on the wallpaper. No issue behind this one; it came out of reviewing the widgets on-device.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/33
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 9630bd104d feat: let a widget show tasks from several lists (#32)
Widget config now multi-selects real lists (smart lists stay
single-select, mutually exclusive) and offers an optional "group by
list" sort that heads each list's tasks with its name.

Closes #25

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/32
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 7c3b62eb0d feat: setting to default a new task's start to today (#31)
A new Settings → Task form toggle, off by default, pre-fills a fresh
task's start with the current moment instead of leaving it blank.

Closes #27

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/31
2026-10-05 18:49:08 +02:00
239 changed files with 1444 additions and 12552 deletions
+2 -2
View File
@@ -26,14 +26,14 @@ Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes, and for anything touching the provider
read/write paths (OpenTasks / tasks.org installed).
./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
python3 scripts/check_translations.py
-->
### Checklist
- [ ] `./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug` passes locally
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
- [ ] Provider details stay inside `data/tasks/`
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
+6 -8
View File
@@ -127,12 +127,10 @@ jobs:
- name: Setup Java
if: steps.scope.outputs.code == 'true'
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4
with:
distribution: 'jetbrains'
java-version: '21'
distribution: 'zulu'
java-version: '17'
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
@@ -180,18 +178,18 @@ jobs:
# No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variants only)
- name: Lint (debug variant only)
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintFullDebug lintOfflineDebug
run: ./gradlew lintDebug
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
# that task would compile the vendored suite and run none of it, which is
# the whole safety argument in dav/PROVENANCE.md.
- name: Unit tests
if: steps.scope.outputs.code == 'true'
run: ./gradlew testFullDebugUnitTest testOfflineDebugUnitTest :dav:test :caldav:test
run: ./gradlew testDebugUnitTest :dav:test :caldav:test
- name: Assemble debug APKs
- name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug
+1 -1
View File
@@ -2,7 +2,7 @@ name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintFullDebug (ExtraTranslation).
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
#
# Runs on every PR (no path filter) so the required "Translations / check"
# status is always reported — like the `ci` job. A path-filtered workflow is
+47 -22
View File
@@ -3,7 +3,7 @@ name: Beta — Codeberg pre-release
# A beta is cut by pushing a release branch whose committed versionName is
# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the
# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
# its Codeberg pre-release doesn't carry the APK yet, this runs the unit tests, builds and
# no tag exists for that version yet, this runs the unit tests, builds and
# signs the APK with the app key, records a Gitea pre-release (with the R8
# mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
#
@@ -27,7 +27,7 @@ jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: light
runs-on: docker
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
@@ -36,28 +36,58 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and whether it still needs publishing
- name: Resolve version and whether it is a new beta
id: v
env:
# Codeberg, not Gitea: see the same lookup in release.yaml.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
run: |
set -e
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
echo "Not a beta — nothing to do."
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
BASE=$(echo "$INFO" | sed -n 's/^base_version=//p')
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"
exit 0
fi
# Done only once its Codeberg pre-release carries the APK, so a failed
# publish is redone by the next push; refused unless newer than the
# latest stable release.
GATE=$(bash scripts/release_gate.sh)
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
# Fatal on anything but a clean 200/404, as in release.yaml: guessing
# "no tag" during an outage would re-cut a published beta.
tag_status() {
curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/$1" || echo 000
}
# 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:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: gradle
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
@@ -70,12 +100,10 @@ jobs:
submodules: recursive
- name: Setup Java
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4
with:
distribution: 'jetbrains'
java-version: '21'
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
@@ -128,7 +156,7 @@ jobs:
grep -E 'versionName|versionCode' app/build.gradle.kts
- name: Unit tests
run: ./gradlew testFullDebugUnitTest
run: ./gradlew testDebugUnitTest
# 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.
@@ -147,8 +175,7 @@ jobs:
storeFile=upload-keystore.jks
EOF
# Both flavors; the offline one goes to Codeberg only (issue #39).
- name: Build release APKs
- name: Build release APK
run: ./gradlew assembleRelease
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
@@ -163,8 +190,7 @@ jobs:
bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
# The point of the whole workflow, so NOT continue-on-error.
@@ -176,6 +202,5 @@ jobs:
run: |
set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/full/release/app-full-release.apk \
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
+47 -23
View File
@@ -48,7 +48,7 @@ jobs:
# secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj'
runs-on: light
runs-on: docker
outputs:
is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }}
@@ -62,6 +62,17 @@ jobs:
- name: Resolve version and whether it is a new release
id: v
env:
# Tags are read from Codeberg, which is canonical — deliberately NOT
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
# source does not have. A tag minted here on Gitea is therefore wiped
# by the next sync (Codeberg does not have it yet) and only reappears
# once the tag push at the end of this workflow propagates back.
# Asking Gitea inside that window would report "no tag" for a release
# that already shipped, and cut it a second time.
# Public repo, so this read needs no token.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
run: |
set -e
# versionName -> versionCode, channel and the pre-release flag (set
@@ -83,11 +94,30 @@ jobs:
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
exit 1
fi
# Tags are read from Codeberg, the canonical forge, by exact name: its
# git/refs/tags API matches by prefix, so v1.1.0-beta.1 would read as
# v1.1.0. A lookup error is fatal rather than read as "no tag".
GATE=$(bash scripts/release_gate.sh)
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
#
# Anything other than a clean 200/404 is treated as fatal rather than
# as "no tag". A Codeberg outage or a network blip would otherwise
# read as absent and re-cut a release that has already shipped —
# republishing to F-Droid. Failing here is recoverable; a duplicate
# release is not.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
exit 1
;;
esac
# 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.
@@ -105,7 +135,7 @@ jobs:
release:
needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: gradle
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
@@ -120,12 +150,10 @@ jobs:
submodules: recursive
- name: Setup Java
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
# here keeps Gradle from downloading it via foojay.
uses: actions/setup-java@v4
with:
distribution: 'jetbrains'
java-version: '21'
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
@@ -184,7 +212,7 @@ jobs:
# Test the exact commit being shipped (only on a real release).
- name: Unit tests
if: env.IS_RELEASE == 'true'
run: ./gradlew testFullDebugUnitTest
run: ./gradlew testDebugUnitTest
- name: Setup Android Keystore
if: env.IS_RELEASE == 'true'
@@ -202,9 +230,7 @@ jobs:
storeFile=upload-keystore.jks
EOF
# 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
- name: Build release APK
if: env.IS_RELEASE == 'true'
run: ./gradlew assembleRelease
@@ -260,7 +286,7 @@ jobs:
run: |
set -e
mkdir -p fdroid/repo
cp app/build/outputs/apk/full/release/app-full-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
# Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this
@@ -322,8 +348,7 @@ jobs:
set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
# Publish the release on Codeberg, which is canonical for tags and
@@ -346,8 +371,7 @@ jobs:
set -e
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/full/release/app-full-release.apk \
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
# Play takes an App Bundle, not the APK: a second artifact from the same
@@ -362,7 +386,7 @@ jobs:
- name: Build release AAB
if: env.IS_RELEASE == 'true'
continue-on-error: true
run: ./gradlew bundleFullRelease
run: ./gradlew bundleRelease
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
@@ -374,7 +398,7 @@ jobs:
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
with:
name: release-aab-${{ needs.detect.outputs.version }}
path: app/build/outputs/bundle/fullRelease/app-full-release.aab
path: app/build/outputs/bundle/release/app-release.aab
if-no-files-found: error
retention-days: 14
@@ -468,7 +492,7 @@ jobs:
run: |
set -euo pipefail
# Absolute: a lane body runs from fastlane/, not the workspace root.
AAB="$GITHUB_WORKSPACE/dist/app-full-release.aab"
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
bundle exec fastlane deploy \
aab:"$AAB" \
-62
View File
@@ -7,65 +7,11 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Added
- A reminder can count from the task's start as well as from its due date, and
tasks with only a start date get reminders too. Default reminders can mix
both, e.g. a day before the start and an hour before the due date; each
applies to tasks that have its date (#72).
- An all-day task's reminders can each have their own time, so one task can
remind you at 09:00 and again at 17:00 (#78).
- Other apps can read the tasks kept in Agendula's own database through an
OpenTasks-compatible provider, once you grant them permission. Apps that
already support OpenTasks or tasks.org need only a small change to support
Agendula too (#43).
- With a second permission, other apps can also add, edit, complete and delete
those tasks, including their reminders and tags. They can tick off a single
occurrence of a recurring task. Task lists stay read-only for them (#63).
### Changed
- A new task no longer takes a default reminder whose time has already
passed, so a start of "today" does not ring the moment the task is saved.
- All-day default reminders are whole days; one set in hours rounds to the
nearest day.
- Switching a task between timed and all-day converts its reminders right
away in the editor, so what you see is what gets saved.
- The editor shows the default reminders a new task, or a newly added date,
will get, before you save. Removing them leaves the task without a reminder
instead of falling back to the default.
- 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 date, or one that gets a new date, takes the default
reminder for that date 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.
- Screens now open and close the way they do in Calendula: they slide in, and
a back swipe previews the screen behind before closing (#66).
- Settings now use Calendula's names and layout. Reminders is now
Notifications, Task form is now New task form, and export and import moved
out of Storage into a new Backup & restore section (#66).
## [1.1.0] - 2026-10-10
### Added
- Four new home-screen widgets: Today, Up next, Lists and Week.
- A widget can show tasks from several lists at once, optionally grouped by
list.
- A setting under Settings → Task form pre-fills a new task's start with today.
- An offline version of Agendula with no sync and no network access at all,
published as a separate APK on Codeberg (#39).
- Tags. Add them while editing a task, see them on the task and in lists,
browse them from the home screen and filter any list by them. They are saved
as `CATEGORIES`, so they sync with Nextcloud Tasks, tasks.org and DAVx⁵ (#35).
- Filters. Save your own view of the open tasks by due date (overdue, today,
upcoming, no date, in any combination), lists, tags and minimum priority.
They live on the home screen and can be shown in the Tasks widget, so today
and overdue tasks can now share one widget (#47).
### Changed
- The Tasks widget has agenda-style rows, an optional row colour and an "All
@@ -75,17 +21,9 @@ All notable changes to this project are documented here. The format follows
showed.
- Synced lists can be renamed and deleted, not only device-only ones. A
read-only share shows the edit button greyed out and explains why on tap.
- The sort button in task lists now opens "Sort and filter", which can also
filter by tag.
- While editing a task, its title moves into the top bar once you scroll past
it.
### Fixed
- The list editor's Where and smart-lists rows no longer have double padding.
- A field you tap now scrolls fully above the keyboard, and the "add subtask"
row in task lists no longer ends up hidden behind it.
- All-day tasks west of UTC no longer show up a day early in Overdue, Today
and Upcoming.
## [1.0.0] - 2026-09-21
+3 -3
View File
@@ -28,9 +28,9 @@ layer.
## Build, test, lint
```sh
./gradlew :app:assembleDebug # build the debug APKs (full + offline)
./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR)
./gradlew :app:assembleDebug # build the debug APK
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintDebug # Android lint (CI runs this on every PR)
```
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
+6 -57
View File
@@ -15,10 +15,6 @@ required.</p>
</p>
<p>
<a href="https://f-droid.org/packages/de.jeanlucmakiola.agendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
&nbsp;
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
&nbsp;
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
@@ -54,8 +50,7 @@ many small *florets* — so the two apps are florets of one bloom.)
this and the following ones, or the whole series.
- **Reminders** that fire at the exact time: several per task, separate defaults
for timed and all-day tasks (globally and per list), Done and Snooze right on
the notification, and they survive reboots. They sync as standard `VALARM`s,
so they also ring in other CalDAV clients, and theirs ring here.
the notification, and they survive reboots.
- **iCalendar import and export** — open an `.ics` or a zip of them, or write any
list out as standard `.ics` files.
- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
@@ -81,16 +76,6 @@ back to a full comparison where it does not. Everything the store does not model
is kept verbatim and sent back unchanged, so passing your tasks through Agendula
does not quietly lose fields another client wrote.
### Tested With
The following CalDAV Services have been Tested
| Service | Version | Date | Status | User |
|---|---|---|---|---|
| **Nextcloud** | 25.0.0 | Oct 9th 2026 | ✅ | [@jlmakiola](https://codeberg.org/jlmakiola) [@Schanbro](https://codeberg.org/Schanbro)
## Where your tasks live
| | Where | Sync | Needs |
@@ -102,10 +87,6 @@ Agendula's own store is an ordinary app database, so it **coexists with
OpenTasks rather than replacing it**. If you already sync through a provider,
Agendula can work on top of it exactly as before.
Other apps can read and write the tasks in Agendula's own store through an
OpenTasks-compatible provider, once you grant them permission. See
[docs/CONTENT_PROVIDER.md](docs/CONTENT_PROVIDER.md).
Switching between the two moves nothing — each store keeps its own tasks — so
**Settings → Storage** asks before it switches, and offers to **copy** a
provider's tasks into Agendula's own store when you want to move over. The copy
@@ -116,20 +97,10 @@ proprietary, and open standards — CalDAV and iCalendar — are the lane.
## Install
### F-Droid (recommended)
### F-Droid repository
Agendula is in the official [F-Droid](https://f-droid.org) repository. Search
for Agendula in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.agendula/).
F-Droid builds from source on its own schedule, so a new version usually shows
up there a few days after release.
### Self-hosted F-Droid repo (fastest updates)
The release pipeline builds, signs and publishes every release to a
self-hosted F-Droid repository, so new versions land there first. Add it once
and your F-Droid client handles updates from then on:
Every release is built, signed and published to a self-hosted F-Droid
repository. Add it once and your F-Droid client handles updates from then on:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
@@ -148,30 +119,9 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
serves. For automatic updates from there, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** and
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
Each release carries two APKs (see [Offline version](#offline-version)), so set
*Filter APKs by regular expression* to `^agendula_v` to always get the regular
app.
Betas of upcoming versions are published there too, as pre-releases; to test
them, switch on *Include prereleases* for Agendula in Obtainium.
### Google Play
Agendula is on
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula).
Play builds are signed with Google's key rather than mine, so switching between
Play and any other channel means uninstalling first. Tasks kept only in
Agendula's own database go with it, so sync them to a CalDAV account first.
### Offline version
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
without CalDAV sync and **without the network permission**, so it cannot
connect to anything. It installs alongside the regular app rather than replacing
it; move tasks between the two with Settings → Backup & restore → Export / Import. In
Obtainium, pick it with *Filter APKs by regular expression* set to
`^agendula-offline_v`.
### Build from source
```sh
@@ -186,9 +136,8 @@ JDK 17 and the Android SDK are all it needs; see
## Translations
Translations are managed on a self-hosted **Weblate**, and partial ones are
fine — an untranslated string simply falls back to English.
<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>
fine — an untranslated string simply falls back to English. Agendula ships in
English, German and Brazilian Portuguese so far.
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
+7 -53
View File
@@ -1,4 +1,3 @@
import com.android.build.api.artifact.SingleArtifact
import java.util.Properties
import java.io.FileInputStream
@@ -33,8 +32,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 1020001
versionName = "1.2.0-beta.1"
versionCode = 1010001
versionName = "1.1.0-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -47,20 +46,6 @@ android {
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
}
// `offline` (#39): no sync code, network libraries or network permission.
flavorDimensions += "network"
productFlavors {
create("full") {
dimension = "network"
buildConfigField("boolean", "SYNC_ENABLED", "true")
}
create("offline") {
dimension = "network"
applicationIdSuffix = ".offline"
buildConfigField("boolean", "SYNC_ENABLED", "false")
}
}
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
@@ -223,26 +208,26 @@ dependencies {
// @HiltWorker plumbing.
implementation(libs.androidx.work.runtime.ktx)
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
"fullImplementation"(libs.androidx.browser)
implementation(libs.androidx.browser)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
"fullImplementation"(libs.unifiedpush.connector)
implementation(libs.unifiedpush.connector)
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
implementation(libs.dmfs.lib.recur)
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
"fullImplementation"(project(":dav"))
implementation(project(":dav"))
// Discovery, auth and Nextcloud Login Flow v2.
"fullImplementation"(project(":caldav"))
implementation(project(":caldav"))
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
// with no framework, and android.jar's stub factory returns null under
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
// real implementation here.
"testFullImplementation"(libs.xpp3)
testImplementation(libs.xpp3)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
@@ -281,34 +266,3 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
}
/** Fails the build if the offline flavor's merged manifest asks for the network. */
abstract class VerifyNoNetworkPermissions : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val manifest: RegularFileProperty
@TaskAction
fun verify() {
val text = manifest.get().asFile.readText()
val found = listOf("INTERNET", "ACCESS_NETWORK_STATE", "ACCESS_WIFI_STATE", "CHANGE_NETWORK_STATE")
.filter { "\"android.permission.$it\"" in text }
if (found.isNotEmpty()) {
throw GradleException(
"The offline flavor's merged manifest declares ${found.joinToString()}. " +
"Remove it in app/src/offline/AndroidManifest.xml with tools:node=\"remove\".",
)
}
}
}
androidComponents {
onVariants(selector().withFlavor("network" to "offline")) { variant ->
val name = variant.name.replaceFirstChar { it.uppercase() }
val verify = tasks.register<VerifyNoNetworkPermissions>("verify${name}Manifest") {
manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
}
tasks.matching { it.name in setOf("package$name", "package${name}Bundle", "check") }
.configureEach { dependsOn(verify) }
}
}
@@ -1,529 +0,0 @@
{
"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')"
]
}
}
@@ -1,548 +0,0 @@
{
"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,15 +212,6 @@ class OneShotImportTest {
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
}
@Test
fun importsCategoryPropertiesAsTags() {
importer.importFrom(fixture())
val tasks = taskRows()
assertThat(tasks.getValue("Buy milk").categories).containsExactly("Errands")
assertThat(tasks.getValue("Ship the release").categories).isEmpty()
}
// --- running it -----------------------------------------------------------
@Test
@@ -1,157 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.content.Context
import android.content.pm.PermissionInfo
import android.os.Build
import android.os.ParcelFileDescriptor
import androidx.core.net.toUri
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/** The provider as another app meets it: manifest, permission, URIs. */
@RunWith(AndroidJUnit4::class)
class AgendulaTaskProviderTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val authority = "${context.packageName}.tasks"
private val readPermission = "${context.packageName}.permission.READ_TASKS"
private val writePermission = "${context.packageName}.permission.WRITE_TASKS"
private val resolver = context.contentResolver
private fun uri(path: String) = "content://$authority/$path".toUri()
@Test
fun isExportedUnderItsPermissions() {
val info = requireNotNull(context.packageManager.resolveContentProvider(authority, 0))
assertThat(info.name).isEqualTo(AgendulaTaskProvider::class.java.name)
assertThat(info.exported).isTrue()
assertThat(info.readPermission).isEqualTo(readPermission)
assertThat(info.writePermission).isEqualTo(writePermission)
}
@Test
fun bothPermissionsAreDangerous() {
listOf(readPermission, writePermission).forEach { permission ->
val info = context.packageManager.getPermissionInfo(permission, 0)
assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS)
assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty()
assertThat(info.loadDescription(context.packageManager)?.toString()).isNotEmpty()
}
}
@Test
fun anAppWithoutThePermissionIsDenied() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
// The shell is another uid that was never granted it.
val output = shell("content query --uri content://$authority/tasks")
assertThat(output).contains("Permission Denial")
}
@Test
fun anAppWithoutTheWritePermissionCannotWrite() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
val output = shell("content insert --uri content://$authority/tasks --bind title:s:x --bind list_id:i:1")
assertThat(output).contains("Permission Denial")
assertThat(output).contains(writePermission)
}
@Test
fun reportsOpenTasksMimeTypes() {
ProviderTable.entries.forEach { table ->
assertThat(resolver.getType(uri(table.path))).isEqualTo(table.mimeType(single = false))
assertThat(resolver.getType(uri("${table.path}/3"))).isEqualTo(table.mimeType(single = true))
}
}
@Test
fun everyTableAnswersWithItsColumns() {
ProviderTable.entries.forEach { table ->
resolver.query(uri(table.path), null, null, null, null)!!.use {
assertThat(it.columnNames.toList()).containsExactlyElementsIn(table.columns.map { c -> c.first }).inOrder()
}
}
}
@Test
fun aCursorWatchesTheUriItWasQueriedFor() {
val tasks = uri("tasks")
resolver.query(tasks, null, null, null, null)!!.use { assertThat(it.notificationUri).isEqualTo(tasks) }
}
@Test
fun aRowUriReadsOnlyThatRow() {
// Ids start at 1, so 0 names nothing whatever the store holds.
resolver.query(uri("tasks/0"), null, null, null, null)!!.use { assertThat(it.count).isEqualTo(0) }
resolver.query(uri("tasks/0"), null, "1 = 1 OR 1 = 1", null, null)!!.use { assertThat(it.count).isEqualTo(0) }
}
@Test
fun anUnknownUriIsRejected() {
assertThrows(IllegalArgumentException::class.java) { resolver.query(uri("events"), null, null, null, null) }
// ContentResolver.getType swallows the provider's exception and answers null.
assertThat(runCatching { resolver.getType(uri("tasks/abc")) }.getOrNull()).isNull()
}
@Test
fun listsAreReadOnly() {
val values = ContentValues().apply { put("list_name", "x") }
assertThrows(UnsupportedOperationException::class.java) { resolver.insert(uri("tasklists"), values) }
assertThrows(UnsupportedOperationException::class.java) { resolver.update(uri("tasklists/1"), values, null, null) }
assertThrows(UnsupportedOperationException::class.java) { resolver.delete(uri("tasklists/1"), null, null) }
}
@Test
fun aRowUriTakesNoInsert() {
assertThrows(IllegalArgumentException::class.java) {
resolver.insert(uri("tasks/1"), ContentValues().apply { put("title", "x") })
}
}
@Test
fun aBadWriteReachesTheClientAsIllegalArgument() {
// No list has this id, so nothing in the app's real store is touched.
val values = ContentValues().apply {
put("list_id", Long.MAX_VALUE)
put("title", "x")
}
assertThrows(IllegalArgumentException::class.java) { resolver.insert(uri("tasks"), values) }
}
@Test
fun aFailingBatchWritesNothing() {
val missingList = Long.MAX_VALUE
val operations = arrayListOf(
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
)
assertThrows(IllegalArgumentException::class.java) { resolver.applyBatch(authority, operations) }
resolver.query(uri("tasks"), arrayOf("_id"), "list_id = ?", arrayOf(missingList.toString()), null)!!
.use { assertThat(it.count).isEqualTo(0) }
}
/** stdout and stderr of [command]; the denial lands on stderr. */
private fun shell(command: String): String {
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommandRwe(command)
stdin.close()
return listOf(stdout, stderr).joinToString("\n") { pfd ->
ParcelFileDescriptor.AutoCloseInputStream(pfd).bufferedReader().use { it.readText() }
}
}
}
@@ -1,214 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** The snapshot follows the Room store, the storage mode and the clock, and says when it moved. */
@RunWith(AndroidJUnit4::class)
class ProviderSnapshotSourceTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var db: TasksDatabase
private lateinit var resolver: ProviderResolver
private lateinit var source: ProviderSnapshotSource
private val clock = MutableClock(Instant.parse("2026-10-10T08:00:00Z"))
private val changes = LinkedBlockingQueue<Uri>()
private val observer = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
changes += uri ?: Uri.EMPTY
}
}
private var listId = 0L
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
resolver = ProviderResolver(NoProviderInstalled)
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}, clock = clock)
listId = db.taskLists().insert(TaskListEntity(name = "Home", color = 0xFF112233.toInt()))
context.contentResolver.registerContentObserver(TasksContract.authorityUri(source.authority), true, observer)
}
@After
fun tearDown() {
context.contentResolver.unregisterContentObserver(observer)
db.close()
}
private fun query(table: ProviderTable, selection: String? = null, args: Array<String>? = null): Cursor =
source.query(table, null, selection, args, null)
private fun titles(): List<String?> = query(ProviderTable.TASKS).use { cursor ->
val index = cursor.getColumnIndexOrThrow(Tasks.TITLE)
buildList { while (cursor.moveToNext()) add(cursor.getString(index)) }
}
private fun insert(title: String, due: Instant? = null, rrule: String? = null): Long =
db.tasks().insert(TaskEntity(listId = listId, uid = title, title = title, due = due, rrule = rrule, timezone = "UTC"))
/** Waits for the coalesced notification, then drains any stragglers. */
private fun awaitChange() {
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
Thread.sleep(700)
changes.clear()
}
private fun assertNoChange() {
assertThat(changes.poll(1500, TimeUnit.MILLISECONDS)).isNull()
}
@Test
fun servesTheOwnStore() {
val id = insert("Milk")
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 10))
assertThat(titles()).containsExactly("Milk")
query(ProviderTable.LISTS).use {
it.moveToFirst()
assertThat(it.getString(it.getColumnIndexOrThrow(Lists.NAME))).isEqualTo("Home")
}
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
query(ProviderTable.PROPERTIES).use { assertThat(it.count).isEqualTo(1) }
}
@Test
fun aDeletedTaskIsNotPublished() {
val id = insert("Gone")
insert("Kept")
db.tasks().markDeleted(id, clock.now())
assertThat(titles()).containsExactly("Kept")
}
@Test
fun aWriteNotifiesObserversAndIsServedNext() {
source.start()
assertThat(titles()).isEmpty()
insert("Later")
awaitChange()
assertThat(titles()).containsExactly("Later")
}
@Test
fun aBurstOfWritesNotifiesOnce() {
source.start()
titles()
repeat(5) { insert("t$it") }
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
assertNoChange()
}
@Test
fun externalModeServesNothing() {
insert("Mine")
resolver.storageMode = StorageMode.EXTERNAL
ProviderTable.entries.forEach { table -> query(table).use { assertThat(it.count).isEqualTo(0) } }
}
@Test
fun switchingModeNotifiesAndRebuilds() {
source.start()
insert("Mine")
assertThat(titles()).containsExactly("Mine")
changes.clear()
resolver.storageMode = StorageMode.EXTERNAL
awaitChange()
assertThat(titles()).isEmpty()
resolver.storageMode = StorageMode.OWN
awaitChange()
assertThat(titles()).containsExactly("Mine")
}
@Test
fun writesInExternalModeDoNotNotify() {
resolver.storageMode = StorageMode.EXTERNAL
source.start()
titles()
insert("Hidden")
assertNoChange()
}
@Test
fun aClientSelectionNarrowsTheRows() {
insert("a")
insert("b")
query(ProviderTable.TASKS, "${Tasks.TITLE} = ?", arrayOf("b")).use { assertThat(it.count).isEqualTo(1) }
}
@Test
fun anHourOldSnapshotIsRebuiltAgainstTheClock() {
source.start()
insert("Daily", due = Instant.parse("2026-10-10T09:00:00Z"), rrule = "FREQ=DAILY;COUNT=3")
awaitChange()
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-10T09:00:00Z").toEpochMilliseconds())
clock.current += 1.days
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-11T09:00:00Z").toEpochMilliseconds())
awaitChange()
}
@Test
fun aFreshSnapshotIsReused() {
insert("a")
titles()
clock.current += 30.minutes
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
assertNoChange()
}
private fun currentOriginalTime(): Long =
query(ProviderTable.INSTANCES, "${Instances.DISTANCE_FROM_CURRENT} = 0").use {
assertThat(it.moveToFirst()).isTrue()
it.getLong(it.getColumnIndexOrThrow(Instances.INSTANCE_ORIGINAL_TIME))
}
}
private class MutableClock(var current: Instant) : Clock {
override fun now(): Instant = current
}
private object NoProviderInstalled : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = null
override fun isGranted(permission: String): Boolean = false
override fun appLabel(packageName: String): String? = null
}
@@ -1,811 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.awaitCancellation
import kotlin.time.Instant
/** Writes from another app, applied to the own store through the published tables. */
@RunWith(AndroidJUnit4::class)
class ProviderWriterTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var db: TasksDatabase
private lateinit var resolver: ProviderResolver
private lateinit var source: ProviderSnapshotSource
private lateinit var writer: ProviderWriter
private var afterWrites = 0
private var home = 0L
/** Truncated to the store's granularity, and on the hour so occurrences land on round times. */
private val today = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() / 3_600_000 * 3_600_000)
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
resolver = ProviderResolver(NoProvider)
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {})
writer = ProviderWriter(db, RoomTasksDataSource(db), source, resolver, afterWrite = { afterWrites++ })
home = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
}
@After
fun tearDown() = db.close()
private fun task(id: Long) = db.tasks().entity(id)
private fun insert(vararg values: Pair<String, Any?>): Long =
writer.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home, *values))
private fun byId(table: ProviderTable, id: Long) = "${table.idColumn} = $id"
private fun synced(readOnly: Boolean = false): Long {
val account = db.accounts().insert(AccountEntity(displayName = "me", username = "me"))
return db.taskLists().insert(
TaskListEntity(name = "Work", color = 0, accountId = account, href = "https://s/w/", isReadOnly = readOnly),
)
}
private fun dailySeries(title: String = "Daily"): Long = db.tasks().insert(
TaskEntity(listId = home, uid = title, title = title, dtstart = today - 1.days, due = today - 1.days + 1.hours,
rrule = "FREQ=DAILY;COUNT=5", timezone = "UTC"),
)
/** Each instance of [taskId]'s series by its original time, with its task row and status. */
private fun instances(): List<Triple<Long, Long, Int>> =
source.rows(
ProviderTable.INSTANCES,
listOf(Instances.INSTANCE_ORIGINAL_TIME, Instances.TASK_ID, Tasks.STATUS),
null,
null,
).map { (original, taskId, status) -> Triple(original as Long, taskId as Long, (status as Long).toInt()) }
private fun instanceId(original: Instant): Long =
source.rows(
ProviderTable.INSTANCES,
listOf(Tasks.ID),
"${Instances.INSTANCE_ORIGINAL_TIME} = ?",
arrayOf(original.toEpochMilliseconds().toString()),
).single().single() as Long
// --- inserts ----------------------------------------------------------------
@Test
fun anInsertedTaskIsPublishedAndOwedASync() {
val id = insert(Tasks.TITLE to "Milk", Tasks.PRIORITY to 1)
val stored = task(id)!!
assertThat(stored.title).isEqualTo("Milk")
assertThat(stored.priority).isEqualTo(1)
assertThat(stored.isDirty).isTrue()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.TITLE), byId(ProviderTable.TASKS, id), null))
.containsExactly(listOf("Milk"))
assertThat(afterWrites).isEqualTo(1)
}
@Test
fun anInsertNeedsAListThatExistsAndIsWritable() {
assertThrows(IllegalArgumentException::class.java) { writer.insert(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x")) }
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.LIST_ID to 999L) }
val readOnly = synced(readOnly = true)
assertThrows(UnsupportedOperationException::class.java) { insert(Tasks.LIST_ID to readOnly) }
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun anOccurrenceCannotBeInsertedAsATask() {
val series = dailySeries()
assertThrows(IllegalArgumentException::class.java) {
insert(Tasks.ORIGINAL_INSTANCE_ID to series, Tasks.ORIGINAL_INSTANCE_TIME to today.toEpochMilliseconds())
}
}
@Test
fun aDuplicateUidIsRejected() {
insert(Tasks.UID to "same")
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.UID to "same") }
}
@Test
fun listsAndInstancesTakeNoInserts() {
assertThrows(UnsupportedOperationException::class.java) {
writer.insert(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "New"))
}
assertThrows(UnsupportedOperationException::class.java) { writer.insert(ProviderTable.INSTANCES, emptyMap()) }
assertThrows(UnsupportedOperationException::class.java) {
writer.update(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "x"), null, null)
}
assertThrows(UnsupportedOperationException::class.java) { writer.delete(ProviderTable.LISTS, null, null) }
}
@Test
fun anUnknownColumnIsRejected() {
assertThrows(IllegalArgumentException::class.java) { insert("etag" to "x") }
}
@Test
fun nothingIsWrittenInExternalMode() {
val id = insert(Tasks.TITLE to "Mine")
resolver.storageMode = StorageMode.EXTERNAL
assertThrows(IllegalStateException::class.java) { insert() }
assertThrows(IllegalStateException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), null, null)
}
assertThat(task(id)!!.title).isEqualTo("Mine")
}
// --- updates ----------------------------------------------------------------
@Test
fun anUpdateChangesTheRowsItsSelectionMatches() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("b"))
assertThat(count).isEqualTo(1)
assertThat(task(a)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(task(b)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(b)!!.completedAt).isNotNull()
}
@Test
fun anUpdateMatchingNothingChangesNothing() {
insert(Tasks.TITLE to "a")
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.TITLE} = 'zzz'", null)).isEqualTo(0)
}
@Test
fun aTaskInAReadOnlyListCannotChange() {
val readOnly = synced(readOnly = true)
val id = db.tasks().insert(TaskEntity(listId = readOnly, uid = "r", title = "theirs"))
assertThrows(UnsupportedOperationException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "mine"), byId(ProviderTable.TASKS, id), null)
}
assertThrows(UnsupportedOperationException::class.java) {
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
}
assertThat(task(id)!!.title).isEqualTo("theirs")
}
@Test
fun aTaskMovesBetweenLists() {
val work = synced()
val id = insert(Tasks.TITLE to "t")
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
assertThat(db.tasks().live().single { it.title == "t" }.listId).isEqualTo(work)
}
@Test
fun aParentMustBeInTheSameListAndNotADescendant() {
val work = synced()
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val elsewhere = insert(Tasks.LIST_ID to work, Tasks.TITLE to "elsewhere")
assertThat(task(child)!!.parentId).isEqualTo(parent)
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to elsewhere), byId(ProviderTable.TASKS, child), null)
}
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to child), byId(ProviderTable.TASKS, parent), null)
}
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.PARENT_ID to 999L) }
}
@Test
fun movingASeriesDropsTheOverridesItNoLongerGenerates() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).hasSize(1)
writer.update(
ProviderTable.TASKS,
mapOf(Tasks.DTSTART to (today + 2.hours).toEpochMilliseconds(), Tasks.DUE to (today + 3.hours).toEpochMilliseconds()),
byId(ProviderTable.TASKS, series),
null,
)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aClientCannotTouchSyncState() {
val work = synced()
val id = db.tasks().insert(
TaskEntity(listId = work, uid = "s", title = "t", href = "/s.ics", etag = "e", sequence = 2, unknownProperties = "X-A:1"),
)
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "new", "_sync_id" to "x", "sync1" to "y"), byId(ProviderTable.TASKS, id), null)
val stored = task(id)!!
assertThat(stored.title).isEqualTo("new")
assertThat(stored.href).isEqualTo("/s.ics")
assertThat(stored.etag).isEqualTo("e")
assertThat(stored.sequence).isEqualTo(2)
assertThat(stored.unknownProperties).isEqualTo("X-A:1")
assertThat(stored.isDirty).isTrue()
}
// --- instances --------------------------------------------------------------
@Test
fun completingAnOccurrenceTicksOffOnlyThatOne() {
val series = dailySeries()
val count = writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(count).isEqualTo(1)
assertThat(task(series)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
val override = db.tasks().overrides(series).single()
assertThat(override.recurrenceId).isEqualTo(today)
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
val statuses = instances().associate { it.first to it.third }
assertThat(statuses[today.toEpochMilliseconds()]).isEqualTo(TasksContract.STATUS_COMPLETED)
assertThat(statuses.filterKeys { it != today.toEpochMilliseconds() }.values.toSet())
.containsExactly(TasksContract.STATUS_NEEDS_ACTION)
}
@Test
fun anOccurrenceCanBeCancelledAndReopened() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 3), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.CANCELLED)
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun contentWrittenToAnOccurrenceEditsTheSeries() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun anOccurrenceCannotBeMoved() {
dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.DUE to 0L), byId(ProviderTable.INSTANCES, instanceId(today)), null)
}
}
@Test
fun anOccurrenceHasNoPartialProgress() {
dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50), byId(ProviderTable.INSTANCES, instanceId(today)), null)
}
}
@Test
fun aPlainTaskInstanceWritesTheTask() {
val id = insert(Tasks.TITLE to "t", Tasks.DUE to today.toEpochMilliseconds())
val instance = source.rows(ProviderTable.INSTANCES, listOf(Tasks.ID), "${Instances.TASK_ID} = $id", null).single().single() as Long
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50, Tasks.DUE to (today + 1.days).toEpochMilliseconds()), byId(ProviderTable.INSTANCES, instance), null)
assertThat(task(id)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(task(id)!!.due).isEqualTo(today + 1.days)
}
@Test
fun deletingAnOccurrenceExceptsIt() {
val series = dailySeries()
assertThat(writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)).isEqualTo(1)
assertThat(task(series)!!.exdate).isNotNull()
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
assertThat(instances()).hasSize(4)
}
@Test
fun deletingAModifiedOccurrenceExceptsItToo() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).isEmpty()
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
}
// --- deletes ----------------------------------------------------------------
@Test
fun aParentGoesWithItsSubtasks() {
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
val other = insert(Tasks.TITLE to "other")
assertThat(writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)).isEqualTo(1)
assertThat(task(parent)).isNull()
assertThat(task(child)).isNull()
assertThat(task(grandchild)).isNull()
assertThat(task(other)).isNotNull()
}
@Test
fun aSyncedTaskLeavesATombstone() {
val work = synced()
val id = insert(Tasks.LIST_ID to work, Tasks.TITLE to "t")
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
assertThat(task(id)!!.isDeleted).isTrue()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(0)
}
@Test
fun deletingAnOverrideThroughTasksExceptsTheOccurrence() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, override), null)
assertThat(task(series)).isNotNull()
assertThat(task(series)!!.exdate).isNotNull()
assertThat(instances()).hasSize(4)
}
// --- properties -------------------------------------------------------------
@Test
fun anAlarmCanBeAddedChangedAndRemoved() {
val id = insert(Tasks.TITLE to "t", Tasks.DUE to (today + 1.days).toEpochMilliseconds())
db.tasks().update(task(id)!!.copy(isDirty = false))
val propertyId = writer.insert(
ProviderTable.PROPERTIES,
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE, Alarm.MINUTES_BEFORE to "15", Alarm.REFERENCE to "1"),
)
val alarm = db.alarms().forTask(id).single()
assertThat(alarm.minutesBefore).isEqualTo(15)
assertThat(alarm.reference).isEqualTo(AlarmReference.DUE)
assertThat(task(id)!!.isDirty).isTrue()
assertThat(propertyId).isEqualTo(ProviderIds.alarm(alarm.id))
writer.update(ProviderTable.PROPERTIES, mapOf(Alarm.MINUTES_BEFORE to 60, Alarm.REFERENCE to 2), byId(ProviderTable.PROPERTIES, propertyId), null)
assertThat(db.alarms().forTask(id).single().minutesBefore).isEqualTo(60)
assertThat(db.alarms().forTask(id).single().reference).isEqualTo(AlarmReference.START)
writer.delete(ProviderTable.PROPERTIES, "${Properties.MIMETYPE} = ?", arrayOf(Alarm.MIMETYPE))
assertThat(db.alarms().forTask(id)).isEmpty()
}
@Test
fun tagsCanBeAddedRenamedAndRemoved() {
val id = insert(Tasks.TITLE to "t")
val propertyId = writer.insert(
ProviderTable.PROPERTIES,
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to " errand "),
)
assertThat(task(id)!!.categories).containsExactly("errand")
writer.update(ProviderTable.PROPERTIES, mapOf(Category.NAME to "shopping"), byId(ProviderTable.PROPERTIES, propertyId), null)
assertThat(task(id)!!.categories).containsExactly("shopping")
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to "shopping"))
assertThat(task(id)!!.categories).containsExactly("shopping")
writer.delete(ProviderTable.PROPERTIES, "${Category.NAME} = ?", arrayOf("shopping"))
assertThat(task(id)!!.categories).isEmpty()
}
@Test
fun aPropertyCannotChangeItsKindOrTask() {
val id = insert(Tasks.TITLE to "t")
val other = insert(Tasks.TITLE to "o")
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 5))
val propertyId = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), null, null).single().single() as Long
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.MIMETYPE to Category.MIMETYPE), byId(ProviderTable.PROPERTIES, propertyId), null)
}
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to other), byId(ProviderTable.PROPERTIES, propertyId), null)
}
}
@Test
fun onlyAlarmsAndTagsCanBeWritten() {
val id = insert(Tasks.TITLE to "t")
assertThrows(IllegalArgumentException::class.java) {
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to "vnd.android.cursor.item/comment"))
}
assertThrows(IllegalArgumentException::class.java) {
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE))
}
}
// --- batches ----------------------------------------------------------------
@Test
fun aBatchIsAllOrNothing() {
assertThrows(IllegalArgumentException::class.java) {
writer.batch {
insert(Tasks.TITLE to "first")
insert(Tasks.TITLE to "second")
insert(Tasks.LIST_ID to 999L)
}
}
assertThat(db.tasks().live()).isEmpty()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
}
@Test
fun aBatchSeesItsOwnWritesAndReArmsRemindersOnce() {
writer.batch {
insert(Tasks.TITLE to "first")
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("first"))
}
assertThat(db.tasks().live().single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(afterWrites).isEqualTo(1)
}
// --- review regressions -----------------------------------------------------
@Test
fun aBatchDeletingTwoPropertiesDeletesThoseTwo() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
listOf(5, 10).forEach { db.alarms().insert(TaskAlarmEntity(taskId = a, minutesBefore = it)) }
db.alarms().insert(TaskAlarmEntity(taskId = b, minutesBefore = 15))
val ids = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), "${Properties.TASK_ID} = $a", null)
.map { it.single() as Long }
writer.batch { ids.forEach { writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, it), null) } }
assertThat(db.alarms().forTask(a)).isEmpty()
assertThat(db.alarms().forTask(b).map { it.minutesBefore }).containsExactly(15)
}
@Test
fun aBatchDeletingTwoOccurrencesDeletesThoseTwo() {
dailySeries()
val first = instanceId(today)
val second = instanceId(today + 1.days)
writer.batch {
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, first), null)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, second), null)
}
assertThat(instances().map { it.first }).containsExactly(
(today - 1.days).toEpochMilliseconds(),
(today + 2.days).toEpochMilliseconds(),
(today + 3.days).toEpochMilliseconds(),
)
}
@Test
fun anInstanceIdOutlivesAnotherOccurrencesDeletion() {
dailySeries()
val later = instanceId(today + 2.days)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(instanceId(today + 2.days)).isEqualTo(later)
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, later), null))
.isEqualTo(1)
assertThat(instances().single { it.third == TasksContract.STATUS_COMPLETED }.first)
.isEqualTo((today + 2.days).toEpochMilliseconds())
}
@Test
fun aRecurringSubtaskOutsideTheWindowGoesWithItsParent() {
val parent = insert(Tasks.TITLE to "parent")
val longAgo = today - 1000.days
val subtask = db.tasks().insert(
TaskEntity(listId = home, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2",
timezone = "UTC", parentId = parent),
)
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
assertThat(task(subtask)).isNull()
}
@Test
fun anOverriddenRecurringSubtaskGoesWholeWithItsParent() {
val parent = insert(Tasks.TITLE to "parent")
val subtask = dailySeries("sub")
db.tasks().update(task(subtask)!!.copy(parentId = parent))
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
assertThat(task(subtask)).isNull()
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun aModifiedOccurrenceCanBeWrittenBackAsRead() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
val row = readRow(ProviderTable.TASKS, override)
writer.update(ProviderTable.TASKS, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.TASKS, override), null)
assertThat(task(override)!!.title).isEqualTo("Renamed")
}
@Test
fun anInstanceCanBeWrittenBackAsReadToCompleteIt() {
val series = dailySeries()
val instance = instanceId(today)
val row = readRow(ProviderTable.INSTANCES, instance)
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instance), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(series)!!.dtstart).isEqualTo(today - 1.days)
}
@Test
fun movingASubtreeMovesItsSubtasks() {
val work = synced()
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, parent), null)
assertThat(listOf(parent, child, grandchild).map { task(it)!!.listId }).containsExactly(work, work, work)
assertThat(task(child)!!.parentId).isEqualTo(parent)
assertThat(task(grandchild)!!.parentId).isEqualTo(child)
}
@Test
fun aMoveOntoATakenUidIsRejected() {
val work = synced()
insert(Tasks.LIST_ID to work, Tasks.UID to "same")
val id = insert(Tasks.UID to "same")
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
}
assertThat(task(id)!!.listId).isEqualTo(home)
}
@Test
fun contentOnAModifiedOccurrenceStillEditsTheSeries() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(task(override)!!.title).isEqualTo("Renamed")
assertThat(task(override)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aWriteThatChangesNothingOwesNoSync() {
val id = insert(Tasks.TITLE to "t")
db.tasks().update(task(id)!!.copy(isDirty = false))
val modified = task(id)!!.lastModified
writer.update(ProviderTable.TASKS, readRow(ProviderTable.TASKS, id), byId(ProviderTable.TASKS, id), null)
writer.update(ProviderTable.TASKS, emptyMap(), byId(ProviderTable.TASKS, id), null)
assertThat(task(id)!!.isDirty).isFalse()
assertThat(task(id)!!.lastModified).isEqualTo(modified)
}
@Test
fun reopeningAnOpenOccurrenceForksNothing() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aWriteBeforeStartupSettlesIsRefused() {
val stuck = ProviderSnapshotSource(context, db, resolver, awaitStartup = { awaitCancellation() }, startupWait = 50.milliseconds)
val early = ProviderWriter(db, RoomTasksDataSource(db), stuck, resolver, afterWrite = {})
assertThrows(IllegalStateException::class.java) { early.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home)) }
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun aSingleRowWriteNeedsNoSnapshot() {
val id = insert(Tasks.TITLE to "t")
val unknown = 987_654L
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, unknown), null)).isEqualTo(0)
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(1)
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.INSTANCES, unknown), null)).isEqualTo(0)
assertThat(writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, unknown), null)).isEqualTo(0)
}
// --- second review regressions -----------------------------------------------
@Test
fun tickingOffAModifiedOccurrenceAsReadLeavesTheSeriesAlone() {
val series = dailySeries("Standup")
// Reopening forks an override, which then gets its own title as the app's "this occurrence" edit would.
store().setCompletedInstance(series, today, false)
val override = db.tasks().overrides(series).single()
db.tasks().update(override.copy(title = "Standup (moved room)"))
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Standup")
assertThat(task(override.id)!!.title).isEqualTo("Standup (moved room)")
assertThat(task(override.id)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun anInstanceWrittenBackWithOnlyProgressChangedIsCompleted() {
val series = dailySeries()
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.PERCENT_COMPLETE to 100), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aBadZoneOnAnInstanceReportsTheZone() {
dailySeries()
val instance = instanceId(today)
val error = assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TZ to "Europe/Berln"), byId(ProviderTable.INSTANCES, instance), null)
}
assertThat(error).hasMessageThat().contains("Unknown time zone")
}
@Test
fun anInProcessSeriesCanBeWrittenBackAsRead() {
val series = dailySeries()
db.tasks().update(task(series)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 50))
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(task(series)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aFloatingSeriesCanBeWrittenBackAsRead() {
val series = db.tasks().insert(
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
)
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(series)!!.timezone).isNull()
}
@Test
fun aParentLoopDeletesInsteadOfRecursingForever() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b", Tasks.PARENT_ID to a)
db.tasks().update(task(a)!!.copy(parentId = b))
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, a), null)
assertThat(task(a)).isNull()
assertThat(task(b)).isNull()
}
@Test
fun aBulkMoveWorksWhicheverOrderTheRowsComeIn() {
val work = synced()
// The subtask gets the lower id, so it comes first unless the writer reorders.
val child = insert(Tasks.TITLE to "child")
val parent = insert(Tasks.TITLE to "parent")
db.tasks().update(task(child)!!.copy(parentId = parent))
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), "${Tasks.LIST_ID} = ?", arrayOf(home.toString()))
assertThat(count).isEqualTo(2)
assertThat(task(child)!!.listId).isEqualTo(work)
assertThat(task(parent)!!.listId).isEqualTo(work)
assertThat(task(child)!!.parentId).isEqualTo(parent)
}
@Test
fun aRowSelectedWithAnArgumentIsWritten() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID}=?", arrayOf(b.toString()))).isEqualTo(1)
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID} = ?", arrayOf("987654"))).isEqualTo(0)
assertThat(task(a)!!.title).isEqualTo("a")
assertThat(task(b)!!.title).isEqualTo("x")
}
@Test
fun garbageExceptionDatesAreRejected() {
val series = dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.EXDATE to "tomorrow"), byId(ProviderTable.TASKS, series), null)
}
assertThat(task(series)!!.exdate).isNull()
}
private fun store() = RoomTasksDataSource(db)
/** Every column of one published row, as a client that read it would write it back. */
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
val columns = table.columns.map { it.first }
val row = source.rows(table, columns, byId(table, id), null).single()
return columns.zip(row).toMap() - table.idColumn
}
}
private object NoProvider : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = null
override fun isGranted(permission: String): Boolean = false
override fun appLabel(packageName: String): String? = null
}
@@ -1,141 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.Cursor
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/** The SQL a client sends runs against the snapshot as it would against OpenTasks. */
@RunWith(AndroidJUnit4::class)
class SnapshotDatabaseTest {
private val db = SnapshotDatabase()
private fun task(id: Long, title: String?, due: Long? = null): ProviderRow =
mapOf(Tasks.ID to id, Tasks.LIST_ID to 1L, Tasks.TITLE to title, Tasks.DUE to due, Tasks.PRIORITY to 0)
private fun load(vararg tasks: ProviderRow, lists: List<ProviderRow> = emptyList()) = db.load(
ProviderSnapshot(
ProviderTable.entries.associateWith {
when (it) {
ProviderTable.TASKS -> tasks.toList()
ProviderTable.LISTS -> lists
else -> emptyList()
}
},
),
)
private fun Cursor.strings(column: String): List<String?> = use {
val index = getColumnIndexOrThrow(column)
buildList { while (moveToNext()) add(if (isNull(index)) null else getString(index)) }
}
@Test
fun aNullProjectionReturnsEveryColumnInTableOrder() {
load(task(1, "a"))
db.query(ProviderTable.TASKS, null, null, null, null).use {
assertThat(it.columnNames.toList()).containsExactlyElementsIn(ProviderTable.TASKS.columns.map { c -> c.first })
.inOrder()
assertThat(it.count).isEqualTo(1)
}
}
@Test
fun selectionArgumentsAndSortOrderApply() {
load(task(1, "b", due = 30), task(2, "a", due = 10), task(3, "c", due = 20), task(4, "skip", due = 5))
val titles = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), "${Tasks.TITLE} != ?", arrayOf("skip"), "${Tasks.DUE} ASC")
.strings(Tasks.TITLE)
assertThat(titles).containsExactly("a", "c", "b").inOrder()
}
@Test
fun typesSurviveTheCopy() {
load(task(1, null, due = 1_700_000_000_000))
db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE, Tasks.DUE, Tasks.PRIORITY), null, null, null).use {
it.moveToFirst()
assertThat(it.isNull(0)).isTrue()
assertThat(it.getType(1)).isEqualTo(Cursor.FIELD_TYPE_INTEGER)
assertThat(it.getLong(1)).isEqualTo(1_700_000_000_000)
assertThat(it.getInt(2)).isEqualTo(0)
}
}
@Test
fun anUnsupportedColumnReadsAsNull() {
load(task(1, "a"))
assertThat(db.query(ProviderTable.TASKS, arrayOf("_sync_id"), null, null, null).strings("_sync_id"))
.containsExactly(null)
}
@Test
fun anUnknownProjectionColumnIsRejected() {
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, arrayOf("no_such_column"), null, null, null)
}
}
@Test
fun anUnknownSelectionColumnIsRejected() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, null, "sqlite_master.name IS NOT NULL", null, null)
}
}
@Test
fun aSelectionCannotBreakOutOfItsWhereClause() {
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, null, "1) UNION SELECT name FROM sqlite_master --", null, null)
}
}
@Test
fun eachTableAnswersOnlyForItsOwnRows() {
load(task(1, "a"), lists = listOf(mapOf(Lists.ID to 7L, Lists.NAME to "Home")))
assertThat(db.query(ProviderTable.LISTS, arrayOf(Lists.NAME), null, null, null).strings(Lists.NAME))
.containsExactly("Home")
db.query(ProviderTable.INSTANCES, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
}
@Test
fun aReloadReplacesTheRowsButNotAnOpenCursor() {
load(task(1, "old"))
val before = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null)
load(task(2, "new"))
assertThat(before.strings(Tasks.TITLE)).containsExactly("old")
assertThat(db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null).strings(Tasks.TITLE))
.containsExactly("new")
}
@Test
fun anEmptySnapshotEmptiesEveryTable() {
load(task(1, "a"))
db.load(ProviderSnapshot.EMPTY)
ProviderTable.entries.forEach { table ->
db.query(table, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
}
}
}
@@ -5,7 +5,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
@@ -366,23 +366,41 @@ class RoomTasksDataSourceTest {
@Test
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
val id = source.insertTask(form(due = now + 1.days))
val start = TaskReminder(minutesBefore = 10, fromStart = true)
// The whole reminder, not just the minute count: collapsing it to a bare
// Int is what fired an imported START-referenced alarm off DUE.
source.setReminders(id, listOf(start, TaskReminder(30)))
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
// Int is what fired an imported START-referenced alarm off DUE, and an
// alarm this seam sets from the UI is always due-referenced.
source.setAlarm(id, 30)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30))
source.setReminders(id, listOf(TaskReminder(60)))
source.setAlarm(id, 60)
assertThat(db.alarms().forTask(id)).hasSize(1)
assertThat(source.reminders()[id]).containsExactly(TaskReminder(60))
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
source.setAlarm(id, null)
assertThat(source.alarms()).doesNotContainKey(id)
}
@Test
fun settingTheEditableReminderLeavesTheOthersAlone() {
val id = source.insertTask(form(due = now + 1.days))
val start = TaskReminder(minutesBefore = 10, fromStart = true)
source.setReminders(id, listOf(start, TaskReminder(30), TaskReminder(120)))
source.setAlarm(id, 45)
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30), TaskReminder(45)).inOrder()
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(45))
source.setAlarm(id, null)
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30))
}
@Test
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
val id = source.insertTask(form(due = now + 1.days))
makeRecurring(id, now)
source.setReminders(id, listOf(TaskReminder(30)))
source.setAlarm(id, 30)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
@@ -393,134 +411,6 @@ class RoomTasksDataSourceTest {
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), reminders = listOf(TaskReminder(15), TaskReminder(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 aSaveWritesBeforeStartAndBeforeDueRemindersAlike() {
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), reminders = listOf(TaskReminder(5, fromStart = true), TaskReminder(45)))
assertThat(source.reminders()[id]).containsExactly(TaskReminder(5, 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(TaskReminder(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(TaskReminder(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(TaskReminder(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(TaskReminder(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 settingRemindersOwesTheServerAPut() {
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(30)))
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
}
@Test
fun deletingATaskInALocalListRemovesItOutright() {
val id = source.insertTask(form())
@@ -549,7 +439,7 @@ class RoomTasksDataSourceTest {
fun aForkedOccurrenceCarriesTheSeriesReminder() {
val id = source.insertTask(form())
makeRecurring(id, now)
source.setReminders(id, listOf(TaskReminder(30)))
source.setAlarm(id, minutesBeforeDue = 30)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
@@ -557,7 +447,7 @@ class RoomTasksDataSourceTest {
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
// The fork copies the master's properties, which is what carries the
// reminder across.
// reminder across — the repository is what puts the series' own back.
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
}
@@ -649,47 +539,4 @@ class RoomTasksDataSourceTest {
assertThat(thrown).isNotNull()
}
@Test
fun subtaskIdsNameRowsNotOccurrences() {
val parent = source.insertTask(form("parent"))
val longAgo = now - 1000.days
val outOfWindow = db.tasks().insert(
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
)
val series = db.tasks().insert(
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
)
source.setCompletedInstance(series, now, true)
val plain = source.insertTask(form("plain").copy(parentId = parent))
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
}
@Test
fun deletingASubtreeEndsOnAParentLoop() {
val a = source.insertTask(form("a"))
val b = source.insertTask(form("b").copy(parentId = a))
val c = source.insertTask(form("c").copy(parentId = b))
db.tasks().update(db.tasks().entity(a)!!.copy(parentId = c))
source.deleteWithSubtasks(a)
assertThat(listOf(a, b, c).mapNotNull { db.tasks().entity(it) }).isEmpty()
}
@Test
fun deletingFollowingFromTheFirstOccurrenceTakesTheWholeSubtree() {
val start = now
val series = db.tasks().insert(
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3"),
)
val child = source.insertTask(form("child").copy(parentId = series))
val grandchild = source.insertTask(form("grandchild").copy(parentId = child))
source.deleteFollowing(series, start)
assertThat(listOf(series, child, grandchild).mapNotNull { db.tasks().entity(it) }).isEmpty()
}
}
@@ -8,7 +8,27 @@ import org.junit.Rule
import org.junit.Test
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)
class TasksDatabaseMigrationTest {
@@ -42,111 +62,6 @@ 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 {
const val TEST_DB = "migration-test.db"
}
@@ -11,7 +11,7 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
@@ -107,17 +107,6 @@ class ExternalImportTest {
assertThat(importer.hasRun.first()).isTrue()
}
@Test
fun copiesTagsCleanedUp() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(7L to listOf(task(100, "Milk", tags = listOf("Shop", " Shop ", "", "Home"))))
importer.run()
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).single().task.categories)
.containsExactly("Shop", "Home").inOrder()
}
/** Every list arrives device-only: the account belongs to the sync app. */
@Test
fun importedListsAreDeviceOnly() = runBlocking {
@@ -252,7 +241,6 @@ class ExternalImportTest {
title: String,
uid: String? = "uid-$id",
parentId: Long? = null,
tags: List<String> = emptyList(),
) = ExportTask(
taskId = id,
uid = uid,
@@ -272,7 +260,6 @@ class ExternalImportTest {
rrule = null,
rdate = null,
parentId = parentId,
tags = tags,
)
private companion object {
@@ -294,25 +281,23 @@ private class FakeExternalStore : TasksDataSource {
return tasks[listId].orEmpty()
}
override fun reminders(): Map<Long, List<TaskReminder>> = alarms.mapValues { listOf(it.value) }
override fun alarms(): Map<Long, TaskReminder> = alarms
override fun tasks(query: TaskQuery): List<Task> = unused()
override fun task(taskId: Long): Task? = unused()
override fun subtasks(parentTaskId: Long): List<Task> = unused()
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = unused()
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
unused()
override fun insertTask(form: TaskForm): Long = unused()
override fun updateTask(taskId: Long, form: TaskForm) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
override fun deleteTask(taskId: Long) = unused()
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
unused()
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
unused()
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = unused()
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
override fun createLocalList(name: String, color: Int): Long = unused()
-88
View File
@@ -1,88 +0,0 @@
<?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>
@@ -1,131 +0,0 @@
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() }
}
}
@@ -1,34 +0,0 @@
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,35 +0,0 @@
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,
)
}
@@ -1,81 +0,0 @@
package de.jeanlucmakiola.agendula.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
/** Settings → Accounts and the screens under it, layered over the hub. */
@Composable
internal fun AccountSections(
section: SettingsSection?,
initialAccountId: Long?,
onSection: (SettingsSection?) -> Unit,
) {
// Hoisted so the add flow can refresh the list it returns to.
val accountsViewModel: AccountsViewModel = hiltViewModel()
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
val signInAgain: (AccountEntity) -> Unit = { account ->
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
onSection(SettingsSection.AddAccount)
}
// Which account the detail screen is showing; the section alone cannot say.
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
// Accounts stays composed under Add account, for the same reason Backup
// stays composed under export and import: the deeper screen slides over it.
val accountsOpen = section == SettingsSection.Accounts ||
section?.parent == SettingsSection.Accounts
SlideInSection(visible = accountsOpen) {
AccountsScreen(
onAddAccount = {
// A sign-in abandoned half-way must not turn this into one.
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
onSection(SettingsSection.AddAccount)
},
onSignInAgain = signInAgain,
onOpenAccount = {
openAccount = it
onSection(SettingsSection.Account)
},
onOpenStorage = { onSection(SettingsSection.Storage) },
onBack = { onSection(null) },
viewModel = accountsViewModel,
visible = accountsOpen,
)
}
val accountOpen = section == SettingsSection.Account
SlideInSection(visible = accountOpen) {
openAccount?.let { id ->
AccountDetailScreen(
accountId = id,
onBack = { onSection(SettingsSection.Accounts) },
onRemoved = { onSection(SettingsSection.Accounts) },
onSignInAgain = signInAgain,
onOpenStorage = { onSection(SettingsSection.Storage) },
viewModel = accountsViewModel,
visible = accountOpen,
)
}
}
SlideInSection(visible = section == SettingsSection.AddAccount) {
AddAccountScreen(
// The list stays composed underneath, so nothing re-runs its
// init and OnResume never fires on a section change. It no longer
// needs to: the accounts come from an observed query, so a new
// account — and every later sync — arrives on its own.
onDone = { onSection(SettingsSection.Accounts) },
onBack = { onSection(SettingsSection.Accounts) },
viewModel = addAccountViewModel,
)
}
}
+76 -22
View File
@@ -9,25 +9,35 @@
selected External mode. Both are dangerous-level.
StorageMode.OWN needs nothing here: it is a Room database in our own data
directory. -->
directory. Agendula publishes no ContentProvider and declares no
permissions of its own. -->
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
<!-- What another app requests to read or write the own store through
AgendulaTaskProvider. Dangerous, like OpenTasks' own: the user grants
them per app. Suffixed per build, so debug and release can coexist. -->
<permission
android:name="${applicationId}.permission.READ_TASKS"
android:description="@string/permission_read_tasks_description"
android:label="@string/permission_read_tasks_label"
android:protectionLevel="dangerous" />
<permission
android:name="${applicationId}.permission.WRITE_TASKS"
android:description="@string/permission_write_tasks_description"
android:label="@string/permission_write_tasks_label"
android:protectionLevel="dangerous" />
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
but it shows in F-Droid's permission diff, so declare it deliberately
rather than letting it appear from nowhere.
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
(whose failure mode is a fatal RemoteServiceException) and a Play
requirement for a video demo.
Two more permissions appear in the merged manifest without being
declared here, and both come from work-runtime: WAKE_LOCK, and
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
API 31 WorkManager implements expedited work with a foreground service,
and minSdk is 29, so it is load-bearing for the "Sync now" button.
Removing it with tools:node="remove" would break expedited work on
exactly the older devices that need it most. Noted because it shows in
F-Droid's permission diff and would otherwise look unexplained. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -45,17 +55,23 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<!-- Custom Tabs provider detection. Without this entry it silently finds
nothing on API 30+, and the Nextcloud login flow falls back to an
external browser for no visible reason. -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application
android:name=".AgendulaApp"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Agendula"
@@ -247,15 +263,53 @@
</intent-filter>
</receiver>
<!-- The own store under the OpenTasks contract. Empty, and refusing
writes, while External mode is active. -->
<!-- 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.tasks.provider.AgendulaTaskProvider"
android:authorities="${applicationId}.tasks"
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:readPermission="${applicationId}.permission.READ_TASKS"
android:writePermission="${applicationId}.permission.WRITE_TASKS"
tools:ignore="SystemPermissionTypo" />
android:permission="android.permission.ACCOUNT_MANAGER">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".data.sync.SyncAdapterService"
android:exported="true"
android:permission="android.permission.BIND_SYNC_ADAPTER">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
</service>
<!-- UnifiedPush: the connector binds this to deliver endpoints and
WebDAV-Push messages. Not exported; the connector's own receiver is
what distributors talk to. -->
<service
android:name=".data.sync.push.AgendulaPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<!-- WorkManager's on-demand initialisation. Removing the default
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
@@ -10,13 +10,12 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.provider.ProviderSnapshotSource
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
import de.jeanlucmakiola.floret.crash.CrashConfig
@@ -88,7 +87,6 @@ class AgendulaApp : Application(), Configuration.Provider {
scheduler.sync()
}
runCatching { entryPoint.taskWidgetUpdater().start() }
runCatching { entryPoint.providerSnapshotSource().start() }
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
}
}
@@ -107,7 +105,7 @@ class AgendulaApp : Application(), Configuration.Provider {
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
runCatching {
entryPoint.taskNotifier().refreshChannel()
entryPoint.channelRefreshers().forEach { it.refreshChannel() }
entryPoint.syncNoticeNotifier().refreshChannel()
}
}
@@ -122,8 +120,7 @@ class AgendulaApp : Application(), Configuration.Provider {
fun applicationScope(): CoroutineScope
fun databaseCheckpoint(): DatabaseCheckpoint
fun taskNotifier(): TaskNotifier
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun syncNoticeNotifier(): SyncNoticeNotifier
fun taskWidgetUpdater(): TaskWidgetUpdater
fun providerSnapshotSource(): ProviderSnapshotSource
}
}
@@ -25,6 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.di.LaunchHook
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
@@ -32,6 +33,10 @@ import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
@@ -50,6 +55,14 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
@Inject lateinit var accounts: AccountRepository
@Inject lateinit var syncTrigger: SyncTrigger
@Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
@Inject lateinit var push: PushRegistrar
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
@@ -81,9 +94,34 @@ class MainActivity : ComponentActivity() {
// Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
// Each hook on its own, so one waiting on the network holds up no other.
// Sync hard on app open: the periodic worker's interval is a floor, and
// in the `rare` and `restricted` App Standby buckets it may not have run
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
// schedule lost to "clear app data" or to a restore.
//
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
// so onCreate runs again on every rotation, theme switch, locale change
// and font-scale change — each of which would otherwise start a fresh
// network sync the moment the previous one finished.
if (savedInstanceState == null) {
launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
lifecycleScope.launch {
runCatching {
accounts.rescheduleAll()
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
// A login flow the previous process died in the middle of.
// Its password, if the user approved, exists nowhere else.
pendingLoginFlows.reclaim()
}
// Last and on its own: it waits on the network, and must not
// hold up the reclaim above. Re-registering on open is what
// the connector recommends.
runCatching { push.updateAll() }
}
}
// Variant hooks: the demo seeder in debug builds, nothing in release.
if (savedInstanceState == null) {
lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -142,15 +180,10 @@ class MainActivity : ComponentActivity() {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
/** The account a sign-in notification is about, on the intent it opens. */
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
private const val ACTION_OPEN_FILTER = "de.jeanlucmakiola.agendula.action.OPEN_FILTER"
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
@@ -200,19 +233,11 @@ class MainActivity : ComponentActivity() {
.putExtra(EXTRA_LIST_ID, listId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** Opens one saved filter. */
fun filterIntent(context: Context, filterId: Long): Intent =
Intent(ACTION_OPEN_FILTER, "agendula://filter/$filterId".toUri(), context, MainActivity::class.java)
.putExtra(EXTRA_FILTER_ID, filterId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
internal fun navRequestOf(intent: Intent?): NavRequest? {
if (intent == null) return null
if (BuildConfig.SYNC_ENABLED) {
intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenAccount(it) }
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
}
intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenAccount(it) }
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
when (intent.action) {
ACTION_NEW_TASK -> return NavRequest.NewTask(
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
@@ -223,8 +248,6 @@ class MainActivity : ComponentActivity() {
?.let { return NavRequest.OpenSmart(it) }
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenList(it) }
ACTION_OPEN_FILTER -> intent.getLongExtra(EXTRA_FILTER_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenFilter(it) }
}
importIntentUri(intent)?.let { return NavRequest.Import(it) }
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
@@ -1,22 +0,0 @@
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,6 +12,13 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
@@ -20,6 +27,7 @@ import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
@@ -43,13 +51,37 @@ import javax.inject.Singleton
* reconciled", and the credential store to accounts that ask to be signed in
* again — all states the app already knows how to be in, unlike a launch loop.
*/
internal fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "agendula_prefs",
corruptionHandler = replaceCorrupted(),
)
/** See [CredentialsDataStore] for why this is a separate file. */
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
name = CREDENTIALS_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/** See [SyncStateDataStore] for why this is a separate file. */
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
name = SYNC_STATE_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
*/
const val CREDENTIALS_DATASTORE = "agendula_credentials"
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
*/
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
@Module
@InstallIn(SingletonComponent::class)
abstract class DataBindModule {
@@ -62,6 +94,22 @@ abstract class DataBindModule {
@Singleton
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
@Binds
@Singleton
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
@Binds
@Singleton
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
@Binds
@Singleton
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
@Binds
@Singleton
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
// Deliberately unqualified-free of the routing above: this is the external
// store itself, for the one caller that has to read it while another store is
// the active one.
@@ -80,6 +128,18 @@ object DataProvideModule {
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.agendulaDataStore
@Provides
@Singleton
@CredentialsDataStore
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.credentialsDataStore
@Provides
@Singleton
@SyncStateDataStore
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.syncStateDataStore
@Provides
@Singleton
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
@@ -89,7 +149,6 @@ object DataProvideModule {
// backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3)
.build()
/**
@@ -7,8 +7,9 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
/**
* Something a build variant runs on a genuine app open (not on a configuration
* change): sync in the `full` flavor, the demo seeder in debug builds.
* Something a build variant wants to run when the app is launched with [Intent].
* Release builds contribute none; the debug source set adds the demo seeder, so
* that class never ships.
*/
fun interface LaunchHook {
suspend fun onLaunch(intent: Intent)
@@ -17,6 +17,37 @@ annotation class IoDispatcher
@Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope
/**
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
*
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
* `datastore/`, and a restored ciphertext is permanently undecryptable because
* Keystore keys are non-exportable. Its own file is what lets the backup rules
* exclude the credentials and nothing else — excluding the whole database or
* all of DataStore would trade a latent bug for a live one.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class CredentialsDataStore
/**
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
* counters and the full-reconciliation clock.
*
* Its own file for the same reason the credentials have one: Auto Backup
* includes `datastore/`, and every value in here is a statement about *this*
* device's conversation with a server. Restored onto a new install they are all
* lies, and two of them are dangerous — a restored "reconciled recently" makes
* the engine trust a sync token for another day, which is precisely the silently
* pruned change log the full path exists to catch, and a restored quarantine
* count silently skips resources that were never tried here.
*
* Not user data, so nothing is lost by excluding it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncStateDataStore
/**
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
@@ -1,83 +0,0 @@
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,22 +10,16 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.floret.reminders.ReminderLead
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
import de.jeanlucmakiola.floret.reminders.normalizeReminders
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import java.time.DayOfWeek
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@@ -61,10 +55,10 @@ data class Settings(
val dynamicColor: Boolean = true,
/** The list a new task defaults to; `null` = first available. */
val defaultListId: Long? = null,
/** Default reminders (0 before due = at due time); empty = none. */
val defaultReminders: List<ReminderLead> = listOf(ReminderLead(0)),
/** Default reminders for all-day tasks, in whole days, rung at [allDayReminderMinuteOfDay]. */
val defaultAllDayReminders: List<ReminderLead> = listOf(ReminderLead(0)),
/** Default reminders, as minutes before due (0 = at due time); empty = none. */
val defaultReminderMinutes: List<Int> = listOf(0),
/** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */
val defaultAllDayReminderMinutes: List<Int> = listOf(0),
/** Local time (minutes from midnight) an all-day task's reminder counts back from. */
val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
/** Master switch for due reminders; off clears every scheduled alarm. */
@@ -78,13 +72,13 @@ data class Settings(
*/
val bottomAddBar: Boolean = false,
/**
* Per-list overrides of [defaultReminders]: a list present in the map
* Per-list overrides of [defaultReminderMinutes]: a list present in the map
* overrides the global default (an empty list = no reminder); absent =
* inherit.
*/
val perListReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
/** Per-list overrides of [defaultAllDayReminders], same shape as [perListReminderOverride]. */
val perListAllDayReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */
val perListAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(),
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
@@ -113,16 +107,12 @@ data class Settings(
}
/** The lead times for a task in [listId]: its override if set, else the global default. */
fun reminderLeadsFor(listId: Long): List<ReminderLead> =
perListReminderOverride.reminderLeadsFor(listId, defaultReminders)
fun reminderLeadsFor(listId: Long): List<Int> =
perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes)
/** The lead times for an all-day task in [listId]. */
fun allDayReminderLeadsFor(listId: Long): List<ReminderLead> =
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminders)
/** The default reminders a task in [listId] takes — the one rule for which default applies. */
fun defaultRemindersFor(listId: Long, allDay: Boolean): List<TaskReminder> =
(if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)).map { it.asTaskReminder() }
fun allDayReminderLeadsFor(listId: Long): List<Int> =
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
}
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@@ -138,18 +128,16 @@ class SettingsPrefs @Inject constructor(
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
// The single lead of earlier versions carries over until a list is saved.
defaultReminders = p[DEFAULT_REMINDERS]?.let(::parseLeads)
?: listOf(ReminderLead(p[REMINDER_LEAD] ?: 0)),
defaultAllDayReminders = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseLeads)?.inWholeDays()
?: listOf(ReminderLead(0)),
defaultReminderMinutes = p[DEFAULT_REMINDERS]?.let(::parseMinutes)
?: listOf(p[REMINDER_LEAD] ?: 0),
defaultAllDayReminderMinutes = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseMinutes) ?: listOf(0),
allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 }
?: DEFAULT_ALL_DAY_REMINDER_MINUTE,
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
perListReminderOverride = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]),
perListAllDayReminderOverride = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE])
.mapValues { it.value.inWholeDays() },
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]),
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(),
@@ -195,12 +183,12 @@ class SettingsPrefs @Inject constructor(
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
}
suspend fun setDefaultReminders(leads: List<ReminderLead>) = dataStore.edit {
it[DEFAULT_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
suspend fun setDefaultReminderMinutes(minutes: List<Int>) = dataStore.edit {
it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",")
}
suspend fun setDefaultAllDayReminders(leads: List<ReminderLead>) = dataStore.edit {
it[DEFAULT_ALL_DAY_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = dataStore.edit {
it[DEFAULT_ALL_DAY_REMINDERS] = minutes.normalizeReminders().joinToString(",")
}
suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) =
@@ -253,16 +241,16 @@ class SettingsPrefs @Inject constructor(
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
current.applyReminderOverride(listId, override)
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
}
/** Set (or clear) a list's all-day reminder override. */
suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
val current = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
current.applyReminderOverride(listId, override)
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
}
suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p ->
@@ -271,34 +259,6 @@ class SettingsPrefs @Inject constructor(
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
}
/** The saved filters, oldest first, with the list ids of the current store. */
val savedFilters: Flow<List<SavedFilter>> =
dataStore.data.map { p -> p[SAVED_FILTERS] to p[STORAGE_MODE] }
.distinctUntilChanged()
.map { (raw, mode) -> SavedFilterCodec.decode(raw, (storedMode(mode) ?: resolver.autoMode()).name) }
.distinctUntilChanged()
/** One saved filter; `null` while it doesn't exist. */
fun savedFilter(id: Long): Flow<SavedFilter?> =
savedFilters.map { all -> all.firstOrNull { it.id == id } }.distinctUntilChanged()
/** Store [filter]; an id of 0 makes a new one. Returns the filter's id. */
suspend fun saveFilter(filter: SavedFilter): Long {
var id = filter.id
dataStore.edit { p ->
if (id <= 0) {
id = p[SAVED_FILTER_NEXT_ID] ?: 1L
p[SAVED_FILTER_NEXT_ID] = id + 1
}
p[SAVED_FILTERS] = SavedFilterCodec.upsert(p[SAVED_FILTERS], filter.copy(id = id), modeOf(p).name)
}
return id
}
suspend fun deleteFilter(id: Long) = dataStore.edit { p ->
p[SAVED_FILTERS] = SavedFilterCodec.remove(p[SAVED_FILTERS], id)
}
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
@@ -328,8 +288,6 @@ class SettingsPrefs @Inject constructor(
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
}
}
@@ -340,13 +298,6 @@ class SettingsPrefs @Inject constructor(
*/
private val reminderCodec = ReminderOverrideCodec.DEFAULT
/** `s5,30` → [5 before start, 30 before due]; an empty string is an explicit "no reminder". */
private fun parseLeads(stored: String): List<ReminderLead> =
stored.split(',').mapNotNull { ReminderLead.parse(it)?.takeIf { lead -> lead.minutes >= 0 } }.normalizeReminders()
/**
* All-day defaults as whole days. Before 1.2 an all-day default could be hours
* before the all-day time; a lead that is not whole days now names a time of day.
*/
private fun List<ReminderLead>.inWholeDays(): List<ReminderLead> =
map { it.asTaskReminder().inWholeDays().asReminderLead() }.normalizeReminders()
/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */
private fun parseMinutes(stored: String): List<Int> =
stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders()
@@ -1,9 +1,8 @@
package de.jeanlucmakiola.agendula.data.reminders
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.domain.AllDayReminder
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.calendarDate
import java.time.LocalTime
import java.time.ZoneId
@@ -37,53 +36,31 @@ internal object ReminderPlanner {
// One reminder per *occurrence* and lead: a recurring series yields a row
// per occurrence, all sharing a taskId, so this is a Set rather than a
// taskId-keyed Map.
.filter { !it.isClosed && (it.due != null || it.start != null) }
.filter { !it.isClosed && it.due != null }
.flatMap { task ->
// Reminders set on the task itself win — every one of them;
// otherwise the task's list may override the global lead, or opt
// out entirely (override = null). All-day tasks have their own
// day-scale defaults.
//
// ⚠️ Present-but-empty is "none", not "the default": the store's
// answer for a task whose alarms another client turned off. A
// default here would keep ringing for what the user switched off.
// A default counts only from a date the task has; its own reminders
// stand in each date for the other when the task lacks it.
val own = perTask[task.taskId]
val reminders = own ?: settings.defaultRemindersFor(task.listId, task.isAllDay)
.filter { (if (it.fromStart) task.start else task.due) != null }
val created = task.created?.toEpochMilliseconds()
reminders.mapNotNull { reminder ->
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due ?: task.start!!
val trigger = triggerAt(
anchor,
task.isAllDay,
reminder.minutesBefore,
settings.allDayReminderMinuteOfDay,
zone,
)
// A default never rings for a moment before the task existed:
// that is a task saved after its default had passed, not a
// reminder missed while the device was off.
if (own == null && created != null && trigger < created) return@mapNotNull null
// Dismissed on another client already: firing it here within
// the missed-reminder grace would ring for something done with.
//
// ⚠️ An all-day reminder without a time of its own 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 && reminder.allDay.minuteOfDay == null) {
triggerAt(anchor, true, reminder.minutesBefore, 0, zone)
} else {
trigger
}
val acknowledged = reminder.acknowledgedAt?.let { earliest <= it.toEpochMilliseconds() } == true
if (acknowledged) return@mapNotNull null
// Reminders set on the task itself win — every one of them, not just
// the one the editor shows; otherwise the task's list may override the
// global lead, or opt out entirely (override = null). All-day tasks
// have their own day-scale defaults.
val reminders = perTask[task.taskId].orEmpty().ifEmpty {
val leads = if (task.isAllDay) settings.allDayReminderLeadsFor(task.listId)
else settings.reminderLeadsFor(task.listId)
leads.map { TaskReminder(it) }
}
reminders.map { reminder ->
// A stored reminder says what it counts back from. Ours are always
// before due, but an imported dmfs alarm or another client's can be
// before *start*.
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
ScheduledReminder(
taskId = task.taskId,
triggerAt = trigger,
triggerAt = triggerAt(
anchor,
task.isAllDay,
reminder.minutesBefore,
settings.allDayReminderMinuteOfDay,
zone,
),
occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
)
}
@@ -103,9 +80,8 @@ internal object ReminderPlanner {
*
* ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a
* storage convention and not a moment — firing off it rang at 02:00 in
* Berlin and the evening before in New York. It is read as its calendar
* date instead, and the lead as [TaskReminder.allDay]: whole days ring at
* [allDayMinuteOfDay] local time, anything else at the time it names.
* Berlin and the evening before in New York. It is read as its calendar date
* at [allDayMinuteOfDay] local time instead, and the lead counts back from that.
*/
fun triggerAt(
anchor: Instant,
@@ -114,11 +90,12 @@ internal object ReminderPlanner {
allDayMinuteOfDay: Int,
zone: ZoneId,
): Long {
if (!allDay) return anchor.toEpochMilliseconds() - leadMinutes.coerceAtLeast(0) * 60_000L
val (days, minuteOfDay) = AllDayReminder.of(leadMinutes)
val minute = minuteOfDay ?: allDayMinuteOfDay
val time = LocalTime.of(minute / 60 % 24, minute % 60)
return anchor.calendarDate(allDay = true).minusDays(days.toLong())
.atTime(time).atZone(zone).toInstant().toEpochMilli()
val base = if (allDay) {
val time = LocalTime.of(allDayMinuteOfDay / 60 % 24, allDayMinuteOfDay % 60)
anchor.calendarDate(allDay = true).atTime(time).atZone(zone).toInstant().toEpochMilli()
} else {
anchor.toEpochMilliseconds()
}
return base - leadMinutes.coerceAtLeast(0) * 60_000L
}
}
@@ -1,10 +1,8 @@
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.ResourceValidator
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.TaskListEntity
import at.bitfire.dav4jvm.exception.UnauthorizedException
@@ -424,9 +422,8 @@ class CollectionSyncer(
/** Null when the resource was rejected before it left the device. */
private fun serialize(local: LocalResource): String? {
val alarms = store.alarmsFor(local.live.map { it.id })
val todos = local.live.map { row ->
VTodoMapper.write(row, parentUidOf(row), now(), alarms[row.id].orEmpty())
VTodoMapper.write(row, parentUidOf(row), now())
}
val calendar = CalendarResource.build(todos)
@@ -781,17 +778,14 @@ class CollectionSyncer(
val losing = existing.values.firstOrNull { it.isDirty }
val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first()
val masterRow = upsert(master, existing[master.entity.recurrenceId], null, href, eTag)
val masterRow = upsert(master.entity, existing[master.entity.recurrenceId], null, href, eTag)
val masterId = masterRow.id
parents[uid] = master.parentUid
val written = mutableListOf(masterRow)
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(
override, existing[override.entity.recurrenceId], masterId, href, eTag,
override.entity, existing[override.entity.recurrenceId], masterId, href, eTag,
)
}
val kept = written.map { it.recurrenceId }.toSet()
@@ -838,13 +832,12 @@ class CollectionSyncer(
/** @return the row as it now stands, so the caller can index it. */
private fun upsert(
mapped: VTodoMapper.Mapped,
incoming: TaskEntity,
existing: TaskEntity?,
masterId: Long?,
href: String,
eTag: String?,
): TaskEntity {
val incoming = mapped.entity
// Local-only columns the server has no opinion about. Taking the
// mapper's defaults here would silently reset the user's ordering and
// per-task colour on every download.
@@ -861,42 +854,12 @@ class CollectionSyncer(
// set uploads what was just downloaded.
isDirty = false,
isDeleted = false,
// The store's to change, from what the alarms do.
remindersOff = existing?.remindersOff ?: false,
)
// The server's alarms replace ours, as every other field does: a
// 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: a lead past the end of its
* anchor is clamped, 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 before-start reminder 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 {
alarm
}
return if (existing == null) {
row.copy(id = store.insert(row))
} else {
store.update(row)
row
}
}
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
@@ -54,7 +53,38 @@ class RemoteListRepository @Inject constructor(
private val resolver: ProviderResolver,
private val push: PushStore,
@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.
@@ -65,7 +95,7 @@ class RemoteListRepository @Inject constructor(
* able to say so, and the only moment that costs nothing is while the user
* is looking at the picker.
*/
override suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
// ⚠️ Empty in External mode, whatever the accounts table holds. The
// lists on screen then come from a third-party provider, so a row
// inserted into ours would exist, sync, and be visible to nobody.
@@ -90,7 +120,7 @@ class RemoteListRepository @Inject constructor(
*
* @return the new list's local id, or why there is none.
*/
override suspend fun create(
suspend fun create(
accountId: Long,
name: String,
color: Int,
@@ -159,7 +189,7 @@ class RemoteListRepository @Inject constructor(
* a row that has already been renamed locally by then reads as a rename that
* worked and then quietly reverted on the next sync.
*/
override suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
@@ -197,7 +227,7 @@ class RemoteListRepository @Inject constructor(
* grades 404 and 410 that way — because otherwise a collection someone
* removed from another client leaves a row here that nothing can get rid of.
*/
override suspend fun delete(listId: Long): Outcome = withContext(io) {
suspend fun delete(listId: Long): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
@@ -1,52 +0,0 @@
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
}
@@ -14,7 +14,6 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.R
import javax.inject.Inject
import javax.inject.Singleton
@@ -34,7 +33,7 @@ import javax.inject.Singleton
@Singleton
class SyncNoticeNotifier @Inject constructor(
@ApplicationContext private val context: Context,
) : ChannelRefresher {
) {
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
@@ -124,7 +123,7 @@ class SyncNoticeNotifier @Inject constructor(
* extra names the account for routing to Settings → Accounts → it.
*/
private fun signInIntent(accountId: Long): Intent =
MainActivity.openIntent(context).putExtra(MainActivity.EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
MainActivity.openIntent(context).putExtra(EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
private fun ensureSignInChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
@@ -171,13 +170,16 @@ class SyncNoticeNotifier @Inject constructor(
}
/** Re-create the channel, if it exists, in the current language. */
override fun refreshChannel() {
fun refreshChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
}
companion object {
/** The account a sign-in notification is about, on the intent it opens. */
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
private const val CHANNEL_ID = "sync_notices"
private const val NOTIFICATION_ID = 2
private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
@@ -1,6 +0,0 @@
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,6 +1,5 @@
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.TasksDatabase
import javax.inject.Inject
@@ -12,33 +11,17 @@ import javax.inject.Inject
* discovery: the reconciliation above this interface is where local edits are
* discarded, tombstones swept and conflicts resolved, and every one of those is
* a decision that should be provable without a device. Room's test double is
* Robolectric plus an in-memory database; this is a dozen methods.
* Robolectric plus an in-memory database; this is eight methods.
*/
interface SyncStore {
/** Every row in a list, **tombstones included**. */
fun rowsIn(listId: Long): List<TaskEntity>
fun insert(row: TaskEntity): Long
fun update(row: TaskEntity)
/** 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>)
/**
@@ -73,26 +56,12 @@ class RoomSyncStore @Inject constructor(
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
override fun insert(row: TaskEntity) = database.tasks().insert(row)
override fun update(row: TaskEntity) {
database.tasks().update(row)
}
override fun 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>) {
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
}
@@ -35,11 +35,7 @@ import kotlin.time.Duration.Companion.seconds
@Singleton
class SyncTrigger @Inject constructor(
@ApplicationContext private val context: Context,
) : SyncRequests {
override fun syncNow(accountName: String) {
enqueue(accountName)
}
) {
/**
* Starts a sync now.
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
@@ -21,7 +19,6 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
@@ -86,27 +83,6 @@ class AndroidTasksDataSource @Inject constructor(
override fun subtasks(parentTaskId: Long): List<Task> =
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
/**
* Read from the `tasks` table, so a recurring subtask with no occurrence in
* the window still counts. All columns and a lenient `_deleted`, as in
* [exportTasks], since the table's shape varies across providers.
*/
override fun subtaskIds(parentTaskId: Long): List<Long> = resolver.query(
TasksContract.tasksUri(authority()),
null,
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(parentTaskId.toString()),
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null) continue
r.getLong(Tasks.ID)?.takeIf { it != parentTaskId }?.let(::add)
}
}
} ?: emptyList()
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
val uri = TasksContract.instancesUri(authority())
// projection = null (all columns): the instances view differs across
@@ -114,14 +90,11 @@ class AndroidTasksDataSource @Inject constructor(
// `is_recurring` / `distance_from_current`). The by-name mapper reads
// whatever is present and nulls the rest; the repository re-sorts, so we
// don't depend on a provider sort column either.
val tasks = resolver.query(uri, null, selection, args, null)
return resolver.query(uri, null, selection, args, null)
?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
} ?: emptyList()
if (tasks.isEmpty()) return tasks
val tags = categories(tasks.map { it.taskId })
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
}
override fun exportTasks(listId: Long): List<ExportTask> {
@@ -129,7 +102,7 @@ class AndroidTasksDataSource @Inject constructor(
// table's shape varies across provider versions, and the by-name mapper
// reads what's there.
val uri = TasksContract.tasksUri(authority())
val tasks = resolver.query(
return resolver.query(
uri,
null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
@@ -142,58 +115,24 @@ class AndroidTasksDataSource @Inject constructor(
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList()
if (tasks.isEmpty()) return tasks
val tags = categories(tasks.map { it.taskId })
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
override fun insertTask(form: TaskForm): Long {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
// One batch, so a refused tag or alarm leaves no task behind for a retry
// to duplicate.
val authority = authority()
val ops = arrayListOf(
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
.withValues(values.toContentValues())
.build(),
)
normalizeTags(form.tags).forEach { tag ->
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
.withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) })
.withValueBackReference(Properties.TASK_ID, 0)
.build()
}
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")
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
?: throw TaskWriteFailedException("insert task")
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
}
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) {
override fun updateTask(taskId: Long, form: TaskForm) {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
setCategories(taskId, form.tags)
writeReminders(taskId, reminders)
}
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
if (reminders == null) return
setReminders(taskId, reminders)
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
val occurrence = task(seriesId, occurrenceStart)
val anchors = resolver.query(
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
@@ -212,8 +151,6 @@ class AndroidTasksDataSource @Inject constructor(
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
setCategories(seriesId, form.tags)
writeReminders(seriesId, reminders)
}
/**
@@ -222,10 +159,10 @@ class AndroidTasksDataSource @Inject constructor(
* task. Per-occurrence edits past the split point are dropped rather than
* carried over — the provider offers no way to re-parent an exception.
*/
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long {
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
updateSeries(seriesId, occurrenceStart, form, reminders)
updateSeries(seriesId, occurrenceStart, form)
return seriesId
}
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
@@ -235,12 +172,12 @@ class AndroidTasksDataSource @Inject constructor(
).size
endSeriesBefore(seriesId, spec, occurrenceStart)
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
return insertTask(form.copy(rrule = rule, percentComplete = null), reminders)
return insertTask(form.copy(rrule = rule, percentComplete = null))
}
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
val spec = seriesSpec(seriesId) ?: return
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteWithSubtasks(seriesId)
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId)
endSeriesBefore(seriesId, spec, occurrenceStart)
}
@@ -301,49 +238,13 @@ class AndroidTasksDataSource @Inject constructor(
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
}
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
if (reminders == 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 }
writeReminders(taskId, reminders)
try {
writeInstance(taskId, occurrenceStart, form)
} finally {
setReminders(taskId, seriesReminders)
}
}
private fun writeInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId)
// The fork copies the series' properties onto the override, so the tags
// go onto the series first and are put back once the override exists.
// Unchanged tags are inherited as they are, and the series is left alone.
val seriesTags = categories(listOf(taskId))[taskId].orEmpty()
val retag = normalizeTags(form.tags) != seriesTags
if (retag) setCategories(taskId, form.tags)
var failure: Throwable? = null
try {
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
} catch (e: Throwable) {
failure = e
throw e
} finally {
if (retag) {
try {
setCategories(taskId, seriesTags)
} catch (e: Throwable) {
failure?.addSuppressed(e) ?: throw e
}
}
}
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
}
/**
@@ -370,6 +271,21 @@ class AndroidTasksDataSource @Inject constructor(
}
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
val uri = TasksContract.propertiesUri(authority())
// Replace rather than update: the provider's AlarmHandler re-validates the
// whole row on every update, so a partial edit throws. Only the editable
// (last before-due) row goes — a start-relative or additional alarm
// another client wrote is not ours to drop.
alarmRows(taskId).lastOrNull { !it.second.fromStart }?.let { (propertyId, _) ->
resolver.delete(uri, "${Properties.PROPERTY_ID} = ?", arrayOf(propertyId.toString()))
}
if (minutesBeforeDue != null) {
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
?: throw TaskWriteFailedException("set alarm for task $taskId")
}
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
val uri = TasksContract.propertiesUri(authority())
resolver.delete(
@@ -386,6 +302,9 @@ class AndroidTasksDataSource @Inject constructor(
override fun reminders(): Map<Long, List<TaskReminder>> =
readAlarms(null).groupBy({ it.first }) { it.third }
override fun alarms(): Map<Long, TaskReminder> =
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
/** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */
private fun alarmRows(taskId: Long): List<Pair<Long, TaskReminder>> =
readAlarms(taskId).map { it.second to it.third }
@@ -429,58 +348,6 @@ class AndroidTasksDataSource @Inject constructor(
} ?: emptyList()
}
/** Tags by task id for [taskIds], in stored order. */
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
resolver.query(
TasksContract.propertiesUri(authority()),
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
selection,
args,
Properties.PROPERTY_ID,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID) ?: continue
val name = reader.getString(TasksContract.Category.NAME) ?: continue
add(id to name)
}
}
} ?: emptyList()
}
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
}
/**
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
* leave half the tags, and leaves them alone when nothing changed so the task
* isn't dirtied.
*/
private fun setCategories(taskId: Long, tags: List<String>) {
val wanted = normalizeTags(tags)
if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return
val authority = authority()
val uri = TasksContract.propertiesUri(authority)
val ops = arrayListOf(
ContentProviderOperation.newDelete(uri)
.withSelection(
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE),
)
.build(),
)
wanted.forEach {
ops += ContentProviderOperation.newInsert(uri)
.withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues())
.build()
}
runCatching { resolver.applyBatch(authority, ops) }
.getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) }
}
override fun setCancelled(taskId: Long, cancelled: Boolean) {
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
@@ -593,6 +460,3 @@ class AndroidTasksDataSource @Inject constructor(
return cv
}
}
/** SQLite's default bound-argument limit on older Android releases. */
private const val MAX_SQL_ARGS = 999
@@ -5,8 +5,8 @@ class ProviderUnavailableException :
IllegalStateException("No tasks provider installed")
/** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
RuntimeException("Task write failed: $operation", cause)
class TaskWriteFailedException(operation: String) :
RuntimeException("Task write failed: $operation")
/**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -35,24 +34,23 @@ class ModeRoutingTasksDataSource(
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
override fun task(taskId: Long): Task? = active().task(taskId)
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
active().updateSeries(seriesId, occurrenceStart, form, reminders)
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
active().splitSeries(seriesId, occurrenceStart, form, reminders)
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) =
active().updateSeries(seriesId, occurrenceStart, form)
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long =
active().splitSeries(seriesId, occurrenceStart, form)
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
active().deleteOccurrence(seriesId, occurrenceStart)
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
active().deleteFollowing(seriesId, occurrenceStart)
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
override fun subtaskIds(parentTaskId: Long): List<Long> = active().subtaskIds(parentTaskId)
override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId)
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = active().insertTask(form, reminders)
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) =
active().updateTask(taskId, form, reminders)
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
active().updateInstance(taskId, occurrenceStart, form, reminders)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
active().updateInstance(taskId, occurrenceStart, form)
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
override fun reminders(): Map<Long, List<TaskReminder>> = active().reminders()
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
active().setReminders(taskId, reminders)
@@ -1,101 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskReminder
import java.time.ZoneId
import kotlin.time.Instant
internal object StoredReminders {
/**
* The reminders to store for [form], given the task as it was.
*
* A date the task gets in this write brings the list's or the app's
* default for it *as the task's 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 getting its first date takes the defaults only when the form
* names no reminder; one that already had a date takes those of the new
* date that no reminder of the form counts from. Otherwise the form is
* kept, so an edit that clears every reminder means "use the default
* here" as before.
*
* The form's reminders are already of its kind, timed or all-day — see
* [switched]. Reminders the editor [chose][TaskForm.remindersChosen] are
* kept as they are: it showed these defaults already.
*/
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
val wanted = form.reminders.map { it.lead }.distinct()
if (form.remindersChosen) return wanted
return withDefaults(form, current?.start, current?.due, settings, now, zone)
}
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
fun withDefaults(
form: TaskForm,
start: Instant?,
due: Instant?,
settings: Settings,
now: Long,
zone: ZoneId,
): List<TaskReminder> {
val wanted = form.reminders.map { it.lead }.distinct()
val had = anchorsOf(start, due)
val gained = anchorsOf(form.start, form.due) - had
if (gained.isEmpty()) return wanted
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
val named = wanted.map { it.fromStart }.toSet()
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
}
/**
* [reminders] for [form] once it has switched between timed and all-day.
* Reminders that are exactly the old kind's default become the new kind's;
* the rest keep what still means something: whole days, rounded to them on
* an all-day task.
*/
fun switched(
reminders: List<TaskReminder>,
form: TaskForm,
settings: Settings,
now: Long,
zone: ZoneId,
): List<TaskReminder> {
if (reminders.isEmpty()) return reminders
if (reminders.toSet() == settings.defaultRemindersFor(form.listId, !form.isAllDay).toSet()) {
return defaultsFor(form, settings, now, zone)
}
val kept = if (form.isAllDay) {
reminders.map { it.inWholeDays() }
} else {
reminders.filter { it.allDay.minuteOfDay == null }
}
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
}
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
/**
* The defaults [form] takes: those counting from a date it has. A one-off
* task leaves out what is already past — a start of "now" would ring at once.
*/
private fun defaultsFor(form: TaskForm, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> =
settings.defaultRemindersFor(form.listId, form.isAllDay).filter { reminder ->
val anchor = (if (reminder.fromStart) form.start else form.due) ?: return@filter false
form.rrule != null || ReminderPlanner.triggerAt(
anchor,
form.isAllDay,
reminder.minutesBefore,
settings.allDayReminderMinuteOfDay,
zone,
) >= now
}
}
@@ -113,12 +113,6 @@ object TaskWriteMapper {
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
)
fun categoryValues(taskId: Long, name: String): Map<String, Any?> = mapOf(
Properties.TASK_ID to taskId,
Properties.MIMETYPE to TasksContract.Category.MIMETYPE,
TasksContract.Category.NAME to name,
)
/** The user-owned columns of a list — what an edit is allowed to change. */
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
Lists.NAME to name.trim(),
@@ -86,12 +86,6 @@ object TasksContract {
const val UID = "_uid"
const val DELETED = "_deleted"
const val HAS_ALARMS = "has_alarms"
const val ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"
const val LIST_OWNER = "list_owner"
const val LIST_ACCESS_LEVEL = "list_access_level"
const val VISIBLE = "visible"
}
/**
@@ -109,7 +103,6 @@ object TasksContract {
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
const val DISTANCE_FROM_CURRENT = "distance_from_current"
const val IS_RECURRING = "is_recurring"
const val INSTANCE_DURATION = "instance_duration"
/**
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
@@ -125,10 +118,6 @@ object TasksContract {
const val PROPERTY_ID = "property_id"
const val TASK_ID = "task_id"
const val MIMETYPE = "mimetype"
const val DATA0 = "data0"
const val DATA1 = "data1"
const val DATA2 = "data2"
const val DATA3 = "data3"
}
/**
@@ -165,21 +154,6 @@ object TasksContract {
const val TYPE_MESSAGE = 1
}
/**
* A category property row — one tag. DAVx5 maps these to and from
* `CATEGORIES`. Inserting with only [NAME] is enough: the provider looks the
* category up in the task's account, or creates it, and fills in `data0`.
*/
object Category {
const val MIMETYPE = "vnd.android.cursor.item/category"
/** `data1` — the tag as spelled. */
const val NAME = "data1"
/** `data2` — the tag's colour, if any. */
const val COLOR = "data2"
}
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
// --- status values (TaskColumns.STATUS_*) --------------------------------
@@ -3,9 +3,26 @@ package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskReminder
import kotlin.time.Instant
/**
* A stored reminder: how long before, and what it counts back from.
*
* [fromStart] matters because both stores can hold a `START`-referenced alarm —
* the dmfs import preserves one, and an external provider's other clients write
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
* to a number here is what silently fired those reminders off the wrong anchor.
*/
data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false)
/**
* The one reminder Agendula's single-reminder editor shows and writes: the last
* before-due one, which is where a write lands, so it stays the same one across
* saves. Anything else a task carries — a start-relative alarm, or the extra ones
* another client or the dmfs import brought — is left to ride along.
*/
fun List<TaskReminder>.editable(): TaskReminder? = lastOrNull { !it.fromStart }
/** What to fetch from the provider. Smart-list date logic is applied above this. */
data class TaskQuery(
val listId: Long? = null,
@@ -29,38 +46,8 @@ interface TasksDataSource {
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
fun subtasks(parentTaskId: Long): List<Task>
/**
* The task rows directly under [parentTaskId]: one id per subtask, its series
* master when it recurs, whether or not any occurrence falls in the window
* [subtasks] expands.
*/
fun subtaskIds(parentTaskId: Long): List<Long> =
subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId }
/**
* Delete [taskId] and its subtasks, all the way down; left behind they would
* hang off a tombstone. Each task once, so a parent loop an import brought in
* ends instead of recursing forever.
*/
fun deleteWithSubtasks(taskId: Long) {
val seen = HashSet<Long>()
fun delete(id: Long) {
if (!seen.add(id)) return
subtaskIds(id).forEach(::delete)
deleteTask(id)
}
delete(taskId)
}
/**
* 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.
*
* Each takes the task's whole set of [reminders], before-start and
* before-due alike, and `null` leaves the stored ones alone.
*/
fun insertTask(form: TaskForm, reminders: List<TaskReminder>? = null): Long
fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>? = null)
fun insertTask(form: TaskForm): Long
fun updateTask(taskId: Long, form: TaskForm)
/**
* Update a single occurrence of a recurring task, addressed by the task row and
@@ -73,19 +60,27 @@ interface TasksDataSource {
* instance row id keeps this seam independent of any one store's row
* numbering; External mode maps it back to an instance row itself.
*/
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
/**
* Set (or clear, with `null`) the task's [editable] reminder lead, stored as an
* Alarm property row. Every other reminder on the task is left untouched. The
* provider never fires it — [de.jeanlucmakiola.agendula.data.reminders
* .ReminderScheduler] does — but persisting it here is what syncs the lead and
* shares it with other OpenTasks clients.
*/
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
/**
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
* content from [form], and the series moved by however far that occurrence moved.
*/
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm)
/**
* "This and following": end [seriesId] before [occurrenceStart] and continue
* from there as a new series built from [form]. Returns the new series' id.
*/
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null): Long
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
@@ -94,15 +89,14 @@ interface TasksDataSource {
fun deleteFollowing(seriesId: Long, occurrenceStart: Instant)
/** Make [taskId]'s reminders exactly [reminders]. A synced task is owed a PUT for it. */
/** Replace every reminder on [taskId] with [reminders], in order. */
fun setReminders(taskId: Long, reminders: List<TaskReminder>)
/**
* Every task's reminders, by task id, in stored order. One query, for the
* scheduler. 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>>
/** Every task's reminders, by task id, in stored order. One query, for the scheduler. */
fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) }
/** Every task's [editable] reminder, by task id. */
fun alarms(): Map<Long, TaskReminder>
/**
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter
@@ -15,9 +13,6 @@ import kotlin.time.Instant
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
/** Tasks for a filter; [savedFilter] is a saved filter's definition, `null` once it's deleted. */
data class FilteredTasks(val tasks: List<Task>, val savedFilter: SavedFilter? = null)
/**
* The single entry point the UI layer uses. Flows re-emit automatically when the
* provider changes (our writes *and* external sync like DAVx5), via the
@@ -32,10 +27,7 @@ interface TasksRepository {
*/
fun writableTaskLists(): Flow<List<TaskList>> =
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
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>
fun tasks(filter: TaskFilter): Flow<List<Task>>
/**
* The direct children of [parentId], re-emitting live like the other flows.
@@ -83,6 +75,13 @@ interface TasksRepository {
scope: RecurringScope = RecurringScope.AllOccurrences,
)
/**
* The per-task reminder lead in minutes before due, or `null` if the task has
* none (in which case the list's / global setting applies). Read when the edit
* form loads so saving can't silently drop it.
*/
suspend fun reminderFor(taskId: Long): Int?
/** Every reminder stored on the task, in order — including start-relative ones. */
suspend fun remindersFor(taskId: Long): List<TaskReminder>
@@ -1,10 +1,7 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
@@ -20,11 +17,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -39,26 +32,12 @@ class TasksRepositoryImpl @Inject constructor(
private val dataSource: TasksDataSource,
private val providerResolver: ProviderResolver,
private val startupGate: StartupGate,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : TasksRepository {
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
@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 tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
SeriesCollapse.visible(dataSource.subtasks(parentId))
@@ -76,7 +55,7 @@ class TasksRepositoryImpl @Inject constructor(
}
}
private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List<Task> {
private fun loadTasks(filter: TaskFilter): List<Task> {
val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
@@ -87,8 +66,6 @@ class TasksRepositoryImpl @Inject constructor(
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
// several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
is TaskFilter.Tagged -> TaskQuery(includeCompleted = true)
is TaskFilter.Saved -> TaskQuery(includeCompleted = true)
}
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -100,7 +77,7 @@ class TasksRepositoryImpl @Inject constructor(
(if (t.isCompleted) done + 1 else done) to (total + 1)
}
return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) }
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
@@ -111,18 +88,11 @@ class TasksRepositoryImpl @Inject constructor(
override suspend fun createTask(form: TaskForm): Long =
withContext(io) {
dataSource.insertTask(form, storedRemindersFor(form, current = null))
val id = dataSource.insertTask(form)
writeReminders(id, form)
id
}
private suspend fun storedRemindersFor(form: TaskForm, current: Task?): List<TaskReminder> =
StoredReminders.of(
form,
current,
settingsPrefs.settings.first(),
Clock.System.now().toEpochMilliseconds(),
ZoneId.systemDefault(),
)
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
withContext(io) {
val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" }
@@ -130,9 +100,6 @@ class TasksRepositoryImpl @Inject constructor(
}
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(
TaskForm(
title = task.title,
@@ -145,10 +112,10 @@ class TasksRepositoryImpl @Inject constructor(
parentId = parentId,
location = task.location,
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) {
SeriesCollapse.visible(dataSource.subtasks(task.taskId))
.filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED }
@@ -157,6 +124,9 @@ class TasksRepositoryImpl @Inject constructor(
return id
}
override suspend fun reminderFor(taskId: Long): Int? =
withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() }
override suspend fun remindersFor(taskId: Long): List<TaskReminder> =
withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) }
@@ -180,17 +150,18 @@ class TasksRepositoryImpl @Inject constructor(
val seen = current?.lastModified
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
}
val reminders = storedRemindersFor(form, current)
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId != null && occurrence != null) {
when (scope) {
RecurringScope.AllOccurrences -> {
dataSource.updateSeries(seriesId, occurrence, form, reminders)
writeReminders(seriesId, form)
dataSource.updateSeries(seriesId, occurrence, form)
return@withContext
}
RecurringScope.ThisAndFollowing -> {
dataSource.splitSeries(seriesId, occurrence, form, reminders)
val created = dataSource.splitSeries(seriesId, occurrence, form)
writeReminders(created, form)
return@withContext
}
RecurringScope.ThisOccurrence -> Unit
@@ -198,11 +169,31 @@ class TasksRepositoryImpl @Inject constructor(
}
// A recurring task's start/due are one occurrence's resolved times, so
// writing them back to the task row would re-anchor the whole series.
// Going through the occurrence forks an override instead, which takes
// the reminders on its own row and leaves the series' alone.
// Going through the occurrence forks an override instead.
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
if (instance == null) dataSource.updateTask(taskId, form, reminders)
else dataSource.updateInstance(taskId, instance, form, reminders)
if (instance == null) {
writeReminders(taskId, form)
dataSource.updateTask(taskId, form)
return@withContext
}
// ⚠️ Written to the series, and then put back. Forking an occurrence
// copies the task's *current* properties onto the new override row,
// so setting the alarm beforehand is the only way to carry it
// across — and leaving it there would silently give every other
// occurrence in the series the reminder the user set on one of them.
val seriesReminders = dataSource.reminders()[taskId].orEmpty()
writeReminders(taskId, form)
try {
dataSource.updateInstance(taskId, instance, form)
} finally {
dataSource.setReminders(taskId, seriesReminders)
}
}
/** The form's before-due reminders; a start-relative one from elsewhere is kept as it was. */
private fun writeReminders(taskId: Long, form: TaskForm) {
val kept = dataSource.reminders()[taskId].orEmpty().filter { it.fromStart }
dataSource.setReminders(taskId, kept + form.reminders.distinct().sorted().map { TaskReminder(it) })
}
override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) =
@@ -235,7 +226,7 @@ class TasksRepositoryImpl @Inject constructor(
val current = dataSource.task(taskId, occurrenceStart)
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId)
when (scope) {
RecurringScope.ThisOccurrence ->
// An override row deletes itself into an EXDATE; a generated
@@ -243,10 +234,18 @@ class TasksRepositoryImpl @Inject constructor(
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
else dataSource.deleteOccurrence(seriesId, occurrence)
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId)
}
}
/** A parent goes with its subtasks; left behind they would hang off a tombstone. */
private fun deleteWithSubtasks(taskId: Long) {
dataSource.subtasks(taskId).map { it.taskId }.distinct()
.filter { it != taskId }
.forEach { dataSource.deleteTask(it) }
dataSource.deleteTask(taskId)
}
override suspend fun createLocalList(name: String, color: Int): Long =
withContext(io) { dataSource.createLocalList(name, color) }
@@ -1,228 +0,0 @@
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 de.jeanlucmakiola.agendula.domain.DAY_MINUTES
import de.jeanlucmakiola.agendula.domain.MIN_ALL_DAY_LEAD
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 — on an all-day task, after the end of its
* day. 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).
*
* ## All-day tasks
*
* A relative trigger counts from the date's midnight, so the lead is the
* reminder as [de.jeanlucmakiola.agendula.domain.TaskReminder.allDay] reads it:
* `-P1D` is a day before at this device's all-day time, which this mapper cannot
* see, and `PT9H` is 09:00 on the day. An absolute trigger has no zone to read
* a time of day in, so it rounds to the nearest whole day.
*/
object AlarmCodec {
/** 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
// A lead in seconds is not one the planner can reproduce.
if (before % 60 != 0L) return null
val minutes = before / 60
val latest = if (task.isAllDay) MIN_ALL_DAY_LEAD.toLong() else 0L
return minutes.takeIf { it in latest..Int.MAX_VALUE }?.let { Lead(it.toInt(), 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) }
}
/**
* [read] as builds before 1.2 stored it on an all-day task: rounded to whole
* days. Such a row keeps the other client's time of day instead of overwriting it.
*/
private fun readAsWholeDays(read: Lead, task: TaskEntity): Lead? =
if (task.isAllDay && read.minutesBefore % DAY_MINUTES != 0) {
read.copy(minutesBefore = roundToDays(read.minutesBefore * 60L).coerceAtLeast(0).toInt())
} else {
null
}
/** 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 row whose anchor the task has lost is written against the other
* date, 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 = when {
row.reference == AlarmReference.START && task.dtstart == null -> AlarmReference.DUE
row.reference == AlarmReference.DUE && task.due == null -> AlarmReference.START
else -> row.reference
}
val lead = Lead(row.minutesBefore, reference)
row.raw?.let(::parse)?.let { original ->
val read = resolve(original, task)
if (read == lead || read != null && readAsWholeDays(read, 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(if (task.isAllDay) MIN_ALL_DAY_LEAD else 0).toLong()
val seconds = -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: a lead past the end
* of its anchor is clamped, 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.
*
* ⚠️ As authored from the row, not re-emitted from [TaskAlarmEntity.raw]:
* a row rounded to whole days before 1.2 re-emits a time of day it does
* not hold, and would keep its rounded lead against the very alarm that
* carries it.
*/
fun wireLead(row: TaskAlarmEntity, task: TaskEntity): Lead? =
toVAlarm(row.copy(raw = null), 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,6 +1,5 @@
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.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
@@ -10,7 +9,6 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import java.time.ZoneId
import kotlin.time.Clock
import kotlin.time.Instant
@@ -53,11 +51,10 @@ import kotlin.time.Instant
*
* ## Alarms
*
* A `VALARM` [AlarmCodec] can fire exactly as written is claimed into
* [Mapped.alarms] — rows for `task_alarms`, carrying the original verbatim —
* and [write] authors the task's rows back. Any other `VALARM` (Apple's default
* and location alarms, repeating ones, snoozes) stays in the residue and is
* re-emitted untouched after the authored ones.
* `VALARM`s round-trip in the residue and are never authored here. Local
* reminders live in `task_alarms` and are never serialised. The two stores are
* disjoint, so neither can destroy the other. Merging them is a later
* decision, not a silent one.
*/
object VTodoMapper {
@@ -84,8 +81,6 @@ object VTodoMapper {
// Parameterised copies stay in the residue verbatim; authoring the
// column beside them would emit the same exclusions twice.
"RDATE", "EXDATE",
// Several lines, or one the column would not re-serialise exactly.
"CATEGORIES",
)
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
@@ -97,8 +92,6 @@ object VTodoMapper {
val uidWasMissing: Boolean,
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
val droppedResidue: Boolean,
/** The claimed `VALARM`s, `taskId` unset. Not counted against the residue cap. */
val alarms: List<TaskAlarmEntity> = emptyList(),
)
// ---------------------------------------------------------------- read
@@ -207,9 +200,6 @@ object VTodoMapper {
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
)
val (categories, categoriesProperty) = readCategories(vtodo)
claim(categoriesProperty)
val sequenceProperty = vtodo.property("SEQUENCE")
val sequence = take(
sequenceProperty,
@@ -235,7 +225,6 @@ object VTodoMapper {
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
categories = categories,
// URI, not TEXT — escaping it would corrupt a query string.
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
status = status,
@@ -267,18 +256,8 @@ object VTodoMapper {
.filterIndexed { index, _ -> index !in claimed }
.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) +
ICalSerializer.serializeAll(residueComponents)
ICalSerializer.serializeAll(vtodo.components)
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
return Mapped(
@@ -288,7 +267,6 @@ object VTodoMapper {
parentUid = parentUid,
uidWasMissing = uid.isEmpty(),
droppedResidue = tooLarge,
alarms = alarms,
)
}
@@ -298,19 +276,16 @@ object VTodoMapper {
* Serialises [entity] back to a VTODO.
*
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
* [now] is the `DTSTAMP`. [alarms] are the task's reminder rows; one whose
* anchor the task no longer has is left out (see [AlarmCodec.toVAlarm]).
* [now] is the `DTSTAMP`.
*/
fun write(
entity: TaskEntity,
parentUid: String? = null,
now: Instant = Clock.System.now(),
alarms: List<TaskAlarmEntity> = emptyList(),
): ICalComponent {
val residue = parseResidue(entity.unknownProperties)
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
val keptResidue =
residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) }
val suppressed = keptResidue
.map { it.name.uppercase() }
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
@@ -332,7 +307,6 @@ object VTodoMapper {
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
add("LOCATION", entity.location?.let(ICalValues::escapeText))
add("URL", entity.url)
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
add("STATUS", entity.status.toICalName())
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
@@ -366,7 +340,7 @@ object VTodoMapper {
return ICalComponent(
name = "VTODO",
properties = properties + keptResidue,
components = alarms.mapNotNull { AlarmCodec.toVAlarm(it, entity, now) } + residue.components,
components = residue.components,
)
}
@@ -412,7 +386,6 @@ object VTodoMapper {
private fun contradictsResidue(
property: ICalProperty,
residue: ICalComponent,
residueCategories: List<String>,
entity: TaskEntity,
parentUid: String?,
): Boolean {
@@ -460,7 +433,6 @@ object VTodoMapper {
"PRIORITY" -> entity.priority != PRIORITY_NONE
"PERCENT-COMPLETE" -> entity.percentComplete != null
"CLASS" -> entity.classification != null
"CATEGORIES" -> entity.categories != residueCategories
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
// so the column stays at its fallback and the residue always wins.
else -> false
@@ -503,23 +475,6 @@ object VTodoMapper {
// -------------------------------------------------------------- helpers
/**
* Every `CATEGORIES` copy's values, merged, and the one line to claim — only
* when it is the sole, parameterless copy and the column re-serialises it
* byte for byte. Anything else is read for display and stays in the residue.
*/
private fun readCategories(vtodo: ICalComponent): Pair<List<String>, ICalProperty?> {
val copies = vtodo.properties("CATEGORIES")
val values = normalizeTags(copies.flatMap(::categoryValues))
val claimable = copies.singleOrNull()?.takeIf {
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
}
return values to claimable
}
private fun categoryValues(property: ICalProperty): List<String> =
normalizeTags(ICalValues.parseTextList(property.value))
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
private class TimeRead(
@@ -15,7 +15,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -222,17 +221,12 @@ class OneShotImport @Inject constructor(
}
val alarms = mutableListOf<LegacyAlarm>()
val categories = mutableMapOf<Long, MutableList<String>>()
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties ORDER BY property_id", null)
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null)
.use { cursor ->
val r = CursorColumnReader(cursor)
while (cursor.moveToNext()) {
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 taskId = r.getLong("task_id") ?: continue
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
alarms += LegacyAlarm(
taskId = taskId,
@@ -247,7 +241,7 @@ class OneShotImport @Inject constructor(
}
}
return LegacySnapshot(lists, rows, alarms, categories.mapValues { normalizeTags(it.value) })
return LegacySnapshot(lists, rows, alarms)
}
// --- writing into Room ----------------------------------------------------
@@ -282,12 +276,7 @@ class OneShotImport @Inject constructor(
// the unique index; a fresh uid costs the row nothing it still has.
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
?: UUID.randomUUID().toString()
val entity = row.entity.copy(
listId = listId,
uid = uid,
recurrenceId = recurrenceId,
categories = snapshot.categories[row.id].orEmpty(),
)
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
val newId = taskDao.insert(entity)
taskIds[row.id] = newId
inserted += row to entity.copy(id = newId)
@@ -390,7 +379,6 @@ class OneShotImport @Inject constructor(
const val ARCHIVE_NAME = "tasks.db.imported"
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
private const val CATEGORY_MIMETYPE = "vnd.android.cursor.item/category"
private const val REFERENCE_START = "2"
private val SIDECARS = listOf("-journal", "-wal", "-shm")
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
@@ -403,8 +391,6 @@ private class LegacySnapshot(
val lists: List<LegacyList>,
val tasks: List<LegacyTaskRow>,
val alarms: List<LegacyAlarm>,
/** Tags by legacy task `_id`. */
val categories: Map<Long, List<String>>,
)
private class LegacyList(val id: Long, val entity: TaskListEntity)
@@ -1,99 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentProvider
import android.content.ContentProviderOperation
import android.content.ContentProviderResult
import android.content.ContentUris
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
/**
* Agendula's own store, published under the OpenTasks contract (`tasklists`,
* `tasks`, `instances`, `properties`) at `<applicationId>.tasks`, so an app that
* already works with OpenTasks or tasks.org works with Agendula by swapping the
* authority. Reads are guarded by `<applicationId>.permission.READ_TASKS`, writes
* by `<applicationId>.permission.WRITE_TASKS`; [ProviderWriter] applies them. See
* `docs/CONTENT_PROVIDER.md`.
*/
class AgendulaTaskProvider : ContentProvider() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ProviderEntryPoint {
fun snapshotSource(): ProviderSnapshotSource
fun writer(): ProviderWriter
}
private val entryPoint by lazy {
EntryPointAccessors.fromApplication(requireNotNull(context), ProviderEntryPoint::class.java)
}
private val source: ProviderSnapshotSource by lazy { entryPoint.snapshotSource() }
private val writer: ProviderWriter by lazy { entryPoint.writer() }
private val matcher by lazy {
UriMatcher(UriMatcher.NO_MATCH).apply {
ProviderTable.entries.forEach { table ->
addURI(source.authority, table.path, table.ordinal * 2)
addURI(source.authority, "${table.path}/#", table.ordinal * 2 + 1)
}
}
}
override fun onCreate(): Boolean = true
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
val (table, id) = match(uri)
return source.query(table, projection, table.selectionFor(id, selection), selectionArgs, sortOrder).apply {
setNotificationUri(requireNotNull(context).contentResolver, uri)
}
}
override fun getType(uri: Uri): String {
val (table, id) = match(uri)
return table.mimeType(single = id != null)
}
override fun insert(uri: Uri, values: ContentValues?): Uri {
val (table, id) = match(uri)
require(id == null) { "Insert into ${table.path}, not into one of its rows" }
return ContentUris.withAppendedId(uri, writer.insert(table, values.toMap()))
}
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
val (table, id) = match(uri)
return writer.update(table, values.toMap(), table.selectionFor(id, selection), selectionArgs)
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val (table, id) = match(uri)
return writer.delete(table, table.selectionFor(id, selection), selectionArgs)
}
/** All or nothing, as against OpenTasks, and observers hear of it once. */
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> =
writer.batch { super.applyBatch(operations) }
private fun match(uri: Uri): Pair<ProviderTable, Long?> {
val code = matcher.match(uri)
require(code != UriMatcher.NO_MATCH) { "Unknown URI $uri" }
val table = ProviderTable.entries[code / 2]
if (code % 2 == 0) return table to null
val id = requireNotNull(uri.lastPathSegment?.toLongOrNull()) { "Unknown URI $uri" }
return table to id
}
private fun ContentValues?.toMap(): ProviderValues =
this?.keySet()?.associateWith { get(it) }.orEmpty()
}
@@ -1,116 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
/** One table [AgendulaTaskProvider] serves, with its columns in cursor order. */
enum class ProviderTable(val path: String, val columns: List<Pair<String, String>>) {
LISTS(
Lists.PATH,
listOf(
Lists.ID to "INTEGER PRIMARY KEY",
Lists.NAME to "TEXT",
Lists.COLOR to "INTEGER",
Lists.ACCESS_LEVEL to "INTEGER",
Lists.VISIBLE to "INTEGER",
Lists.SYNC_ENABLED to "INTEGER",
Lists.OWNER to "TEXT",
Lists.ACCOUNT_NAME to "TEXT",
Lists.ACCOUNT_TYPE to "TEXT",
),
),
TASKS(Tasks.PATH, listOf(Tasks.ID to "INTEGER PRIMARY KEY") + TASK_COLUMNS),
INSTANCES(
Instances.PATH,
listOf(
Tasks.ID to "INTEGER PRIMARY KEY",
Instances.TASK_ID to "INTEGER",
Instances.INSTANCE_START to "INTEGER",
Instances.INSTANCE_START_SORTING to "INTEGER",
Instances.INSTANCE_DUE to "INTEGER",
Instances.INSTANCE_DUE_SORTING to "INTEGER",
Instances.INSTANCE_DURATION to "INTEGER",
Instances.INSTANCE_ORIGINAL_TIME to "INTEGER",
Instances.DISTANCE_FROM_CURRENT to "INTEGER",
Instances.IS_RECURRING to "INTEGER",
) + TASK_COLUMNS,
),
PROPERTIES(
Properties.PATH,
listOf(
Properties.PROPERTY_ID to "INTEGER PRIMARY KEY",
Properties.TASK_ID to "INTEGER",
Properties.MIMETYPE to "TEXT",
Properties.DATA0 to "TEXT",
Properties.DATA1 to "TEXT",
Properties.DATA2 to "TEXT",
Properties.DATA3 to "TEXT",
),
),
;
val idColumn: String get() = columns.first().first
/** [selection] narrowed to the row [id] names, for a `/<id>` URI. */
fun selectionFor(id: Long?, selection: String?): String? =
if (id == null) selection else listOfNotNull("$idColumn = $id", selection?.let { "($it)" }).joinToString(" AND ")
/** OpenTasks' MIME type for a whole table, or for one row of it. */
fun mimeType(single: Boolean): String =
"${if (single) "vnd.android.cursor.item" else "vnd.android.cursor.dir"}/org.dmfs.tasks.$path"
}
/** Every `tasks` column but `_id`; the instances view carries them too. */
private val TASK_COLUMNS: List<Pair<String, String>>
get() = listOf(
Tasks.LIST_ID to "INTEGER",
Tasks.LIST_NAME to "TEXT",
Tasks.LIST_COLOR to "INTEGER",
Tasks.LIST_OWNER to "TEXT",
Tasks.LIST_ACCESS_LEVEL to "INTEGER",
Tasks.VISIBLE to "INTEGER",
Tasks.ACCOUNT_NAME to "TEXT",
Tasks.ACCOUNT_TYPE to "TEXT",
Tasks.UID to "TEXT",
Tasks.DELETED to "INTEGER",
Tasks.TITLE to "TEXT",
Tasks.DESCRIPTION to "TEXT",
Tasks.LOCATION to "TEXT",
Tasks.URL to "TEXT",
Tasks.PRIORITY to "INTEGER",
Tasks.CLASSIFICATION to "INTEGER",
Tasks.STATUS to "INTEGER",
Tasks.PERCENT_COMPLETE to "INTEGER",
Tasks.COMPLETED to "INTEGER",
Tasks.IS_CLOSED to "INTEGER",
Tasks.IS_NEW to "INTEGER",
Tasks.TASK_COLOR to "INTEGER",
Tasks.DTSTART to "INTEGER",
Tasks.DUE to "INTEGER",
Tasks.DURATION to "TEXT",
Tasks.IS_ALLDAY to "INTEGER",
Tasks.TZ to "TEXT",
Tasks.RRULE to "TEXT",
Tasks.RDATE to "TEXT",
Tasks.EXDATE to "TEXT",
Tasks.ORIGINAL_INSTANCE_ID to "INTEGER",
Tasks.ORIGINAL_INSTANCE_TIME to "INTEGER",
Tasks.ORIGINAL_INSTANCE_ALLDAY to "INTEGER",
Tasks.PARENT_ID to "INTEGER",
Tasks.SORTING to "INTEGER",
Tasks.HAS_ALARMS to "INTEGER",
Tasks.CREATED to "INTEGER",
Tasks.LAST_MODIFIED to "INTEGER",
) + UNSUPPORTED_TASK_COLUMNS.map { it to "TEXT" }
/**
* OpenTasks columns Agendula has nothing for. Published as `NULL` so a client
* that projects them reads nothing rather than failing the query.
*/
private val UNSUPPORTED_TASK_COLUMNS = listOf(
"geo", "organizer", "pinned", "has_properties", "completed_is_allday", "original_instance_sync_id",
"version", "_dirty", "_sync_id", "sync_version",
"sync1", "sync2", "sync3", "sync4", "sync5", "sync6", "sync7", "sync8",
)
@@ -1,267 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.toInt
import java.time.ZoneId
import kotlin.time.Instant
typealias ProviderRow = Map<String, Any?>
/**
* Ids that stay put for as long as what they name exists, so a client can read
* an id and write to it later, or several in one batch. The one exception is
* two tags on one task whose spellings hash alike (about one in 2³¹): removing
* the one that sorts first moves the other onto its id.
*
* An instance is its series and its original time; a plain task's one
* instance has none. An alarm property is the alarm row; a tag property is its
* task and its spelling. Alarms take even ids and tags odd ones, so the two
* never meet.
*/
object ProviderIds {
fun instance(seriesId: Long, originalTime: Instant?): Long =
(seriesId shl 32) or ((originalTime?.epochSeconds ?: 0L) and 0xFFFFFFFFL)
fun instanceSeries(instanceId: Long): Long = instanceId ushr 32
fun instanceSeconds(instanceId: Long): Long = instanceId and 0xFFFFFFFFL
fun alarm(alarmId: Long): Long = alarmId shl 1
fun isAlarm(propertyId: Long): Boolean = propertyId and 1L == 0L
fun alarmRow(propertyId: Long): Long = propertyId ushr 1
/** The property id of each of [taskId]'s [categories], by spelling. */
fun categories(taskId: Long, categories: List<String>): Map<String, Long> =
categories(taskId, categories, ::tagHash)
internal fun categories(taskId: Long, categories: List<String>, hash: (String) -> Long): Map<String, Long> {
val used = HashSet<Long>()
// Two spellings on one task with one hash: the one that sorts later takes
// the next free slot, wrapping inside the tag bits so it never reaches the
// task's. Sorting keeps that independent of the tags' order.
val ids = categories.sorted().associateWith { name ->
var bits = hash(name) and TAG_BITS
while (!used.add(bits)) bits = (bits + 1) and TAG_BITS
(((taskId shl 31) or bits) shl 1) or 1L
}
return categories.associateWith(ids::getValue)
}
fun categoryTask(propertyId: Long): Long = propertyId ushr 32
private const val TAG_BITS = 0x7FFFFFFFL
/** 64-bit FNV-1a over the spelling's UTF-8, folded to 31 bits. */
private fun tagHash(name: String): Long {
var hash = -0x340d631b7bdddcdbL
name.encodeToByteArray().forEach { byte ->
hash = (hash xor (byte.toLong() and 0xFF)) * 0x100000001b3L
}
return (hash xor (hash ushr 31) xor (hash ushr 62)) and TAG_BITS
}
}
/** The Room store laid out as OpenTasks' tables, ready to be queried. */
class ProviderSnapshot(val rows: Map<ProviderTable, List<ProviderRow>>) {
companion object {
val EMPTY = ProviderSnapshot(ProviderTable.entries.associateWith { emptyList() })
/**
* Lays out the store. [tasks] must hold no tombstones. [accountType] is
* what a list attached to an account reports; a device-only list reports
* OpenTasks' local account so clients treat it the way they already do.
*/
fun build(
lists: List<TaskListRow>,
tasks: List<TaskEntity>,
alarms: List<TaskAlarmEntity>,
accountType: String,
now: Instant,
zone: ZoneId = ZoneId.systemDefault(),
): ProviderSnapshot {
val listsById = lists.associateBy { it.list.id }
val alarmsByTask = alarms.groupBy { it.taskId }
val taskRows = tasks.mapNotNull { task ->
val list = listsById[task.listId] ?: return@mapNotNull null
task.id to taskColumns(task, list, alarmsByTask[task.id].orEmpty().isNotEmpty(), accountType)
}.toMap()
val overrides = tasks.filter { it.masterId != null }.groupBy { it.masterId }
val instances = tasks.filter { it.masterId == null && it.id in taskRows }.flatMap { task ->
occurrences(task, overrides[task.id].orEmpty(), now).mapNotNull { occurrence ->
val columns = taskRows[occurrence.row.id] ?: return@mapNotNull null
columns + instanceColumns(ProviderIds.instance(task.id, occurrence.originalTime), occurrence, zone)
}
}
return ProviderSnapshot(
mapOf(
ProviderTable.LISTS to lists.map { listColumns(it, accountType) },
ProviderTable.TASKS to taskRows.map { (id, columns) -> columns + (Tasks.ID to id) },
ProviderTable.INSTANCES to instances,
ProviderTable.PROPERTIES to properties(tasks.filter { it.id in taskRows }, alarms),
),
)
}
private fun listColumns(row: TaskListRow, accountType: String): ProviderRow = mapOf(
Lists.ID to row.list.id,
Lists.NAME to row.list.name,
Lists.COLOR to row.list.color,
Lists.ACCESS_LEVEL to 0,
Lists.VISIBLE to row.list.isVisible.bit,
Lists.SYNC_ENABLED to row.list.isSynced.bit,
Lists.OWNER to row.list.owner,
Lists.ACCOUNT_NAME to accountName(row),
Lists.ACCOUNT_TYPE to accountTypeOf(row, accountType),
)
private fun taskColumns(task: TaskEntity, list: TaskListRow, hasAlarms: Boolean, accountType: String) =
mapOf(
Tasks.LIST_ID to task.listId,
Tasks.LIST_NAME to list.list.name,
Tasks.LIST_COLOR to list.list.color,
Tasks.LIST_OWNER to list.list.owner,
Tasks.LIST_ACCESS_LEVEL to 0,
Tasks.VISIBLE to list.list.isVisible.bit,
Tasks.ACCOUNT_NAME to accountName(list),
Tasks.ACCOUNT_TYPE to accountTypeOf(list, accountType),
Tasks.UID to task.uid,
Tasks.DELETED to 0,
Tasks.TITLE to task.title,
Tasks.DESCRIPTION to task.description,
Tasks.LOCATION to task.location,
Tasks.URL to task.url,
Tasks.PRIORITY to task.priority,
Tasks.CLASSIFICATION to task.classification,
Tasks.STATUS to task.status.toInt(),
Tasks.PERCENT_COMPLETE to task.percentComplete,
Tasks.COMPLETED to task.completedAt?.toEpochMilliseconds(),
Tasks.IS_CLOSED to (task.status == TaskStatus.COMPLETED || task.status == TaskStatus.CANCELLED).bit,
Tasks.IS_NEW to (task.status == TaskStatus.NEEDS_ACTION && task.percentComplete == null).bit,
Tasks.TASK_COLOR to task.color,
Tasks.DTSTART to task.dtstart?.toEpochMilliseconds(),
Tasks.DUE to task.due?.toEpochMilliseconds(),
Tasks.DURATION to task.duration,
Tasks.IS_ALLDAY to task.isAllDay.bit,
Tasks.TZ to task.timezone,
Tasks.RRULE to task.rrule,
Tasks.RDATE to task.rdate,
Tasks.EXDATE to task.exdate,
Tasks.ORIGINAL_INSTANCE_ID to task.masterId,
Tasks.ORIGINAL_INSTANCE_TIME to task.recurrenceId?.toEpochMilliseconds(),
Tasks.ORIGINAL_INSTANCE_ALLDAY to task.masterId?.let { task.isAllDay.bit },
Tasks.PARENT_ID to task.parentId,
Tasks.SORTING to task.sortOrder,
Tasks.HAS_ALARMS to hasAlarms.bit,
Tasks.CREATED to task.createdAt?.toEpochMilliseconds(),
Tasks.LAST_MODIFIED to task.lastModified?.toEpochMilliseconds(),
)
/** One instance: the row it reads from, its times, and where it sits in its series. */
private class Occurrence(
val row: TaskEntity,
val start: Instant?,
val due: Instant?,
val originalTime: Instant?,
val distance: Int,
val recurring: Boolean,
)
private fun occurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<Occurrence> {
val series = seriesOccurrences(master, overrides, now)
?: return listOf(Occurrence(master, master.dtstart, master.due, null, 0, recurring = false))
return series.mapIndexed { index, occurrence ->
Occurrence(
row = occurrence.override ?: master,
start = occurrence.start,
due = occurrence.due,
originalTime = occurrence.anchor,
distance = occurrence.distance ?: index,
recurring = true,
)
}
}
private fun instanceColumns(id: Long, occurrence: Occurrence, zone: ZoneId): ProviderRow {
val allDay = occurrence.row.isAllDay
val start = occurrence.start?.toEpochMilliseconds()
val due = occurrence.due?.toEpochMilliseconds()
return mapOf(
Tasks.ID to id,
Instances.TASK_ID to occurrence.row.id,
Instances.INSTANCE_START to start,
Instances.INSTANCE_START_SORTING to start?.let { sorting(it, allDay, zone) },
Instances.INSTANCE_DUE to due,
Instances.INSTANCE_DUE_SORTING to due?.let { sorting(it, allDay, zone) },
Instances.INSTANCE_DURATION to if (start != null && due != null) due - start else null,
Instances.INSTANCE_ORIGINAL_TIME to occurrence.originalTime?.toEpochMilliseconds(),
Instances.DISTANCE_FROM_CURRENT to occurrence.distance,
Instances.IS_RECURRING to occurrence.recurring.bit,
)
}
/**
* An all-day date is stored as UTC midnight; OpenTasks' sorting columns
* move it to local midnight so it sorts among the timed tasks of that day.
*/
private fun sorting(millis: Long, allDay: Boolean, zone: ZoneId): Long {
if (!allDay) return millis
val offset = zone.rules.getOffset(java.time.Instant.ofEpochMilli(millis)).totalSeconds * 1000L
return millis - offset
}
private fun properties(tasks: List<TaskEntity>, alarms: List<TaskAlarmEntity>): List<ProviderRow> {
val live = tasks.mapTo(HashSet()) { it.id }
val alarmRows = alarms.filter { it.taskId in live }.map {
mapOf(
Properties.PROPERTY_ID to ProviderIds.alarm(it.id),
Properties.TASK_ID to it.taskId,
Properties.MIMETYPE to Alarm.MIMETYPE,
Alarm.MINUTES_BEFORE to it.minutesBefore.toString(),
Alarm.REFERENCE to when (it.reference) {
AlarmReference.DUE -> Alarm.REFERENCE_DUE
AlarmReference.START -> Alarm.REFERENCE_START
}.toString(),
Alarm.MESSAGE to it.message,
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE.toString(),
)
}
val categoryRows = tasks.flatMap { task ->
ProviderIds.categories(task.id, task.categories).map { (name, id) ->
mapOf(
Properties.PROPERTY_ID to id,
Properties.TASK_ID to task.id,
Properties.MIMETYPE to Category.MIMETYPE,
Category.NAME to name,
)
}
}
return alarmRows + categoryRows
}
private fun accountName(row: TaskListRow): String =
row.accountDisplayName ?: TasksContract.LOCAL_ACCOUNT_NAME
private fun accountTypeOf(row: TaskListRow, accountType: String): String =
if (row.list.accountId == null) TasksContract.LOCAL_ACCOUNT_TYPE else accountType
private val Boolean.bit: Int get() = if (this) 1 else 0
}
}
@@ -1,182 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.Context
import android.database.Cursor
import android.os.Handler
import android.os.Looper
import androidx.room.InvalidationTracker
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.room.OBSERVED_TABLES
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/** Rebuilt at least this often, so the expansion window and `distance_from_current` follow the clock. */
private val MAX_AGE = 1.hours
private val STARTUP_WAIT = 10.seconds
private val NOTIFY_DELAY = 500.milliseconds
/**
* Serves [AgendulaTaskProvider]: keeps a [SnapshotDatabase] of the Room store,
* rebuilt lazily after a write or a storage-mode switch, and tells observers of
* the provider's authority when either happens.
*
* Empty in [StorageMode.EXTERNAL]: the Room store is not the user's data then,
* and clients should read OpenTasks or tasks.org directly.
*/
@Singleton
class ProviderSnapshotSource internal constructor(
private val context: Context,
private val database: TasksDatabase,
private val resolver: ProviderResolver,
private val awaitStartup: suspend () -> Unit,
private val clock: Clock = Clock.System,
private val startupWait: Duration = STARTUP_WAIT,
) {
@Inject constructor(
@ApplicationContext context: Context,
database: TasksDatabase,
resolver: ProviderResolver,
startupGate: StartupGate,
) : this(context, database, resolver, startupGate::awaitReady)
private val started = AtomicBoolean(false)
private val snapshot by lazy { SnapshotDatabase() }
private val generation = AtomicLong(1)
private var builtGeneration = 0L
private var builtAt: Instant? = null
private val handler = Handler(Looper.getMainLooper())
private val notify = Runnable {
context.contentResolver.notifyChange(TasksContract.authorityUri(authority), null)
}
val authority: String get() = "${context.packageName}.$AUTHORITY_SUFFIX"
/** Starts watching for changes. Idempotent; touches the database, so not from the main thread. */
fun start() {
if (!started.compareAndSet(false, true)) return
database.invalidationTracker.addObserver(
object : InvalidationTracker.Observer(OBSERVED_TABLES) {
override fun onInvalidated(tables: Set<String>) {
if (resolver.mode() == StorageMode.OWN) changed()
}
},
)
resolver.onModeChanged(::changed)
}
@Synchronized
fun query(
table: ProviderTable,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
refresh()
return snapshot.query(table, projection, selection, selectionArgs, sortOrder)
}
/**
* Runs [block] holding the snapshot's lock, once startup has settled which
* store is the user's. A write refuses to run without that, where a read
* falls back to answering from whichever store autoMode() picks.
*/
fun <T> locked(block: () -> T): T {
start()
checkNotNull(runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }) {
"Agendula is still starting; try again"
}
return synchronized(this, block)
}
/** The [columns] of every row of [table] that [selection] matches, as of now. */
@Synchronized
fun rows(table: ProviderTable, columns: List<String>, selection: String?, selectionArgs: Array<out String>?): List<List<Any?>> =
query(table, columns.toTypedArray(), selection, selectionArgs, null).use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
List(cursor.columnCount) { column ->
when (cursor.getType(column)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(column)
else -> cursor.getString(column)
}
},
)
}
}
}
/**
* Marks the snapshot stale at once. A provider write calls this so the next
* operation of the same batch sees it, before Room's own invalidation lands.
*/
fun invalidate() {
generation.incrementAndGet()
}
@Synchronized
private fun refresh() {
start()
runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }
val now = clock.now()
val current = generation.get()
val expired = builtAt.let { it == null || now - it > MAX_AGE }
if (current != builtGeneration || expired) {
snapshot.load(build(now))
builtGeneration = current
// Instance ids and distances move with the clock, so a timed rebuild is a change too.
if (expired && builtAt != null) notifyObservers()
builtAt = now
}
}
private fun build(now: Instant): ProviderSnapshot {
if (resolver.mode() != StorageMode.OWN) return ProviderSnapshot.EMPTY
// Read consistently in one transaction; expand outside it, so the app's
// own writes do not wait on recurrence expansion.
val (lists, tasks, alarms) = database.runInTransaction<Triple<List<TaskListRow>, List<TaskEntity>, List<TaskAlarmEntity>>> {
Triple(database.taskLists().lists(), database.tasks().live(), database.alarms().all())
}
return ProviderSnapshot.build(lists, tasks, alarms, context.getString(R.string.account_type), now)
}
private fun changed() {
generation.incrementAndGet()
notifyObservers()
}
/** Coalesced, so a sync committing many small transactions wakes each client once. */
private fun notifyObservers() {
handler.removeCallbacks(notify)
handler.postDelayed(notify, NOTIFY_DELAY.inWholeMilliseconds)
}
companion object {
const val AUTHORITY_SUFFIX = "tasks"
}
}
@@ -1,304 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.forAllDay
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.statusFromInt
import org.dmfs.rfc5545.recur.RecurrenceRule
import java.time.ZoneId
import kotlin.time.Instant
/** A client's `ContentValues`, as plain Kotlin values. */
typealias ProviderValues = Map<String, Any?>
/** The `tasks` columns a client may write. Everything else the table publishes is ignored on a write. */
val WRITABLE_TASK_COLUMNS: Set<String> = setOf(
Tasks.LIST_ID, Tasks.UID, Tasks.TITLE, Tasks.DESCRIPTION, Tasks.LOCATION, Tasks.URL, Tasks.PRIORITY,
Tasks.CLASSIFICATION, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED, Tasks.TASK_COLOR, Tasks.DTSTART,
Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.PARENT_ID,
Tasks.SORTING,
)
/** The columns that say whether a task, or one occurrence of it, is done. */
val COMPLETION_COLUMNS: Set<String> = setOf(Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED)
/** The columns that place a task in time; one occurrence of a series cannot be moved through the provider. */
val TIME_COLUMNS: Set<String> = setOf(
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
)
/** What a write to one occurrence of a series does to it. */
enum class OccurrenceChange { COMPLETE, CANCEL, REOPEN }
private val ICAL_DATE = Regex("""\d{8}(T\d{6}Z?)?""")
private val ICAL_DURATION = Regex("""[+-]?P(\d+W|(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?)""")
/**
* Turns a client's OpenTasks `ContentValues` into [TaskEntity] changes. Pure, so
* the rules are testable on the JVM. A bad value throws [IllegalArgumentException],
* which reaches the client as is.
*
* Only [WRITABLE_TASK_COLUMNS] change anything. The rest of what the provider
* publishes is accepted and ignored, so a client can write back a row it read;
* the fields Agendula keeps for CalDAV (`href`, `etag`, `sequence`, unknown
* properties) are not published and so cannot be written at all.
*/
object ProviderTaskWriter {
/**
* [values] narrowed to the columns in [writable]. A column [table] does not
* have at all is a client bug and throws.
*/
fun writable(table: ProviderTable, values: ProviderValues, writable: Set<String>): ProviderValues {
val known = table.columns.mapTo(HashSet()) { it.first }
val unknown = values.keys - known
require(unknown.isEmpty()) { "Unknown column(s) for ${table.path}: ${unknown.sorted().joinToString()}" }
return values.filterKeys { it in writable }
}
/** A new task from [values], which must name its list. */
fun newTask(values: ProviderValues, uid: String, now: Instant, tzId: String): TaskEntity {
val v = Values(values)
val listId = requireNotNull(v.long(Tasks.LIST_ID)) { "A new task needs a ${Tasks.LIST_ID}" }
val base = TaskEntity(listId = listId, uid = v.string(Tasks.UID)?.trim()?.ifEmpty { null } ?: uid, createdAt = now)
// As in OpenTasks, a new task inserted without a zone is floating.
val zoned = if (Tasks.TZ in v) values else values + (Tasks.TZ to null)
return applied(base, zoned - Tasks.LIST_ID - Tasks.UID, now, tzId)
}
/**
* [current] with [values] applied. Identity and sync columns are left alone,
* and [current] itself comes back when nothing changed, so a write that
* changes nothing owes the server nothing.
*/
fun patch(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
val edited = applied(current, values, now, tzId)
return if (edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) == current) current else edited
}
/**
* The columns in [values] that would change [current] on their own. Writing
* back what a row published changes nothing, so a client that read a row can
* send all of it with the one value it meant to change.
*/
fun changedColumns(current: TaskEntity, values: ProviderValues, tzId: String): Set<String> {
val stamp = current.lastModified ?: Instant.DISTANT_PAST
return values.keys.filterTo(HashSet()) { key ->
val edited = runCatching { applied(current, mapOf(key to values[key]), stamp, tzId) }.getOrNull()
edited == null || edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) != current
}
}
private fun applied(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
val v = Values(values)
val isOverride = current.masterId != null
if (Tasks.UID in v) require(v.string(Tasks.UID)?.trim() == current.uid) { "A task's ${Tasks.UID} cannot change" }
if (isOverride) {
// NULL is what the row publishes, so writing it back is fine.
require(listOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE).none { it in v && v.text(it) != null }) {
"A modified occurrence carries no recurrence rule of its own"
}
if (Tasks.LIST_ID in v) require(v.long(Tasks.LIST_ID) == current.listId) { "A modified occurrence stays in its series' list" }
if (Tasks.PARENT_ID in v) require(v.long(Tasks.PARENT_ID) == current.parentId) { "A modified occurrence keeps its series' parent" }
}
val listId = if (Tasks.LIST_ID in v) requireNotNull(v.long(Tasks.LIST_ID)) { "${Tasks.LIST_ID} cannot be null" } else current.listId
val parentId = if (Tasks.PARENT_ID in v) v.long(Tasks.PARENT_ID)?.takeIf { it > 0 } else current.parentId
require(parentId == null || parentId != current.id || current.id == 0L) { "A task cannot be its own parent" }
val allDay = if (Tasks.IS_ALLDAY in v) v.boolean(Tasks.IS_ALLDAY) else current.isAllDay
val dtstart = (if (Tasks.DTSTART in v) v.instant(Tasks.DTSTART) else current.dtstart)?.forAllDay(allDay)
val due = (if (Tasks.DUE in v) v.instant(Tasks.DUE) else current.due)?.forAllDay(allDay)
val duration = when {
Tasks.DURATION in v -> v.string(Tasks.DURATION)?.trim()?.ifEmpty { null }
?.also { require(ICAL_DURATION.matches(it)) { "Not an iCalendar duration: $it" } }
Tasks.DUE in v && due != null -> null
else -> current.duration
}
require(due == null || duration == null) { "${Tasks.DUE} and ${Tasks.DURATION} are mutually exclusive" }
require(dtstart == null || due == null || due >= dtstart) { "${Tasks.DUE} is before ${Tasks.DTSTART}" }
val timezone = when {
allDay -> null
Tasks.TZ in v -> v.string(Tasks.TZ)?.also { zone ->
require(runCatching { ZoneId.of(zone) }.isSuccess) { "Unknown time zone: $zone" }
}
dtstart == null && due == null -> null
// A timed task without a zone is floating (RFC 5545 §3.3.5) and stays so.
current.dtstart != null || current.due != null -> if (current.isAllDay) tzId else current.timezone
else -> current.timezone ?: tzId
}
val rrule = if (Tasks.RRULE in v) normalizedRule(v.string(Tasks.RRULE)) else current.rrule
val ruleDropped = Tasks.RRULE in v && rrule == null && current.rrule != null
val rdate = if (Tasks.RDATE in v) dateList(Tasks.RDATE, v, current.rdate) else current.rdate.takeUnless { ruleDropped }
val exdate = if (Tasks.EXDATE in v) dateList(Tasks.EXDATE, v, current.exdate) else current.exdate.takeUnless { ruleDropped }
require(rrule == null || dtstart != null || due != null) { "A recurring task needs a ${Tasks.DTSTART} or ${Tasks.DUE}" }
val (status, percent, completedAt) = completion(current, v, now)
return current.copy(
listId = listId,
title = if (Tasks.TITLE in v) v.string(Tasks.TITLE)?.trim() else current.title,
description = if (Tasks.DESCRIPTION in v) v.text(Tasks.DESCRIPTION) else current.description,
location = if (Tasks.LOCATION in v) v.text(Tasks.LOCATION) else current.location,
url = if (Tasks.URL in v) v.text(Tasks.URL) else current.url,
priority = if (Tasks.PRIORITY in v) v.int(Tasks.PRIORITY, 0..9) ?: TasksContract.PRIORITY_NONE else current.priority,
classification = if (Tasks.CLASSIFICATION in v) v.int(Tasks.CLASSIFICATION, 0..2) else current.classification,
color = if (Tasks.TASK_COLOR in v) v.int(Tasks.TASK_COLOR) else current.color,
sortOrder = if (Tasks.SORTING in v) v.int(Tasks.SORTING) ?: 0 else current.sortOrder,
status = status,
percentComplete = percent,
completedAt = completedAt,
dtstart = dtstart,
due = due,
duration = duration,
isAllDay = allDay,
timezone = timezone,
rrule = rrule,
rdate = rdate,
exdate = exdate,
parentId = parentId,
lastModified = now,
isDirty = true,
)
}
/**
* What [values] do to one occurrence of a series, or `null` when they say
* nothing about its completion. An occurrence is done, cancelled or open;
* partial progress belongs to a task, not to one of its occurrences.
*/
fun occurrenceChange(values: ProviderValues): OccurrenceChange? {
val v = Values(values.filterKeys { it in COMPLETION_COLUMNS })
return when {
Tasks.STATUS in v -> when (v.int(Tasks.STATUS, 0..3)) {
TasksContract.STATUS_COMPLETED -> OccurrenceChange.COMPLETE
TasksContract.STATUS_CANCELLED -> OccurrenceChange.CANCEL
TasksContract.STATUS_NEEDS_ACTION, null -> OccurrenceChange.REOPEN
else -> throw IllegalArgumentException("An occurrence is completed, cancelled or open, not in process")
}
Tasks.PERCENT_COMPLETE in v -> when (v.int(Tasks.PERCENT_COMPLETE, 0..100)) {
100 -> OccurrenceChange.COMPLETE
0, null -> OccurrenceChange.REOPEN
else -> throw IllegalArgumentException("An occurrence is completed or open; partial progress belongs to its task")
}
Tasks.COMPLETED in v -> if (v.instant(Tasks.COMPLETED) != null) OccurrenceChange.COMPLETE else OccurrenceChange.REOPEN
else -> null
}
}
/**
* Status, progress and completion time after a write. They move together the
* way OpenTasks keeps them: a status implies its progress and time, progress
* implies a status, and a completion time alone completes the task. When a
* row is written back, the column that changed decides, not the ones it
* carries unchanged.
*/
private fun completion(current: TaskEntity, values: Values, now: Instant): Triple<TaskStatus, Int?, Instant?> {
val v = Values(values.map.filterKeys { it !in COMPLETION_COLUMNS || it in completionChanged(current, values) })
val percent = if (Tasks.PERCENT_COMPLETE in v) v.int(Tasks.PERCENT_COMPLETE, 0..100) else current.percentComplete
val completed = if (Tasks.COMPLETED in v) v.instant(Tasks.COMPLETED) else current.completedAt
return when {
Tasks.STATUS in v -> when (val status = statusFromInt(v.int(Tasks.STATUS, 0..3))) {
TaskStatus.COMPLETED -> Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent else 100, completed ?: now)
TaskStatus.NEEDS_ACTION ->
Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent?.takeIf { it < 100 } else null, null)
else -> Triple(status, percent, null)
}
Tasks.PERCENT_COMPLETE in v -> when {
percent == null -> Triple(current.status, null, current.completedAt)
percent >= 100 -> Triple(TaskStatus.COMPLETED, percent, completed ?: now)
percent > 0 -> Triple(TaskStatus.IN_PROCESS, percent, null)
else -> Triple(TaskStatus.NEEDS_ACTION, percent, null)
}
Tasks.COMPLETED in v -> when {
completed != null -> Triple(TaskStatus.COMPLETED, 100, completed)
// Clearing the time reopens a completed task; an open one has none to clear.
current.status == TaskStatus.COMPLETED -> Triple(TaskStatus.NEEDS_ACTION, null, null)
else -> Triple(current.status, current.percentComplete, null)
}
else -> Triple(current.status, current.percentComplete, current.completedAt)
}
}
/**
* The completion columns in [v] that differ from [current]. All of them when
* none does, so a write that only repeats the row still settles the rest.
*/
private fun completionChanged(current: TaskEntity, v: Values): Set<String> {
val present = COMPLETION_COLUMNS.filterTo(HashSet()) { it in v }
val changed = present.filterTo(HashSet()) { column ->
when (column) {
Tasks.STATUS -> statusFromInt(v.int(Tasks.STATUS, 0..3)) != current.status
Tasks.PERCENT_COMPLETE -> v.int(Tasks.PERCENT_COMPLETE, 0..100) != current.percentComplete
else -> v.instant(Tasks.COMPLETED) != current.completedAt
}
}
return changed.ifEmpty { present }
}
/**
* An `RDATE`/`EXDATE` value: DATE or DATE-TIME values, comma-separated, of
* one kind. [stored] written back as is stays, whatever shape it synced in.
*/
private fun dateList(column: String, v: Values, stored: String?): String? {
val raw = v.string(column)?.trim()?.ifEmpty { null } ?: return null
if (raw == stored?.trim()) return stored
val values = raw.split(',').map(String::trim)
require(values.all { ICAL_DATE.matches(it) }) { "$column takes iCalendar dates or date-times: $raw" }
// lib-recur reads the whole list or none of it, so it has to be one kind.
val kinds = values.map { if (it.length == 8) "date" else if (it.endsWith('Z')) "utc" else "local" }
require(kinds.distinct().size == 1) { "$column mixes value kinds: $raw" }
return values.joinToString(",")
}
private fun normalizedRule(raw: String?): String? {
val rule = raw?.trim()?.removePrefix("RRULE:")?.ifEmpty { null } ?: return null
require(runCatching { RecurrenceRule(rule) }.isSuccess) { "Not a recurrence rule: $rule" }
return rule
}
}
/** Lenient reads over [values]: a client may hand a number as a string or a flag as an integer. */
internal class Values(private val values: ProviderValues) {
val keys: Set<String> get() = values.keys
val map: ProviderValues get() = values
operator fun contains(key: String) = key in values
fun string(key: String): String? = values[key]?.toString()
/** A free-text column: trimmed, blank stored as nothing. */
fun text(key: String): String? = string(key)?.trim()?.ifEmpty { null }
fun long(key: String): Long? = when (val value = values[key]) {
null -> null
is Number -> value.toLong()
is Boolean -> if (value) 1L else 0L
is String -> requireNotNull(value.trim().toLongOrNull()) { "$key is not a number: $value" }
else -> throw IllegalArgumentException("$key is not a number: $value")
}
fun int(key: String, range: IntRange? = null): Int? = long(key)?.let { value ->
require(value in Int.MIN_VALUE..Int.MAX_VALUE) { "$key is out of range: $value" }
value.toInt().also { require(range == null || it in range) { "$key must be in $range: $it" } }
}
fun boolean(key: String): Boolean = when (val value = values[key]) {
null -> false
is Boolean -> value
is String -> when (value.trim().lowercase()) {
"1", "true" -> true
"0", "false", "" -> false
else -> throw IllegalArgumentException("$key is not a flag: $value")
}
else -> long(key) != 0L
}
fun instant(key: String): Instant? = long(key)?.let(Instant::fromEpochMilliseconds)
}
@@ -1,441 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.sqlite.SQLiteConstraintException
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.normalizeTags
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Instant
/** One row by id, `<id column> = N` or `<id column> = ?`. */
private val SINGLE_ROW = Regex("""\s*(\w+)\s*=\s*(\d+|\?)\s*""")
/**
* Applies another app's writes to the own store for [AgendulaTaskProvider].
*
* A write's selection runs against the published snapshot, so it matches the
* rows the client can read, and each matched row is then written through
* [RoomTasksDataSource] under the rules the app's own edits follow. `tasklists`
* stays read-only. On `instances`, completion columns tick off that one
* occurrence of a series; other content columns edit the series, and a changed
* time column is refused, since one occurrence cannot be moved this way.
*
* Each call runs in one transaction, and a batch in one more around them.
*/
@Singleton
class ProviderWriter internal constructor(
private val database: TasksDatabase,
private val store: RoomTasksDataSource,
private val source: ProviderSnapshotSource,
private val resolver: ProviderResolver,
private val afterWrite: () -> Unit,
private val clock: Clock = Clock.System,
) {
@Inject constructor(
database: TasksDatabase,
store: RoomTasksDataSource,
source: ProviderSnapshotSource,
resolver: ProviderResolver,
scheduler: ReminderScheduler,
@ApplicationScope scope: CoroutineScope,
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
private var batchDepth = 0
private var batchWrote = false
/** Inserts one row and returns its published id. */
fun insert(table: ProviderTable, values: ProviderValues): Long = write {
when (table) {
ProviderTable.TASKS -> insertTask(values)
ProviderTable.PROPERTIES -> insertProperty(values)
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
}
/** Updates every row [selection] matches and returns how many changed. */
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = write {
when (table) {
ProviderTable.TASKS -> {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
val matched = ids(table, selection, args)
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
}
ProviderTable.INSTANCES -> {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
instances(selection, args).count { updateInstance(it, writable) }
}
ProviderTable.PROPERTIES -> ids(table, selection, args).count { updateProperty(it, values) }
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
}
/** Deletes every row [selection] matches and returns how many went. */
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = write {
when (table) {
ProviderTable.TASKS -> ids(table, selection, args).count { deleteTask(it) }
ProviderTable.INSTANCES -> instances(selection, args).count { deleteInstance(it) }
ProviderTable.PROPERTIES -> ids(table, selection, args).count { deleteProperty(it) }
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
}
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
fun <T> batch(block: () -> T): T = source.locked {
batchDepth++
try {
database.runInTransaction<T> { block() }
} finally {
// A snapshot built mid-batch may hold rows a failure rolled back.
source.invalidate()
batchDepth--
if (batchDepth == 0 && batchWrote) {
batchWrote = false
afterWrite()
}
}
}
private fun <T> write(block: () -> T): T = source.locked {
check(resolver.mode() == StorageMode.OWN) {
"Agendula is working on top of an external tasks provider; write to that one instead"
}
val result = try {
database.runInTransaction<T> { block() }
} catch (e: TaskWriteFailedException) {
throw IllegalArgumentException(e.message, e)
} catch (e: SQLiteConstraintException) {
throw IllegalArgumentException(e.message, e)
} finally {
source.invalidate()
}
if (batchDepth == 0) afterWrite() else batchWrote = true
result
}
// --- tasks ------------------------------------------------------------------
private fun insertTask(values: ProviderValues): Long {
require(values[Tasks.ORIGINAL_INSTANCE_ID] == null && values[Tasks.ORIGINAL_INSTANCE_TIME] == null) {
"Modify an occurrence through ${Instances.PATH}, not by inserting one"
}
val writable = ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS)
val task = ProviderTaskWriter.newTask(writable, UUID.randomUUID().toString(), clock.now(), zone())
requireWritableList(task.listId)
// The unique index cannot catch this: SQLite treats the masters' NULL recurrence_id as distinct.
require(database.tasks().byUid(task.listId, task.uid) == null) { "List ${task.listId} already has a task ${task.uid}" }
requireParent(task)
return store.insertEntity(task)
}
private fun updateTask(taskId: Long, values: ProviderValues): Boolean {
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(current.listId)
val edited = ProviderTaskWriter.patch(current, values, clock.now(), zone())
if (edited == current) return true
val moved = edited.listId != current.listId
if (moved) {
requireWritableList(edited.listId)
requireFreeUid(edited.listId, current.uid)
}
if (edited.parentId != current.parentId || moved) requireParent(edited)
store.replaceEntity(current, edited)
// A subtree moves as one: a subtask has to be in its parent's list.
if (moved) store.subtaskIds(taskId).forEach { moveSubtree(it, edited.listId, hashSetOf(taskId)) }
return true
}
private fun moveSubtree(taskId: Long, listId: Long, seen: MutableSet<Long> = HashSet()) {
if (!seen.add(taskId)) return
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return
if (current.listId != listId) {
requireFreeUid(listId, current.uid)
store.replaceEntity(current, current.copy(listId = listId, lastModified = clock.now(), isDirty = true))
}
store.subtaskIds(taskId).forEach { moveSubtree(it, listId, seen) }
}
/**
* [ids] with every parent before its subtasks, so a bulk move carries a
* subtree along before reaching its subtasks, whatever order they came in.
*/
private fun parentsFirst(ids: List<Long>): List<Long> {
if (ids.size < 2) return ids
fun depth(id: Long): Int {
val seen = HashSet<Long>()
var parent = database.tasks().entity(id)?.parentId
while (parent != null && seen.add(parent)) parent = database.tasks().entity(parent)?.parentId
return seen.size
}
val depths = ids.associateWith(::depth)
return ids.sortedBy(depths::getValue)
}
private fun deleteTask(taskId: Long): Boolean {
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(current.listId)
// An override deletes itself into an EXDATE on its master.
if (current.masterId != null) store.deleteTask(taskId) else store.deleteWithSubtasks(taskId)
return true
}
// --- instances --------------------------------------------------------------
/** One occurrence: the row it reads from, its series, and when it was due to happen. */
private class InstanceRow(val taskId: Long, val seriesId: Long, val originalTime: Instant?, val recurring: Boolean)
private fun instances(selection: String?, args: Array<out String>?): List<InstanceRow> {
singleId(ProviderTable.INSTANCES, selection, args)?.let { return listOfNotNull(instanceRow(it)) }
return source.rows(
ProviderTable.INSTANCES,
listOf(Tasks.ID, Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME, Instances.IS_RECURRING),
selection,
args,
).map { (id, taskId, original, recurring) ->
InstanceRow(
taskId = taskId as Long,
seriesId = ProviderIds.instanceSeries(id as Long),
originalTime = (original as Long?)?.let(Instant::fromEpochMilliseconds),
recurring = recurring == 1L,
)
}
}
/** [instanceId] read straight from the store, without rebuilding the snapshot. */
private fun instanceRow(instanceId: Long): InstanceRow? {
val master = database.tasks().entity(ProviderIds.instanceSeries(instanceId))
?.takeIf { !it.isDeleted && it.masterId == null } ?: return null
val occurrences = seriesOccurrences(master, database.tasks().overrides(master.id), clock.now())
?: return InstanceRow(master.id, master.id, null, recurring = false)
.takeIf { ProviderIds.instanceSeconds(instanceId) == 0L }
val occurrence = occurrences.firstOrNull { ProviderIds.instance(master.id, it.anchor) == instanceId } ?: return null
return InstanceRow(occurrence.override?.id ?: master.id, master.id, occurrence.anchor, recurring = true)
}
private fun updateInstance(row: InstanceRow, values: ProviderValues): Boolean {
val occurrence = row.originalTime
if (!row.recurring || occurrence == null) return updateTask(row.taskId, values)
val shown = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(shown.listId)
// A row read back carries every column; only what differs from it is a write.
val changed = ProviderTaskWriter.changedColumns(shown, values, zone())
val moved = changed intersect TIME_COLUMNS
if (moved.isNotEmpty()) {
// A bad value reports itself rather than as a move.
ProviderTaskWriter.patch(shown, values.filterKeys { it in moved }, clock.now(), zone())
throw IllegalArgumentException(
"One occurrence of a recurring task cannot be moved (${moved.sorted().joinToString()}); write the series through ${Tasks.PATH}",
)
}
val content = values.filterKeys { it in changed && it !in COMPLETION_COLUMNS }
if (content.isNotEmpty()) {
updateTask(row.seriesId, content)
// A modified occurrence shows its own copy of the content, so it takes the write too.
if (row.taskId != row.seriesId) updateTask(row.taskId, content - Tasks.LIST_ID - Tasks.PARENT_ID)
}
if (changed.none { it in COMPLETION_COLUMNS }) return true
// The column the client changed decides, not the unchanged ones it carries along.
val change = ProviderTaskWriter.occurrenceChange(values.filterKeys { it in changed && it in COMPLETION_COLUMNS })
?: return true
val status = database.tasks().entity(row.taskId)?.status
when (change) {
OccurrenceChange.COMPLETE ->
if (status != TaskStatus.COMPLETED) store.setCompletedInstance(row.taskId, occurrence, true)
OccurrenceChange.CANCEL ->
if (status != TaskStatus.CANCELLED) store.setCancelledInstance(row.taskId, occurrence, true)
OccurrenceChange.REOPEN ->
if (status != TaskStatus.NEEDS_ACTION) store.setCompletedInstance(row.taskId, occurrence, false)
}
return true
}
private fun deleteInstance(row: InstanceRow): Boolean {
val occurrence = row.originalTime
if (!row.recurring || occurrence == null) return deleteTask(row.taskId)
val task = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(task.listId)
if (task.masterId != null) store.deleteTask(task.id) else store.deleteOccurrence(task.id, occurrence)
return true
}
// --- properties -------------------------------------------------------------
/** What a published property id stands for, read from the store. */
private sealed interface PropertyRef {
val task: TaskEntity
class AlarmRow(val alarm: TaskAlarmEntity, override val task: TaskEntity) : PropertyRef
class CategoryRow(val name: String, override val task: TaskEntity) : PropertyRef
}
private fun propertyRef(propertyId: Long): PropertyRef? {
if (ProviderIds.isAlarm(propertyId)) {
val alarm = database.alarms().entity(ProviderIds.alarmRow(propertyId)) ?: return null
val task = database.tasks().entity(alarm.taskId)?.takeUnless { it.isDeleted } ?: return null
return PropertyRef.AlarmRow(alarm, task)
}
val task = database.tasks().entity(ProviderIds.categoryTask(propertyId))?.takeUnless { it.isDeleted } ?: return null
val name = ProviderIds.categories(task.id, task.categories).entries.firstOrNull { it.value == propertyId }?.key
?: return null
return PropertyRef.CategoryRow(name, task)
}
private fun insertProperty(values: ProviderValues): Long {
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys))
val taskId = requireNotNull(v.long(Properties.TASK_ID)) { "A property needs a ${Properties.TASK_ID}" }
val task = liveTask(taskId)
return when (v.string(Properties.MIMETYPE)) {
Alarm.MIMETYPE -> {
val alarm = alarm(TaskAlarmEntity(taskId = taskId, minutesBefore = 0), v, requireMinutes = true)
var id = 0L
store.writeAlarm(taskId) { id = database.alarms().insert(alarm) }
ProviderIds.alarm(id)
}
Category.MIMETYPE -> {
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
setCategories(task, task.categories + name)
val categories = database.tasks().entity(taskId)!!.categories
ProviderIds.categories(taskId, categories).getValue(name)
}
else -> throw IllegalArgumentException("Only alarm and category properties can be written")
}
}
private fun updateProperty(propertyId: Long, values: ProviderValues): Boolean {
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys - Properties.PROPERTY_ID))
val ref = propertyRef(propertyId) ?: return false
requireWritableList(ref.task.listId)
requireSame(v, Properties.TASK_ID, ref.task.id)
val mimetype = if (ref is PropertyRef.AlarmRow) Alarm.MIMETYPE else Category.MIMETYPE
if (Properties.MIMETYPE in v) require(v.string(Properties.MIMETYPE) == mimetype) { "A property's mimetype cannot change" }
when (ref) {
is PropertyRef.AlarmRow -> {
val edited = alarm(ref.alarm, v, requireMinutes = false)
if (edited != ref.alarm) store.writeAlarm(ref.task.id) { database.alarms().update(edited) }
}
is PropertyRef.CategoryRow -> if (Category.NAME in v) {
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
setCategories(ref.task, ref.task.categories.map { if (it == ref.name) name else it })
}
}
return true
}
private fun deleteProperty(propertyId: Long): Boolean {
val ref = propertyRef(propertyId) ?: return false
requireWritableList(ref.task.listId)
when (ref) {
is PropertyRef.AlarmRow -> store.writeAlarm(ref.task.id) { database.alarms().delete(ref.alarm.id) }
is PropertyRef.CategoryRow -> setCategories(ref.task, ref.task.categories - ref.name)
}
return true
}
private fun alarm(base: TaskAlarmEntity, v: Values, requireMinutes: Boolean): TaskAlarmEntity {
if (requireMinutes) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) { "An alarm needs ${Alarm.MINUTES_BEFORE}" }
val reference = when (if (Alarm.REFERENCE in v) v.int(Alarm.REFERENCE) else null) {
null -> base.reference
Alarm.REFERENCE_DUE -> AlarmReference.DUE
Alarm.REFERENCE_START -> AlarmReference.START
else -> throw IllegalArgumentException("${Alarm.REFERENCE} must be ${Alarm.REFERENCE_DUE} (due) or ${Alarm.REFERENCE_START} (start)")
}
return base.copy(
minutesBefore = if (Alarm.MINUTES_BEFORE in v) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) {
"${Alarm.MINUTES_BEFORE} cannot be null"
} else base.minutesBefore,
reference = reference,
message = if (Alarm.MESSAGE in v) v.text(Alarm.MESSAGE) else base.message,
)
}
private fun setCategories(task: TaskEntity, categories: List<String>) {
val normalized = normalizeTags(categories)
if (normalized == task.categories) return
store.replaceEntity(task, task.copy(categories = normalized, lastModified = clock.now(), isDirty = true))
}
// --- checks -----------------------------------------------------------------
/**
* The ids [selection] matches. A row URI's plain `<id column> = N` is checked
* against the store directly; anything else runs against the snapshot.
*/
private fun ids(table: ProviderTable, selection: String?, args: Array<out String>?): List<Long> {
singleId(table, selection, args)?.let { id ->
val exists = when (table) {
ProviderTable.TASKS -> database.tasks().entity(id)?.isDeleted == false
ProviderTable.PROPERTIES -> propertyRef(id) != null
else -> true
}
return listOfNotNull(id.takeIf { exists })
}
return source.rows(table, listOf(table.idColumn), selection, args).map { it.single() as Long }
}
private fun singleId(table: ProviderTable, selection: String?, args: Array<out String>?): Long? {
val match = SINGLE_ROW.matchEntire(selection ?: return null) ?: return null
if (match.groupValues[1] != table.idColumn) return null
val value = match.groupValues[2]
return when {
value == "?" -> args?.singleOrNull()?.trim()?.toLongOrNull()
args.isNullOrEmpty() -> value.toLongOrNull()
else -> null
}
}
private fun requireFreeUid(listId: Long, uid: String) {
require(database.tasks().byUid(listId, uid)?.isDeleted != false) { "List $listId already has a task $uid" }
}
private fun liveTask(taskId: Long): TaskEntity {
val task = requireNotNull(database.tasks().entity(taskId)?.takeUnless { it.isDeleted }) { "No task $taskId" }
requireWritableList(task.listId)
return task
}
private fun requireWritableList(listId: Long) {
val list = requireNotNull(database.taskLists().entity(listId)) { "No task list $listId" }
if (list.isReadOnly) throw UnsupportedOperationException("Task list $listId is read-only")
}
/** A parent has to be a live, top-level task in the same list, and not one of the task's own subtasks. */
private fun requireParent(task: TaskEntity) {
var parentId = task.parentId ?: return
val parent = database.tasks().entity(parentId)?.takeUnless { it.isDeleted }
require(parent != null && parent.masterId == null) { "No task $parentId to be a parent" }
require(parent.listId == task.listId) { "A subtask has to be in its parent's list" }
val seen = HashSet<Long>()
while (true) {
require(parentId != task.id) { "Task ${task.id} cannot be its own ancestor" }
if (!seen.add(parentId)) break
parentId = database.tasks().entity(parentId)?.parentId ?: break
}
}
private fun requireSame(v: Values, column: String, expected: Long) {
if (column in v) require(v.long(column) == expected) { "A property's $column cannot change" }
}
private fun zone(): String = ZoneId.systemDefault().id
}
@@ -1,88 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentValues
import android.database.Cursor
import android.database.MatrixCursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteQueryBuilder
import android.os.Build
import androidx.core.database.sqlite.transaction
/**
* An in-memory SQLite copy of a [ProviderSnapshot], so a client's own
* `selection`, `selectionArgs` and `sortOrder` run as SQL exactly as they would
* against OpenTasks. Holds only what the caller may already read.
*
* Not thread-safe; [ProviderSnapshotSource] serialises access.
*/
internal class SnapshotDatabase {
private val db: SQLiteDatabase = SQLiteDatabase.create(null).apply {
ProviderTable.entries.forEach { table ->
execSQL(
"CREATE TABLE ${table.path} (" +
table.columns.joinToString { (name, type) -> "$name $type" } + ")",
)
}
}
fun load(snapshot: ProviderSnapshot) {
db.transaction {
ProviderTable.entries.forEach { table ->
delete(table.path, null, null)
snapshot.rows[table].orEmpty().forEach { insertOrThrow(table.path, null, it.toContentValues()) }
}
}
}
/**
* Runs the query and copies the result out, so the cursor a client holds
* survives the next [load].
*/
fun query(
table: ProviderTable,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
val builder = SQLiteQueryBuilder().apply {
tables = table.path
isStrict = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
isStrictColumns = true
isStrictGrammar = true
}
projectionMap = table.columns.associate { (name, _) -> name to name }
}
builder.query(db, projection, selection, selectionArgs, null, null, sortOrder).use { source ->
val copy = MatrixCursor(source.columnNames, source.count)
while (source.moveToNext()) {
copy.addRow(
Array(source.columnCount) { column ->
when (source.getType(column)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> source.getLong(column)
Cursor.FIELD_TYPE_FLOAT -> source.getDouble(column)
Cursor.FIELD_TYPE_BLOB -> source.getBlob(column)
else -> source.getString(column)
}
},
)
}
return copy
}
}
private fun ProviderRow.toContentValues() = ContentValues(size).also { values ->
forEach { (key, value) ->
when (value) {
null -> values.putNull(key)
is Long -> values.put(key, value)
is Int -> values.put(key, value)
is String -> values.put(key, value)
else -> values.put(key, value.toString())
}
}
}
}
@@ -1,101 +0,0 @@
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()
}
}
@@ -1,44 +0,0 @@
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,8 +2,6 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.TypeConverter
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.toInt
import kotlin.time.Instant
@@ -38,17 +36,3 @@ object Converters {
fun alarmReferenceFrom(value: String): AlarmReference =
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,10 +5,8 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
import java.util.UUID
import kotlin.time.Instant
/**
@@ -73,7 +71,7 @@ data class TaskListEntity(
*
* ⚠️ It was set by a rename and read by nobody. A collection's name and
* colour are now written server-first by
* [de.jeanlucmakiola.agendula.data.sync.RemoteLists], so there is
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], so there is
* 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.
*/
@@ -145,10 +143,6 @@ data class TaskEntity(
@ColumnInfo(name = "url") val url: String? = null,
/** ARGB override for the list colour. */
@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
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
@@ -200,31 +194,12 @@ data class TaskEntity(
* verbatim on write so a round-trip cannot silently lose a field.
*/
@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. */
enum class AlarmReference { DUE, START }
/**
* 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.
*/
/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */
@Entity(
tableName = "task_alarms",
foreignKeys = [
@@ -244,6 +219,4 @@ data class TaskAlarmEntity(
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
@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,7 +49,6 @@ object RoomTaskMapper {
description = row.task.description,
location = row.task.location,
url = row.task.url,
tags = row.task.categories,
priority = priorityFromICal(row.task.priority),
status = row.task.status,
percentComplete = row.task.percentComplete,
@@ -80,7 +79,6 @@ object RoomTaskMapper {
description = task.description,
location = task.location,
url = task.url,
tags = task.categories,
priority = priorityFromICal(task.priority),
status = task.status,
percentComplete = task.percentComplete,
@@ -2,8 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.InvalidationTracker
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.editable
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
@@ -31,54 +31,7 @@ private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "
/** Enough to count every occurrence before a split point without bounding a real series. */
internal const val SPLIT_COUNT_CEILING = 100_000
/** The window every read of the store expands a series over. */
internal fun expansionWindow(now: Instant) = ExpansionWindow(
from = now - WINDOW_BACK,
until = now + WINDOW_FORWARD,
pivot = now,
)
/**
* One occurrence's resolved start and due. A timed series keeps each
* occurrence's duration; a due-anchored one has no start to offset from, so
* the anchor *is* the due date.
*/
internal fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
if (master.dtstart == null) return null to anchor
val length = master.due?.let { it - master.dtstart }
return anchor to length?.let { anchor + it }
}
/** One occurrence of a series: its anchor, the override replacing it if any, and its resolved times. */
internal class SeriesOccurrence(
val anchor: Instant,
val override: TaskEntity?,
val start: Instant?,
val due: Instant?,
val distance: Int?,
)
/**
* Every occurrence of [master] inside the expansion window, with any
* `RECURRENCE-ID` override substituted for the occurrence it replaces, or
* `null` when [master] does not recur.
*/
internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<SeriesOccurrence>? {
val spec = master.recurrenceSpec() ?: return null
val anchors = RecurrenceExpander.expand(spec, expansionWindow(now))
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
val byAnchor = overrides.associateBy { it.recurrenceId }
return anchors.mapIndexed { index, anchor ->
val override = byAnchor[anchor]
val (start, due) = if (override != null) override.dtstart to override.due else occurrenceTimes(master, anchor)
SeriesOccurrence(anchor, override, start, due, distances.getOrNull(index))
}
}
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
private const val ACK_CACHE_SIZE = 512
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
/**
* [TasksDataSource] over Agendula's own Room store.
@@ -148,32 +101,19 @@ class RoomTasksDataSource @Inject constructor(
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
}
/** One transaction, so a failure part-way leaves the subtree whole. */
override fun deleteWithSubtasks(taskId: Long) = tx { super.deleteWithSubtasks(taskId) }
override fun subtaskIds(parentTaskId: Long): List<Long> =
tasks.subtasks(parentTaskId).map { it.task.id }.filter { it != parentTaskId }
override fun exportTasks(listId: Long): List<ExportTask> =
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
override fun reminders(): Map<Long, List<TaskReminder>> {
val stored = alarms.all().groupBy({ it.taskId }) { it.asReminder().copy(acknowledgedAt = acknowledged(it)) }
// Turned off elsewhere: present and empty, so nothing falls back to a default.
return stored + alarms.remindersOff().filterNot(stored::containsKey).associateWith { emptyList() }
}
/** `raw` → its `ACKNOWLEDGED`, so a scheduler pass does not re-parse every alarm. */
private val acknowledgements = object : LinkedHashMap<String, Instant?>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Instant?>) = size > ACK_CACHE_SIZE
}
private fun acknowledged(row: TaskAlarmEntity): Instant? {
val raw = row.raw ?: return null
return synchronized(acknowledgements) {
if (raw in acknowledgements) acknowledgements[raw] else row.acknowledgedAt().also { acknowledgements[raw] = it }
override fun reminders(): Map<Long, List<TaskReminder>> =
alarms.all().groupBy({ it.taskId }) {
TaskReminder(
minutesBefore = it.minutesBefore,
fromStart = it.reference == AlarmReference.START,
)
}
}
override fun alarms(): Map<Long, TaskReminder> =
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
/**
* Every occurrence of [row] inside the expansion window, with any
@@ -183,69 +123,70 @@ class RoomTasksDataSource @Inject constructor(
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
*/
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
val occurrences = seriesOccurrences(row.task, overrides, now) ?: return listOf(RoomTaskMapper.task(row))
return occurrences.map { occurrence ->
val override = occurrence.override
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
val window = ExpansionWindow(
from = now - WINDOW_BACK,
until = now + WINDOW_FORWARD,
pivot = now,
)
val anchors = RecurrenceExpander.expand(spec, window)
if (anchors.isEmpty()) return emptyList()
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
val byAnchor = overrides.associateBy { it.recurrenceId }
return anchors.mapIndexedNotNull { index, anchor ->
val override = byAnchor[anchor]
if (override != null) {
RoomTaskMapper.task(
row = row.copy(task = override),
occurrenceStart = occurrence.anchor,
start = occurrence.start,
due = occurrence.due,
distanceFromCurrent = occurrence.distance,
occurrenceStart = anchor,
start = override.dtstart,
due = override.due,
distanceFromCurrent = distances[index],
seriesId = row.task.id,
recurrenceRule = row.task.rrule,
)
} else {
val (start, due) = occurrenceTimes(row.task, anchor)
RoomTaskMapper.task(
row = row,
occurrenceStart = occurrence.anchor,
start = occurrence.start,
due = occurrence.due,
distanceFromCurrent = occurrence.distance,
occurrenceStart = anchor,
start = start,
due = due,
distanceFromCurrent = distances[index],
)
}
}
}
/**
* One occurrence's resolved start and due. A timed series keeps each
* occurrence's duration; a due-anchored one has no start to offset from, so
* the anchor *is* the due date.
*/
private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
if (master.dtstart == null) return null to anchor
val length = master.due?.let { it - master.dtstart }
return anchor to length?.let { anchor + it }
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
override fun insertTask(form: TaskForm): Long {
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone())
var id = 0L
tx {
id = tasks.insert(TaskFormWriter.withRule(entity, form.rrule))
reminders?.let { alarms.syncForTask(id, it) }
writes.onWritten(setOf(form.listId))
}
return id
return tasks.insert(TaskFormWriter.withRule(entity, form.rrule)).also { writes.onWritten(setOf(form.listId)) }
}
/**
* [reminders] as [taskId]'s own, inside the caller's transaction.
*
* ⚠️ Part of the task write rather than a second call after it. Once
* reminders sync, a sync landing between the two would upload the task
* without them, or with the ones it is about to lose.
*/
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
if (reminders == null) return
alarms.syncForTask(taskId, reminders)
}
private fun storedReminders(taskId: Long): List<TaskReminder> =
alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder)
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = tx {
override fun updateTask(taskId: Long, form: TaskForm) {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
writeReminders(taskId, reminders)
if (current.masterId == null && edited.listId != current.listId) {
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
move(current, TaskFormWriter.withRule(edited, form.rrule))
database.runInTransaction { move(current, TaskFormWriter.withRule(edited, form.rrule)) }
writes.onWritten(setOf(current.listId, edited.listId))
return@tx
return
}
// ⚠️ An override's list and parent are the master's, the rule
// [updateInstance] states and this path has to keep. An overridden
@@ -279,23 +220,8 @@ class RoomTasksDataSource @Inject constructor(
plan.tombstone?.let { tasks.insert(it) }
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) = tx {
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = tx {
val master = seriesMaster(seriesId)
// ⚠️ The overrides too, where they still carry the series' reminders.
// A fork copies them as a snapshot — completing one occurrence is
// enough — and each override uploads its own VALARMs, so leaving them
// would keep ringing those occurrences at the old lead everywhere. One
// whose reminders were set for that occurrence alone keeps them.
if (reminders != null) {
val before = storedReminders(master.id)
tasks.overrides(master.id)
.filter { storedReminders(it.id) == before }
.forEach {
writeReminders(it.id, reminders)
if (form.remindersChosen && reminders.isEmpty()) alarms.setRemindersOff(it.id, true)
}
}
writeReminders(master.id, reminders)
val (start, due) = resolvedTimes(master, occurrenceStart)
val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone())
// A moved or re-ruled series no longer generates the anchors its
@@ -311,14 +237,14 @@ class RoomTasksDataSource @Inject constructor(
writes.onWritten(setOf(master.listId, edited.listId))
}
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long {
var created = seriesId
tx {
val master = seriesMaster(seriesId)
val spec = master.recurrenceSpec()
val anchor = master.recurrenceAnchor
if (spec == null || anchor == null || occurrenceStart <= anchor) {
updateSeries(seriesId, occurrenceStart, form, reminders)
updateSeries(seriesId, occurrenceStart, form)
return@tx
}
val now = clock.now()
@@ -361,9 +287,7 @@ class RoomTasksDataSource @Inject constructor(
else TaskFormWriter.shiftValues(laterExceptions, delta, master.timezone ?: zone())
val fresh = TaskFormWriter.withRule(applied, rule).let { if (it.rrule != null) it.copy(exdate = exceptions) else it }
val newId = tasks.insert(fresh)
// A new UID, so a new resource: the alarms are copies, not the same ones.
alarms.copyToTask(master.id, newId, sameResource = false)
writeReminders(newId, reminders)
alarms.forTask(master.id).forEach { alarms.insert(it.copy(id = 0, taskId = newId)) }
val unmoved = fresh.dtstart == start && fresh.due == due && sameRule
tasks.overrides(master.id)
@@ -398,7 +322,8 @@ class RoomTasksDataSource @Inject constructor(
val master = tasks.entity(seriesId) ?: return@tx
val anchor = master.recurrenceAnchor
if (anchor == null || occurrenceStart <= anchor) {
deleteWithSubtasks(seriesId)
tasks.subtasks(seriesId).forEach { deleteTask(it.task.id) }
deleteTask(seriesId)
return@tx
}
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, clock.now(), zone()))
@@ -427,7 +352,7 @@ class RoomTasksDataSource @Inject constructor(
* which is the model least compatible with sync; the override shares its
* master's UID, which is exactly what makes it an override.
*/
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) = tx {
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
val now = clock.now()
val existing = tasks.override(taskId, occurrenceStart)
@@ -436,9 +361,8 @@ class RoomTasksDataSource @Inject constructor(
// master's, whatever the form was carrying.
val edited = TaskFormWriter.apply(existing, form, now, zone())
tasks.update(edited.copy(listId = master.listId, parentId = master.parentId))
writeReminders(existing.id, reminders)
writes.onWritten(setOf(master.listId))
return@tx
return
}
val (start, due) = occurrenceTimes(master, occurrenceStart)
val fork = TaskFormWriter.apply(
@@ -450,19 +374,28 @@ class RoomTasksDataSource @Inject constructor(
// The list and parent come from the master: moving one occurrence between
// lists or parents is not something the override model expresses.
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
// The occurrence starts out with the series' reminders and then takes
// the form's — the master's own are never touched.
alarms.copyToTask(taskId, id, sameResource = true)
writeReminders(id, reminders)
alarms.replaceAllForTask(id, alarms.forTask(taskId))
writes.onWritten(setOf(master.listId))
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
if (!alarms.syncForTask(taskId, reminders)) return@tx
// The reminders are part of the VTODO now, so changing them is an edit.
val current = tasks.entity(taskId) ?: return@tx
tasks.update(current.copy(isDirty = true, lastModified = clock.now()))
writes.onWritten(setOf(current.listId))
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
alarms.replaceEditable(
taskId,
minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) },
)
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
alarms.replaceAllForTask(
taskId,
reminders.map {
TaskAlarmEntity(
taskId = taskId,
minutesBefore = it.minutesBefore,
reference = if (it.fromStart) AlarmReference.START else AlarmReference.DUE,
)
},
)
}
override fun setCancelled(taskId: Long, cancelled: Boolean) {
@@ -514,7 +447,7 @@ class RoomTasksDataSource @Inject constructor(
}
val (start, due) = occurrenceTimes(master, occurrenceStart)
val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due)))
alarms.copyToTask(taskId, id, sameResource = true)
alarms.replaceAllForTask(id, alarms.forTask(taskId))
writes.onWritten(setOf(master.listId))
}
@@ -582,53 +515,12 @@ class RoomTasksDataSource @Inject constructor(
writes.onWritten(setOf(current.listId))
}
// --- whole-row writes, for the published provider --------------------------
/** Inserts [entity] as it stands, for a client that wrote the row itself. */
internal fun insertEntity(entity: TaskEntity): Long {
if (lists.exists(entity.listId) == 0) throw TaskWriteFailedException("insert task: no list ${entity.listId}")
return tasks.insert(entity).also { writes.onWritten(setOf(entity.listId)) }
}
/**
* Writes [edited] over [current] under the rules [updateTask] and
* [updateSeries] keep: a task moves lists through [TaskMove], an override
* keeps its master's list and parent, and a series whose anchor or rule
* changed drops the overrides it no longer generates.
*/
internal fun replaceEntity(current: TaskEntity, edited: TaskEntity) {
tx {
val reshaped = current.masterId == null && current.recurrenceSpec() != null &&
(edited.dtstart != current.dtstart || edited.due != current.due || edited.rrule != current.rrule)
if (reshaped) tasks.overrides(current.id).forEach { tasks.delete(it.id) }
when {
current.masterId != null -> tasks.update(edited.copy(listId = current.listId, parentId = current.parentId))
edited.listId != current.listId -> {
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
move(current, edited)
}
else -> tasks.update(edited)
}
}
writes.onWritten(setOf(current.listId, edited.listId))
}
/** Adds, changes or removes one reminder, and marks its task changed so the reminder syncs. */
internal fun writeAlarm(taskId: Long, write: () -> Unit) {
val task = tasks.entity(taskId) ?: throw TaskWriteFailedException("alarm on task $taskId")
tx {
write()
tasks.update(task.copy(lastModified = clock.now(), isDirty = true))
}
writes.onWritten(setOf(task.listId))
}
override fun createLocalList(name: String, color: Int): Long =
lists.insert(TaskListEntity(name = name.trim(), color = color))
/**
* ⚠️ Device-only lists. An account-backed collection is renamed through
* [de.jeanlucmakiola.agendula.data.sync.RemoteLists], which sends
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], which sends
* the PROPPATCH first and writes here only once the server has taken it.
*
* This used to set `is_dirty` for an account list instead, on the theory
@@ -4,11 +4,6 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import kotlin.time.Instant
/** Reads and writes over `task_alarms`. */
@Dao
@@ -21,137 +16,37 @@ interface TaskAlarmDao {
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id")
fun forTask(taskId: Long): List<TaskAlarmEntity>
/** The reminders of several tasks in one query — one resource's rows, for an upload. */
@Query("SELECT * FROM task_alarms WHERE task_id IN (:taskIds) ORDER BY id")
fun forTasks(taskIds: List<Long>): List<TaskAlarmEntity>
@Query("SELECT * FROM task_alarms WHERE id = :id")
fun entity(id: Long): TaskAlarmEntity?
@Insert
fun insert(alarm: TaskAlarmEntity): Long
@Update
fun update(alarm: TaskAlarmEntity): Int
@Query("DELETE FROM task_alarms WHERE id = :id")
fun delete(id: Long): Int
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
fun deleteForTask(taskId: Long): Int
/** See [TaskEntity.remindersOff]. */
@Query("UPDATE tasks SET reminders_off = :off WHERE id = :taskId")
fun setRemindersOff(taskId: Long, off: Boolean): Int
/** The tasks whose alarms were turned off elsewhere — "none", not "the default". */
@Query("SELECT id FROM tasks WHERE reminders_off = 1")
fun remindersOff(): List<Long>
/** Set the task's only reminder, or clear every one with `null`. */
@Transaction
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) {
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) = replaceAllForTask(taskId, listOfNotNull(alarm))
/**
* Replace every reminder on the task. The rows that land are always new ones —
* ids are cleared so alarms lifted off another task (forking an occurrence
* copies the master's) insert instead of colliding.
*/
@Transaction
fun replaceAllForTask(taskId: Long, alarms: List<TaskAlarmEntity>) {
deleteForTask(taskId)
alarms.forEach { insert(it.copy(id = 0, taskId = taskId)) }
}
/**
* Set the task's editable reminder — its last before-due one — or clear it
* with `null`. Start-relative and additional reminders stay as they are.
*/
@Transaction
fun replaceEditable(taskId: Long, alarm: TaskAlarmEntity?) {
forTask(taskId).lastOrNull { it.reference == AlarmReference.DUE }?.let { delete(it.id) }
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
}
/**
* Make the task's reminders [wanted], touching only what differs.
*
* ⚠️ A diff, not a delete-and-reinsert. A row that is still wanted keeps its
* [TaskAlarmEntity.uid] and its [TaskAlarmEntity.raw] — which [TaskReminder]
* carries neither of, so a rewrite from it would give every save a new
* alarm identity on the server and throw away another client's `VALARM` the
* user never touched.
*
* Duplicates count: two identical leads are two rows.
*
* @return whether anything changed.
*/
@Transaction
fun syncForTask(taskId: Long, wanted: List<TaskReminder>): Boolean {
val unmatched = wanted.map { it.lead }.toMutableList()
var changed = false
forTask(taskId).forEach { row ->
if (!unmatched.remove(row.asReminder())) {
delete(row.id)
changed = true
}
}
unmatched.forEach {
insert(it.toAlarmEntity(taskId))
changed = true
}
// A reminder of its own again, so the "turned off elsewhere" no longer holds.
if (wanted.isNotEmpty()) setRemindersOff(taskId, false)
return changed
}
/**
* Replace the task's reminders with what a download carried.
*
* Incoming alarms are matched to the rows already here by uid first, then by
* lead, so a reminder the server merely re-described keeps its row rather
* than being churned into a new one.
*
* A download that takes away every alarm the task had marks it
* [TaskEntity.remindersOff]; one that carries alarms clears that.
*/
@Transaction
fun replaceFromServer(taskId: Long, incoming: List<TaskAlarmEntity>) {
val existing = forTask(taskId).toMutableList()
val hadAny = existing.isNotEmpty()
val placed = incoming.map { alarm ->
val match = existing.firstOrNull { it.uid == alarm.uid }
?: existing.firstOrNull { it.asReminder() == alarm.asReminder() }
match?.let(existing::remove)
alarm.copy(id = match?.id ?: 0, taskId = taskId)
}
existing.forEach { delete(it.id) }
placed.forEach { if (it.id == 0L) insert(it) else update(it) }
when {
incoming.isNotEmpty() -> setRemindersOff(taskId, false)
hadAny -> setRemindersOff(taskId, true)
}
}
/**
* Copy [fromTaskId]'s reminders onto [toTaskId].
*
* [sameResource] is for an override forked inside the same series — the
* same `VTODO` UID, so the alarms keep their identity. Anything else is a
* different task on the server and gets fresh uids: two tasks naming one
* alarm is an identity no other client expects.
*/
@Transaction
fun copyToTask(fromTaskId: Long, toTaskId: Long, sameResource: Boolean) {
forTask(fromTaskId).forEach { insert(if (sameResource) it.copy(id = 0, taskId = toTaskId) else it.freshCopy(toTaskId)) }
}
}
/** What the row means to the scheduler and the editor, without its identity. */
fun TaskAlarmEntity.asReminder() = TaskReminder(minutesBefore, reference == AlarmReference.START)
/** The inverse of [asReminder]: a new row, with an identity of its own. */
fun TaskReminder.toAlarmEntity(taskId: Long) = TaskAlarmEntity(
taskId = taskId,
minutesBefore = minutesBefore,
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
)
/** The same reminder on another task: what it means, never who it is on the server. */
fun TaskAlarmEntity.freshCopy(taskId: Long) =
TaskAlarmEntity(taskId = taskId, minutesBefore = minutesBefore, reference = reference, message = message)
/**
* The `ACKNOWLEDGED` stamp of the `VALARM` the row was read from, if any.
*
* Read off [TaskAlarmEntity.raw] rather than kept in a column: it is the other
* client's bookkeeping, and it changes only when a download replaces the row.
*/
fun TaskAlarmEntity.acknowledgedAt(): Instant? {
val text = raw ?: return null
if (!text.contains("ACKNOWLEDGED", ignoreCase = true)) return null
val property = runCatching { ICalParser.parse(text) }.getOrNull()?.property("ACKNOWLEDGED") ?: return null
return (ICalValues.parseTime(property) as? ICalValues.TimeValue.Timed)?.instant
}
@@ -105,10 +105,6 @@ interface TaskDao {
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
fun exportTasks(listId: Long): List<TaskEntity>
/** Every row but the tombstones, overrides included — what the published provider lays out. */
@Query("SELECT * FROM tasks WHERE is_deleted = 0")
fun live(): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
fun dirty(): List<TaskEntity>
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds
@@ -51,7 +50,6 @@ object TaskFormWriter {
description = form.description?.trim()?.ifBlank { null },
location = form.location?.trim()?.ifBlank { null },
url = form.url?.trim()?.ifBlank { null },
categories = normalizeTags(form.tags),
priority = form.priority.toICal(),
percentComplete = percent,
status = statusFor(percent, current.status),
@@ -63,7 +61,6 @@ object TaskFormWriter {
isAllDay = form.isAllDay,
timezone = if (timed) tzId else null,
parentId = form.parentId?.takeIf { it > 0 },
remindersOff = if (form.remindersChosen) form.reminders.isEmpty() else current.remindersOff,
lastModified = now,
isDirty = true,
)
@@ -3,10 +3,6 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import java.util.UUID
import kotlin.time.Instant
/**
* Agendula's own task store. Four tables, designed from
@@ -22,7 +18,7 @@ import kotlin.time.Instant
TaskEntity::class,
TaskAlarmEntity::class,
],
version = 3,
version = 1,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -34,144 +30,5 @@ abstract class TasksDatabase : RoomDatabase() {
companion object {
const val NAME = "agendula-tasks.db"
/**
* v2 adds `categories`. Until then `CATEGORIES` lived in the residue, and
* an unchanged task is never downloaded again, so the values are lifted
* out here. Not marked dirty: nothing changed on the server's side.
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE tasks ADD COLUMN categories TEXT NOT NULL DEFAULT ''")
val lifted = mutableListOf<Pair<Long, CategoriesLift.Result>>()
db.query(
"SELECT id, unknown_properties FROM tasks WHERE unknown_properties LIKE '%CATEGORIES%'",
).use { cursor ->
while (cursor.moveToNext()) {
val result = CategoriesLift.lift(cursor.getString(1))
if (result.categories.isNotEmpty()) lifted += cursor.getLong(0) to result
}
}
lifted.forEach { (id, result) ->
val categories = CategoriesConverter.toText(result.categories)
if (result.claimed) {
db.execSQL(
"UPDATE tasks SET categories = ?, unknown_properties = ? WHERE id = ?",
arrayOf<Any?>(categories, result.residue, id),
)
} else {
db.execSQL("UPDATE tasks SET categories = ? WHERE id = ?", arrayOf<Any?>(categories, id))
}
}
}
}
/**
* v3 syncs reminders as `VALARM`s, which takes two things the rows did not
* have: an identity on the wire (`uid`) and the original text (`raw`).
*
* Claimable `VALARM`s are lifted out of the residue for every row, synced
* or not — an unchanged task is never downloaded again, and an imported
* one never was. Not marked dirty: the server already has them. Where a
* task held the same lead in both stores (an override forked before v3
* copies the master's alarms *and* carries its `VALARM`s), the lifted
* one wins: it is the one with an identity.
*
* Reminders that so far lived only on this device are pushed once, by
* marking their tasks dirty — bounded to open tasks in synced lists whose
* reminder can still fire, so the upgrade is not a burst of PUTs landing
* on other clients as long-missed alarms.
*
* `tasks.reminders_off` starts at 0 everywhere: until now nothing could
* have turned a synced task's alarms off.
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE task_alarms ADD COLUMN uid TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE task_alarms ADD COLUMN raw TEXT")
db.execSQL("ALTER TABLE tasks ADD COLUMN reminders_off INTEGER NOT NULL DEFAULT 0")
// A version-4 UUID per row, in one statement: `randomblob` is
// evaluated per row, and the upgrade runs on app start.
db.execSQL(
"""
UPDATE task_alarms SET uid =
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
substr(lower(hex(randomblob(2))), 2) || '-' ||
substr('89ab', 1 + (abs(random()) % 4), 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
lower(hex(randomblob(6)))
""".trimIndent(),
)
data class Lifted(val taskId: Long, val result: AlarmLift.Result)
val lifted = mutableListOf<Lifted>()
db.query(
"SELECT id, dtstart, due, is_all_day, rrule, rdate, unknown_properties FROM tasks " +
"WHERE unknown_properties LIKE '%VALARM%'",
).use { c ->
while (c.moveToNext()) {
val task = AlarmLift.Task(
dtstart = if (c.isNull(1)) null else Instant.fromEpochMilliseconds(c.getLong(1)),
due = if (c.isNull(2)) null else Instant.fromEpochMilliseconds(c.getLong(2)),
isAllDay = c.getInt(3) != 0,
recurring = !c.isNull(4) || !c.isNull(5),
)
val result = AlarmLift.lift(c.getString(6), task)
if (result.alarms.isNotEmpty()) lifted += Lifted(c.getLong(0), result)
}
}
lifted.forEach { (taskId, result) ->
val local = mutableListOf<Triple<Long, Int, String>>()
db.query(
"SELECT id, minutes_before, reference FROM task_alarms WHERE task_id = ?",
arrayOf<Any?>(taskId),
).use { c -> while (c.moveToNext()) local += Triple(c.getLong(0), c.getInt(1), c.getString(2)) }
result.alarms.forEach { alarm ->
local.firstOrNull { it.second == alarm.minutesBefore && it.third == alarm.reference.name }
?.let { duplicate ->
local.remove(duplicate)
db.execSQL("DELETE FROM task_alarms WHERE id = ?", arrayOf<Any?>(duplicate.first))
}
db.execSQL(
"INSERT INTO task_alarms (task_id, minutes_before, reference, uid, raw) VALUES (?, ?, ?, ?, ?)",
arrayOf<Any?>(
taskId,
alarm.minutesBefore,
alarm.reference.name,
alarm.uid ?: UUID.randomUUID().toString(),
alarm.raw,
),
)
}
db.execSQL("UPDATE tasks SET unknown_properties = ? WHERE id = ?", arrayOf<Any?>(result.residue, taskId))
}
// ⚠️ `raw IS NULL` is "never on the server": everything lifted
// above carries its original. Status 0/1 are NEEDS-ACTION and
// IN-PROCESS (`ICalStatus`).
//
// ⚠️ Never a list the upload cannot write to. A dirty row is
// also one the download will not overwrite, so marking a task in
// a read-only share — or a confidential one, which the upload
// skips in a shared collection — would freeze it for good: never
// sent, and the owner's edits never taken. Classification 2 is
// CONFIDENTIAL.
//
// LAST-MODIFIED moves with it: the body changes, and a client
// reconciling by that stamp would otherwise keep its stale copy.
val now = System.currentTimeMillis()
db.execSQL(
"""
UPDATE tasks SET is_dirty = 1, last_modified = ?
WHERE is_deleted = 0
AND status IN (0, 1)
AND (classification IS NULL OR classification != 2)
AND list_id IN (SELECT id FROM task_lists WHERE account_id IS NOT NULL AND is_read_only = 0)
AND (rrule IS NOT NULL OR rdate IS NOT NULL OR due > ?)
AND id IN (SELECT task_id FROM task_alarms WHERE raw IS NULL)
""".trimIndent(),
arrayOf<Any?>(now, now),
)
}
}
}
}
@@ -7,15 +7,15 @@ import androidx.datastore.preferences.core.edit
import de.jeanlucmakiola.agendula.data.di.ExternalStore
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.toAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.normalizeTags
import de.jeanlucmakiola.agendula.domain.toICal
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
@@ -199,7 +199,7 @@ class ExternalImport @Inject constructor(
var alarmCount = 0
for ((sourceTaskId, reminders) in snapshot.alarms) {
val taskId = taskIds[sourceTaskId] ?: continue
reminders.forEach { alarmDao.insert(it.toAlarmEntity(taskId)) }
reminders.forEach { alarmDao.insert(it.toEntity(taskId)) }
alarmCount += reminders.size
}
@@ -213,7 +213,6 @@ class ExternalImport @Inject constructor(
description = description,
location = location,
url = url,
categories = normalizeTags(tags),
status = status,
percentComplete = percentComplete,
completedAt = completedAt,
@@ -227,6 +226,12 @@ class ExternalImport @Inject constructor(
lastModified = lastModified,
)
private fun TaskReminder.toEntity(taskId: Long) = TaskAlarmEntity(
taskId = taskId,
minutesBefore = minutesBefore,
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
)
private fun verify(written: TransferCounts, before: TransferCounts) {
val after = tableCounts()
check(after.lists - before.lists == written.lists) {

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