Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fde7ae290 | ||
|
|
5d273e05c7 | ||
|
|
59582b3641 | ||
|
|
3540d2fb7c | ||
|
|
3812e950ca | ||
|
|
c6d62c36f4 | ||
|
|
a4fbbf43c6 | ||
|
|
d81e99084e | ||
|
|
9a87837917 | ||
|
|
9630bd104d | ||
|
|
7c3b62eb0d |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -7,31 +7,11 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Other apps can read the tasks kept in Agendula's own database through an
|
||||
OpenTasks-compatible provider, once you grant them permission. Apps that
|
||||
already support OpenTasks or tasks.org need only a small change to support
|
||||
Agendula too (#43).
|
||||
- With a second permission, other apps can also add, edit, complete and delete
|
||||
those tasks, including their reminders and tags. They can tick off a single
|
||||
occurrence of a recurring task. Task lists stay read-only for them (#63).
|
||||
|
||||
## [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
|
||||
@@ -41,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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
||||
|
||||
<a href="https://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>
|
||||
@@ -80,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 |
|
||||
@@ -101,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
|
||||
@@ -115,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):
|
||||
@@ -147,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 → Storage → Export / Import. In
|
||||
Obtainium, pick it with *Filter APKs by regular expression* set to
|
||||
`^agendula-offline_v`.
|
||||
|
||||
### Build from source
|
||||
|
||||
```sh
|
||||
@@ -185,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
@@ -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 = 1010099
|
||||
versionName = "1.1.0"
|
||||
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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
-9
@@ -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
|
||||
|
||||
-157
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
-214
@@ -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
|
||||
}
|
||||
-811
@@ -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
|
||||
}
|
||||
-141
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
-43
@@ -539,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-34
@@ -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,39 +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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
|
||||
-13
@@ -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 {
|
||||
|
||||
@@ -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,78 +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 Storage
|
||||
// stays composed under Export: the deeper screen slides over it.
|
||||
val accountsOpen = section == SettingsSection.Accounts ||
|
||||
section?.parent == SettingsSection.Accounts
|
||||
SlideInSection(visible = accountsOpen) {
|
||||
AccountsScreen(
|
||||
onAddAccount = {
|
||||
// A sign-in abandoned half-way must not turn this into one.
|
||||
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
|
||||
onSection(SettingsSection.AddAccount)
|
||||
},
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenAccount = {
|
||||
openAccount = it
|
||||
onSection(SettingsSection.Account)
|
||||
},
|
||||
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||
onBack = { onSection(null) },
|
||||
viewModel = accountsViewModel,
|
||||
)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Account) {
|
||||
openAccount?.let { id ->
|
||||
AccountDetailScreen(
|
||||
accountId = id,
|
||||
onBack = { onSection(SettingsSection.Accounts) },
|
||||
onRemoved = { onSection(SettingsSection.Accounts) },
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||
viewModel = accountsViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.AddAccount) {
|
||||
AddAccountScreen(
|
||||
// The list stays composed underneath, so nothing re-runs its
|
||||
// init and OnResume never fires on a section change. It no longer
|
||||
// needs to: the accounts come from an observed query, so a new
|
||||
// account — and every later sync — arrives on its own.
|
||||
onDone = { onSection(SettingsSection.Accounts) },
|
||||
onBack = { onSection(SettingsSection.Accounts) },
|
||||
viewModel = addAccountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,6 +55,12 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
<!-- Custom Tabs provider detection. Without this entry it silently finds
|
||||
nothing on API 30+, and the Nextcloud login flow falls back to an
|
||||
external browser for no visible reason. -->
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@@ -55,6 +71,7 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Agendula"
|
||||
@@ -246,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)
|
||||
.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,7 +10,6 @@ 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
|
||||
@@ -21,7 +20,6 @@ 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
|
||||
@@ -261,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 }
|
||||
}
|
||||
@@ -318,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-6
@@ -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
|
||||
}
|
||||
+6
-4
@@ -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
-5
@@ -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,6 +1,5 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
@@ -20,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
|
||||
@@ -85,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
|
||||
@@ -113,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> {
|
||||
@@ -128,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
|
||||
@@ -141,39 +115,21 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
|
||||
} ?: emptyList()
|
||||
if (tasks.isEmpty()) return tasks
|
||||
val tags = categories(tasks.map { it.taskId })
|
||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
override fun insertTask(form: TaskForm): Long {
|
||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||
// One batch, so a refused tag leaves no task behind for a retry to duplicate.
|
||||
val authority = authority()
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
|
||||
.withValues(values.toContentValues())
|
||||
.build(),
|
||||
)
|
||||
normalizeTags(form.tags).forEach { tag ->
|
||||
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
|
||||
.withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) })
|
||||
.withValueBackReference(Properties.TASK_ID, 0)
|
||||
.build()
|
||||
}
|
||||
val results = runCatching { resolver.applyBatch(authority, ops) }
|
||||
.getOrElse { throw TaskWriteFailedException("insert task", it) }
|
||||
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
|
||||
?: throw TaskWriteFailedException("insert task: no id")
|
||||
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
|
||||
?: throw TaskWriteFailedException("insert task")
|
||||
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
|
||||
}
|
||||
|
||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||
setCategories(taskId, form.tags)
|
||||
}
|
||||
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||
@@ -195,7 +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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,7 +177,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
|
||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
|
||||
val spec = seriesSpec(seriesId) ?: return
|
||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteWithSubtasks(seriesId)
|
||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId)
|
||||
endSeriesBefore(seriesId, spec, occurrenceStart)
|
||||
}
|
||||
|
||||
@@ -288,28 +243,8 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||
// 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")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -413,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")
|
||||
@@ -577,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
|
||||
|
||||
@@ -43,8 +43,6 @@ class ModeRoutingTasksDataSource(
|
||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
|
||||
active().deleteFollowing(seriesId, occurrenceStart)
|
||||
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
||||
override fun subtaskIds(parentTaskId: Long): List<Long> = active().subtaskIds(parentTaskId)
|
||||
override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId)
|
||||
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
|
||||
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
|
||||
|
||||
|
||||
@@ -113,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_*) --------------------------------
|
||||
|
||||
@@ -46,29 +46,6 @@ interface TasksDataSource {
|
||||
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
|
||||
fun subtasks(parentTaskId: Long): List<Task>
|
||||
|
||||
/**
|
||||
* The task rows directly under [parentTaskId]: one id per subtask, its series
|
||||
* master when it recurs, whether or not any occurrence falls in the window
|
||||
* [subtasks] expands.
|
||||
*/
|
||||
fun subtaskIds(parentTaskId: Long): List<Long> =
|
||||
subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId }
|
||||
|
||||
/**
|
||||
* Delete [taskId] and its subtasks, all the way down; left behind they would
|
||||
* hang off a tombstone. Each task once, so a parent loop an import brought in
|
||||
* ends instead of recursing forever.
|
||||
*/
|
||||
fun deleteWithSubtasks(taskId: Long) {
|
||||
val seen = HashSet<Long>()
|
||||
fun delete(id: Long) {
|
||||
if (!seen.add(id)) return
|
||||
subtaskIds(id).forEach(::delete)
|
||||
deleteTask(id)
|
||||
}
|
||||
delete(taskId)
|
||||
}
|
||||
|
||||
fun insertTask(form: TaskForm): Long
|
||||
fun updateTask(taskId: Long, form: TaskForm)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
@@ -14,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
|
||||
@@ -31,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.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
@@ -19,10 +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.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -37,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))
|
||||
@@ -74,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
|
||||
@@ -85,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))
|
||||
@@ -98,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)
|
||||
@@ -133,7 +112,6 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
parentId = parentId,
|
||||
location = task.location,
|
||||
url = task.url,
|
||||
tags = task.tags,
|
||||
),
|
||||
)
|
||||
val reminders = dataSource.reminders()[task.taskId].orEmpty()
|
||||
@@ -248,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
|
||||
@@ -256,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) }
|
||||
|
||||
|
||||
@@ -9,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
|
||||
@@ -82,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. */
|
||||
@@ -203,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,
|
||||
@@ -231,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,
|
||||
@@ -291,9 +284,8 @@ object VTodoMapper {
|
||||
now: Instant = Clock.System.now(),
|
||||
): ICalComponent {
|
||||
val residue = parseResidue(entity.unknownProperties)
|
||||
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
|
||||
val keptResidue =
|
||||
residue.properties.filterNot { contradictsResidue(it, residue, 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 }
|
||||
@@ -315,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())
|
||||
@@ -395,7 +386,6 @@ object VTodoMapper {
|
||||
private fun contradictsResidue(
|
||||
property: ICalProperty,
|
||||
residue: ICalComponent,
|
||||
residueCategories: List<String>,
|
||||
entity: TaskEntity,
|
||||
parentUid: String?,
|
||||
): Boolean {
|
||||
@@ -443,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
|
||||
@@ -486,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)
|
||||
|
||||
-99
@@ -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
|
||||
}
|
||||
}
|
||||
-182
@@ -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"
|
||||
}
|
||||
}
|
||||
-304
@@ -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,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,7 +5,6 @@ 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 kotlin.time.Instant
|
||||
@@ -72,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.
|
||||
*/
|
||||
@@ -144,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+38
-105
@@ -31,51 +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")
|
||||
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
||||
|
||||
/**
|
||||
* [TasksDataSource] over Agendula's own Room store.
|
||||
@@ -145,12 +101,6 @@ 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)
|
||||
|
||||
@@ -173,31 +123,54 @@ 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): Long {
|
||||
@@ -349,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()))
|
||||
@@ -541,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,7 +4,6 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
|
||||
/** Reads and writes over `task_alarms`. */
|
||||
@Dao
|
||||
@@ -17,15 +16,9 @@ interface TaskAlarmDao {
|
||||
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id")
|
||||
fun forTask(taskId: 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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -3,8 +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
|
||||
|
||||
/**
|
||||
* Agendula's own task store. Four tables, designed from
|
||||
@@ -20,7 +18,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
TaskEntity::class,
|
||||
TaskAlarmEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -32,36 +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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
@@ -214,7 +213,6 @@ class ExternalImport @Inject constructor(
|
||||
description = description,
|
||||
location = location,
|
||||
url = url,
|
||||
categories = normalizeTags(tags),
|
||||
status = status,
|
||||
percentComplete = percentComplete,
|
||||
completedAt = completedAt,
|
||||
|
||||
@@ -52,8 +52,6 @@ data class Task(
|
||||
val description: String?,
|
||||
val location: String?,
|
||||
val url: String?,
|
||||
/** `CATEGORIES`, as the task spells them. */
|
||||
val tags: List<String> = emptyList(),
|
||||
val priority: Priority,
|
||||
val status: TaskStatus,
|
||||
val percentComplete: Int?,
|
||||
@@ -163,10 +161,3 @@ fun TaskStatus.toInt(): Int = when (this) {
|
||||
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
|
||||
TaskStatus.CANCELLED -> ICalStatus.CANCELLED
|
||||
}
|
||||
|
||||
/** Tags as every writer stores them: trimmed, no blanks, each spelling once, order kept. */
|
||||
fun normalizeTags(tags: List<String>): List<String> = tags.map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
|
||||
/** Every tag on [tasks], each spelling once, alphabetically ignoring case. */
|
||||
fun tagsInUse(tasks: Iterable<Task>): List<String> =
|
||||
tasks.flatMap { it.tags }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Where an open task's due date falls, relative to today. */
|
||||
enum class DueWindow { OVERDUE, TODAY, UPCOMING, NO_DATE }
|
||||
|
||||
/** How a [SavedFilter] with several tags reads them. */
|
||||
enum class TagMatch { ANY, ALL }
|
||||
|
||||
/**
|
||||
* A user-made view over the open tasks. An empty criterion doesn't narrow:
|
||||
* no [due] windows means any due date, no [listIds] any list, no [tags] any tag.
|
||||
* [minPriority] [Priority.NONE] lets every priority through.
|
||||
*
|
||||
* [listsElsewhere] is set when the filter also has lists in another task store.
|
||||
* With none of its lists here it then matches nothing rather than every list.
|
||||
*/
|
||||
data class SavedFilter(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val due: Set<DueWindow> = emptySet(),
|
||||
val listIds: Set<Long> = emptySet(),
|
||||
val tags: Set<String> = emptySet(),
|
||||
val tagMatch: TagMatch = TagMatch.ANY,
|
||||
val minPriority: Priority = Priority.NONE,
|
||||
val listsElsewhere: Boolean = false,
|
||||
) {
|
||||
/** Whether the filter narrows by list at all. */
|
||||
val anyList: Boolean get() = listIds.isEmpty() && !listsElsewhere
|
||||
|
||||
/** This filter without the list ids missing from [existing], e.g. lists since deleted. */
|
||||
fun withListsIn(existing: Set<Long>): SavedFilter = copy(listIds = listIds intersect existing)
|
||||
|
||||
fun matches(task: Task, todayStart: Instant, todayEnd: Instant): Boolean {
|
||||
if (task.isClosed) return false
|
||||
if (listsElsewhere && listIds.isEmpty()) return false
|
||||
if (listIds.isNotEmpty() && task.listId !in listIds) return false
|
||||
if (task.priority < minPriority) return false
|
||||
if (due.isNotEmpty() && dueWindowOf(task, todayStart, todayEnd) !in due) return false
|
||||
return when {
|
||||
tags.isEmpty() -> true
|
||||
tagMatch == TagMatch.ALL -> task.tags.containsAll(tags)
|
||||
else -> task.tags.any { it in tags }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where [task]'s due falls against local today ([todayStart] to [todayEnd], from
|
||||
* [DayWindow] in [zone]). An all-day due is stored as UTC midnight, so it is read
|
||||
* as its calendar date first rather than compared as a raw instant.
|
||||
*/
|
||||
fun dueWindowOf(
|
||||
task: Task,
|
||||
todayStart: Instant,
|
||||
todayEnd: Instant,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): DueWindow {
|
||||
val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
|
||||
return when {
|
||||
due == null -> DueWindow.NO_DATE
|
||||
due < todayStart -> DueWindow.OVERDUE
|
||||
due < todayEnd -> DueWindow.TODAY
|
||||
else -> DueWindow.UPCOMING
|
||||
}
|
||||
}
|
||||
|
||||
/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */
|
||||
data class NewTaskPreset(
|
||||
val listIds: Set<Long> = emptySet(),
|
||||
val tags: List<String> = emptyList(),
|
||||
val dueDate: LocalDate? = null,
|
||||
val priority: Priority = Priority.NONE,
|
||||
)
|
||||
|
||||
/**
|
||||
* The preset for a new task in this filter. Due prefers today, then tomorrow;
|
||||
* a filter that admits undated tasks, or only overdue ones, leaves it empty.
|
||||
*/
|
||||
fun SavedFilter.newTaskPreset(today: LocalDate): NewTaskPreset = NewTaskPreset(
|
||||
listIds = listIds,
|
||||
tags = when {
|
||||
tagMatch == TagMatch.ALL -> tags.sorted()
|
||||
else -> listOfNotNull(tags.minOrNull())
|
||||
},
|
||||
dueDate = when {
|
||||
due.isEmpty() || DueWindow.NO_DATE in due -> null
|
||||
DueWindow.TODAY in due -> today
|
||||
DueWindow.UPCOMING in due -> today.plusDays(1)
|
||||
else -> null
|
||||
},
|
||||
priority = minPriority,
|
||||
)
|
||||
@@ -5,16 +5,12 @@ import kotlin.time.Instant
|
||||
/** The built-in "smart" lists, computed from due dates rather than membership. */
|
||||
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
||||
|
||||
/** What a task screen is showing: one real list, a smart list, or a tag. */
|
||||
/** What a task screen is showing: one real list, or a smart list. */
|
||||
sealed interface TaskFilter {
|
||||
data class OfList(val listId: Long) : TaskFilter
|
||||
/** Several lists shown together, e.g. one widget covering more than one list. */
|
||||
data class OfLists(val listIds: Set<Long>) : TaskFilter
|
||||
data class Smart(val list: SmartList) : TaskFilter
|
||||
/** Every task carrying [tag], open and completed, across all lists. */
|
||||
data class Tagged(val tag: String) : TaskFilter
|
||||
/** A [SavedFilter] by id; the repository resolves it to its definition. */
|
||||
data class Saved(val id: Long) : TaskFilter
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,32 +20,22 @@ sealed interface TaskFilter {
|
||||
*/
|
||||
object TaskFiltering {
|
||||
|
||||
/** [saved] is the definition behind a [TaskFilter.Saved]; without one it matches nothing. */
|
||||
fun matches(
|
||||
task: Task,
|
||||
filter: TaskFilter,
|
||||
todayStart: Instant,
|
||||
todayEnd: Instant,
|
||||
saved: SavedFilter? = null,
|
||||
): Boolean =
|
||||
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||
when (filter) {
|
||||
is TaskFilter.OfList -> task.listId == filter.listId
|
||||
is TaskFilter.OfLists -> task.listId in filter.listIds
|
||||
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
||||
is TaskFilter.Tagged -> filter.tag in task.tags
|
||||
is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false
|
||||
}
|
||||
|
||||
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
|
||||
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
|
||||
|
||||
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||
when (list) {
|
||||
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean {
|
||||
val due = task.due
|
||||
return when (list) {
|
||||
SmartList.COMPLETED -> task.isCompleted
|
||||
SmartList.ALL -> !task.isClosed
|
||||
SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
|
||||
SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
|
||||
SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY
|
||||
SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
|
||||
SmartList.NO_DATE -> !task.isClosed && due == null
|
||||
SmartList.OVERDUE -> !task.isClosed && due != null && due < todayStart
|
||||
SmartList.TODAY -> !task.isClosed && due != null && due >= todayStart && due < todayEnd
|
||||
SmartList.UPCOMING -> !task.isClosed && due != null && due >= todayEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ data class TaskForm(
|
||||
val reminders: List<Int> = emptyList(),
|
||||
val location: String? = null,
|
||||
val url: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
/** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */
|
||||
val rrule: String? = null,
|
||||
) {
|
||||
@@ -45,7 +44,7 @@ enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITH
|
||||
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
|
||||
* disclosure generic, so adding a field later is one entry plus its card.
|
||||
*/
|
||||
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url, Tags }
|
||||
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url }
|
||||
|
||||
/** The optional fields that already carry a value — auto-revealed when editing. */
|
||||
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
||||
@@ -57,5 +56,4 @@ fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
||||
if (rrule != null) add(TaskFormField.Recurrence)
|
||||
if (!location.isNullOrBlank()) add(TaskFormField.Location)
|
||||
if (!url.isNullOrBlank()) add(TaskFormField.Url)
|
||||
if (tags.isNotEmpty()) add(TaskFormField.Tags)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ object TaskSections {
|
||||
|
||||
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
|
||||
if (task.isCompleted) return TaskSection.COMPLETED
|
||||
return when (dueWindowOf(task, todayStart, todayEnd)) {
|
||||
DueWindow.OVERDUE -> TaskSection.OVERDUE
|
||||
DueWindow.TODAY -> TaskSection.TODAY
|
||||
DueWindow.UPCOMING -> TaskSection.UPCOMING
|
||||
DueWindow.NO_DATE -> TaskSection.NO_DATE
|
||||
val due = task.due ?: return TaskSection.NO_DATE
|
||||
return when {
|
||||
due < todayStart -> TaskSection.OVERDUE
|
||||
due < todayEnd -> TaskSection.TODAY
|
||||
else -> TaskSection.UPCOMING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user